From 1a06a8d14a2f35d7e2286e86e9be6624adad61aa Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 30 Aug 2022 16:44:12 -0400 Subject: [PATCH 01/13] 1.09-x - tweaks to the units and to make sure the sicce pump returns as a sensor --- .gitignore | 8 +++++++- custom_components/apex/const.py | 9 +++++---- custom_components/apex/sensor.py | 4 +--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 176615f..f8119f9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -*$py.class \ No newline at end of file +*$py.class + +# MacOS drops these files all over the place to store finder view preferences. They can be safelu deleted. +.DS_Store + +# Intellij IDEA stores project information in this directory +.idea diff --git a/custom_components/apex/const.py b/custom_components/apex/const.py index daa34ab..b539bbb 100644 --- a/custom_components/apex/const.py +++ b/custom_components/apex/const.py @@ -15,9 +15,10 @@ SENSORS = { "Temp": {"icon": "mdi:water-thermometer", "measurement": "°C"}, - "Cond": {"icon": "mdi:shaker-outline"}, - "pH": {"icon": "mdi:test-tube", "measurement": "pH"}, - "ORP": {"icon": "mdi:test-tube"}, + "Cond": {"icon": "mdi:shaker-outline", "measurement": "ppt"}, + "in": {"icon": "mdi:ruler", "measurement": "in"}, + "pH": {"icon": "mdi:test-tube", "measurement": " "}, + "ORP": {"icon": "mdi:test-tube", "measurement": "mV"}, "digital": {"icon": "mdi:digital-ocean"}, "Amps": { "icon" : "mdi:lightning-bolt-circle", "measurement": "A"}, "pwr": {"icon" : "mdi:power-plug", "measurement": "W"}, @@ -37,4 +38,4 @@ } UPDATE_INTERVAL = "update_interval" -UPDATE_INTERVAL_DEFAULT = 60 \ No newline at end of file +UPDATE_INTERVAL_DEFAULT = 60 diff --git a/custom_components/apex/sensor.py b/custom_components/apex/sensor.py index d148c2d..0fe7b3d 100644 --- a/custom_components/apex/sensor.py +++ b/custom_components/apex/sensor.py @@ -1,9 +1,7 @@ import logging import re -from datetime import datetime, timedelta from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle, dt from . import ApexEntity from .const import DOMAIN, SENSORS, MEASUREMENTS @@ -19,7 +17,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensor = ApexSensor(entry, value, config_entry.options) async_add_entities([sensor], True) for value in entry.data["outputs"]: - if value["type"] == "dos" or value["type"] == "variable" or value["type"] == "virtual": + if value["type"] == "dos" or value["type"] == "variable" or value["type"] == "virtual" or value["type"] == "iotaPump|Sicce|Syncra": sensor = ApexSensor(entry, value, config_entry.options) async_add_entities([sensor], True) From 36c9816dff329baffdaf472ad452bb9038fe2f34 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Wed, 31 Aug 2022 18:43:44 -0400 Subject: [PATCH 02/13] 1.09-x - force set advanced ctype --- custom_components/apex/apex.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index 2cdba3e..cf250a2 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -213,10 +213,13 @@ def set_variable(self, did, code): return {"error": "Variable/did not found"} - if variable["ctype"] != "Advanced": - _LOGGER.debug("Only Advanced mode currently supported") - return {"error": "Given variable was not of type Advanced"} + # I don't think it's necessary to warn on this, that just forces me to go to the Apex + # interface and set it... + # if variable["ctype"] != "Advanced": + # _LOGGER.debug("Only Advanced mode currently supported") + # return {"error": "Given variable was not of type Advanced"} + variable["ctype"] = "Advanced" variable["prog"] = code r = requests.put( From 562bf1c6fc7d8daa8455b7873c20bca853ca16a7 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 13:09:18 -0400 Subject: [PATCH 03/13] 1.09-x - update to include update_dos_profile --- custom_components/apex/apex.py | 145 ++++++++++++++------------------- 1 file changed, 59 insertions(+), 86 deletions(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index cf250a2..fbeb8dc 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -26,32 +26,18 @@ def __init__( def auth(self): - headers = { - **defaultHeaders - } - data = { - "login" : self.username, - "password": self.password, - "remember_me" : False - } + headers = {**defaultHeaders} + data = {"login" : self.username, "password": self.password, "remember_me" : False} # Try logging in 3 times due to controller timeout login = 0 while login < 3: - r = requests.post( - "http://" + self.deviceip + "/rest/login", - headers = headers, - json = data - ) - - - _LOGGER.debug(r.text) + r = requests.post("http://" + self.deviceip + "/rest/login", headers = headers, json = data) + # _LOGGER.debug(r.request.body) _LOGGER.debug(r.status_code) - # _LOGGER.debug(r.text) - # _LOGGER.debug(r.request.body) + _LOGGER.debug(r.text) if r.status_code == 200: - result = r.json() - self.sid = result["connect.sid"] + self.sid = r.json()["connect.sid"] return True if r.status_code == 404: self.version = "old" @@ -59,18 +45,16 @@ def auth(self): else: print("Status code failure") login += 1 + + # XXX does there need to be some sort of sleep here? + return False def oldstatus(self): # Function for returning information on old controllers (Currently not authenticated) - headers = { - **defaultHeaders - } - - r = requests.get( - "http://" + self.deviceip + "/cgi-bin/status.xml?" + str(round(time.time())), - headers = headers - ) + headers = {**defaultHeaders} + + r = requests.get("http://" + self.deviceip + "/cgi-bin/status.xml?" + str(round(time.time())), headers = headers) xml = xmltodict.parse(r.text) # Code to convert old style to new style json result = {} @@ -120,19 +104,12 @@ def status(self): return result i = 0 while i <= 3: - headers = { - **defaultHeaders, - "Cookie" : "connect.sid=" + self.sid - } - r = requests.get( - "http://" + self.deviceip + "/rest/status?_=" + str(round(time.time())), - headers = headers - ) + headers = {**defaultHeaders, "Cookie" : "connect.sid=" + self.sid} + r = requests.get("http://" + self.deviceip + "/rest/status?_=" + str(round(time.time())), headers = headers) #_LOGGER.debug(r.text) if r.status_code == 200: - result = r.json() - return result + return r.json() elif r.status_code == 401: self.auth() else: @@ -148,71 +125,40 @@ def config(self): if self.sid is None: _LOGGER.debug("We are none") self.auth() - headers = { - **defaultHeaders, - "Cookie" : "connect.sid=" + self.sid - } - - r = requests.get( - "http://" + self.deviceip + "/rest/config?_=" + str(round(time.time())), - headers = headers - ) + headers = {**defaultHeaders, "Cookie" : "connect.sid=" + self.sid } + + r = requests.get( "http://" + self.deviceip + "/rest/config?_=" + str(round(time.time())), headers = headers) #_LOGGER.debug(r.text) if r.status_code == 200: - result = r.json() - return result + return r.json() else: print("Error occured") def toggle_output(self, did, state): - headers = { - **defaultHeaders, - "Cookie" : "connect.sid=" + self.sid - } - - - data = { - "did" : did, - "status": [ - state, - "", - "OK", - "" - ], - "type": "outlet" - - } - + headers = {**defaultHeaders, "Cookie" : "connect.sid=" + self.sid} + # I gave this "type": "outlet" a bit of side-eye, but it seems to be fine even if the + # target is not technically an outlet. + data = {"did" : did, "status": [state, "", "OK", ""], "type": "outlet"} _LOGGER.debug(data) - r = requests.put( - "http://" + self.deviceip + "/rest/status/outputs/" + did, - headers = headers, - json = data - ) - + r = requests.put("http://" + self.deviceip + "/rest/status/outputs/" + did, headers = headers, json = data) data = r.json() _LOGGER.debug(data) return data - def set_variable(self, did, code): - headers = { - **defaultHeaders, - "Cookie" : "connect.sid=" + self.sid - } + headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} config = self.config() variable = None for value in config["oconf"]: if value["did"] == did: variable = value - + if variable == None: return {"error": "Variable/did not found"} - # I don't think it's necessary to warn on this, that just forces me to go to the Apex # interface and set it... # if variable["ctype"] != "Advanced": @@ -221,17 +167,44 @@ def set_variable(self, did, code): variable["ctype"] = "Advanced" variable["prog"] = code - - r = requests.put( - "http://" + self.deviceip + "/rest/config/oconf/" + did, - headers = headers, - json = variable - ) _LOGGER.debug(variable) + r = requests.put("http://" + self.deviceip + "/rest/config/oconf/" + did, headers=headers, json=variable) _LOGGER.debug(r.text) return {"error": ""} + def update_dos_profile(self, profile_id, target_rate): + headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} + config = self.config() + profile = config["pconf"][profile_id - 1] + if int(profile["ID"]) != profile_id: + return {"error": "Profile index mismatch"} + + # the DOS profile is a pump speed, forward/reverse, target amount, over a time period, and dose count + # "data": {"mode": 21, "count": 255, "time": 60, "amount": 1} + # the mode is 4 bits for the speed, and always forward (you can't calibrate the reverse, so why bother?) + # pump speed is given by ["250 mL / min", "125 mL / min", "60 mL / min", "25 mL / min", "12 mL / min", "7 mL / min"] + + # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and + # then find the slowest pump speed possible to manage sound levels. + pump_speeds = [250, 125, 60, 25, 12, 7] + target_rate = int(target_rate * 10) / 10.0 + target_pump_speed = min(target_rate * 3, pump_speeds[0]) + pump_speed_index = len(pump_speeds) - 1 + while pump_speeds[pump_speed_index] < target_pump_speed: + pump_speed_index -= 1 + + # bits 0-4 of the 'mode' value are the pump speed index, and bit 5 specifies 'forward' or + # 'reverse'. we always use 'forward' because you can't calibrate the reverse direction using + # the Apex dashboard + mode = pump_speed_index + 16 + + profile["data"] = {"mode": mode, "count": 255, "time": 60, "amount": target_rate} + _LOGGER.debug(profile) + + #r = requests.put("http://" + self.deviceip + "/rest/config/pconf/" + profile_id, headers=headers, json=profile) + #_LOGGER.debug(r.text) + return {"error": ""} From 40e6e63448c49f48b320b10bcde57da4aa6773f7 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 13:32:49 -0400 Subject: [PATCH 04/13] 1.09-x - service update --- custom_components/apex/__init__.py | 36 ++++++++++++++++++---------- custom_components/apex/apex.py | 16 ++++++------- custom_components/apex/services.yaml | 19 +++++++++++++-- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/custom_components/apex/__init__.py b/custom_components/apex/__init__.py index 58787da..1f4160a 100644 --- a/custom_components/apex/__init__.py +++ b/custom_components/apex/__init__.py @@ -6,7 +6,6 @@ import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError @@ -32,7 +31,6 @@ _LOGGER = logging.getLogger(__name__) - async def async_setup(hass: HomeAssistant, config: dict): """Set up the Apex component.""" hass.data.setdefault(DOMAIN, {}) @@ -56,7 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): await coordinator.async_refresh() # Get initial data - if not coordinator.last_update_success: raise ConfigEntryNotReady @@ -73,19 +70,26 @@ async def async_set_options_service(service_call): async def async_set_variable_service(service_call): await hass.async_add_executor_job(set_variable, hass, service_call, coordinator) + async def async_set_dos_rate_service(service_call): + await hass.async_add_executor_job(set_dos_rate, hass, service_call, coordinator) + hass.services.async_register( DOMAIN, - "set_output", + "set_output", async_set_options_service ) hass.services.async_register( DOMAIN, - "set_variable", + "set_variable", async_set_variable_service ) - + hass.services.async_register( + DOMAIN, + "set_dos_rate", + async_set_dos_rate_service + ) return True @@ -93,7 +97,8 @@ async def async_set_variable_service(service_call): def set_output(hass, service, coordinator): did = service.data.get("did").strip() setting = service.data.get("setting").strip() - status = coordinator.apex.toggle_output(did, setting) + coordinator.apex.toggle_output(did, setting) + def set_variable(hass, service, coordinator): did = service.data.get("did").strip() @@ -103,6 +108,14 @@ def set_variable(hass, service, coordinator): raise HomeAssistantError(status["error"]) +def set_dos_rate(hass, service, coordinator): + pid = int(service.data.get("pid").strip()) + rate = float(service.data.get("rate").strip()) + status = coordinator.apex.set_dos_rate(pid, rate) + if status["error"] != "": + raise HomeAssistantError(status["error"]) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( @@ -147,8 +160,8 @@ async def _async_update_data(self): data["config"] = await self._hass.async_add_executor_job( self.apex.config # Fetch new status ) - #_LOGGER.debug("Refreshing Now") - #_LOGGER.debug(data) + # _LOGGER.debug("Refreshing Now") + # _LOGGER.debug(data) return data except Exception as ex: @@ -164,14 +177,13 @@ class ApexEntity(CoordinatorEntity): """Defines a base Apex entity.""" def __init__( - self, *, device_id: str, name: str, coordinator: ApexDataUpdateCoordinator + self, *, device_id: str, name: str, coordinator: ApexDataUpdateCoordinator ): """Initialize the entity.""" super().__init__(coordinator) self._device_id = device_id self._name = name - async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() @@ -202,5 +214,3 @@ def device_info(self): "manufacturer": MANUFACTURER, "test": "TEST" } - - diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index fbeb8dc..3e8edaf 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -174,23 +174,19 @@ def set_variable(self, did, code): return {"error": ""} - def update_dos_profile(self, profile_id, target_rate): + def set_dos_rate(self, profile_id, rate): headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} config = self.config() profile = config["pconf"][profile_id - 1] if int(profile["ID"]) != profile_id: return {"error": "Profile index mismatch"} - # the DOS profile is a pump speed, forward/reverse, target amount, over a time period, and dose count - # "data": {"mode": 21, "count": 255, "time": 60, "amount": 1} - # the mode is 4 bits for the speed, and always forward (you can't calibrate the reverse, so why bother?) - # pump speed is given by ["250 mL / min", "125 mL / min", "60 mL / min", "25 mL / min", "12 mL / min", "7 mL / min"] - # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and # then find the slowest pump speed possible to manage sound levels. + # XXX TODO handle rates less than 0.1ml/min by dosing over multiple minutes? Is this necessary? pump_speeds = [250, 125, 60, 25, 12, 7] - target_rate = int(target_rate * 10) / 10.0 - target_pump_speed = min(target_rate * 3, pump_speeds[0]) + rate = int(rate * 10) / 10.0 + target_pump_speed = min(rate * 3, pump_speeds[0]) pump_speed_index = len(pump_speeds) - 1 while pump_speeds[pump_speed_index] < target_pump_speed: pump_speed_index -= 1 @@ -200,7 +196,9 @@ def update_dos_profile(self, profile_id, target_rate): # the Apex dashboard mode = pump_speed_index + 16 - profile["data"] = {"mode": mode, "count": 255, "time": 60, "amount": target_rate} + # the DOS profile is the mode, target amount, target time period (one minute), and dose count + # "data": {"mode": 21, "amount": 1, "time": 60, "count": 255} + profile["data"] = {"mode": mode, "amount": rate, "time": 60, "count": 255} _LOGGER.debug(profile) #r = requests.put("http://" + self.deviceip + "/rest/config/pconf/" + profile_id, headers=headers, json=profile) diff --git a/custom_components/apex/services.yaml b/custom_components/apex/services.yaml index d64c177..b3ef330 100644 --- a/custom_components/apex/services.yaml +++ b/custom_components/apex/services.yaml @@ -23,11 +23,26 @@ set_variable: name: DID description: "DID of the selected variable or output to modify" example: "Cntl_A1" - selector: + selector: text: code: name: Code description: "Code to modify on the variable/output" example: "Set 75" selector: - text: \ No newline at end of file + text: +set_dos_rate: + description: "Set the dosing rate profile" + fields: + pid: + name: PID + description: "ID of the DOS profile" + example: 1 + selector: + text: + rate: + name: Rate + description: "The desired dosing rate, in mL / min. (0.1 - 250)" + example: 1.2 + selector: + text: From b370e1ba1ff63258cd670e9658a660718a6e29e8 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 14:05:21 -0400 Subject: [PATCH 05/13] 1.09-x - remove strip from the non-text entity --- custom_components/apex/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/apex/__init__.py b/custom_components/apex/__init__.py index 1f4160a..a219b96 100644 --- a/custom_components/apex/__init__.py +++ b/custom_components/apex/__init__.py @@ -109,8 +109,8 @@ def set_variable(hass, service, coordinator): def set_dos_rate(hass, service, coordinator): - pid = int(service.data.get("pid").strip()) - rate = float(service.data.get("rate").strip()) + pid = int(service.data.get("pid")) + rate = float(service.data.get("rate")) status = coordinator.apex.set_dos_rate(pid, rate) if status["error"] != "": raise HomeAssistantError(status["error"]) From 957c3703784f810ffaa2f019ef404fa412d5d3f8 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 14:33:33 -0400 Subject: [PATCH 06/13] 1.09-x - enable the profile setting - with a little bit of boundary checking... --- custom_components/apex/apex.py | 70 +++++++++++++--------------- custom_components/apex/services.yaml | 2 +- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index 3e8edaf..73f69d7 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -1,10 +1,8 @@ -import json import logging import requests import time import xmltodict - defaultHeaders = { "Accept": "*/*", "Content-Type": "application/json" @@ -12,9 +10,10 @@ _LOGGER = logging.getLogger(__name__) + class Apex(object): def __init__( - self, username, password, deviceip + self, username, password, deviceip ): self.username = username @@ -23,15 +22,13 @@ def __init__( self.sid = None self.version = "new" - - def auth(self): headers = {**defaultHeaders} - data = {"login" : self.username, "password": self.password, "remember_me" : False} + data = {"login": self.username, "password": self.password, "remember_me": False} # Try logging in 3 times due to controller timeout login = 0 while login < 3: - r = requests.post("http://" + self.deviceip + "/rest/login", headers = headers, json = data) + r = requests.post("http://" + self.deviceip + "/rest/login", headers=headers, json=data) # _LOGGER.debug(r.request.body) _LOGGER.debug(r.status_code) _LOGGER.debug(r.text) @@ -54,7 +51,7 @@ def oldstatus(self): # Function for returning information on old controllers (Currently not authenticated) headers = {**defaultHeaders} - r = requests.get("http://" + self.deviceip + "/cgi-bin/status.xml?" + str(round(time.time())), headers = headers) + r = requests.get("http://" + self.deviceip + "/cgi-bin/status.xml?" + str(round(time.time())), headers=headers) xml = xmltodict.parse(r.text) # Code to convert old style to new style json result = {} @@ -86,13 +83,11 @@ def oldstatus(self): outputdata["type"] = "outlet" outputs.append(outputdata) - result["outputs"] = outputs _LOGGER.debug(result) return result - def status(self): _LOGGER.debug(self.sid) if self.sid is None: @@ -104,9 +99,9 @@ def status(self): return result i = 0 while i <= 3: - headers = {**defaultHeaders, "Cookie" : "connect.sid=" + self.sid} - r = requests.get("http://" + self.deviceip + "/rest/status?_=" + str(round(time.time())), headers = headers) - #_LOGGER.debug(r.text) + headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} + r = requests.get("http://" + self.deviceip + "/rest/status?_=" + str(round(time.time())), headers=headers) + # _LOGGER.debug(r.text) if r.status_code == 200: return r.json() @@ -118,17 +113,16 @@ def status(self): i += 1 def config(self): - if self.version == "old": result = {} return result if self.sid is None: _LOGGER.debug("We are none") self.auth() - headers = {**defaultHeaders, "Cookie" : "connect.sid=" + self.sid } + headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} - r = requests.get( "http://" + self.deviceip + "/rest/config?_=" + str(round(time.time())), headers = headers) - #_LOGGER.debug(r.text) + r = requests.get("http://" + self.deviceip + "/rest/config?_=" + str(round(time.time())), headers=headers) + # _LOGGER.debug(r.text) if r.status_code == 200: return r.json() @@ -136,14 +130,14 @@ def config(self): print("Error occured") def toggle_output(self, did, state): - headers = {**defaultHeaders, "Cookie" : "connect.sid=" + self.sid} + headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} # I gave this "type": "outlet" a bit of side-eye, but it seems to be fine even if the # target is not technically an outlet. - data = {"did" : did, "status": [state, "", "OK", ""], "type": "outlet"} + data = {"did": did, "status": [state, "", "OK", ""], "type": "outlet"} _LOGGER.debug(data) - r = requests.put("http://" + self.deviceip + "/rest/status/outputs/" + did, headers = headers, json = data) + r = requests.put("http://" + self.deviceip + "/rest/status/outputs/" + did, headers=headers, json=data) data = r.json() _LOGGER.debug(data) return data @@ -156,7 +150,7 @@ def set_variable(self, did, code): if value["did"] == did: variable = value - if variable == None: + if variable is None: return {"error": "Variable/did not found"} # I don't think it's necessary to warn on this, that just forces me to go to the Apex @@ -183,26 +177,28 @@ def set_dos_rate(self, profile_id, rate): # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and # then find the slowest pump speed possible to manage sound levels. - # XXX TODO handle rates less than 0.1ml/min by dosing over multiple minutes? Is this necessary? pump_speeds = [250, 125, 60, 25, 12, 7] rate = int(rate * 10) / 10.0 - target_pump_speed = min(rate * 3, pump_speeds[0]) - pump_speed_index = len(pump_speeds) - 1 - while pump_speeds[pump_speed_index] < target_pump_speed: - pump_speed_index -= 1 + if int(pump_speeds[0] / 3) > rate > 0.1: + target_pump_speed = rate * 3 + pump_speed_index = len(pump_speeds) - 1 + while pump_speeds[pump_speed_index] < target_pump_speed: + pump_speed_index -= 1 - # bits 0-4 of the 'mode' value are the pump speed index, and bit 5 specifies 'forward' or - # 'reverse'. we always use 'forward' because you can't calibrate the reverse direction using - # the Apex dashboard - mode = pump_speed_index + 16 + # bits 0-4 of the 'mode' value are the pump speed index, and bit 5 specifies 'forward' or + # 'reverse'. we always use 'forward' because you can't calibrate the reverse direction using + # the Apex dashboard + mode = pump_speed_index + 16 - # the DOS profile is the mode, target amount, target time period (one minute), and dose count - # "data": {"mode": 21, "amount": 1, "time": 60, "count": 255} - profile["data"] = {"mode": mode, "amount": rate, "time": 60, "count": 255} - _LOGGER.debug(profile) + # the DOS profile is the mode, target amount, target time period (one minute), and dose count + # "data": {"mode": 21, "amount": 1, "time": 60, "count": 255} + profile["data"] = {"mode": mode, "amount": rate, "time": 60, "count": 255} + _LOGGER.debug(profile) - #r = requests.put("http://" + self.deviceip + "/rest/config/pconf/" + profile_id, headers=headers, json=profile) - #_LOGGER.debug(r.text) + r = requests.put("http://" + self.deviceip + "/rest/config/pconf/" + profile_id, headers=headers, json=profile) + # _LOGGER.debug(r.text) - return {"error": ""} + return {"error": ""} + # XXX TODO handle rates less than 0.1ml/min by dosing over multiple minutes? Is this necessary? + return {"error": f"Requested rate ({rate} mL / min) is out of the supported range [0.1 .. {int(pump_speeds[0] / 3)}]."} diff --git a/custom_components/apex/services.yaml b/custom_components/apex/services.yaml index b3ef330..50ddf41 100644 --- a/custom_components/apex/services.yaml +++ b/custom_components/apex/services.yaml @@ -42,7 +42,7 @@ set_dos_rate: text: rate: name: Rate - description: "The desired dosing rate, in mL / min. (0.1 - 250)" + description: "The desired dosing rate, in mL / min. (effective range is 0.1 - 83)" example: 1.2 selector: text: From fd832e121e048da1e05f58ccf2c653adb8f1889e Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 14:56:44 -0400 Subject: [PATCH 07/13] 1.09-x - try another way of building the request string --- custom_components/apex/apex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index 73f69d7..812b914 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -195,7 +195,7 @@ def set_dos_rate(self, profile_id, rate): profile["data"] = {"mode": mode, "amount": rate, "time": 60, "count": 255} _LOGGER.debug(profile) - r = requests.put("http://" + self.deviceip + "/rest/config/pconf/" + profile_id, headers=headers, json=profile) + r = requests.put(f"http://{self.deviceip}/rest/config/pconf/{profile_id}", headers=headers, json=profile) # _LOGGER.debug(r.text) return {"error": ""} From 2898ff2a77119970dc42cbc0ebf59f44b4f291a4 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 15:13:58 -0400 Subject: [PATCH 08/13] 1.09-x - use a 2x safety margin --- custom_components/apex/apex.py | 9 ++++++--- custom_components/apex/services.yaml | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index 812b914..ed8181c 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -176,11 +176,14 @@ def set_dos_rate(self, profile_id, rate): return {"error": "Profile index mismatch"} # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and - # then find the slowest pump speed possible to manage sound levels. + # then find the slowest pump speed possible to manage sound levels. Neptune uses a 3x safety + # margin to extend the life of the pump, but the setting only appears to be enforced in the + # Fusion UI. We use a 2x margin because we can. pump_speeds = [250, 125, 60, 25, 12, 7] rate = int(rate * 10) / 10.0 - if int(pump_speeds[0] / 3) > rate > 0.1: - target_pump_speed = rate * 3 + safety_margin = 2 + if int(pump_speeds[0] / safety_margin) > rate > 0.1: + target_pump_speed = rate * safety_margin pump_speed_index = len(pump_speeds) - 1 while pump_speeds[pump_speed_index] < target_pump_speed: pump_speed_index -= 1 diff --git a/custom_components/apex/services.yaml b/custom_components/apex/services.yaml index 50ddf41..8b6ed1d 100644 --- a/custom_components/apex/services.yaml +++ b/custom_components/apex/services.yaml @@ -42,7 +42,7 @@ set_dos_rate: text: rate: name: Rate - description: "The desired dosing rate, in mL / min. (effective range is 0.1 - 83)" + description: "The desired dosing rate, in mL / min. (effective range is 0.1 - 125)" example: 1.2 selector: text: From fec4de2ca95145d93ba6c77c58415ca3264c883b Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 15:45:55 -0400 Subject: [PATCH 09/13] 1.09-x - redo - the integration completely controls the DOS now - turns the pump OFF or sets the profile - updates the profile to be named the way we want (dose_{did}) --- custom_components/apex/__init__.py | 5 +- custom_components/apex/apex.py | 77 +++++++++++++++++----------- custom_components/apex/services.yaml | 16 ++++-- 3 files changed, 60 insertions(+), 38 deletions(-) diff --git a/custom_components/apex/__init__.py b/custom_components/apex/__init__.py index a219b96..926b1e2 100644 --- a/custom_components/apex/__init__.py +++ b/custom_components/apex/__init__.py @@ -109,9 +109,10 @@ def set_variable(hass, service, coordinator): def set_dos_rate(hass, service, coordinator): - pid = int(service.data.get("pid")) + did = service.data.get("did").strip() + profile_id = int(service.data.get("pid")) rate = float(service.data.get("rate")) - status = coordinator.apex.set_dos_rate(pid, rate) + status = coordinator.apex.set_dos_rate(did, profile_id, rate) if status["error"] != "": raise HomeAssistantError(status["error"]) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index ed8181c..b01c2e1 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -168,40 +168,55 @@ def set_variable(self, did, code): return {"error": ""} - def set_dos_rate(self, profile_id, rate): + def set_dos_rate(self, did, profile_id, rate): headers = {**defaultHeaders, "Cookie": "connect.sid=" + self.sid} config = self.config() + profile = config["pconf"][profile_id - 1] if int(profile["ID"]) != profile_id: return {"error": "Profile index mismatch"} - # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and - # then find the slowest pump speed possible to manage sound levels. Neptune uses a 3x safety - # margin to extend the life of the pump, but the setting only appears to be enforced in the - # Fusion UI. We use a 2x margin because we can. - pump_speeds = [250, 125, 60, 25, 12, 7] - rate = int(rate * 10) / 10.0 - safety_margin = 2 - if int(pump_speeds[0] / safety_margin) > rate > 0.1: - target_pump_speed = rate * safety_margin - pump_speed_index = len(pump_speeds) - 1 - while pump_speeds[pump_speed_index] < target_pump_speed: - pump_speed_index -= 1 - - # bits 0-4 of the 'mode' value are the pump speed index, and bit 5 specifies 'forward' or - # 'reverse'. we always use 'forward' because you can't calibrate the reverse direction using - # the Apex dashboard - mode = pump_speed_index + 16 - - # the DOS profile is the mode, target amount, target time period (one minute), and dose count - # "data": {"mode": 21, "amount": 1, "time": 60, "count": 255} - profile["data"] = {"mode": mode, "amount": rate, "time": 60, "count": 255} - _LOGGER.debug(profile) - - r = requests.put(f"http://{self.deviceip}/rest/config/pconf/{profile_id}", headers=headers, json=profile) - # _LOGGER.debug(r.text) - - return {"error": ""} - - # XXX TODO handle rates less than 0.1ml/min by dosing over multiple minutes? Is this necessary? - return {"error": f"Requested rate ({rate} mL / min) is out of the supported range [0.1 .. {int(pump_speeds[0] / 3)}]."} + min_rate = 0.1 + if rate > min_rate: + # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and + # then find the slowest pump speed possible to manage sound levels. Neptune uses a 3x + # safety margin to extend the life of the pump, but the setting only appears to be + # enforced in the Fusion UI. We use a 2x margin because we can. + pump_speeds = [250, 125, 60, 25, 12, 7] + safety_margin = 2 + rate = int(rate * 10) / 10.0 + if int(pump_speeds[0] / safety_margin) >= rate: + target_pump_speed = rate * safety_margin + pump_speed_index = len(pump_speeds) - 1 + while pump_speeds[pump_speed_index] < target_pump_speed: + pump_speed_index -= 1 + + # bits 0-4 of the 'mode' value are the pump speed index, and bit 5 specifies + # 'forward' or 'reverse'. we always use 'forward' because you can't calibrate the + # reverse direction using the Apex dashboard + mode = pump_speed_index + 16 + + # we set the profile to be what we need it to be so the user doesn't have to do + # anything except choose the profile to use + profile["type"] = "dose" + profile["name"] = f"Dose_{did}" + + # the DOS profile is the mode, target amount, target time period (one minute), and + # dose count + profile["data"] = {"mode": mode, "amount": rate, "time": 60, "count": 255} + _LOGGER.debug(profile) + + r = requests.put(f"http://{self.deviceip}/rest/config/pconf/{profile_id}", headers=headers, json=profile) + # _LOGGER.debug(r.text) + + # turn the pump on + self.set_variable(did, f"Set {profile['name']}") + + # return no error + return {"error": ""} + + # XXX TODO handle rates less than 0.1ml/min by dosing over multiple minutes? Is this necessary? + return {"error": f"Requested rate ({rate} mL / min) is out of the supported range [0.1 .. {int(pump_speeds[0] / 3)}]."} + else: + # turn the pump off + self.set_variable(did, f"Set OFF") diff --git a/custom_components/apex/services.yaml b/custom_components/apex/services.yaml index 8b6ed1d..58b5480 100644 --- a/custom_components/apex/services.yaml +++ b/custom_components/apex/services.yaml @@ -32,17 +32,23 @@ set_variable: selector: text: set_dos_rate: - description: "Set the dosing rate profile" + description: "Set the dosing rate for a DOS pump" fields: + did: + name: DID + description: "DID of the DOS pump" + example: "4_1" + selector: + text: pid: - name: PID - description: "ID of the DOS profile" - example: 1 + name: Profile ID + description: "Profile ID assign to the DOS pump, the integration will name it appropriately" + example: 11 selector: text: rate: name: Rate - description: "The desired dosing rate, in mL / min. (effective range is 0.1 - 125)" + description: "The desired dosing rate, in mL / min. (effective range is 0 - 125)" example: 1.2 selector: text: From f4e5b5d2f7aae96ab6c6bfe0d375534bc0fc8a5c Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 15:47:18 -0400 Subject: [PATCH 10/13] 1.09-x - tweaks --- custom_components/apex/__init__.py | 2 +- custom_components/apex/services.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/apex/__init__.py b/custom_components/apex/__init__.py index 926b1e2..e25f990 100644 --- a/custom_components/apex/__init__.py +++ b/custom_components/apex/__init__.py @@ -110,7 +110,7 @@ def set_variable(hass, service, coordinator): def set_dos_rate(hass, service, coordinator): did = service.data.get("did").strip() - profile_id = int(service.data.get("pid")) + profile_id = int(service.data.get("profile_id")) rate = float(service.data.get("rate")) status = coordinator.apex.set_dos_rate(did, profile_id, rate) if status["error"] != "": diff --git a/custom_components/apex/services.yaml b/custom_components/apex/services.yaml index 58b5480..ab37788 100644 --- a/custom_components/apex/services.yaml +++ b/custom_components/apex/services.yaml @@ -40,9 +40,9 @@ set_dos_rate: example: "4_1" selector: text: - pid: + profile_id: name: Profile ID - description: "Profile ID assign to the DOS pump, the integration will name it appropriately" + description: "Profile ID to assign to the DOS pump, the integration will rename it appropriately." example: 11 selector: text: From 0773a1c90d1df0b7e8bc2ff147ee085b9b90bd23 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 15:55:22 -0400 Subject: [PATCH 11/13] 1.09-x - fix error handling --- custom_components/apex/apex.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index b01c2e1..7e1fe6e 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -210,13 +210,11 @@ def set_dos_rate(self, did, profile_id, rate): # _LOGGER.debug(r.text) # turn the pump on - self.set_variable(did, f"Set {profile['name']}") - - # return no error - return {"error": ""} - - # XXX TODO handle rates less than 0.1ml/min by dosing over multiple minutes? Is this necessary? - return {"error": f"Requested rate ({rate} mL / min) is out of the supported range [0.1 .. {int(pump_speeds[0] / 3)}]."} + return self.set_variable(did, f"Set {profile['name']}") + else: + return {"error": f"Requested rate ({rate} mL / min) exceeds the supported range (limit {int(pump_speeds[0] / safety_margin)} mL / min)."} else: + # XXX TODO handle 0 < rate < 0.1ml/min by dosing over multiple minutes? Is this necessary? + # turn the pump off - self.set_variable(did, f"Set OFF") + return self.set_variable(did, f"Set OFF") From 9d919b143e68c6831697b05544133f2e1992593d Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 16:07:05 -0400 Subject: [PATCH 12/13] 1.09-x - tweak to make the DOS start the new profile immediately --- custom_components/apex/apex.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index 7e1fe6e..9a67ba8 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -176,6 +176,11 @@ def set_dos_rate(self, did, profile_id, rate): if int(profile["ID"]) != profile_id: return {"error": "Profile index mismatch"} + # turn the pump off to start - this will enable a new profile setting to start immediately + # without it, the DOS will wait until the current profile period expires + return self.set_variable(did, f"Set OFF") + + # check if the requested rate is greater than the OFF threshold min_rate = 0.1 if rate > min_rate: # our input is a target rate (ml/min). we want to map this to the nearest 0.1ml/min, and @@ -215,6 +220,4 @@ def set_dos_rate(self, did, profile_id, rate): return {"error": f"Requested rate ({rate} mL / min) exceeds the supported range (limit {int(pump_speeds[0] / safety_margin)} mL / min)."} else: # XXX TODO handle 0 < rate < 0.1ml/min by dosing over multiple minutes? Is this necessary? - - # turn the pump off - return self.set_variable(did, f"Set OFF") + return {"error": ""} From a7f4b0ca9ea18f85ff43dacf6c7b03255a768c37 Mon Sep 17 00:00:00 2001 From: Bretton Wade Date: Tue, 13 Sep 2022 16:13:02 -0400 Subject: [PATCH 13/13] 1.09-x - error handling don't just always turn off the pump --- custom_components/apex/apex.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/apex/apex.py b/custom_components/apex/apex.py index 9a67ba8..910c789 100644 --- a/custom_components/apex/apex.py +++ b/custom_components/apex/apex.py @@ -178,7 +178,9 @@ def set_dos_rate(self, did, profile_id, rate): # turn the pump off to start - this will enable a new profile setting to start immediately # without it, the DOS will wait until the current profile period expires - return self.set_variable(did, f"Set OFF") + off = self.set_variable(did, f"Set OFF") + if off["error"] != "": + return off # check if the requested rate is greater than the OFF threshold min_rate = 0.1