pax_global_header00006660000000000000000000000064146124633050014516gustar00rootroot0000000000000052 comment=4a42b91150f565f34fed22d7b312c7389e47a5da ChandlerSystems-dropmqttapi-4a42b91/000077500000000000000000000000001461246330500175205ustar00rootroot00000000000000ChandlerSystems-dropmqttapi-4a42b91/.gitignore000066400000000000000000000060061461246330500215120ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ChandlerSystems-dropmqttapi-4a42b91/LICENSE000066400000000000000000000020671461246330500205320ustar00rootroot00000000000000MIT License Copyright (c) 2023 Chandler Systems, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ChandlerSystems-dropmqttapi-4a42b91/README.md000066400000000000000000000001011461246330500207670ustar00rootroot00000000000000# dropmqttapi Python MQTT API for DROP water management products ChandlerSystems-dropmqttapi-4a42b91/pyproject.toml000066400000000000000000000012261461246330500224350ustar00rootroot00000000000000# pyproject.toml [build-system] requires = ["setuptools>=61.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "dropmqttapi" version = "1.0.3" description = "MQTT API for DROP water management products" readme = "README.md" authors = [{ name = "Patrick Frazer", email = "pfrazer@chandlersystemsinc.com" }] license = { file = "LICENSE" } classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", ] keywords = ["drop", "mqtt", "api"] dependencies = [ ] requires-python = ">=3.11" [project.urls] Homepage = "https://github.com/ChandlerSystems/dropmqttapi" ChandlerSystems-dropmqttapi-4a42b91/src/000077500000000000000000000000001461246330500203075ustar00rootroot00000000000000ChandlerSystems-dropmqttapi-4a42b91/src/dropmqttapi/000077500000000000000000000000001461246330500226535ustar00rootroot00000000000000ChandlerSystems-dropmqttapi-4a42b91/src/dropmqttapi/__init__.py000066400000000000000000000000241461246330500247600ustar00rootroot00000000000000"""DROP MQTT API."""ChandlerSystems-dropmqttapi-4a42b91/src/dropmqttapi/discovery.py000066400000000000000000000062151461246330500252400ustar00rootroot00000000000000"""DROP Discovery.""" from __future__ import annotations import json import logging _LOGGER = logging.getLogger(__name__) KEY_DEVICE_TYPE = "devType" KEY_DEVICE_DESCRIPTION = "devDesc" KEY_DEVICE_NAME = "name" class DropDiscovery: """Class for parsing MQTT discovery messages for DROP devices.""" def __init__(self, domain: str) -> None: """Initialize.""" self._domain = domain self._hub_id = "" self._device_id = "" self._device_type = "" self._device_desc = "" self._name = "" self._owner_id = "" self._data_topic = "" self._command_topic = "" async def parse_discovery(self, discovery_topic: str, payload: str | bytes) -> bool: """Parse an MQTT discovery message and return True if successful.""" try: json_data = json.loads(payload) except ValueError: _LOGGER.error( "Invalid DROP MQTT discovery payload on %s: %s", discovery_topic, payload, ) return False # Extract the DROP hub ID and DROP device ID from the MQTT topic. topic_elements = discovery_topic.split("/") if not ( topic_elements[2].startswith("DROP-") and topic_elements[3].isnumeric() ): return False self._hub_id = topic_elements[2] self._device_id = topic_elements[3] # Discovery data must include the DROP device type and name. if ( KEY_DEVICE_TYPE in json_data and KEY_DEVICE_DESCRIPTION in json_data and KEY_DEVICE_NAME in json_data ): self._device_type = json_data[KEY_DEVICE_TYPE] self._device_desc = json_data[KEY_DEVICE_DESCRIPTION] self._name = json_data[KEY_DEVICE_NAME] else: _LOGGER.error( "Incomplete MQTT discovery payload on %s: %s", discovery_topic, payload ) return False self._data_topic = f"{self._domain}/{self._hub_id}/data/{self._device_id}/#" self._command_topic = f"{self._domain}/{self._hub_id}/cmd/{self._device_id}" self._owner_id = f"{self._hub_id}_255" # Hub has static device ID _LOGGER.debug("MQTT discovery on %s: %s", discovery_topic, payload) return True @property def hub_id(self): """Return DROP Hub ID.""" return self._hub_id @property def device_id(self): """Return DROP device ID.""" return self._device_id @property def name(self): """Return device name.""" return self._name @property def device_type(self): """Return device type.""" return self._device_type @property def device_desc(self): """Return device description.""" return self._device_desc @property def data_topic(self): """Return MQTT data topic.""" return self._data_topic @property def command_topic(self): """Return MQTT command topic.""" return self._command_topic @property def owner_id(self): """Return device owner ID.""" return self._owner_id ChandlerSystems-dropmqttapi-4a42b91/src/dropmqttapi/mqttapi.py000066400000000000000000000130111461246330500247000ustar00rootroot00000000000000"""DROP MQTT API.""" from __future__ import annotations import json import logging from typing import Any _LOGGER = logging.getLogger(__name__) class DropAPI: """Class for parsing MQTT data messages for DROP devices.""" _data_cache: dict[str, Any] def __init__(self) -> None: """Initialize the DROP API.""" self._data_cache = {} def parse_drop_message( self, topic: str, payload: str | bytes, qos: int, retain: bool ) -> bool: """Parse an MQTT payload message and return True if any of the data has changed.""" data_changed = False try: json_data = json.loads(payload) if isinstance(json_data, dict): for k, v in json_data.items(): if isinstance(k, str): if k not in self._data_cache or self._data_cache[k] != v: self._data_cache[k] = v data_changed = True if data_changed: _LOGGER.debug("New data for %s: %s", topic, payload) except ValueError: _LOGGER.error("Invalid JSON (%s): %s", topic, payload) return False return data_changed # API mapping def battery(self) -> int | None: """Return battery percentage.""" return self.get_int_val("battery") def current_flow_rate(self) -> float | None: """Return current flow rate in gpm.""" return self.get_float_val("curFlow") def peak_flow_rate(self) -> float | None: """Return peak flow rate in gpm.""" return self.get_float_val("peakFlow") def water_used_today(self) -> float | None: """Return water used today in gallons.""" return self.get_float_val("usedToday") def average_water_used(self) -> float | None: """Return average water used in gallons.""" return self.get_float_val("avgUsed") def capacity_remaining(self) -> float | None: """Return softener capacity remaining in gallons.""" return self.get_float_val("capacity") def current_system_pressure(self) -> float | None: """Return current system pressure in PSI.""" return self.get_float_val("psi") def high_system_pressure(self) -> int | None: """Return high system pressure today in PSI.""" return self.get_int_val("psiHigh") def low_system_pressure(self) -> int | None: """Return low system pressure in PSI.""" return self.get_int_val("psiLow") def temperature(self) -> float | None: """Return temperature.""" return self.get_float_val("temp") def inlet_tds(self) -> int | None: """Return inlet TDS in PPM.""" return self.get_int_val("tdsIn") def outlet_tds(self) -> int | None: """Return outlet TDS in PPM.""" return self.get_int_val("tdsOut") def cart1(self) -> int | None: """Return cartridge 1 life remaining.""" return self.get_int_val("cart1") def cart2(self) -> int | None: """Return cartridge 2 life remaining.""" return self.get_int_val("cart2") def cart3(self) -> int | None: """Return cartridge 3 life remaining.""" return self.get_int_val("cart3") def leak_detected(self) -> int | None: """Return leak detected value.""" return self.get_int_val("leak") def sensor_high(self) -> int | None: """Return sensor high indication.""" return self.get_int_val("sens") def power(self) -> int | None: """Return power state.""" return self.get_int_val("pwrOff") == 0 def notification_pending(self) -> int | None: """Return notification pending value.""" return self.get_int_val("notif") def salt_low(self) -> int | None: """Return salt low value.""" return self.get_int_val("salt") def reserve_in_use(self) -> int | None: """Return reserve in use value.""" return self.get_int_val("resInUse") def pump_status(self) -> int | None: """Return pump status value.""" return self.get_int_val("pump") def water(self) -> int | None: """Return water state value.""" return self.get_int_val("water") def bypass(self) -> int | None: """Return bypass state value.""" return self.get_int_val("bypass") def protect_mode(self) -> int | None: """Return protect mode state value.""" return self.get_string_val("pMode") def get_int_val(self, key: str) -> int | None: """Return the specified API value as an int or None if it is unknown.""" if key in self._data_cache and self._data_cache[key] is not None: return int(self._data_cache[key]) return None def get_float_val(self, key: str) -> float | None: """Return the specified API value as a float or None if it is unknown.""" if key in self._data_cache and self._data_cache[key] is not None: return float(self._data_cache[key]) return None def get_string_val(self, key: str) -> str | None: """Return the specified API value as a string or None if it is unknown.""" if key in self._data_cache and self._data_cache[key] is not None: return str(self._data_cache[key]) return None def set_water_message(self, value: int) -> str: return f'{{"water":{value}}}' def set_bypass_message(self, value: int) -> str: return f'{{"bypass":{value}}}' def set_protect_mode_message(self, value: str) -> str: return f'{{"pMode":"{value}"}}'