├── tests ├── .pylintrc ├── pytest.ini ├── context.py ├── client.py ├── simulator.py ├── test_async_locator.py ├── test_peekable_queue.py ├── test_protocol_ping.py ├── test_protocol_packet.py ├── test_protocol_channel.py ├── test_protocol_version.py ├── test_protocol_file.py ├── test_protocol_pack_command.py ├── test_spaman.py ├── test_protocol_hello.py └── test_protocol_watercare.py ├── src └── geckolib │ ├── _version.py │ ├── utils │ ├── cui │ │ ├── __init__.py │ │ └── config.py │ ├── __init__.py │ └── async_command.py │ ├── async_taskman.py │ ├── automation │ ├── blower.py │ ├── waterfall.py │ ├── bubblegen.py │ ├── button.py │ ├── power.py │ ├── lockmode.py │ ├── keypad_backlight.py │ ├── __init__.py │ ├── select.py │ ├── ingrid.py │ ├── heatpump.py │ ├── number.py │ ├── base.py │ ├── keypad.py │ ├── watercare.py │ ├── light.py │ ├── reminders.py │ ├── mrsteam.py │ ├── switch.py │ ├── bainultra.py │ └── sensors.py │ ├── driver │ ├── packs │ │ ├── __init__.py │ │ ├── inxe.py │ │ ├── inxm.py │ │ ├── inyj.py │ │ ├── inyt.py │ │ ├── inmix.py │ │ ├── mia.py │ │ ├── mrsteam.py │ │ ├── inxe-2.py │ │ ├── inmixextended.py │ │ ├── inxe-64k.py │ │ ├── inye-v3.py │ │ ├── inyj-v2.py │ │ ├── inyj-v3.py │ │ ├── inyt-v2.py │ │ ├── inclear-32k.py │ │ ├── intouch2-co.py │ │ ├── mas-ibc-16k.py │ │ ├── mas-ibc-32k.py │ │ ├── ink600-64k.py │ │ ├── interface-audio-8k.py │ │ ├── mrsteam-cfg-1.py │ │ ├── mrsteam-cfg-2.py │ │ ├── mrsteam-cfg-3.py │ │ ├── mas-ibc-32k-cfg-1.py │ │ ├── inclear-32k-cfg-2.py │ │ └── inmix-cfg-1.py │ ├── protocol │ │ ├── ping.py │ │ ├── unhandled.py │ │ ├── rferr.py │ │ ├── __init__.py │ │ ├── firmware.py │ │ ├── getchannel.py │ │ ├── version.py │ │ ├── configfile.py │ │ ├── packet.py │ │ ├── hello.py │ │ ├── packcommand.py │ │ └── reminders.py │ ├── observable.py │ ├── __init__.py │ └── async_peekablequeue.py │ ├── async_spa_descriptor.py │ ├── __main__.py │ ├── spa_state.py │ ├── async_tasks.py │ ├── config.py │ ├── __init__.py │ └── spa_events.py ├── requirements.txt ├── setup.py ├── scripts ├── lint ├── test ├── setup └── generate ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── python-publish.yml │ └── python-package.yml ├── .vscode └── settings.json ├── pyproject.toml ├── ruff.toml ├── setup.cfg ├── .devcontainer.json └── .gitignore /tests/.pylintrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/geckolib/_version.py: -------------------------------------------------------------------------------- 1 | """Single module version.""" 2 | 3 | VERSION = "1.0.15" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pip>=21.3.1 2 | ruff==0.9.9 3 | requests==2.32.3 4 | pytest-asyncio==0.25.3 5 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | # pytest.ini 2 | [pytest] 3 | asyncio_mode=auto 4 | asyncio_default_fixture_loop_scope = "function" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup main.""" 2 | 3 | import setuptools 4 | 5 | if __name__ == "__main__": 6 | setuptools.setup() 7 | -------------------------------------------------------------------------------- /src/geckolib/utils/cui/__init__.py: -------------------------------------------------------------------------------- 1 | """Geckolib cui utils.""" 2 | 3 | from .cui import CUI 4 | 5 | __all__ = ["CUI"] 6 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../tests" 6 | pytest 7 | cd "$(dirname "$0")" 8 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt -------------------------------------------------------------------------------- /scripts/generate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../tests" 6 | python3 packgen.py 7 | 8 | cd "../src/geckolib/driver/packs" 9 | 10 | ruff format . 11 | ruff check . --fix 12 | -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | """Fix the path.""" # noqa: INP001 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) # noqa: PTH100, PTH118, PTH120 7 | 8 | from geckolib import * # noqa: F403 9 | -------------------------------------------------------------------------------- /src/geckolib/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """GeckoLib utilities.""" 2 | 3 | from .async_command import AsyncCmd 4 | from .cui import CUI 5 | from .shell import GeckoShell 6 | from .simulator import GeckoSimulator 7 | from .snapshot import GeckoSnapshot 8 | 9 | __all__ = ["CUI", "AsyncCmd", "GeckoShell", "GeckoSimulator", "GeckoSnapshot"] 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /src/geckolib/async_taskman.py: -------------------------------------------------------------------------------- 1 | """GeckoAsyncTaskMan is a wrapper on AsyncTasks with some shared properties.""" 2 | 3 | from .async_tasks import AsyncTasks 4 | 5 | 6 | class GeckoAsyncTaskMan(AsyncTasks): 7 | """Async tasks with spa name and id.""" 8 | 9 | unique_id = "" 10 | spa_name = "" 11 | 12 | def __init__(self) -> None: 13 | """Initialize the async task manager class.""" 14 | super().__init__() 15 | -------------------------------------------------------------------------------- /src/geckolib/automation/blower.py: -------------------------------------------------------------------------------- 1 | """Gecko Blowers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from .pump import GeckoPump 8 | 9 | if TYPE_CHECKING: 10 | from geckolib.automation.async_facade import GeckoAsyncFacade 11 | 12 | 13 | class GeckoBlower(GeckoPump): 14 | """Blowers are based on pumps.""" 15 | 16 | def __init__(self, facade: GeckoAsyncFacade) -> None: 17 | """Initialize the blower.""" 18 | super().__init__(facade, "Blower", "BL") 19 | -------------------------------------------------------------------------------- /src/geckolib/automation/waterfall.py: -------------------------------------------------------------------------------- 1 | """Gecko Waterfall.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from .pump import GeckoPump 8 | 9 | if TYPE_CHECKING: 10 | from geckolib.automation.async_facade import GeckoAsyncFacade 11 | 12 | 13 | class GeckoWaterfall(GeckoPump): 14 | """Blowers are based on pumps.""" 15 | 16 | def __init__(self, facade: GeckoAsyncFacade) -> None: 17 | """Initialize the waterfall.""" 18 | super().__init__(facade, "Waterfall", "Waterfall") 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": false, 3 | "python.linting.flake8Enabled": true, 4 | "python.linting.mypyEnabled": true, 5 | "python.linting.enabled": true, 6 | "editor.formatOnPaste": false, 7 | "editor.formatOnSave": true, 8 | "editor.formatOnType": true, 9 | "editor.defaultFormatter": null, 10 | "python.formatting.provider": "black", 11 | "files.trimTrailingWhitespace": true, 12 | "python.testing.pytestArgs": [ 13 | "tests" 14 | ], 15 | "python.testing.unittestEnabled": false, 16 | "python.testing.pytestEnabled": true 17 | } -------------------------------------------------------------------------------- /src/geckolib/driver/packs/__init__.py: -------------------------------------------------------------------------------- 1 | """Pack Module Initialization.""" 2 | 3 | from geckolib.driver.accessor import ( 4 | GeckoBoolStructAccessor, 5 | GeckoByteStructAccessor, 6 | GeckoEnumStructAccessor, 7 | GeckoStructAccessor, 8 | GeckoTempStructAccessor, 9 | GeckoTimeStructAccessor, 10 | GeckoWordStructAccessor, 11 | ) 12 | from geckolib.driver.async_spastruct import GeckoAsyncStructure 13 | 14 | __all__ = [ 15 | "GeckoAsyncStructure", 16 | "GeckoBoolStructAccessor", 17 | "GeckoByteStructAccessor", 18 | "GeckoEnumStructAccessor", 19 | "GeckoStructAccessor", 20 | "GeckoTempStructAccessor", 21 | "GeckoTimeStructAccessor", 22 | "GeckoWordStructAccessor", 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main, dev ] 7 | pull_request: 8 | branches: [ main, dev ] 9 | 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v4.2.2" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.13" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | 31 | - name: "Test" 32 | run: pytest 33 | -------------------------------------------------------------------------------- /src/geckolib/automation/bubblegen.py: -------------------------------------------------------------------------------- 1 | """Gecko Bubble generator on Aux.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from .pump import GeckoPump 8 | 9 | if TYPE_CHECKING: 10 | from geckolib.automation.async_facade import GeckoAsyncFacade 11 | 12 | 13 | class GeckoBubbleGenerator(GeckoPump): 14 | """Blowers are based on pumps.""" 15 | 16 | def __init__(self, facade: GeckoAsyncFacade) -> None: 17 | """Initialize the bubble generator.""" 18 | super().__init__(facade, "Bubble Generator", "Aux") 19 | if self.is_available and "AuxAsBubbleGen" in self.facade.spa.accessors: 20 | self.set_availability( 21 | is_available=self.facade.spa.accessors["AuxAsBubbleGen"].value 22 | ) 23 | -------------------------------------------------------------------------------- /src/geckolib/automation/button.py: -------------------------------------------------------------------------------- 1 | """GeckoButton class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from .base import GeckoAutomationFacadeBase 9 | 10 | if TYPE_CHECKING: 11 | from geckolib.automation.async_facade import GeckoAsyncFacade 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class GeckoButton(GeckoAutomationFacadeBase): 17 | """A button can be pressed ... that's it.""" 18 | 19 | def __init__(self, facade: GeckoAsyncFacade, name: str, keypad: int) -> None: 20 | """Inirtialize the button class.""" 21 | super().__init__(facade, name, f"KEYPAD{keypad}") 22 | self._keypad: int = keypad 23 | 24 | async def async_press(self) -> None: 25 | """Press the button.""" 26 | await self.facade.spa.async_press(self._keypad) 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | target-version = ["py313"] 7 | exclude = 'generated' 8 | line-length = 88 9 | 10 | [tool.isort] 11 | # https://github.com/PyCQA/isort/wiki/isort-Settings 12 | profile = "black" 13 | # will group `import x` and `from x import` of the same module. 14 | force_sort_within_sections = true 15 | known_first_party = [] 16 | forced_separate = [] 17 | combine_as_imports = true 18 | multi_line_output = 3 19 | include_trailing_comma=true 20 | force_grid_wrap=0 21 | use_parentheses=true 22 | line_length=88 23 | indent = " " 24 | # by default isort don't check module indexes 25 | not_skip = '__init__.py' 26 | sections = ['FUTURE','STDLIB','THIRDPARTY','FIRSTPARTY','LOCALFOLDER'] 27 | default_section = 'THIRDPARTY' 28 | #known_first_party = custom_components.gecko 29 | 30 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py313" 4 | 5 | [lint] 6 | select = [ 7 | "ALL", 8 | ] 9 | 10 | ignore = [ 11 | "ANN101", # Missing type annotation for `self` in method 12 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 13 | "D203", # no-blank-line-before-class (incompatible with formatter) 14 | "D212", # multi-line-summary-first-line (incompatible with formatter) 15 | "COM812", # incompatible with formatter 16 | "ISC001", # incompatible with formatter 17 | ] 18 | 19 | [lint.flake8-pytest-style] 20 | fixture-parentheses = false 21 | 22 | [lint.pyupgrade] 23 | keep-runtime-typing = true 24 | 25 | [lint.mccabe] 26 | max-complexity = 25 27 | 28 | [lint.per-file-ignores] 29 | "tests/test_*.py" = ["PT009", "D102", "SLF001", "S101", "PLR2004"] 30 | -------------------------------------------------------------------------------- /tests/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sample client program for geckolib. 3 | 4 | Search for in.touch2 devices and then allows interaction with them 5 | """ # noqa: INP001 6 | 7 | import logging 8 | import sys 9 | from pathlib import Path 10 | 11 | from context import GeckoShell 12 | 13 | 14 | def install_logging() -> None: 15 | """Everyone needs logging, you say when, you say where, you say how much.""" 16 | Path("client.log").unlink(True) # noqa: FBT003 17 | file_logger = logging.FileHandler("client.log") 18 | file_logger.setLevel(logging.DEBUG) 19 | file_logger.setFormatter( 20 | logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") 21 | ) 22 | logging.getLogger().addHandler(file_logger) 23 | logging.getLogger().setLevel(logging.DEBUG) 24 | 25 | 26 | # Run the shell with any extra arguments 27 | install_logging() 28 | GeckoShell.run(sys.argv[1:]) 29 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inxe.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InXE'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InXE" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 1 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inxm.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InXM'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InXM" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 6 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inyj.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InYJ'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InYJ" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 12 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inyt.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InYT'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InYT" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 10 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inmix.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InMix'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InMix" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 14 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aAccessory" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mia.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'MIA'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "MIA" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 3 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aK600UpperControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mrsteam.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'MrSteam'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "MrSteam" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 13 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inxe-2.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InXE-2'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InXE-2" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 1 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inmixextended.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InMixExtended'.""" 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InMixExtended" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 18 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aAccessory" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inxe-64k.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InXE-64K'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InXE-64K" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 1 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inye-v3.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'inYE-V3'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "inYE-V3" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 10 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inyj-v2.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InYJ-V2'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InYJ-V2" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 12 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inyj-v3.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InYJ-V3'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InYJ-V3" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 12 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inyt-v2.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InYT-V2'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InYT-V2" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 10 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /tests/simulator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Spa simulator program for geckolib. 3 | 4 | Pretends to be a spa on the network, 5 | used by integration tests to prevent regression 6 | """ # noqa: INP001 7 | 8 | import logging 9 | import sys 10 | from pathlib import Path 11 | 12 | from context import GeckoSimulator 13 | 14 | 15 | def install_logging() -> None: 16 | """Everyone needs logging, you say when, you say where, you say how much.""" 17 | Path("simulator.log").unlink(True) # noqa: FBT003 18 | file_logger = logging.FileHandler("simulator.log") 19 | file_logger.setLevel(logging.DEBUG) 20 | file_logger.setFormatter( 21 | logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") 22 | ) 23 | logging.getLogger().addHandler(file_logger) 24 | logging.getLogger().setLevel(logging.DEBUG) 25 | 26 | 27 | install_logging() 28 | GeckoSimulator.run(["load snapshots/default.snapshot", "start"] + sys.argv[1:]) 29 | -------------------------------------------------------------------------------- /tests/test_async_locator.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the Async locator class.""" # noqa: INP001 2 | 3 | from unittest import IsolatedAsyncioTestCase 4 | 5 | from context import GeckoAsyncLocator 6 | 7 | 8 | class TestAsyncLocator(IsolatedAsyncioTestCase): 9 | """Test the GeckoAsyncLocator class.""" 10 | 11 | def setUp(self) -> None: 12 | self.locator = GeckoAsyncLocator(None, None) 13 | 14 | async def asyncSetUp(self) -> None: 15 | pass 16 | 17 | async def asyncTearDown(self) -> None: 18 | pass 19 | 20 | def tearDown(self) -> None: 21 | del self.locator 22 | 23 | ##################################################### 24 | 25 | def test_on_construct(self) -> None: 26 | self.assertIsNone(self.locator.spas) 27 | self.assertFalse(self.locator.is_running) 28 | self.assertEqual(self.locator.age, 0) 29 | self.assertFalse(self.locator.has_had_enough_time) 30 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inclear-32k.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'inClear-32K'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "inClear-32K" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 5 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aAccessory" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/intouch2-co.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'inTouch2-CO'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "inTouch2-CO" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 9 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aAccessory" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mas-ibc-16k.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'MAS-IBC-16K'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "MAS-IBC-16K" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 2 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mas-ibc-32k.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'MAS-IBC-32K'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "MAS-IBC-32K" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 2 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aMainControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/ink600-64k.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'InK600-64K'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "InK600-64K" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 7 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aK600UpperControl" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/interface-audio-8k.py: -------------------------------------------------------------------------------- 1 | """GeckoPack - A class to manage the pack for 'inTerface-AUDIO-8K'.""" # noqa: N999 2 | 3 | from . import GeckoAsyncStructure 4 | 5 | 6 | class GeckoPack: 7 | """A GeckoPack class for a specific spa.""" 8 | 9 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 10 | """Initialize the GeckoPack class.""" 11 | self.struct = struct_ 12 | 13 | @property 14 | def name(self) -> str: 15 | """Get the plateform name.""" 16 | return "inTerface-AUDIO-8K" 17 | 18 | @property 19 | def plateform_type(self) -> int: 20 | """Get the plateform type.""" 21 | return 8 22 | 23 | @property 24 | def plateform_segment(self) -> str: 25 | """Get the plateform segment.""" 26 | return "aAccessory" 27 | 28 | @property 29 | def revision(self) -> str: 30 | """Get the SpaPackStruct revision.""" 31 | return "40.00" 32 | -------------------------------------------------------------------------------- /src/geckolib/automation/power.py: -------------------------------------------------------------------------------- 1 | """Energy base class.""" 2 | 3 | from __future__ import annotations 4 | 5 | from abc import abstractmethod 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.automation.base import GeckoAutomationFacadeBase 9 | 10 | if TYPE_CHECKING: 11 | from geckolib.automation.async_facade import GeckoAsyncFacade 12 | 13 | 14 | class GeckoPower(GeckoAutomationFacadeBase): 15 | """Base class for energy counting objects.""" 16 | 17 | def __init__(self, facade: GeckoAsyncFacade, name: str, key: str) -> None: 18 | """Initialize the energy base class.""" 19 | super().__init__(facade, name, key) 20 | self.power: float = 0.0 21 | 22 | @property 23 | @abstractmethod 24 | def is_on(self) -> bool: 25 | """Determine if this device is on or not.""" 26 | 27 | @property 28 | def current_power(self) -> float: 29 | """Get powewr value for this device if it is on.""" 30 | return self.power if self.is_on else 0.0 31 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /src/geckolib/async_spa_descriptor.py: -------------------------------------------------------------------------------- 1 | """GeckoAsyncSpaDescriptor class.""" 2 | 3 | import logging 4 | 5 | from .const import GeckoConstants 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class GeckoAsyncSpaDescriptor: 11 | """A descriptor class for spas that have been discovered on the network.""" 12 | 13 | def __init__( 14 | self, 15 | spa_identifier: bytes, 16 | spa_name: str, 17 | sender: tuple, 18 | ) -> None: 19 | """Initialize the descriptor.""" 20 | self.identifier = spa_identifier 21 | self.name = spa_name 22 | self.ipaddress, self.port = sender 23 | 24 | @property 25 | def identifier_as_string(self) -> str: 26 | """ 27 | The spa identifier as a string. 28 | 29 | Useful for storing in configuration and 30 | then passing as spa_identifier to the facade 31 | """ 32 | return self.identifier.decode(GeckoConstants.MESSAGE_ENCODING) 33 | 34 | @property 35 | def destination(self) -> tuple: 36 | """The destination of this descriptor.""" 37 | return (self.ipaddress, self.port) 38 | 39 | def __repr__(self) -> str: 40 | """Get then string representation.""" 41 | return f"{self.name}({self.identifier_as_string}) [{self.destination}]" 42 | -------------------------------------------------------------------------------- /src/geckolib/__main__.py: -------------------------------------------------------------------------------- 1 | """Main module. Supports python -m geckolib syntax.""" 2 | 3 | # ruff: noqa: T201 4 | 5 | import logging 6 | from pathlib import Path 7 | from sys import argv 8 | 9 | from geckolib import CUI, GeckoShell, GeckoSimulator 10 | 11 | 12 | def usage() -> None: 13 | """Print usage.""" 14 | print("Usage: python3 -m geckolib [client args]") 15 | 16 | 17 | def install_logging(command: str) -> None: 18 | """Everyone needs logging, you say when, you say where, you say how much.""" 19 | Path(f"{command}.log").unlink(True) # noqa: FBT003 20 | file_logger = logging.FileHandler(f"{command}.log") 21 | file_logger.setLevel(logging.DEBUG) 22 | file_logger.setFormatter( 23 | logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") 24 | ) 25 | logging.getLogger().addHandler(file_logger) 26 | logging.getLogger().setLevel(logging.DEBUG) 27 | 28 | 29 | if len(argv) == 1: 30 | usage() 31 | elif argv[1] == "shell": 32 | install_logging(argv[1]) 33 | GeckoShell.run(argv[2:]) 34 | elif argv[1] == "simulator": 35 | install_logging(argv[1]) 36 | GeckoSimulator.run(["load ../tests/snapshots/default.snapshot", "start"] + argv[2:]) 37 | elif argv[1] == "cui": 38 | install_logging(argv[1]) 39 | CUI.launch() 40 | else: 41 | usage() 42 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = geckolib 3 | version = attr: geckolib.__version__ 4 | author = Gazoodle 5 | license = GPLv3 6 | description = An async library to interface with Gecko Alliance products using in.touch2 7 | keywords = Gecko Alliance, in.touch2, library, Home Automation 8 | url = https://github.com/gazoodle/geckolib 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown 11 | classifiers = 12 | Development Status :: 4 - Beta 13 | Programming Language :: Python :: 3.13 14 | Intended Audience :: Developers 15 | Topic :: Software Development :: Libraries 16 | License :: OSI Approved :: GNU General Public License v3 (GPLv3) 17 | Operating System :: OS Independent 18 | 19 | [options] 20 | python_requires = >=3.13 21 | packages = find: 22 | package_dir = 23 | =src 24 | zip_safe = False 25 | 26 | [options.packages.find] 27 | where=src 28 | 29 | # flake8 doesn't currently support pyproject.toml, so settings are in here 30 | [flake8] 31 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 32 | doctests = True 33 | # To work with Black 34 | max-line-length = 88 35 | # Some errors and warnings need to be disabled since black forces them in 36 | # E203: Whitespace before ':' 37 | # W503: Line break occurred before a binary operator 38 | # W504 line break after binary operator 39 | ignore = E203, W503, W504 40 | 41 | -------------------------------------------------------------------------------- /tests/test_peekable_queue.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the AsyncPeekableQueue .""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | from geckolib.driver.async_peekablequeue import AsyncPeekableQueue 6 | 7 | 8 | class TestAsyncPeekableQueue: 9 | """Tests for async peekable queue.""" 10 | 11 | def test_empty_on_construction(self) -> None: 12 | q = AsyncPeekableQueue() 13 | assert not q.is_data_available 14 | assert not q 15 | assert len(q) == 0 16 | 17 | def test_with_state(self) -> None: 18 | q = AsyncPeekableQueue() 19 | q.push(1) 20 | q.push(2) 21 | assert q.is_data_available 22 | assert q 23 | assert len(q) == 2 24 | one = q.pop() 25 | assert q.is_data_available 26 | assert one == 1 27 | assert q 28 | assert len(q) == 1 29 | two = q.pop() 30 | assert two == 2 31 | assert not q.is_data_available 32 | assert not q 33 | assert len(q) == 0 34 | 35 | def test_command_priority(self) -> None: 36 | q = AsyncPeekableQueue() 37 | q.push(1) 38 | q.push(2) 39 | q.push(3) 40 | q.push_command(4) 41 | assert len(q) == 4 42 | assert q.peek() == 4 43 | four = q.pop() 44 | assert four == 4 45 | assert q.is_data_available 46 | assert q.peek() == 1 47 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/ping.py: -------------------------------------------------------------------------------- 1 | """Gecko APING handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from geckolib.config import GeckoConfig 10 | 11 | from .packet import GeckoPacketProtocolHandler 12 | 13 | PING_VERB = b"APING" 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class GeckoPingProtocolHandler(GeckoPacketProtocolHandler): 19 | """Ping handler class.""" 20 | 21 | @staticmethod 22 | def request(**kwargs: Any) -> GeckoPingProtocolHandler: 23 | """Build a PING request class.""" 24 | return GeckoPingProtocolHandler( 25 | content=PING_VERB, timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS, **kwargs 26 | ) 27 | 28 | @staticmethod 29 | def response(**kwargs: Any) -> GeckoPingProtocolHandler: 30 | """Build a PING response class.""" 31 | return GeckoPingProtocolHandler( 32 | content=b"".join([PING_VERB, b"\x00"]), **kwargs 33 | ) 34 | 35 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 36 | """Check if this class can handle the packet bytes.""" 37 | return received_bytes.startswith(PING_VERB) 38 | 39 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 40 | """Handle the packet bytes.""" 41 | remainder = received_bytes[5:] 42 | if len(remainder) > 0: 43 | self._sequence = struct.unpack(">B", remainder)[0] 44 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/unhandled.py: -------------------------------------------------------------------------------- 1 | """Gecko handler for unhanded verbs.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from geckolib.driver.async_udp_protocol import GeckoAsyncUdpProtocol 7 | from geckolib.driver.udp_protocol_handler import GeckoUdpProtocolHandler 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class GeckoUnhandledProtocolHandler(GeckoUdpProtocolHandler): 13 | """Protocol handler to deal with unhandled or unsolicited messages.""" 14 | 15 | def __init__(self, **kwargs: Any) -> None: 16 | """Initialize the default handler.""" 17 | super().__init__(**kwargs) 18 | 19 | def can_handle(self, _received_bytes: bytes, _sender: tuple) -> None: 20 | """Will never get called.""" 21 | 22 | def handle(self, received_bytes: bytes, sender: tuple) -> None: 23 | """Go on then, handle it.""" 24 | 25 | def _can_handle( 26 | self, protocol: GeckoAsyncUdpProtocol, _received_bytes: bytes, _sender: tuple 27 | ) -> bool: 28 | # If the protocol queue is marked, then we can handle it because no 29 | # one else can 30 | if protocol.queue.is_marked: 31 | _LOGGER.debug( 32 | "Packet %s from %s is unhandled and will be ignored", 33 | _received_bytes, 34 | _sender, 35 | ) 36 | return True 37 | # Mark the queue and let others have a go 38 | protocol.queue.mark() 39 | return False 40 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main, dev ] 9 | pull_request: 10 | branches: [ main, dev ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /tests/test_protocol_ping.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoPingProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoPingHandler(unittest.TestCase): 16 | """Ping handler tests.""" 17 | 18 | def test_send_construct_request(self) -> None: 19 | handler = GeckoPingProtocolHandler.request(parms=PARMS) 20 | self.assertEqual( 21 | handler.send_bytes, 22 | b"DESTIDSRCID" 23 | b"APING", 24 | ) 25 | 26 | def test_send_construct_response(self) -> None: 27 | handler = GeckoPingProtocolHandler.response(parms=PARMS) 28 | self.assertEqual( 29 | handler.send_bytes, 30 | b"DESTIDSRCID" 31 | b"APING\x00", 32 | ) 33 | 34 | def test_recv_can_handle(self) -> None: 35 | handler = GeckoPingProtocolHandler.request(parms=PARMS) 36 | self.assertTrue(handler.can_handle(b"APING", PARMS)) 37 | self.assertIsNone(handler._sequence) 38 | 39 | def test_recv_handle(self) -> None: 40 | handler = GeckoPingProtocolHandler.request(parms=PARMS) 41 | handler.handle(b"APING\x00", PARMS) 42 | self.assertFalse(handler.should_remove_handler) 43 | self.assertEqual(handler._sequence, 0) 44 | 45 | 46 | if __name__ == "__main__": 47 | unittest.main() 48 | -------------------------------------------------------------------------------- /src/geckolib/automation/lockmode.py: -------------------------------------------------------------------------------- 1 | """Automation lockmode class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.const import GeckoConstants 9 | 10 | from .select import GeckoSelect 11 | 12 | if TYPE_CHECKING: 13 | from geckolib.automation.async_facade import GeckoAsyncFacade 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class GeckoLockMode(GeckoSelect): 19 | """A select object can select between options and can report the current state.""" 20 | 21 | def __init__(self, facade: GeckoAsyncFacade) -> None: 22 | """Initialize the heatpump class.""" 23 | super().__init__(facade, "Lock Mode", GeckoConstants.KEY_LOCKMODE) 24 | # Set of mappings of constants to UI options 25 | self.mapping = { 26 | "UNLOCK": "Unlocked", 27 | "PARTIAL": "Partial Lock", 28 | "FULL": "Full Lock", 29 | } 30 | self.reverse = {v: k for k, v in self.mapping.items()} 31 | 32 | @property 33 | def state(self) -> str: 34 | """Get the current state via the mapping.""" 35 | return self.mapping[self._accessor.value] 36 | 37 | async def async_set_state(self, new_state: str) -> None: 38 | """Set the state of the select entity.""" 39 | if new_state in self.reverse: 40 | new_state = self.reverse[new_state] 41 | await self._accessor.async_set_value(new_state) 42 | 43 | @property 44 | def states(self) -> list[str]: 45 | """Get the possible states.""" 46 | return list(self.mapping.values()) 47 | -------------------------------------------------------------------------------- /src/geckolib/automation/keypad_backlight.py: -------------------------------------------------------------------------------- 1 | """Automation keypad backlight class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from .select import GeckoSelect 9 | 10 | if TYPE_CHECKING: 11 | from geckolib.automation.async_facade import GeckoAsyncFacade 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class GeckoKeypadBacklight(GeckoSelect): 17 | """The keypad backlight value.""" 18 | 19 | def __init__(self, facade: GeckoAsyncFacade) -> None: 20 | """Initialize the backlight class.""" 21 | super().__init__(facade, "Keypad Backlight", "KeypadBacklightColor") 22 | # Set of mappings of constants to UI options 23 | self.set_mapping( 24 | { 25 | "OFF": "Off", 26 | "RED": "Red", 27 | "GREEN": "Green", 28 | "YELLOW": "Yellow", 29 | "BLUE": "Blue", 30 | "MAGENTA": "Magenta", 31 | "CYAN": "Cyan", 32 | "WHITE": "White", 33 | } 34 | ) 35 | 36 | @property 37 | def state(self) -> str: 38 | """Get the current state via the mapping.""" 39 | return self.mapping[self._accessor.value] 40 | 41 | async def async_set_state(self, new_state: str) -> None: 42 | """Set the state of the select entity.""" 43 | if new_state in self.reverse: 44 | new_state = self.reverse[new_state] 45 | await self._accessor.async_set_value(new_state) 46 | 47 | @property 48 | def states(self) -> list[str]: 49 | """Get the possible states.""" 50 | return list(self.mapping.values()) 51 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gazoodle/geckolib", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "customizations": { 5 | "vscode": { 6 | "extensions": [ 7 | "charliermarsh.ruff", 8 | "github.vscode-pull-request-github", 9 | "ms-python.python", 10 | "ms-python.vscode-pylance", 11 | "ryanluker.vscode-coverage-gutters" 12 | ], 13 | "settings": { 14 | "files.eol": "\n", 15 | "editor.tabSize": 4, 16 | "editor.formatOnPaste": true, 17 | "editor.formatOnSave": true, 18 | "editor.formatOnType": false, 19 | "files.trimTrailingWhitespace": true, 20 | "python.analysis.typeCheckingMode": "basic", 21 | "python.analysis.autoImportCompletions": true, 22 | "python.defaultInterpreterPath": "/usr/local/bin/python", 23 | "[python]": { 24 | "editor.defaultFormatter": "charliermarsh.ruff" 25 | } 26 | } 27 | } 28 | }, 29 | "remoteUser": "vscode", 30 | "features": { 31 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 32 | "packages": [ 33 | "ffmpeg", 34 | "libturbojpeg0", 35 | "libpcap-dev", 36 | "iputils-ping" 37 | ] 38 | } 39 | }, 40 | "postCreateCommand": "pip install -r requirements.txt", 41 | "runArgs": [ 42 | "-v", 43 | "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh", 44 | "--add-host", 45 | "spa=10.1.209.91" 46 | ] 47 | } -------------------------------------------------------------------------------- /src/geckolib/automation/__init__.py: -------------------------------------------------------------------------------- 1 | """GeckoLib automation interface.""" 2 | 3 | from .async_facade import GeckoAsyncFacade 4 | from .bainultra import BainUltra 5 | from .base import GeckoAutomationBase, GeckoAutomationFacadeBase 6 | from .blower import GeckoBlower 7 | from .bubblegen import GeckoBubbleGenerator 8 | from .button import GeckoButton 9 | from .heater import GeckoWaterHeater, GeckoWaterHeaterAbstract 10 | from .heatpump import GeckoHeatPump 11 | from .ingrid import GeckoInGrid 12 | from .inmix import GeckoInMix, GeckoInMixSynchro, GeckoInMixZone 13 | from .keypad import GeckoKeypad 14 | from .keypad_backlight import GeckoKeypadBacklight 15 | from .light import GeckoLight 16 | from .mrsteam import MrSteam 17 | from .number import GeckoNumber 18 | from .pump import GeckoPump 19 | from .reminders import GeckoReminders 20 | from .sensors import GeckoBinarySensor, GeckoErrorSensor, GeckoSensor 21 | from .switch import GeckoSwitch 22 | from .watercare import GeckoWaterCare 23 | from .waterfall import GeckoWaterfall 24 | 25 | __all__ = [ 26 | "BainUltra", 27 | "GeckoAsyncFacade", 28 | "GeckoAutomationBase", 29 | "GeckoAutomationFacadeBase", 30 | "GeckoBinarySensor", 31 | "GeckoBlower", 32 | "GeckoBubbleGenerator", 33 | "GeckoButton", 34 | "GeckoErrorSensor", 35 | "GeckoHeatPump", 36 | "GeckoInGrid", 37 | "GeckoInMix", 38 | "GeckoInMixSynchro", 39 | "GeckoInMixZone", 40 | "GeckoKeypad", 41 | "GeckoKeypadBacklight", 42 | "GeckoLight", 43 | "GeckoNumber", 44 | "GeckoPump", 45 | "GeckoReminders", 46 | "GeckoSensor", 47 | "GeckoSwitch", 48 | "GeckoWaterCare", 49 | "GeckoWaterHeater", 50 | "GeckoWaterHeaterAbstract", 51 | "GeckoWaterfall", 52 | "MrSteam", 53 | ] 54 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/rferr.py: -------------------------------------------------------------------------------- 1 | """Gecko RFERR handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from datetime import UTC, datetime 7 | from typing import Any 8 | 9 | from .packet import GeckoPacketProtocolHandler 10 | 11 | RFERR_VERB = b"RFERR" 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class GeckoRFErrProtocolHandler(GeckoPacketProtocolHandler): 17 | """Handle RFERR.""" 18 | 19 | @staticmethod 20 | def response(**kwargs: Any) -> GeckoRFErrProtocolHandler: 21 | """Generate a response.""" 22 | return GeckoRFErrProtocolHandler(content=RFERR_VERB, **kwargs) 23 | 24 | def __init__(self, **kwargs: Any) -> None: 25 | """Initialize the class.""" 26 | super().__init__(**kwargs) 27 | self._error_count: int = 0 28 | self._last_error_at: datetime | None = None 29 | 30 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 31 | """Can we handle this.""" 32 | return received_bytes.startswith(RFERR_VERB) 33 | 34 | def handle(self, _received_bytes: bytes, _sender: tuple) -> None: 35 | """Handle RFERR.""" 36 | self._error_count += 1 37 | self._last_error_at = datetime.now(tz=UTC) 38 | _LOGGER.debug( 39 | "RF error #%d, intouch2 EN module cannot communicate with spa (CO) module", 40 | self.total_error_count, 41 | ) 42 | 43 | @property 44 | def total_error_count(self) -> int: 45 | """Return total number of RFErr messages that this handler has processed.""" 46 | return self._error_count 47 | 48 | @property 49 | def last_error_at(self) -> datetime | None: 50 | """Return the last time an RFErr occurred, or None if never.""" 51 | return self._last_error_at 52 | -------------------------------------------------------------------------------- /tests/test_protocol_packet.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoPacketProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoPacketHandler(unittest.TestCase): 16 | """Packet handler tests.""" 17 | 18 | def setUp(self) -> None: 19 | """Set up the class.""" 20 | self.handler = GeckoPacketProtocolHandler(content=b"CONTENT", parms=PARMS) 21 | 22 | def test_recv_can_handle(self) -> None: 23 | self.assertTrue(self.handler.can_handle(b"", ())) 24 | self.assertFalse(self.handler.can_handle(b" ", ())) 26 | self.assertFalse(self.handler.can_handle(b"", ())) 27 | 28 | def test_recv_extract_ok(self) -> None: 29 | self.assertFalse( 30 | self.handler.handle( 31 | b"SRCIDDESTID" 32 | b"DATA", 33 | (1, 2), 34 | ) 35 | ) 36 | assert self.handler.parms is not None 37 | self.assertTupleEqual(self.handler.parms, PARMS) 38 | self.assertEqual(self.handler.packet_content, b"DATA") 39 | 40 | def test_send_construct(self) -> None: 41 | self.assertEqual( 42 | self.handler.send_bytes, 43 | b"DESTIDSRCID" 44 | b"CONTENT", 45 | ) 46 | assert self.handler.parms is not None 47 | self.assertEqual(self.handler.parms[0], 1) 48 | self.assertEqual(self.handler.parms[1], 2) 49 | 50 | 51 | if __name__ == "__main__": 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | """Gecko driver communication handlers.""" 2 | 3 | from .configfile import GeckoConfigFileProtocolHandler 4 | from .firmware import GeckoUpdateFirmwareProtocolHandler 5 | from .getchannel import GeckoGetChannelProtocolHandler 6 | from .hello import GeckoHelloProtocolHandler 7 | from .packcommand import GeckoPackCommandProtocolHandler 8 | from .packet import GeckoPacketProtocolHandler 9 | from .ping import GeckoPingProtocolHandler 10 | from .reminders import GeckoRemindersProtocolHandler, GeckoReminderType 11 | from .rferr import GeckoRFErrProtocolHandler 12 | from .statusblock import ( 13 | GeckoAsyncPartialStatusBlockProtocolHandler, 14 | GeckoStatusBlockProtocolHandler, 15 | ) 16 | from .unhandled import GeckoUnhandledProtocolHandler 17 | from .version import GeckoVersionProtocolHandler 18 | from .watercare import ( 19 | GeckoGetWatercareModeProtocolHandler, 20 | GeckoGetWatercareScheduleListProtocolHandler, 21 | GeckoSetWatercareModeProtocolHandler, 22 | GeckoWatercareErrorHandler, 23 | GeckoWatercareScheduleManager, 24 | ) 25 | 26 | __all__ = [ 27 | "GeckoAsyncPartialStatusBlockProtocolHandler", 28 | "GeckoConfigFileProtocolHandler", 29 | "GeckoGetChannelProtocolHandler", 30 | "GeckoGetWatercareModeProtocolHandler", 31 | "GeckoGetWatercareModeProtocolHandler", 32 | "GeckoGetWatercareScheduleListProtocolHandler", 33 | "GeckoHelloProtocolHandler", 34 | "GeckoPackCommandProtocolHandler", 35 | "GeckoPacketProtocolHandler", 36 | "GeckoPingProtocolHandler", 37 | "GeckoRFErrProtocolHandler", 38 | "GeckoReminderType", 39 | "GeckoRemindersProtocolHandler", 40 | "GeckoSetWatercareModeProtocolHandler", 41 | "GeckoStatusBlockProtocolHandler", 42 | "GeckoUnhandledProtocolHandler", 43 | "GeckoUpdateFirmwareProtocolHandler", 44 | "GeckoVersionProtocolHandler", 45 | "GeckoWatercareErrorHandler", 46 | "GeckoWatercareScheduleManager", 47 | ] 48 | -------------------------------------------------------------------------------- /src/geckolib/automation/select.py: -------------------------------------------------------------------------------- 1 | """Automation selection class.""" # noqa: A005 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from .base import GeckoAutomationFacadeBase 9 | 10 | if TYPE_CHECKING: 11 | from geckolib.automation.async_facade import GeckoAsyncFacade 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class GeckoSelect(GeckoAutomationFacadeBase): 17 | """A select object can select between options and can report the current state.""" 18 | 19 | def __init__(self, facade: GeckoAsyncFacade, name: str, tag: str) -> None: 20 | """Initialize the select class.""" 21 | super().__init__(facade, name, tag) 22 | self._accessor = None 23 | if tag in facade.spa.accessors: 24 | self._accessor = facade.spa.accessors[tag] 25 | self._accessor.watch(self._on_change) 26 | self.set_availability(is_available=True) 27 | 28 | @property 29 | def state(self) -> str: 30 | """Get the current state.""" 31 | if self._accessor is None: 32 | return "Unknown" 33 | return self._accessor.value 34 | 35 | async def async_set_state(self, new_state: str) -> None: 36 | """Set the state of the select entity.""" 37 | if self._accessor is None: 38 | _LOGGER.warning("%s can't set state with no accessor.", self) 39 | return 40 | _LOGGER.debug("%s async set state %s", self.name, new_state) 41 | await self._accessor.async_set_value(new_state) 42 | 43 | @property 44 | def states(self) -> list[str]: 45 | """Get the possible states.""" 46 | if self._accessor is None: 47 | return [] 48 | return self._accessor.items 49 | 50 | def __str__(self) -> str: 51 | """Stringize the class.""" 52 | return f"{self.name}: {self.state}" 53 | 54 | @property 55 | def monitor(self) -> str: 56 | """Get monitore string.""" 57 | return f"{self.key}: {self.state}" 58 | -------------------------------------------------------------------------------- /tests/test_protocol_channel.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoGetChannelProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoGetChannelHandler(unittest.TestCase): 16 | """Channel handler tests.""" 17 | 18 | def test_send_construct_request(self) -> None: 19 | handler = GeckoGetChannelProtocolHandler.request(1, parms=PARMS) 20 | self.assertEqual( 21 | handler.send_bytes, 22 | b"DESTIDSRCID" 23 | b"CURCH\x01", 24 | ) 25 | 26 | def test_send_construct_response(self) -> None: 27 | handler = GeckoGetChannelProtocolHandler.response(10, 33, parms=PARMS) 28 | self.assertEqual( 29 | handler.send_bytes, 30 | b"DESTIDSRCID" 31 | b"CHCUR\x0a\x21", 32 | ) 33 | 34 | def test_recv_can_handle(self) -> None: 35 | handler = GeckoGetChannelProtocolHandler() 36 | self.assertTrue(handler.can_handle(b"CURCH", PARMS)) 37 | self.assertTrue(handler.can_handle(b"CHCUR", PARMS)) 38 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 39 | 40 | def test_recv_handle_request(self) -> None: 41 | handler = GeckoGetChannelProtocolHandler() 42 | handler.handle(b"CURCH\x01", PARMS) 43 | self.assertFalse(handler.should_remove_handler) 44 | self.assertEqual(handler._sequence, 1) 45 | 46 | def test_recv_handle_response(self) -> None: 47 | handler = GeckoGetChannelProtocolHandler() 48 | handler.handle(b"CHCUR\x0a\x21", PARMS) 49 | self.assertTrue(handler.should_remove_handler) 50 | self.assertEqual(handler.channel, 10) 51 | self.assertEqual(handler.signal_strength, 33) 52 | 53 | 54 | if __name__ == "__main__": 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /src/geckolib/driver/observable.py: -------------------------------------------------------------------------------- 1 | """Observable class.""" 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from typing import Any 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class Observable: 11 | """ 12 | Class to manage observables. 13 | 14 | Any class derived from this will support the ability to watch for changes. 15 | """ 16 | 17 | def __init__(self) -> None: 18 | """Initialize the observable.""" 19 | self._observers: list[Callable[[Any, Any, Any], None]] = [] 20 | 21 | def watch(self, observer: Callable[[Any, Any, Any], None]) -> None: 22 | """Add an observer to this observable class.""" 23 | if observer in self._observers: 24 | _LOGGER.warning( 25 | "Observer %s already in list, not going to add again", observer 26 | ) 27 | return 28 | self._observers.append(observer) 29 | 30 | def unwatch(self, observer: Callable[[Any, Any, Any], None]) -> None: 31 | """Remove an observer to this observable class.""" 32 | self._observers.remove(observer) 33 | 34 | def unwatch_all(self) -> None: 35 | """Remove all observers on this observable class.""" 36 | _LOGGER.debug("Remove all observers from %r", self) 37 | self._observers.clear() 38 | 39 | def _on_change( 40 | self, sender: Any = None, old_value: Any = None, new_value: Any = None 41 | ) -> None: 42 | """Trigger the change notification for all observers.""" 43 | _LOGGER.debug( 44 | f"{self.__class__.__name__} {sender} changed " # noqa: G004 45 | f"from {old_value} to {new_value}" 46 | ) 47 | for observer in self._observers: 48 | observer(sender, old_value, new_value) 49 | 50 | @property 51 | def has_observers(self) -> bool: 52 | """Determine if this has observers.""" 53 | return len(self._observers) > 0 54 | 55 | def __repr__(self) -> str: 56 | """Get string reprosentation.""" 57 | return f"{self.__class__.__name__} watched by={self._observers!r}" 58 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/firmware.py: -------------------------------------------------------------------------------- 1 | """Gecko UPDTS/SUPDT handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from geckolib.config import GeckoConfig 10 | 11 | from .packet import GeckoPacketProtocolHandler 12 | 13 | UPDTS_VERB = b"UPDTS" 14 | SUPDT_VERB = b"SUPDT" 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class GeckoUpdateFirmwareProtocolHandler(GeckoPacketProtocolHandler): 20 | """Handle UPDTS/SUPDT.""" 21 | 22 | @staticmethod 23 | def request(seq: int, **kwargs: Any) -> GeckoUpdateFirmwareProtocolHandler: 24 | """Generate the request.""" 25 | return GeckoUpdateFirmwareProtocolHandler( 26 | content=b"".join([UPDTS_VERB, struct.pack(">B", seq)]), 27 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS, 28 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 29 | **kwargs, 30 | ) 31 | 32 | @staticmethod 33 | def response(**kwargs: Any) -> GeckoUpdateFirmwareProtocolHandler: 34 | """Generate the response.""" 35 | return GeckoUpdateFirmwareProtocolHandler( 36 | content=b"".join( 37 | [ 38 | SUPDT_VERB, 39 | b"\x00", 40 | ] 41 | ), 42 | **kwargs, 43 | ) 44 | 45 | def __init__(self, **kwargs: Any) -> None: 46 | """Initialize the class.""" 47 | super().__init__(**kwargs) 48 | 49 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 50 | """Is this verb handles.""" 51 | return received_bytes.startswith((UPDTS_VERB, SUPDT_VERB)) 52 | 53 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 54 | """Handle the verb.""" 55 | remainder = received_bytes[5:] 56 | if received_bytes.startswith(UPDTS_VERB): 57 | self._sequence = struct.unpack(">B", remainder[0:1])[0] 58 | return # Stay in the handler list 59 | # Otherwise must be SUPDT 60 | self._should_remove_handler = True 61 | -------------------------------------------------------------------------------- /src/geckolib/utils/cui/config.py: -------------------------------------------------------------------------------- 1 | """Configuration class for complete async example.""" 2 | 3 | import configparser 4 | import logging 5 | from pathlib import Path 6 | 7 | # Configuration file constants 8 | CONFIG_FILE = "cui.ini" 9 | CK_DEFAULT = "DEFAULT" 10 | CK_SPA_ID = "SPA_ID" 11 | CK_SPA_ADDR = "SPA_ADDR" 12 | CK_SPA_NAME = "SPA_NAME" 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class Config: 18 | """Hold the configuration for the complete async example app.""" 19 | 20 | def __init__(self) -> None: 21 | """Initialize the config class.""" 22 | _LOGGER.debug("Read config from %s", CONFIG_FILE) 23 | self._config = configparser.ConfigParser() 24 | self._config.read(CONFIG_FILE) 25 | 26 | def save(self) -> None: 27 | """Save the config file.""" 28 | with Path(CONFIG_FILE).open("w") as configfile: 29 | self._config.write(configfile) 30 | 31 | @property 32 | def spa_id(self) -> str | None: 33 | """Get the spa id.""" 34 | return self._config[CK_DEFAULT].get(CK_SPA_ID, None) 35 | 36 | def set_spa_id(self, spa_id: str | None) -> None: 37 | """Set the spa id.""" 38 | if spa_id is None: 39 | self._config.remove_option(CK_DEFAULT, CK_SPA_ID) 40 | else: 41 | self._config[CK_DEFAULT][CK_SPA_ID] = spa_id 42 | 43 | @property 44 | def spa_address(self) -> str | None: 45 | """Get the spa address.""" 46 | return self._config[CK_DEFAULT].get(CK_SPA_ADDR, None) 47 | 48 | def set_spa_address(self, spa_address: str | None) -> None: 49 | """Set the spa address.""" 50 | if spa_address is None: 51 | self._config.remove_option(CK_DEFAULT, CK_SPA_ADDR) 52 | else: 53 | self._config[CK_DEFAULT][CK_SPA_ADDR] = spa_address 54 | 55 | @property 56 | def spa_name(self) -> str | None: 57 | """Get the spa name.""" 58 | return self._config[CK_DEFAULT].get(CK_SPA_NAME, None) 59 | 60 | def set_spa_name(self, spa_name: str | None) -> None: 61 | """Set the spa name.""" 62 | if spa_name is None: 63 | self._config.remove_option(CK_DEFAULT, CK_SPA_NAME) 64 | else: 65 | self._config[CK_DEFAULT][CK_SPA_NAME] = spa_name 66 | -------------------------------------------------------------------------------- /tests/test_protocol_version.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoVersionProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoVersionHandler(unittest.TestCase): 16 | """Version handler tests.""" 17 | 18 | def test_send_construct_request(self) -> None: 19 | handler = GeckoVersionProtocolHandler.request(1, parms=PARMS) 20 | self.assertEqual( 21 | handler.send_bytes, 22 | b"DESTIDSRCID" 23 | b"AVERS\x01", 24 | ) 25 | 26 | def test_send_construct_response(self) -> None: 27 | handler = GeckoVersionProtocolHandler.response( 28 | (1, 2, 3), (4, 5, 6), parms=PARMS 29 | ) 30 | self.assertEqual( 31 | handler.send_bytes, 32 | b"DESTIDSRCID" 33 | b"SVERS\x00\x01\x02\x03\x00\x04\x05\x06", 34 | ) 35 | 36 | def test_recv_can_handle(self) -> None: 37 | handler = GeckoVersionProtocolHandler() 38 | self.assertTrue(handler.can_handle(b"AVERS", PARMS)) 39 | self.assertTrue(handler.can_handle(b"SVERS", PARMS)) 40 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 41 | 42 | def test_recv_handle_request(self) -> None: 43 | handler = GeckoVersionProtocolHandler() 44 | handler.handle(b"AVERS\x01", PARMS) 45 | self.assertFalse(handler.should_remove_handler) 46 | self.assertEqual(handler._sequence, 1) 47 | 48 | def test_recv_handle_response(self) -> None: 49 | handler = GeckoVersionProtocolHandler() 50 | handler.handle(b"SVERS\x00\x01\x02\x03\x00\x04\x05\x06", PARMS) 51 | self.assertTrue(handler.should_remove_handler) 52 | self.assertEqual(handler.en_build, 1) 53 | self.assertEqual(handler.en_major, 2) 54 | self.assertEqual(handler.en_minor, 3) 55 | self.assertEqual(handler.co_build, 4) 56 | self.assertEqual(handler.co_major, 5) 57 | self.assertEqual(handler.co_minor, 6) 58 | 59 | 60 | if __name__ == "__main__": 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /src/geckolib/automation/ingrid.py: -------------------------------------------------------------------------------- 1 | """Automation inGrid class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.const import GeckoConstants 9 | 10 | from .select import GeckoSelect 11 | 12 | if TYPE_CHECKING: 13 | from geckolib.automation.async_facade import GeckoAsyncFacade 14 | from geckolib.driver.accessor import GeckoBoolStructAccessor 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class GeckoInGrid(GeckoSelect): 20 | """The inGrid mode.""" 21 | 22 | def __init__(self, facade: GeckoAsyncFacade) -> None: 23 | """Initialize the inGrid class.""" 24 | super().__init__(facade, "Heating Management", GeckoConstants.KEY_COOLZONE_MODE) 25 | if GeckoConstants.KEY_INGRID_DETECTED not in self._spa.accessors: 26 | self.set_availability(is_available=False) 27 | else: 28 | in_grid_detected: GeckoBoolStructAccessor = self._spa.accessors[ 29 | GeckoConstants.KEY_INGRID_DETECTED 30 | ] 31 | if not in_grid_detected.value: 32 | self.set_availability(is_available=False) 33 | # Set of mappings of constants to UI options 34 | self.set_mapping( 35 | { 36 | "INTERNAL_HEAT": "Electrical heater", 37 | "HEAT_SAVER": "External heater", 38 | "BOTH_HEAT": "Both", 39 | "HEAT_W_BOOST": "Smart", 40 | } 41 | ) 42 | 43 | @property 44 | def state(self) -> str: 45 | """Get the current state via the mapping.""" 46 | if self._accessor is None: 47 | return "(Unknown)" 48 | return self.mapping[self._accessor.value] 49 | 50 | async def async_set_state(self, new_state: str) -> None: 51 | """Set the state of the select entity.""" 52 | if self._accessor is None: 53 | _LOGGER.warning("%s can't set state with no accessor", self) 54 | return 55 | if new_state in self.reverse: 56 | new_state = self.reverse[new_state] 57 | await self._accessor.async_set_value(new_state) 58 | 59 | @property 60 | def states(self) -> list[str]: 61 | """Get the possible states.""" 62 | if self._accessor is None: 63 | return [] 64 | return list(self.mapping.values()) 65 | -------------------------------------------------------------------------------- /tests/test_protocol_file.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoConfigFileProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoConfigFileHandler(unittest.TestCase): 16 | """Config file tests.""" 17 | 18 | def test_send_construct_request(self) -> None: 19 | handler = GeckoConfigFileProtocolHandler.request(1, parms=PARMS) 20 | self.assertEqual( 21 | handler.send_bytes, 22 | b"DESTIDSRCID" 23 | b"SFILE\x01", 24 | ) 25 | 26 | def test_send_construct_response(self) -> None: 27 | handler = GeckoConfigFileProtocolHandler.response("inXM", 7, 8, parms=PARMS) 28 | self.assertEqual( 29 | handler.send_bytes, 30 | b"DESTIDSRCID" 31 | b"FILES,inXM_C07.xml,inXM_S08.xml", 32 | ) 33 | 34 | def test_recv_can_handle(self) -> None: 35 | handler = GeckoConfigFileProtocolHandler() 36 | self.assertTrue(handler.can_handle(b"SFILE", PARMS)) 37 | self.assertTrue(handler.can_handle(b"FILES", PARMS)) 38 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 39 | 40 | def test_recv_handle_request(self) -> None: 41 | handler = GeckoConfigFileProtocolHandler() 42 | handler.handle(b"SFILE\x01", PARMS) 43 | self.assertFalse(handler.should_remove_handler) 44 | self.assertEqual(handler._sequence, 1) 45 | 46 | def test_recv_handle_response(self) -> None: 47 | handler = GeckoConfigFileProtocolHandler() 48 | handler.handle(b"FILES,inXM_C09.xml,inXM_S09.xml", PARMS) 49 | self.assertTrue(handler.should_remove_handler) 50 | self.assertEqual(handler.plateform_key, "inXM") 51 | self.assertEqual(handler.config_version, 9) 52 | self.assertEqual(handler.log_version, 9) 53 | 54 | @unittest.expectedFailure 55 | def test_recv_handle_response_error(self) -> None: 56 | handler = GeckoConfigFileProtocolHandler() 57 | self.assertTrue(handler.handle(b"FILES,inXM_C09.xml,inYE_S09.xml", PARMS)) 58 | 59 | 60 | if __name__ == "__main__": 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /src/geckolib/spa_state.py: -------------------------------------------------------------------------------- 1 | """Spa state.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | 7 | 8 | class GeckoSpaState(Enum): 9 | """Spa, locator or spa manager state.""" 10 | 11 | IDLE = 1 12 | """Idle state is when the spa manager is first initialized""" 13 | 14 | LOCATING_SPAS = 2 15 | """State when the spa manager is locating spas on the network""" 16 | 17 | LOCATED_SPAS = 3 18 | """State when the spa manager has finished locating spas""" 19 | 20 | CONNECTING = 4 21 | """State when the spa is going through connection protocol""" 22 | 23 | SPA_READY = 5 24 | """State when the spa is ready to have a facade built""" 25 | 26 | CONNECTED = 10 27 | """State when the spa manager is connected to a spa successfully""" 28 | 29 | ERROR_SPA_NOT_FOUND = 50 30 | """State when the spa cannot be found on the the network""" 31 | ERROR_NEEDS_ATTENTION = 51 32 | """State when the user needs to attend""" 33 | ERROR_PING_MISSED = 52 34 | """State when a ping was missed, can auto-connect on restore""" 35 | ERROR_RF_FAULT = 53 36 | """State when the EN module reports it can't talk to the CO module""" 37 | 38 | ERROR_NOT_SUPPORTED = 100 39 | """State when the pack isn't supported, but it looks like it talks the talk""" 40 | 41 | @staticmethod 42 | def to_string(state: GeckoSpaState) -> str: # noqa: PLR0911 43 | """Convert GeckoSpaState to string.""" 44 | if state == GeckoSpaState.CONNECTED: 45 | return "Connected" 46 | if state == GeckoSpaState.CONNECTING: 47 | return "Connecting..." 48 | if state == GeckoSpaState.ERROR_RF_FAULT: 49 | return "Lost contact with spa (RFERR)" 50 | if state == GeckoSpaState.ERROR_PING_MISSED: 51 | return "Lost contact with in.touch2 module" 52 | if state == GeckoSpaState.ERROR_NEEDS_ATTENTION: 53 | return "Needs attention, check logs" 54 | if state == GeckoSpaState.LOCATING_SPAS: 55 | return "Searching for spas..." 56 | if state == GeckoSpaState.LOCATED_SPAS: 57 | return "Choose spa" 58 | if state == GeckoSpaState.ERROR_SPA_NOT_FOUND: 59 | return "Cannot find spa, check logs" 60 | if state == GeckoSpaState.ERROR_NOT_SUPPORTED: 61 | return "Device not supported, check logs" 62 | return f"{state}" 63 | -------------------------------------------------------------------------------- /src/geckolib/driver/__init__.py: -------------------------------------------------------------------------------- 1 | """Gecko driver.""" 2 | 3 | from .accessor import ( 4 | GeckoBoolStructAccessor, 5 | GeckoByteStructAccessor, 6 | GeckoEnumStructAccessor, 7 | GeckoStructAccessor, 8 | GeckoTempStructAccessor, 9 | GeckoTimeStructAccessor, 10 | GeckoWordStructAccessor, 11 | ) 12 | from .async_spastruct import GeckoAsyncStructure 13 | from .async_udp_protocol import GeckoAsyncUdpProtocol 14 | from .observable import Observable 15 | from .protocol import ( 16 | GeckoAsyncPartialStatusBlockProtocolHandler, 17 | GeckoConfigFileProtocolHandler, 18 | GeckoGetChannelProtocolHandler, 19 | GeckoGetWatercareModeProtocolHandler, 20 | GeckoGetWatercareScheduleListProtocolHandler, 21 | GeckoHelloProtocolHandler, 22 | GeckoPackCommandProtocolHandler, 23 | GeckoPacketProtocolHandler, 24 | GeckoPingProtocolHandler, 25 | GeckoRemindersProtocolHandler, 26 | GeckoReminderType, 27 | GeckoRFErrProtocolHandler, 28 | GeckoSetWatercareModeProtocolHandler, 29 | GeckoStatusBlockProtocolHandler, 30 | GeckoUnhandledProtocolHandler, 31 | GeckoUpdateFirmwareProtocolHandler, 32 | GeckoVersionProtocolHandler, 33 | GeckoWatercareErrorHandler, 34 | GeckoWatercareScheduleManager, 35 | ) 36 | from .udp_protocol_handler import GeckoUdpProtocolHandler 37 | 38 | __all__ = [ 39 | "GeckoAsyncPartialStatusBlockProtocolHandler", 40 | "GeckoAsyncStructure", 41 | "GeckoAsyncUdpProtocol", 42 | "GeckoBoolStructAccessor", 43 | "GeckoByteStructAccessor", 44 | "GeckoConfigFileProtocolHandler", 45 | "GeckoEnumStructAccessor", 46 | "GeckoGetChannelProtocolHandler", 47 | "GeckoGetWatercareModeProtocolHandler", 48 | "GeckoGetWatercareScheduleListProtocolHandler", 49 | "GeckoHelloProtocolHandler", 50 | "GeckoPackCommandProtocolHandler", 51 | "GeckoPacketProtocolHandler", 52 | "GeckoPingProtocolHandler", 53 | "GeckoRFErrProtocolHandler", 54 | "GeckoReminderType", 55 | "GeckoRemindersProtocolHandler", 56 | "GeckoSetWatercareModeProtocolHandler", 57 | "GeckoStatusBlockProtocolHandler", 58 | "GeckoStructAccessor", 59 | "GeckoTempStructAccessor", 60 | "GeckoTimeStructAccessor", 61 | "GeckoUdpProtocolHandler", 62 | "GeckoUnhandledProtocolHandler", 63 | "GeckoUpdateFirmwareProtocolHandler", 64 | "GeckoVersionProtocolHandler", 65 | "GeckoWatercareErrorHandler", 66 | "GeckoWatercareScheduleManager", 67 | "GeckoWordStructAccessor", 68 | "Observable", 69 | ] 70 | -------------------------------------------------------------------------------- /src/geckolib/automation/heatpump.py: -------------------------------------------------------------------------------- 1 | """Automation heatpump class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.const import GeckoConstants 9 | 10 | from .select import GeckoSelect 11 | 12 | if TYPE_CHECKING: 13 | from geckolib.automation.async_facade import GeckoAsyncFacade 14 | from geckolib.driver.accessor import ( 15 | GeckoBoolStructAccessor, 16 | ) 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class GeckoHeatPump(GeckoSelect): 22 | """The heatpump mode.""" 23 | 24 | def __init__(self, facade: GeckoAsyncFacade) -> None: 25 | """Initialize the heatpump class.""" 26 | super().__init__(facade, "Heat Pump", GeckoConstants.KEY_COOLZONE_MODE) 27 | 28 | if GeckoConstants.KEY_MODBUS_HEATPUMP_DETECTED not in self._spa.accessors: 29 | self.set_availability(is_available=False) 30 | else: 31 | modbus_heatpump_detected: GeckoBoolStructAccessor = self._spa.accessors[ 32 | GeckoConstants.KEY_MODBUS_HEATPUMP_DETECTED 33 | ] 34 | if not modbus_heatpump_detected.value: 35 | self.set_availability(is_available=False) 36 | 37 | # Set of mappings of constants to UI options 38 | self.set_mapping( 39 | { 40 | "CHILL": "Cool", 41 | "INTERNAL_HEAT": "Electric", 42 | "HEAT_SAVER": "Eco Heat", 43 | "HEAT_W_BOOST": "Smart Heat", 44 | "AUTO_SAVER": "Eco Auto", 45 | "AUTO_W_BOOST": "Smart Auto", 46 | } 47 | ) 48 | 49 | @property 50 | def state(self) -> str: 51 | """Get the current state via the mapping.""" 52 | if self._accessor is None: 53 | return "(Unknown)" 54 | return self.mapping[self._accessor.value] 55 | 56 | async def async_set_state(self, new_state: str) -> None: 57 | """Set the state of the select entity.""" 58 | if self._accessor is None: 59 | _LOGGER.warning("%s can't set state with no accessor", self) 60 | return 61 | if new_state in self.reverse: 62 | new_state = self.reverse[new_state] 63 | await self._accessor.async_set_value(new_state) 64 | 65 | @property 66 | def states(self) -> list[str]: 67 | """Get the possible states.""" 68 | if self._accessor is None: 69 | return [] 70 | return list(self.mapping.values()) 71 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/getchannel.py: -------------------------------------------------------------------------------- 1 | """Gecko CURCH/CHCUR handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from geckolib.config import GeckoConfig 10 | 11 | from .packet import GeckoPacketProtocolHandler 12 | 13 | CURCH_VERB = b"CURCH" 14 | CHCUR_VERB = b"CHCUR" 15 | GETCHANNEL_FORMAT = ">BB" 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class GeckoGetChannelProtocolHandler(GeckoPacketProtocolHandler): 21 | """Handle CURCH/CHCUR verbs.""" 22 | 23 | @staticmethod 24 | def request(seq: int, **kwargs: Any) -> GeckoGetChannelProtocolHandler: 25 | """Generate request.""" 26 | return GeckoGetChannelProtocolHandler( 27 | content=b"".join([CURCH_VERB, struct.pack(">B", seq)]), 28 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS, 29 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 30 | **kwargs, 31 | ) 32 | 33 | @staticmethod 34 | def response( 35 | channel: int, signal_strength: int, **kwargs: Any 36 | ) -> GeckoGetChannelProtocolHandler: 37 | """Generate response.""" 38 | return GeckoGetChannelProtocolHandler( 39 | content=b"".join( 40 | [ 41 | CHCUR_VERB, 42 | struct.pack( 43 | GETCHANNEL_FORMAT, 44 | channel, 45 | signal_strength, 46 | ), 47 | ] 48 | ), 49 | **kwargs, 50 | ) 51 | 52 | def __init__(self, **kwargs: Any) -> None: 53 | """Initialize the class.""" 54 | super().__init__(**kwargs) 55 | self.channel = self.signal_strength = None 56 | 57 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 58 | """Can we handle this verb.""" 59 | return received_bytes.startswith((CURCH_VERB, CHCUR_VERB)) 60 | 61 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 62 | """Handle the verb.""" 63 | remainder = received_bytes[5:] 64 | if received_bytes.startswith(CURCH_VERB): 65 | self._sequence = struct.unpack(">B", remainder)[0] 66 | return # Stay in the handler list 67 | # Otherwise must be CHCUR 68 | ( 69 | self.channel, 70 | self.signal_strength, 71 | ) = struct.unpack(GETCHANNEL_FORMAT, remainder) 72 | self._should_remove_handler = True 73 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mrsteam-cfg-1.py: -------------------------------------------------------------------------------- 1 | """GeckoConfigStruct - A class to manage the ConfigStruct for 'MrSteam v1'.""" # noqa: N999 2 | 3 | from . import ( 4 | GeckoAsyncStructure, 5 | GeckoByteStructAccessor, 6 | GeckoEnumStructAccessor, 7 | GeckoStructAccessor, 8 | GeckoWordStructAccessor, 9 | ) 10 | 11 | 12 | class GeckoConfigStruct: 13 | """Config Struct Class.""" 14 | 15 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 16 | """Initialize the config struct class.""" 17 | self.struct = struct_ 18 | 19 | @property 20 | def version(self) -> int: 21 | """Get the config struct class version.""" 22 | return 1 23 | 24 | @property 25 | def output_keys(self) -> list[str]: 26 | """Output keys property.""" 27 | return [] 28 | 29 | @property 30 | def accessors(self) -> dict[str, GeckoStructAccessor]: 31 | """The structure accessors.""" 32 | return { 33 | "Prog1Setpoint": GeckoByteStructAccessor( 34 | self.struct, "ConfigStructure/All/Prog1Setpoint", 0, "ALL" 35 | ), 36 | "Prog1Runtime": GeckoWordStructAccessor( 37 | self.struct, "ConfigStructure/All/Prog1Runtime", 1, "ALL" 38 | ), 39 | "Prog1Aroma": GeckoEnumStructAccessor( 40 | self.struct, 41 | "ConfigStructure/All/Prog1Aroma", 42 | 3, 43 | None, 44 | ["OFF", "ON"], 45 | None, 46 | None, 47 | "ALL", 48 | ), 49 | "Prog2Setpoint": GeckoByteStructAccessor( 50 | self.struct, "ConfigStructure/All/Prog2Setpoint", 4, "ALL" 51 | ), 52 | "Prog2Runtime": GeckoWordStructAccessor( 53 | self.struct, "ConfigStructure/All/Prog2Runtime", 5, "ALL" 54 | ), 55 | "Prog2Aroma": GeckoEnumStructAccessor( 56 | self.struct, 57 | "ConfigStructure/All/Prog2Aroma", 58 | 7, 59 | None, 60 | ["OFF", "ON"], 61 | None, 62 | None, 63 | "ALL", 64 | ), 65 | "TempUnits": GeckoEnumStructAccessor( 66 | self.struct, 67 | "ConfigStructure/All/TempUnits", 68 | 8, 69 | None, 70 | ["F", "C"], 71 | None, 72 | None, 73 | "ALL", 74 | ), 75 | } 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This library 2 | SpaPackStruct*.xml 3 | sample*.py 4 | *.ini 5 | tests/fetch_spapackstruct.py 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # OS oddities 138 | .DS_Store 139 | -------------------------------------------------------------------------------- /src/geckolib/driver/async_peekablequeue.py: -------------------------------------------------------------------------------- 1 | """A peekable async queue.""" 2 | 3 | import asyncio.queues 4 | import collections 5 | from typing import Any 6 | 7 | 8 | class AsyncPeekableQueue: 9 | """A peekable async queue which allows queue filtering in consumers.""" 10 | 11 | def __init__(self) -> None: 12 | """Initialize the async peekable queue.""" 13 | self._marked = False 14 | self._normal_queue = collections.deque() 15 | self._command_queue = collections.deque() 16 | self._data_available = asyncio.Event() 17 | 18 | def _set_state(self) -> None: 19 | if self._command_queue or self._normal_queue: 20 | self._data_available.set() 21 | else: 22 | self._data_available.clear() 23 | 24 | def peek(self) -> Any: 25 | """Get the queue head or None.""" 26 | if self._command_queue: 27 | return self._command_queue[0] 28 | if self._normal_queue: 29 | return self._normal_queue[0] 30 | return None 31 | 32 | @property 33 | def is_data_available(self) -> bool: 34 | """Is there any data available.""" 35 | return self._data_available.is_set() 36 | 37 | @property 38 | def is_marked(self) -> bool: 39 | """Get the is_marked property.""" 40 | return self._marked 41 | 42 | def pop(self) -> Any: 43 | """Pop the item off the top of the queue.""" 44 | try: 45 | if self._command_queue: 46 | return self._command_queue.popleft() 47 | return self._normal_queue.popleft() 48 | finally: 49 | self._set_state() 50 | self._marked = False 51 | 52 | def push(self, item: Any) -> None: 53 | """Push an item on the bottom of the queue.""" 54 | self._normal_queue.append(item) 55 | self._set_state() 56 | self._marked = False 57 | 58 | def push_command(self, item: Any) -> None: 59 | """Push an item on the bottom of the command queue.""" 60 | self._command_queue.append(item) 61 | self._set_state() 62 | self._marked = False 63 | 64 | def mark(self) -> None: 65 | """Mark the queue.""" 66 | self._marked = True 67 | 68 | async def wait(self) -> None: 69 | """Wait for there to be data queued.""" 70 | await self._data_available.wait() 71 | 72 | def __bool__(self) -> bool: 73 | """Determine if there are any items in the queue.""" 74 | return len(self) > 0 75 | 76 | def __len__(self) -> int: 77 | """Implement the __len__ function.""" 78 | return self._command_queue.__len__() + self._normal_queue.__len__() 79 | -------------------------------------------------------------------------------- /src/geckolib/automation/number.py: -------------------------------------------------------------------------------- 1 | """Automation number class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.driver.accessor import GeckoStructAccessor 9 | 10 | from .base import GeckoAutomationFacadeBase 11 | 12 | if TYPE_CHECKING: 13 | from geckolib.automation.async_facade import GeckoAsyncFacade 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class GeckoNumber(GeckoAutomationFacadeBase): 19 | """A number object can input a value in a range and can report the current state.""" 20 | 21 | def __init__( 22 | self, 23 | facade: GeckoAsyncFacade, 24 | name: str, 25 | tag: str, 26 | unit_accessor: GeckoStructAccessor | str | None = None, 27 | ) -> None: 28 | """Initialize the number class.""" 29 | super().__init__(facade, name, tag) 30 | self._accessor = None 31 | self.native_min_value: float = 0.0 32 | self.native_max_value: float = 100.0 33 | self.native_step: float = 1.0 34 | self.mode = "auto" 35 | if tag in facade.spa.accessors: 36 | self._accessor = facade.spa.accessors[tag] 37 | self._accessor.watch(self._on_change) 38 | self.set_availability(is_available=True) 39 | self._unit_accessor = unit_accessor 40 | if isinstance(self._unit_accessor, GeckoStructAccessor): 41 | self._unit_accessor.watch(self._on_change) 42 | 43 | @property 44 | def native_value(self) -> float: 45 | """Get the current value.""" 46 | if self._accessor is None: 47 | return 0.0 48 | return self._accessor.value 49 | 50 | async def async_set_native_value(self, new_value: float) -> None: 51 | """Set the value of the number entity.""" 52 | if self._accessor is None: 53 | _LOGGER.warning("%s can't set value with no accessor.", self) 54 | return 55 | _LOGGER.debug("%s async set value %f", self.name, new_value) 56 | await self._accessor.async_set_value(int(new_value)) 57 | 58 | @property 59 | def native_unit_of_measurement(self) -> str | None: 60 | """The unit of measurement for the sensor, or None.""" 61 | if isinstance(self._unit_accessor, GeckoStructAccessor): 62 | return self._unit_accessor.value 63 | return self._unit_accessor 64 | 65 | def __str__(self) -> str: 66 | """Stringize the class.""" 67 | return f"{self.name}: {self.native_value}{self.native_unit_of_measurement}" 68 | 69 | @property 70 | def monitor(self) -> str: 71 | """Get monitore string.""" 72 | return f"{self.key}: {self.native_value}{self.native_unit_of_measurement}" 73 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/version.py: -------------------------------------------------------------------------------- 1 | """Gecko AVERS/SVERS handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from geckolib.config import GeckoConfig 10 | 11 | from .packet import GeckoPacketProtocolHandler 12 | 13 | AVERS_VERB = b"AVERS" 14 | SVERS_VERB = b"SVERS" 15 | VERSION_FORMAT = ">HBBHBB" 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class GeckoVersionProtocolHandler(GeckoPacketProtocolHandler): 21 | """Version handler class.""" 22 | 23 | @staticmethod 24 | def request(seq: int, **kwargs: Any) -> GeckoVersionProtocolHandler: 25 | """Generate a request.""" 26 | return GeckoVersionProtocolHandler( 27 | content=b"".join([AVERS_VERB, struct.pack(">B", seq)]), 28 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS, 29 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 30 | **kwargs, 31 | ) 32 | 33 | @staticmethod 34 | def response( 35 | intouch_EN: tuple, # noqa: N803 36 | intouch_CO: tuple, # noqa: N803 37 | **kwargs: Any, 38 | ) -> GeckoVersionProtocolHandler: 39 | """Generate a response.""" 40 | return GeckoVersionProtocolHandler( 41 | content=b"".join( 42 | [ 43 | SVERS_VERB, 44 | struct.pack( 45 | VERSION_FORMAT, 46 | *intouch_EN, 47 | *intouch_CO, 48 | ), 49 | ] 50 | ), 51 | **kwargs, 52 | ) 53 | 54 | def __init__(self, **kwargs: Any) -> None: 55 | """Initialize the version handler class.""" 56 | super().__init__(**kwargs) 57 | self.en_build = self.en_major = self.en_minor = None 58 | self.co_build = self.co_major = self.co_minor = None 59 | 60 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 61 | """Can we handle this verb.""" 62 | return received_bytes.startswith((AVERS_VERB, SVERS_VERB)) 63 | 64 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 65 | """Handle the verb.""" 66 | remainder = received_bytes[5:] 67 | if received_bytes.startswith(AVERS_VERB): 68 | self._sequence = struct.unpack(">B", remainder)[0] 69 | return 70 | # Otherwise must be SVERS 71 | ( 72 | self.en_build, 73 | self.en_major, 74 | self.en_minor, 75 | self.co_build, 76 | self.co_major, 77 | self.co_minor, 78 | ) = struct.unpack(VERSION_FORMAT, remainder) 79 | self._should_remove_handler = True 80 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mrsteam-cfg-2.py: -------------------------------------------------------------------------------- 1 | """GeckoConfigStruct - A class to manage the ConfigStruct for 'MrSteam v2'.""" # noqa: N999 2 | 3 | from . import ( 4 | GeckoAsyncStructure, 5 | GeckoEnumStructAccessor, 6 | GeckoStructAccessor, 7 | GeckoTempStructAccessor, 8 | GeckoWordStructAccessor, 9 | ) 10 | 11 | 12 | class GeckoConfigStruct: 13 | """Config Struct Class.""" 14 | 15 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 16 | """Initialize the config struct class.""" 17 | self.struct = struct_ 18 | 19 | @property 20 | def version(self) -> int: 21 | """Get the config struct class version.""" 22 | return 2 23 | 24 | @property 25 | def output_keys(self) -> list[str]: 26 | """Output keys property.""" 27 | return [] 28 | 29 | @property 30 | def accessors(self) -> dict[str, GeckoStructAccessor]: 31 | """The structure accessors.""" 32 | return { 33 | "Prog1SetpointG": GeckoTempStructAccessor( 34 | self.struct, "ConfigStructure/All/Prog1SetpointG", 0, "ALL" 35 | ), 36 | "Prog1Runtime": GeckoWordStructAccessor( 37 | self.struct, "ConfigStructure/All/Prog1Runtime", 2, "ALL" 38 | ), 39 | "Prog1Aroma": GeckoEnumStructAccessor( 40 | self.struct, 41 | "ConfigStructure/All/Prog1Aroma", 42 | 4, 43 | None, 44 | ["OFF", "ON"], 45 | None, 46 | None, 47 | "ALL", 48 | ), 49 | "Prog2SetpointG": GeckoTempStructAccessor( 50 | self.struct, "ConfigStructure/All/Prog2SetpointG", 5, "ALL" 51 | ), 52 | "Prog2Runtime": GeckoWordStructAccessor( 53 | self.struct, "ConfigStructure/All/Prog2Runtime", 7, "ALL" 54 | ), 55 | "Prog2Aroma": GeckoEnumStructAccessor( 56 | self.struct, 57 | "ConfigStructure/All/Prog2Aroma", 58 | 9, 59 | None, 60 | ["OFF", "ON"], 61 | None, 62 | None, 63 | "ALL", 64 | ), 65 | "TempUnits": GeckoEnumStructAccessor( 66 | self.struct, 67 | "ConfigStructure/All/TempUnits", 68 | 10, 69 | None, 70 | ["F", "C"], 71 | None, 72 | None, 73 | "ALL", 74 | ), 75 | "MinSetpointG": GeckoTempStructAccessor( 76 | self.struct, "ConfigStructure/All/MinSetpointG", 11, "ALL" 77 | ), 78 | "MaxSetpointG": GeckoTempStructAccessor( 79 | self.struct, "ConfigStructure/All/MaxSetpointG", 13, "ALL" 80 | ), 81 | } 82 | -------------------------------------------------------------------------------- /tests/test_protocol_pack_command.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoPackCommandProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoPackCommandHandlers(unittest.TestCase): 16 | """Pack Command tests.""" 17 | 18 | def test_send_construct_key_press(self) -> None: 19 | handler = GeckoPackCommandProtocolHandler.keypress(1, 6, 1, parms=PARMS) 20 | self.assertEqual( 21 | handler.send_bytes, 22 | b"DESTIDSRCID" 23 | b"SPACK\x01\x06\x02\x39\x01", 24 | ) 25 | 26 | def test_send_construct_set_value(self) -> None: 27 | handler = GeckoPackCommandProtocolHandler.set_value( 28 | 1, 6, 9, 9, 15, 2, 702, parms=PARMS 29 | ) 30 | self.assertEqual( 31 | handler.send_bytes, 32 | b"DESTIDSRCID" 33 | b"SPACK\x01\x06\x07\x46\x09\x09\x00\x0f\x02\xbe", 34 | ) 35 | 36 | def test_send_construct_response(self) -> None: 37 | handler = GeckoPackCommandProtocolHandler.response(parms=PARMS) 38 | self.assertEqual( 39 | handler.send_bytes, 40 | b"DESTIDSRCID" 41 | b"PACKS", 42 | ) 43 | 44 | def test_recv_can_handle(self) -> None: 45 | handler = GeckoPackCommandProtocolHandler() 46 | self.assertTrue(handler.can_handle(b"SPACK", PARMS)) 47 | self.assertTrue(handler.can_handle(b"PACKS", PARMS)) 48 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 49 | 50 | def test_recv_handle_key_press(self) -> None: 51 | handler = GeckoPackCommandProtocolHandler() 52 | handler.handle(b"SPACK\x01\x06\x02\x39\x01", PARMS) 53 | self.assertFalse(handler.should_remove_handler) 54 | self.assertEqual(handler._sequence, 1) 55 | self.assertTrue(handler.is_key_press) 56 | self.assertEqual(handler.keycode, 1) 57 | self.assertFalse(handler.is_set_value) 58 | 59 | def test_recv_handle_set_value(self) -> None: 60 | handler = GeckoPackCommandProtocolHandler() 61 | handler.handle(b"SPACK\x01\x06\x07\x46\x09\x09\x00\x0f\x02\xbe", PARMS) 62 | self.assertFalse(handler.should_remove_handler) 63 | self.assertEqual(handler._sequence, 1) 64 | self.assertFalse(handler.is_key_press) 65 | self.assertTrue(handler.is_set_value) 66 | self.assertEqual(handler.position, 15) 67 | self.assertEqual(handler.new_data, b"\x02\xbe") 68 | 69 | def test_recv_handle_response(self) -> None: 70 | handler = GeckoPackCommandProtocolHandler() 71 | handler.handle(b"PACKS", PARMS) 72 | self.assertTrue(handler.should_remove_handler) 73 | self.assertFalse(handler.is_key_press) 74 | self.assertFalse(handler.is_set_value) 75 | 76 | 77 | if __name__ == "__main__": 78 | unittest.main() 79 | -------------------------------------------------------------------------------- /src/geckolib/automation/base.py: -------------------------------------------------------------------------------- 1 | """Automation interface support classes.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.driver import Observable 9 | 10 | if TYPE_CHECKING: 11 | from geckolib.automation.async_facade import GeckoAsyncFacade 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class GeckoAutomationBase(Observable): 17 | """Base of all the automation helper classes.""" 18 | 19 | def __init__(self, unique_id: str, name: str, parent_name: str, key: str) -> None: 20 | """Initialize the base class.""" 21 | super().__init__() 22 | self._unique_id: str = unique_id 23 | self._name: str = name 24 | self._parent_name: str = parent_name 25 | self._key: str = key 26 | self._is_available: bool = False 27 | self.mapping = {} 28 | self.reverse = {} 29 | 30 | def set_mapping(self, mapping: dict) -> None: 31 | """Set the mapping and the reverse mapping helpers.""" 32 | self.mapping = mapping 33 | self.reverse = {v: k for k, v in self.mapping.items()} 34 | 35 | @property 36 | def name(self) -> str: 37 | """All automation items have a name.""" 38 | return self._name 39 | 40 | @property 41 | def parent_name(self) -> str: 42 | """All automation items have a parent that has a name.""" 43 | return self._parent_name 44 | 45 | @property 46 | def key(self) -> str: 47 | """Key into the spa pack.""" 48 | return self._key 49 | 50 | @property 51 | def unique_id(self) -> str: 52 | """A unique id for the property.""" 53 | return f"{self._unique_id}-{self._key}" 54 | 55 | @property 56 | def parent_unique_id(self) -> str: 57 | """The parent unique ID.""" 58 | return f"{self._unique_id}" 59 | 60 | @property 61 | def monitor(self) -> str: 62 | """An abbreviated string for the monitor process in the shell.""" 63 | return f"{self}" 64 | 65 | @property 66 | def is_available(self) -> bool: 67 | """Get the availability of this item.""" 68 | return self._is_available 69 | 70 | def set_availability(self, *, is_available: bool) -> None: 71 | """Set the availability of this item.""" 72 | self._is_available = is_available 73 | 74 | def __repr__(self) -> str: 75 | """Get string representation.""" 76 | return ( 77 | f"{super().__repr__()}(name={self.name}," 78 | f" parent={self.parent_name} key={self.key})" 79 | ) 80 | 81 | 82 | class GeckoAutomationFacadeBase(GeckoAutomationBase): 83 | """Base of all the automation helper classes from the Facade.""" 84 | 85 | def __init__(self, facade: GeckoAsyncFacade, name: str, key: str) -> None: 86 | """Initialize the facade base class.""" 87 | super().__init__(facade.unique_id, name, facade.name, key) 88 | self._facade = facade 89 | self._spa = facade.spa 90 | 91 | @property 92 | def facade(self) -> GeckoAsyncFacade: 93 | """Return the facade that is associated with this automation object.""" 94 | return self._facade 95 | -------------------------------------------------------------------------------- /tests/test_spaman.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the SpaMan class.""" # noqa: INP001 2 | 3 | from typing import Any 4 | from unittest import IsolatedAsyncioTestCase, main 5 | from unittest.mock import patch 6 | 7 | from context import GeckoAsyncSpaDescriptor, GeckoAsyncSpaMan, GeckoSpaEvent 8 | 9 | 10 | class SpaManImpl(GeckoAsyncSpaMan): 11 | """Spa Manager to test with.""" 12 | 13 | def __init__(self) -> None: 14 | """Initialize the spaman class.""" 15 | super().__init__("CLIENT_UUID", spa_identifier="TestID") 16 | self.events = [] 17 | 18 | async def handle_event(self, event: GeckoSpaEvent, **_kwargs: object) -> None: 19 | self.events.append(event) 20 | 21 | 22 | mock_spa_descriptor = GeckoAsyncSpaDescriptor(b"TestID", "Test Name", (1, 2)) 23 | mock_spas = [mock_spa_descriptor] 24 | 25 | 26 | async def mock_discover(self: Any) -> None: 27 | """Mock discovery.""" 28 | await self._event_handler(GeckoSpaEvent.LOCATING_DISCOVERED_SPA) 29 | 30 | 31 | async def mock_connect(self: Any) -> None: 32 | """Mock connection.""" 33 | # await self._event_handler(GeckoSpaEvent.CONNECTION_SPA_COMPLETE) # noqa: ERA001 34 | 35 | 36 | @patch("context.GeckoAsyncSpa._connect", mock_connect) 37 | @patch("context.GeckoAsyncLocator.discover", mock_discover) 38 | @patch("context.GeckoAsyncLocator.spas", mock_spas) 39 | class TestSpaMan(IsolatedAsyncioTestCase): 40 | """Test the SpaMan class.""" 41 | 42 | def setUp(self) -> None: 43 | self.spaman = SpaManImpl() 44 | 45 | async def asyncSetUp(self) -> None: 46 | await self.spaman.__aenter__() 47 | 48 | async def asyncTearDown(self) -> None: 49 | await self.spaman.__aexit__(None) 50 | 51 | def tearDown(self) -> None: 52 | del self.spaman 53 | 54 | ##################################################### 55 | 56 | def test_facade_on_start(self) -> None: 57 | self.assertIsNone(self.spaman.facade) 58 | 59 | async def test_locate_spas(self) -> None: 60 | spas = await self.spaman.async_locate_spas() 61 | self.assertEqual(len(spas), 1) 62 | self.assertEqual(spas[0].identifier_as_string, "TestID") 63 | self.assertEqual(spas[0].name, "Test Name") 64 | self.assertListEqual( 65 | self.spaman.events, 66 | [ 67 | GeckoSpaEvent.SPA_MAN_ENTER, 68 | GeckoSpaEvent.LOCATING_STARTED, 69 | GeckoSpaEvent.LOCATING_STARTED, 70 | GeckoSpaEvent.LOCATING_DISCOVERED_SPA, 71 | GeckoSpaEvent.LOCATING_FINISHED, 72 | ], 73 | ) 74 | 75 | async def test_connect_spa(self) -> None: 76 | facade = await self.spaman.async_connect_to_spa(mock_spa_descriptor) 77 | self.assertListEqual( 78 | self.spaman.events, 79 | [ 80 | GeckoSpaEvent.SPA_MAN_ENTER, 81 | GeckoSpaEvent.LOCATING_STARTED, 82 | GeckoSpaEvent.CLIENT_HAS_STATUS_SENSOR, 83 | GeckoSpaEvent.CLIENT_HAS_RECONNECT_BUTTON, 84 | GeckoSpaEvent.CONNECTION_STARTED, 85 | GeckoSpaEvent.CONNECTION_FINISHED, 86 | ], 87 | ) 88 | self.assertIsNone(facade) 89 | 90 | 91 | if __name__ == "__main__": 92 | main() 93 | -------------------------------------------------------------------------------- /src/geckolib/automation/keypad.py: -------------------------------------------------------------------------------- 1 | """Gecko Keypads.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from geckolib.automation.button import GeckoButton 8 | from geckolib.automation.keypad_backlight import GeckoKeypadBacklight 9 | from geckolib.const import GeckoConstants 10 | 11 | from .base import GeckoAutomationFacadeBase 12 | 13 | if TYPE_CHECKING: 14 | from geckolib.automation.async_facade import GeckoAsyncFacade 15 | 16 | 17 | class GeckoKeypad(GeckoAutomationFacadeBase): 18 | """Keypad management class.""" 19 | 20 | def __init__(self, facade: GeckoAsyncFacade) -> None: 21 | """Initialize the keypad class.""" 22 | super().__init__(facade, "Keypad", "KEYPAD") 23 | 24 | self.backlight = GeckoKeypadBacklight(facade) 25 | 26 | self._buttons = [] 27 | 28 | # Should we show eco mode button too? 29 | 30 | if self.facade.pump_1.is_available: 31 | self._buttons.append( 32 | GeckoButton(facade, "Key Pump 1", GeckoConstants.KEYPAD_PUMP_1) 33 | ) 34 | if self.facade.pump_2.is_available: 35 | self._buttons.append( 36 | GeckoButton(facade, "Key Pump 2", GeckoConstants.KEYPAD_PUMP_2) 37 | ) 38 | if self.facade.pump_3.is_available: 39 | self._buttons.append( 40 | GeckoButton(facade, "Key Pump 3", GeckoConstants.KEYPAD_PUMP_3) 41 | ) 42 | if self.facade.pump_4.is_available: 43 | self._buttons.append( 44 | GeckoButton(facade, "Key Pump 4", GeckoConstants.KEYPAD_PUMP_4) 45 | ) 46 | if self.facade.pump_5.is_available: 47 | self._buttons.append( 48 | GeckoButton(facade, "Key Pump 5", GeckoConstants.KEYPAD_PUMP_5) 49 | ) 50 | if self.facade.blower.is_available: 51 | self._buttons.append( 52 | GeckoButton(facade, "Key Blower", GeckoConstants.KEYPAD_BLOWER) 53 | ) 54 | if self.facade.waterfall.is_available: 55 | self._buttons.append( 56 | GeckoButton(facade, "Key Waterfall", GeckoConstants.KEYPAD_WATERFALL) 57 | ) 58 | if self.facade.bubblegenerator.is_available: 59 | self._buttons.append( 60 | GeckoButton(facade, "Key Bubble Generator", GeckoConstants.KEYPAD_AUX) 61 | ) 62 | 63 | if self.facade.light.is_available: 64 | self._buttons.append( 65 | GeckoButton(facade, "Key Light", GeckoConstants.KEYPAD_LIGHT) 66 | ) 67 | if self.facade.light2.is_available: 68 | self._buttons.append( 69 | GeckoButton(facade, "Key Light 2", GeckoConstants.KEYPAD_LIGHT_120) 70 | ) 71 | 72 | if self.facade.water_heater.is_available: 73 | self._buttons.append( 74 | GeckoButton(facade, "Key Temperature Up", GeckoConstants.KEYPAD_UP) 75 | ) 76 | self._buttons.append( 77 | GeckoButton(facade, "Key Temperature Down", GeckoConstants.KEYPAD_DOWN) 78 | ) 79 | 80 | @property 81 | def buttons(self) -> list[GeckoButton]: 82 | """Get the buttons that have any action.""" 83 | return self._buttons 84 | 85 | def __str__(self) -> str: 86 | """Stringize the class.""" 87 | return f"{self.name}:" 88 | -------------------------------------------------------------------------------- /tests/test_protocol_hello.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoHelloProtocolHandler, 10 | ) 11 | 12 | PARMS = (1, 2, b"SRCID", b"DESTID") 13 | 14 | 15 | class TestGeckoHelloHandler(unittest.TestCase): 16 | """Test the GeckoHelloProtocol classes.""" 17 | 18 | def setUp(self) -> None: 19 | """Set up the class.""" 20 | self.handler = GeckoHelloProtocolHandler(b"") 21 | 22 | def test_send_broadcast_construct(self) -> None: 23 | handler = GeckoHelloProtocolHandler.broadcast() 24 | self.assertEqual(handler.send_bytes, b"1") 25 | 26 | def test_send_client_construct(self) -> None: 27 | handler = GeckoHelloProtocolHandler.client(b"CLIENT") 28 | self.assertEqual(handler.send_bytes, b"CLIENT") 29 | 30 | def test_send_response_construct(self) -> None: 31 | handler = GeckoHelloProtocolHandler.response(b"SPA", "Name") 32 | self.assertEqual(handler.send_bytes, b"SPA|Name") 33 | 34 | def test_recv_can_handle(self) -> None: 35 | self.assertTrue(self.handler.can_handle(b"", ())) 36 | self.assertFalse(self.handler.can_handle(b" ", ())) 38 | self.assertFalse(self.handler.can_handle(b"", ())) 39 | 40 | def test_recv_broadcast(self) -> None: 41 | self.assertFalse(self.handler.handle(b"1", ())) 42 | self.assertTrue(self.handler.was_broadcast_discovery) 43 | self.assertIsNone(self.handler._client_identifier) 44 | self.assertIsNone(self.handler._spa_identifier) 45 | self.assertIsNone(self.handler._spa_name) 46 | 47 | def test_recv_client_ios(self) -> None: 48 | self.assertFalse(self.handler.handle(b"IOSCLIENT", ())) 49 | self.assertFalse(self.handler.was_broadcast_discovery) 50 | self.assertEqual(self.handler.client_identifier, b"IOSCLIENT") 51 | self.assertIsNone(self.handler._spa_identifier) 52 | self.assertIsNone(self.handler._spa_name) 53 | 54 | def test_recv_client_android(self) -> None: 55 | self.assertFalse(self.handler.handle(b"ANDCLIENT", ())) 56 | self.assertFalse(self.handler.was_broadcast_discovery) 57 | self.assertEqual(self.handler.client_identifier, b"ANDCLIENT") 58 | self.assertIsNone(self.handler._spa_identifier) 59 | self.assertIsNone(self.handler._spa_name) 60 | 61 | @unittest.expectedFailure 62 | def test_recv_client_unknown(self) -> None: 63 | self.handler.handle(b"UNKCLIENT", ()) 64 | 65 | def test_recv_response(self) -> None: 66 | self.assertFalse(self.handler.handle(b"SPA|Name", ())) 67 | self.assertFalse(self.handler.was_broadcast_discovery) 68 | self.assertIsNone(self.handler._client_identifier) 69 | self.assertEqual(self.handler.spa_identifier, b"SPA") 70 | self.assertEqual(self.handler.spa_name, "Name") 71 | 72 | def test_recv_can_handle_multiple(self) -> None: 73 | self.test_recv_response() 74 | self.test_recv_client_android() 75 | self.test_recv_broadcast() 76 | 77 | 78 | if __name__ == "__main__": 79 | unittest.main() 80 | -------------------------------------------------------------------------------- /src/geckolib/automation/watercare.py: -------------------------------------------------------------------------------- 1 | """Gecko Watercare.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.const import GeckoConstants 9 | 10 | from .base import GeckoAutomationFacadeBase 11 | 12 | if TYPE_CHECKING: 13 | from geckolib.automation.async_facade import GeckoAsyncFacade 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class GeckoWaterCare(GeckoAutomationFacadeBase): 19 | """Watercare manangement class.""" 20 | 21 | def __init__(self, facade: GeckoAsyncFacade) -> None: 22 | """Initialize watercare class.""" 23 | super().__init__(facade, "WaterCare", "WATERCARE") 24 | self.active_mode: int | None = None 25 | if facade.spa.struct.is_spa_pack: 26 | self.set_availability(is_available=True) 27 | 28 | @property 29 | def mode(self) -> int | None: 30 | """Return the active water care mode.""" 31 | return self.active_mode 32 | 33 | @property 34 | def modes(self) -> list[str]: 35 | """Return all the possible water care modes.""" 36 | return GeckoConstants.WATERCARE_MODE_STRING 37 | 38 | @property 39 | def state(self) -> str: 40 | """Return the current state.""" 41 | if self.mode is None: 42 | return "Unknown" 43 | return GeckoConstants.WATERCARE_MODE_STRING[self.mode] 44 | 45 | @property 46 | def states(self) -> list[str]: 47 | """Return all the states.""" 48 | return self.modes 49 | 50 | async def async_set_state(self, new_mode: str | int) -> None: 51 | """Set the state. Support select style objects.""" 52 | await self.async_set_mode(new_mode) 53 | 54 | async def async_set_mode(self, new_mode: str | int) -> None: 55 | """ 56 | Set the active watercare mode to new_mode. 57 | 58 | new_mode can be a string, in which case the value must be a member of 59 | GeckoConstants.WATERCARE_MODE_STRING, or it can be an integer from 60 | GeckoConstants.WATERCARE_MODE 61 | """ 62 | if isinstance(new_mode, str): 63 | new_mode = GeckoConstants.WATERCARE_MODE_STRING.index(new_mode) 64 | await self._spa.async_set_watercare_mode(new_mode) 65 | self.change_watercare_mode(new_mode) 66 | 67 | def change_watercare_mode(self, new_mode: int) -> None: 68 | """Change the watercare mode.""" 69 | if self.active_mode != new_mode: 70 | old_mode = self.active_mode 71 | self.active_mode = new_mode 72 | self._on_change(self, old_mode, self.active_mode) 73 | 74 | def __str__(self) -> str: 75 | """Stringise the class.""" 76 | if not self.is_available: 77 | return f"{self.name}: Unavailable" 78 | if self.active_mode is None: 79 | return f"{self.name}: Waiting..." 80 | if self.active_mode < 0 or self.active_mode > len( 81 | GeckoConstants.WATERCARE_MODE_STRING 82 | ): 83 | return f"Unknown Water care mode (index:{self.active_mode})" 84 | return f"{self.name}: {GeckoConstants.WATERCARE_MODE_STRING[self.active_mode]}" 85 | 86 | @property 87 | def monitor(self) -> str: 88 | """Get monitor string.""" 89 | if not self.is_available: 90 | return "WC: None" 91 | if self.active_mode is None: 92 | return "WC: ?" 93 | return f"WC: {self.active_mode}" 94 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/configfile.py: -------------------------------------------------------------------------------- 1 | """Gecko FILES/SFILE handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from geckolib.config import GeckoConfig 10 | from geckolib.const import GeckoConstants 11 | 12 | from .packet import GeckoPacketProtocolHandler 13 | 14 | SFILE_VERB = b"SFILE" 15 | FILES_VERB = b"FILES" 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class GeckoConfigFileProtocolHandler(GeckoPacketProtocolHandler): 21 | """Protocol handler class for FILES/SFILE.""" 22 | 23 | @staticmethod 24 | def request(seq: int, **kwargs: Any) -> GeckoConfigFileProtocolHandler: 25 | """Generate the request.""" 26 | return GeckoConfigFileProtocolHandler( 27 | content=b"".join([SFILE_VERB, struct.pack(">B", seq)]), 28 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS * 3, 29 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 30 | **kwargs, 31 | ) 32 | 33 | @staticmethod 34 | def response( 35 | plateform_key: str, config_version: int, log_version: int, **kwargs: Any 36 | ) -> GeckoConfigFileProtocolHandler: 37 | """Generate the response.""" 38 | return GeckoConfigFileProtocolHandler( 39 | content=b"".join( 40 | [ 41 | FILES_VERB, 42 | f",{plateform_key}_C{config_version:02}.xml," 43 | f"{plateform_key}_S{log_version:02}.xml".encode( 44 | GeckoConstants.MESSAGE_ENCODING 45 | ), 46 | ] 47 | ), 48 | **kwargs, 49 | ) 50 | 51 | def __init__(self, **kwargs: Any) -> None: 52 | """Initialise the class.""" 53 | super().__init__(**kwargs) 54 | self.plateform_key = self.config_version = self.log_version = None 55 | 56 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 57 | """Can we handle this verb.""" 58 | return received_bytes.startswith((SFILE_VERB, FILES_VERB)) 59 | 60 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 61 | """Handle the command.""" 62 | remainder = received_bytes[5:] 63 | if received_bytes.startswith(SFILE_VERB): 64 | self._sequence = struct.unpack(">B", remainder)[0] 65 | return # Stay in the handler list 66 | 67 | # Otherwise must be FILES 68 | config = ( 69 | received_bytes[6:] 70 | .decode(GeckoConstants.MESSAGE_ENCODING) 71 | .replace(".xml", "") 72 | .split(",") 73 | ) 74 | # Split the string around the underscore 75 | gecko_pack_config = config[0].split("_") 76 | gecko_pack_log = config[1].split("_") 77 | 78 | if gecko_pack_config[0] != gecko_pack_log[0]: 79 | msg = ( 80 | f"Dissimilar platforms `{gecko_pack_config[0]}`" 81 | f" and `{gecko_pack_log[0]}`" 82 | ) 83 | raise ValueError(msg) 84 | 85 | self.plateform_key = gecko_pack_config[0] 86 | if self.plateform_key in GeckoConstants.PACK_NAME_ADJUSTMENTS: 87 | self.plateform_key = GeckoConstants.PACK_NAME_ADJUSTMENTS[ 88 | self.plateform_key 89 | ] 90 | self.config_version = int(gecko_pack_config[1][1:]) 91 | self.log_version = int(gecko_pack_log[1][1:]) 92 | self._should_remove_handler = True 93 | -------------------------------------------------------------------------------- /src/geckolib/utils/async_command.py: -------------------------------------------------------------------------------- 1 | """AsyncCmd class.""" 2 | 3 | import asyncio 4 | import cmd 5 | import logging 6 | import threading 7 | from queue import Empty, Queue 8 | from typing import Any, Self 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class AsyncCmd(cmd.Cmd): 14 | """ 15 | AsyncCmd. Async command processor based on cmd.Cmd. 16 | 17 | This class will run an async loop in a thread and all the async 18 | functions will run in there, including any do_ commands in the 19 | derived shell that are decorated with the async keyword. 20 | """ 21 | 22 | prompt = "(async-cmd) " 23 | 24 | def __init__(self) -> None: 25 | """Initialize the async class.""" 26 | super().__init__() 27 | self.queue: Queue[tuple] = Queue() 28 | self.stop_event: threading.Event = threading.Event() 29 | self.thread: threading.Thread = threading.Thread( 30 | target=self.run_async_loop, daemon=True 31 | ) 32 | self.thread.start() 33 | 34 | def __enter__(self) -> Self: 35 | """Suport python 'with' syntax.""" 36 | _LOGGER.debug("Start shell %s", self.__class__.__name__) 37 | return self 38 | 39 | def __exit__(self, *exc_info: object) -> None: 40 | """Suport puthon 'with' syntax.""" 41 | _LOGGER.debug("Stop shell %s", self.__class__.__name__) 42 | 43 | def run_async_loop(self) -> None: 44 | """Run the async loop.""" 45 | _LOGGER.debug("Running async loop") 46 | with asyncio.Runner() as runner: 47 | runner.run(self.process_commands()) 48 | 49 | async def process_commands(self) -> None: 50 | """Process command loop.""" 51 | aenter = self.__getattribute__("__aenter__") 52 | if aenter is not None: 53 | _LOGGER.debug("Calling aenter %s", aenter) 54 | await aenter() 55 | 56 | try: 57 | while not self.stop_event.is_set(): 58 | try: 59 | coroutine, args = self.queue.get(timeout=0.01) 60 | _LOGGER.debug("Run coroutine %s with %s", coroutine, args) 61 | await coroutine(*args) 62 | self.queue.task_done() 63 | except Empty: 64 | await asyncio.sleep(0) 65 | except asyncio.CancelledError: 66 | _LOGGER.debug("Command loop cancelled") 67 | raise 68 | except Exception: 69 | _LOGGER.exception("Command loop exception, logged and continue") 70 | finally: 71 | aexit = self.__getattribute__("__aexit__") 72 | if aexit is not None: 73 | _LOGGER.debug("Calling aexit %s", aexit) 74 | await aexit() 75 | 76 | def dispatch(self, coroutine: Any, *args: Any) -> None: 77 | """Dispatch a command the async loop.""" 78 | self.queue.put_nowait((coroutine, args)) 79 | 80 | def precmd(self, line: str) -> str: 81 | """Determine if the command is running in the io loop, or async loop.""" 82 | parts = line.split(" ", 1) 83 | command_name: str = parts[0] 84 | rest: str = parts[1] if len(parts) > 1 else "" 85 | method: Any | None = getattr(self, f"do_{command_name}", None) 86 | if asyncio.iscoroutinefunction(method): 87 | self.dispatch(method, rest) 88 | return "" 89 | return line 90 | 91 | def do_exit(self, _line: str) -> bool: 92 | """Stop the async loop and exits the program.""" 93 | self.stop_event.set() 94 | self.thread.join() 95 | return True 96 | -------------------------------------------------------------------------------- /src/geckolib/async_tasks.py: -------------------------------------------------------------------------------- 1 | """AsyncTasks is a class to farm intenal new tasks out to an awaiter.""" 2 | 3 | import asyncio 4 | import logging 5 | from collections.abc import Coroutine 6 | from typing import Self 7 | 8 | from .config import GeckoConfig, config_sleep, release_config_change_waiters 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class AsyncTasks: 14 | """ 15 | Manage async tasks. 16 | 17 | Allow clients to have tasks in batches so that they can be controlled 18 | individually, or cancelled in a block. 19 | 20 | Can be used in a 'with AsyncTasks' pattern which will run the tasks 21 | in the event loop. 22 | """ 23 | 24 | def __init__(self) -> None: 25 | """Initialize async tasks class.""" 26 | self._tasks: list[asyncio.Task] = [] 27 | 28 | async def __aenter__(self) -> Self: 29 | """Async enter, used from python's with statement.""" 30 | _LOGGER.debug("Async start, adding tidy routine") 31 | self.add_task(self._tidy(), "Tidy tasks", "ASYNC") 32 | return self 33 | 34 | async def __aexit__(self, *_exc_info: object) -> None: 35 | """Async exit, when out of scope.""" 36 | await self.gather() 37 | 38 | def add_task(self, coroutine: Coroutine, name_: str, key_: str) -> asyncio.Task: 39 | """Add tasks to the task list.""" 40 | taskname = f"{key_}:{name_}" 41 | for task in self._tasks: 42 | if not task.done() and task.get_name() == taskname: 43 | msg = f"Task {taskname} already running" 44 | raise RuntimeError(msg) 45 | _LOGGER.debug("Starting task `%s` in domain `%s`", name_, key_) 46 | task = asyncio.create_task(coroutine, name=taskname) 47 | self._tasks.append(task) 48 | return task 49 | 50 | def cancel_key_tasks(self, key_: str) -> None: 51 | """Cancel tasks that use the specified key.""" 52 | for task in self._tasks: 53 | if task.get_name().startswith(f"{key_}:"): 54 | _LOGGER.debug("Cancel task %s", task) 55 | task.cancel() 56 | release_config_change_waiters() 57 | 58 | async def gather(self) -> None: 59 | """Cancel all tasks.""" 60 | for task in self._tasks: 61 | _LOGGER.debug("Cancel task %s", task) 62 | task.cancel() 63 | # Wait for all tasks to complete 64 | try: 65 | _results = await asyncio.gather(*self._tasks, return_exceptions=True) 66 | for item in zip(self._tasks, _results, strict=False): 67 | _LOGGER.debug(" Task %s result `%r`", item[0].get_name(), item[1]) 68 | except Exception: 69 | _LOGGER.exception("AsyncTasks:gather caught exception") 70 | raise 71 | 72 | async def _tidy(self) -> None: 73 | try: 74 | while True: 75 | await config_sleep( 76 | GeckoConfig.TASK_TIDY_FREQUENCY_IN_SECONDS, "Async task tidy" 77 | ) 78 | create_new_tasks = False 79 | if _LOGGER.isEnabledFor(logging.DEBUG): 80 | for task in self._tasks: 81 | if task.done(): 82 | _LOGGER.debug("Tidy task %s", task) 83 | create_new_tasks = True 84 | # Create a new task list with the currently running ones 85 | if create_new_tasks: 86 | self._tasks = [task for task in self._tasks if not task.done()] 87 | except asyncio.CancelledError: 88 | _LOGGER.debug("Tidy loop cancelled") 89 | raise 90 | except Exception: 91 | _LOGGER.exception("Tidy loop caught exception") 92 | raise 93 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/packet.py: -------------------------------------------------------------------------------- 1 | """Gecko handlers.""" 2 | 3 | import logging 4 | import re 5 | from typing import Any 6 | 7 | from geckolib.driver.udp_protocol_handler import GeckoUdpProtocolHandler 8 | 9 | PACKET_OPEN = b"" 10 | PACKET_CLOSE = b"" 11 | SRCCN_OPEN = b"" 12 | SRCCN_CLOSE = b"" 13 | DESCN_OPEN = b"" 14 | DESCN_CLOSE = b"" 15 | DATAS_OPEN = b"" 16 | DATAS_CLOSE = b"" 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class GeckoPacketProtocolHandler(GeckoUdpProtocolHandler): 22 | """Handle the PACKT structure.""" 23 | 24 | def __init__(self, **kwargs: Any) -> None: 25 | """Initialize the class.""" 26 | super().__init__(**kwargs) 27 | self._parms: tuple = kwargs.get("parms") 28 | self._content: bytes = kwargs.get("content") 29 | self._on_get_parms = kwargs.get("on_get_parms") 30 | if self._content is not None and not isinstance(self._content, bytes): 31 | raise TypeError(self._content, "Content must be of type `bytes`") 32 | self._packet_content = None 33 | self._sequence = None 34 | 35 | @property 36 | def send_bytes(self) -> bytes: 37 | """Generate the bytes to send.""" 38 | parms = self.parms 39 | return b"".join( 40 | [ 41 | PACKET_OPEN, 42 | SRCCN_OPEN, 43 | parms[3], 44 | SRCCN_CLOSE, 45 | DESCN_OPEN, 46 | parms[2], 47 | DESCN_CLOSE, 48 | DATAS_OPEN, 49 | self._content, 50 | DATAS_CLOSE, 51 | PACKET_CLOSE, 52 | ] 53 | ) 54 | 55 | @property 56 | def parms(self) -> tuple: 57 | """Delegate parms.""" 58 | if self._on_get_parms is None: 59 | return self._parms 60 | return self._on_get_parms(self) 61 | 62 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 63 | """Can we unwrap this packet.""" 64 | # If the received bytes start with , we can help 65 | return received_bytes.startswith(PACKET_OPEN) and received_bytes.endswith( 66 | PACKET_CLOSE 67 | ) 68 | 69 | def _extract_packet_parts(self, content: bytes) -> tuple: 70 | match = re.search( 71 | b"".join( 72 | [ 73 | SRCCN_OPEN, 74 | b"(.*)", 75 | SRCCN_CLOSE, 76 | DESCN_OPEN, 77 | b"(.*)", 78 | DESCN_CLOSE, 79 | DATAS_OPEN, 80 | b"(.*)", 81 | DATAS_CLOSE, 82 | ] 83 | ), 84 | content, 85 | re.DOTALL, 86 | ) 87 | if match: 88 | return match.groups() 89 | return (None, None, None) 90 | 91 | def handle(self, received_bytes: bytes, sender: tuple) -> None: 92 | """Unwrap the packet and dispatch.""" 93 | ( 94 | src_identifier, 95 | dest_identifier, 96 | self._packet_content, 97 | ) = self._extract_packet_parts(received_bytes[7:-8]) 98 | self._parms = (sender[0], sender[1], src_identifier, dest_identifier) 99 | 100 | @property 101 | def packet_content(self) -> bytes | None: 102 | """Get packet content.""" 103 | return self._packet_content 104 | 105 | def __repr__(self) -> str: 106 | """Get string representation.""" 107 | return ( 108 | f"{super().__repr__()}(parms={self.parms!r}," 109 | f" content={self.packet_content!r})" 110 | ) 111 | -------------------------------------------------------------------------------- /src/geckolib/config.py: -------------------------------------------------------------------------------- 1 | """Configuration management for geckolib.""" 2 | 3 | import asyncio 4 | import logging 5 | from dataclasses import dataclass 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | @dataclass 11 | class _GeckoConfig: 12 | DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 1 13 | """Mininum time in seconds to wait for initial spa discovery even 14 | if one spa has responded""" 15 | 16 | DISCOVERY_TIMEOUT_IN_SECONDS = 1 17 | """Maximum time in seconds to wait for full discovery if no spas 18 | have responded""" 19 | 20 | TASK_TIDY_FREQUENCY_IN_SECONDS = 1 21 | """Time in seconds between task tidyup checks""" 22 | 23 | PING_FREQUENCY_IN_SECONDS = 1 24 | """Frequency in seconds to ping the spa to ensure it is still available""" 25 | 26 | PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS = 1 27 | """Time after which a spa is deemed to be not responding to pings""" 28 | 29 | FACADE_UPDATE_FREQUENCY_IN_SECONDS = 1 30 | """Frequency in seconds to update facade data""" 31 | 32 | SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 1 33 | """Frequency in seconds to request all LOG data from spa""" 34 | 35 | PROTOCOL_TIMEOUT_IN_SECONDS = 1 36 | """Default timeout for most protocol commands""" 37 | 38 | PAUSE_BETWEEN_RETRIES_IN_SECONDS = 1 39 | """Default pause between retry operations""" 40 | 41 | 42 | CONFIG_MEMBERS = [ 43 | attr 44 | for attr in dir(_GeckoConfig) 45 | if not callable(getattr(_GeckoConfig, attr)) and not attr.startswith("__") 46 | ] 47 | 48 | 49 | @dataclass 50 | class _GeckoActiveConfig(_GeckoConfig): 51 | """Gecko active configuration.""" 52 | 53 | DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 4 54 | DISCOVERY_TIMEOUT_IN_SECONDS = 10 55 | TASK_TIDY_FREQUENCY_IN_SECONDS = 5 56 | PING_FREQUENCY_IN_SECONDS = 2 57 | PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS = 10 58 | FACADE_UPDATE_FREQUENCY_IN_SECONDS = 28800 59 | SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 28800 60 | PROTOCOL_TIMEOUT_IN_SECONDS = 4 61 | PAUSE_BETWEEN_RETRIES_IN_SECONDS = 0.4 62 | 63 | 64 | @dataclass 65 | class _GeckoIdleConfig(_GeckoConfig): 66 | """Gecko idle configuration.""" 67 | 68 | DISCOVERY_INITIAL_TIMEOUT_IN_SECONDS = 4 69 | DISCOVERY_TIMEOUT_IN_SECONDS = 10 70 | TASK_TIDY_FREQUENCY_IN_SECONDS = 60 71 | PING_FREQUENCY_IN_SECONDS = 2 72 | PING_DEVICE_NOT_RESPONDING_TIMEOUT_IN_SECONDS = 120 73 | FACADE_UPDATE_FREQUENCY_IN_SECONDS = 3600 74 | SPA_PACK_REFRESH_FREQUENCY_IN_SECONDS = 3600 75 | PROTOCOL_TIMEOUT_IN_SECONDS = 4 76 | PAUSE_BETWEEN_RETRIES_IN_SECONDS = 0.4 77 | 78 | 79 | # Root config 80 | GeckoConfig: _GeckoConfig = _GeckoIdleConfig() 81 | ConfigChangeEvent: asyncio.Event = asyncio.Event() 82 | 83 | 84 | def release_config_change_waiters() -> None: 85 | """Release config change waiters.""" 86 | ConfigChangeEvent.set() 87 | ConfigChangeEvent.clear() 88 | 89 | 90 | def set_config_mode(*, active: bool) -> None: 91 | """Set config mode to active (true) or idle (false).""" 92 | _LOGGER.debug("set_config_mode: %s", active) 93 | new_config = _GeckoActiveConfig() if active else _GeckoIdleConfig() 94 | for member in CONFIG_MEMBERS: 95 | setattr(GeckoConfig, member, getattr(new_config, member)) 96 | release_config_change_waiters() 97 | 98 | 99 | async def config_sleep(delay: float | None, _reason: str) -> bool: 100 | """Sleep wrapper that also handles config changes, returns True on timeout.""" 101 | if delay is None: 102 | await asyncio.sleep(0) 103 | return False 104 | try: 105 | async with asyncio.timeout(delay): 106 | await ConfigChangeEvent.wait() 107 | except TimeoutError: 108 | return True 109 | return False 110 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mrsteam-cfg-3.py: -------------------------------------------------------------------------------- 1 | """GeckoConfigStruct - A class to manage the ConfigStruct for 'MrSteam v3'.""" # noqa: N999 2 | 3 | from . import ( 4 | GeckoAsyncStructure, 5 | GeckoEnumStructAccessor, 6 | GeckoStructAccessor, 7 | GeckoTempStructAccessor, 8 | GeckoWordStructAccessor, 9 | ) 10 | 11 | 12 | class GeckoConfigStruct: 13 | """Config Struct Class.""" 14 | 15 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 16 | """Initialize the config struct class.""" 17 | self.struct = struct_ 18 | 19 | @property 20 | def version(self) -> int: 21 | """Get the config struct class version.""" 22 | return 3 23 | 24 | @property 25 | def output_keys(self) -> list[str]: 26 | """Output keys property.""" 27 | return [] 28 | 29 | @property 30 | def accessors(self) -> dict[str, GeckoStructAccessor]: 31 | """The structure accessors.""" 32 | return { 33 | "Prog1SetpointG": GeckoTempStructAccessor( 34 | self.struct, "ConfigStructure/All/Prog1SetpointG", 0, "ALL" 35 | ), 36 | "Prog1Runtime": GeckoWordStructAccessor( 37 | self.struct, "ConfigStructure/All/Prog1Runtime", 2, "ALL" 38 | ), 39 | "Prog1Aroma": GeckoEnumStructAccessor( 40 | self.struct, 41 | "ConfigStructure/All/Prog1Aroma", 42 | 4, 43 | None, 44 | ["OFF", "ON"], 45 | None, 46 | None, 47 | "ALL", 48 | ), 49 | "Prog2SetpointG": GeckoTempStructAccessor( 50 | self.struct, "ConfigStructure/All/Prog2SetpointG", 5, "ALL" 51 | ), 52 | "Prog2Runtime": GeckoWordStructAccessor( 53 | self.struct, "ConfigStructure/All/Prog2Runtime", 7, "ALL" 54 | ), 55 | "Prog2Aroma": GeckoEnumStructAccessor( 56 | self.struct, 57 | "ConfigStructure/All/Prog2Aroma", 58 | 9, 59 | None, 60 | ["OFF", "ON"], 61 | None, 62 | None, 63 | "ALL", 64 | ), 65 | "TempUnits": GeckoEnumStructAccessor( 66 | self.struct, 67 | "ConfigStructure/All/TempUnits", 68 | 10, 69 | None, 70 | ["F", "C"], 71 | None, 72 | None, 73 | "ALL", 74 | ), 75 | "MinSetpointG": GeckoTempStructAccessor( 76 | self.struct, "ConfigStructure/All/MinSetpointG", 11, "ALL" 77 | ), 78 | "MaxSetpointG": GeckoTempStructAccessor( 79 | self.struct, "ConfigStructure/All/MaxSetpointG", 13, "ALL" 80 | ), 81 | "ValveOut1Type": GeckoEnumStructAccessor( 82 | self.struct, 83 | "ConfigStructure/All/ValveOut1Type", 84 | 15, 85 | None, 86 | ["NONE", "HEAD", "BODY", "WAND"], 87 | None, 88 | None, 89 | "ALL", 90 | ), 91 | "ValveOut2Type": GeckoEnumStructAccessor( 92 | self.struct, 93 | "ConfigStructure/All/ValveOut2Type", 94 | 16, 95 | None, 96 | ["NONE", "HEAD", "BODY", "WAND"], 97 | None, 98 | None, 99 | "ALL", 100 | ), 101 | "ValveOut3Type": GeckoEnumStructAccessor( 102 | self.struct, 103 | "ConfigStructure/All/ValveOut3Type", 104 | 17, 105 | None, 106 | ["NONE", "HEAD", "BODY", "WAND"], 107 | None, 108 | None, 109 | "ALL", 110 | ), 111 | } 112 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/mas-ibc-32k-cfg-1.py: -------------------------------------------------------------------------------- 1 | """GeckoConfigStruct - A class to manage the ConfigStruct for 'MAS-IBC-32K v1'.""" # noqa: N999 2 | 3 | from . import ( 4 | GeckoAsyncStructure, 5 | GeckoEnumStructAccessor, 6 | GeckoStructAccessor, 7 | ) 8 | 9 | 10 | class GeckoConfigStruct: 11 | """Config Struct Class.""" 12 | 13 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 14 | """Initialize the config struct class.""" 15 | self.struct = struct_ 16 | 17 | @property 18 | def version(self) -> int: 19 | """Get the config struct class version.""" 20 | return 1 21 | 22 | @property 23 | def output_keys(self) -> list[str]: 24 | """Output keys property.""" 25 | return [] 26 | 27 | @property 28 | def accessors(self) -> dict[str, GeckoStructAccessor]: 29 | """The structure accessors.""" 30 | return { 31 | "LL_Backrest": GeckoEnumStructAccessor( 32 | self.struct, 33 | "ConfigStructure/All/LL_Backrest", 34 | 0, 35 | None, 36 | ["SINGLE", "DUAL", "DUAL_THERMOPLACE", "DUAL_LITESTREME"], 37 | None, 38 | None, 39 | None, 40 | ), 41 | "LL_Heater_1": GeckoEnumStructAccessor( 42 | self.struct, 43 | "ConfigStructure/All/LL_Heater_1", 44 | 1, 45 | None, 46 | ["Intensity1", "Intensity2", "Intensity3", "Intensity4"], 47 | None, 48 | None, 49 | None, 50 | ), 51 | "LL_Heater_2": GeckoEnumStructAccessor( 52 | self.struct, 53 | "ConfigStructure/All/LL_Heater_2", 54 | 2, 55 | None, 56 | ["Intensity1", "Intensity2", "Intensity3", "Intensity4"], 57 | None, 58 | None, 59 | None, 60 | ), 61 | "LL_Blower": GeckoEnumStructAccessor( 62 | self.struct, 63 | "ConfigStructure/All/LL_Blower", 64 | 3, 65 | None, 66 | ["STANDARD", "HIGH", "LOW"], 67 | None, 68 | None, 69 | None, 70 | ), 71 | "LL_Chromo": GeckoEnumStructAccessor( 72 | self.struct, 73 | "ConfigStructure/All/LL_Chromo", 74 | 4, 75 | None, 76 | ["NONE", "BASIC", "DELUXE"], 77 | None, 78 | None, 79 | None, 80 | ), 81 | "LL_Audio": GeckoEnumStructAccessor( 82 | self.struct, 83 | "ConfigStructure/All/LL_Audio", 84 | 5, 85 | None, 86 | ["NONE", "MP3"], 87 | None, 88 | None, 89 | None, 90 | ), 91 | "LL_Menu": GeckoEnumStructAccessor( 92 | self.struct, 93 | "ConfigStructure/All/LL_Menu", 94 | 6, 95 | None, 96 | ["STANDARD", "HOTEL"], 97 | None, 98 | None, 99 | None, 100 | ), 101 | "LL_ComfortJet": GeckoEnumStructAccessor( 102 | self.struct, 103 | "ConfigStructure/All/LL_ComfortJet", 104 | 7, 105 | None, 106 | ["DEACTIVATE", "ACTIVATE"], 107 | None, 108 | None, 109 | None, 110 | ), 111 | "LL_Aromacloud": GeckoEnumStructAccessor( 112 | self.struct, 113 | "ConfigStructure/All/LL_Aromacloud", 114 | 8, 115 | None, 116 | ["DEACTIVATE", "ACTIVATE"], 117 | None, 118 | None, 119 | None, 120 | ), 121 | } 122 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/hello.py: -------------------------------------------------------------------------------- 1 | """Gecko handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import time 7 | from typing import Any 8 | 9 | from geckolib.const import GeckoConstants 10 | from geckolib.driver.udp_protocol_handler import GeckoUdpProtocolHandler 11 | 12 | HELLO_OPEN = b"" 13 | HELLO_CLOSE = b"" 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class GeckoHelloProtocolHandler(GeckoUdpProtocolHandler): 19 | """Gecko handler.""" 20 | 21 | def __init__(self, content: bytes, **kwargs: Any) -> None: 22 | """Initialize the class.""" 23 | super().__init__(**kwargs) 24 | self._send_bytes = b"".join([HELLO_OPEN, content, HELLO_CLOSE]) 25 | self.was_broadcast_discovery: bool = False 26 | self._client_identifier: bytes | None = None 27 | self._spa_identifier: bytes | None = None 28 | self._spa_name: str | None = None 29 | self.last_response: float = time.monotonic() 30 | 31 | @staticmethod 32 | def broadcast_address(static_ip: str | None) -> tuple: 33 | """Generate a broadcast address.""" 34 | if static_ip is not None: 35 | return (static_ip, GeckoConstants.INTOUCH2_PORT) 36 | return (GeckoConstants.BROADCAST_ADDRESS, GeckoConstants.INTOUCH2_PORT) 37 | 38 | @staticmethod 39 | def broadcast(**kwargs: object) -> GeckoHelloProtocolHandler: 40 | """Generate a broadcast class.""" 41 | return GeckoHelloProtocolHandler(b"1", **kwargs) 42 | 43 | @staticmethod 44 | def client(client_identifier: bytes, **kwargs: object) -> GeckoHelloProtocolHandler: 45 | """Generate a client class.""" 46 | return GeckoHelloProtocolHandler(client_identifier, **kwargs) 47 | 48 | @staticmethod 49 | def response( 50 | spa_identifier: bytes, spa_name: str, **kwargs: object 51 | ) -> GeckoHelloProtocolHandler: 52 | """Generate a server response class.""" 53 | return GeckoHelloProtocolHandler( 54 | b"".join( 55 | [spa_identifier, b"|", spa_name.encode(GeckoConstants.MESSAGE_ENCODING)] 56 | ), 57 | **kwargs, 58 | ) 59 | 60 | @property 61 | def client_identifier(self) -> bytes: 62 | """Get the client identifier or raise exception if not set.""" 63 | assert self._client_identifier is not None # noqa: S101 64 | return self._client_identifier 65 | 66 | @property 67 | def spa_identifier(self) -> bytes: 68 | """Get the spa identifier or raise exception if not set.""" 69 | assert self._spa_identifier is not None # noqa: S101 70 | return self._spa_identifier 71 | 72 | @property 73 | def spa_name(self) -> str: 74 | """Get the spa name or raise exception if not set.""" 75 | assert self._spa_name is not None # noqa: S101 76 | return self._spa_name 77 | 78 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 79 | """Determine if we can handle the packet bytes.""" 80 | return received_bytes.startswith(HELLO_OPEN) and received_bytes.endswith( 81 | HELLO_CLOSE 82 | ) 83 | 84 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 85 | """Handle the packet bytes.""" 86 | content = received_bytes[7:-8] 87 | self.was_broadcast_discovery = False 88 | self._client_identifier = self._spa_identifier = self._spa_name = None 89 | 90 | if content == b"1": 91 | self.was_broadcast_discovery = True 92 | 93 | elif content.startswith((b"IOS", b"AND")): 94 | self._client_identifier = content 95 | 96 | elif not content.find(b"|"): 97 | self._spa_identifier = content 98 | self._spa_name = "Unnamed SPA" 99 | 100 | else: 101 | self._spa_identifier, spa_name = content.split(b"|") 102 | self._spa_name = spa_name.decode(GeckoConstants.MESSAGE_ENCODING) 103 | 104 | def __repr__(self) -> str: 105 | """Get string representation of the class.""" 106 | return ( 107 | f"{super().__repr__()} (was_broadcast={self.was_broadcast_discovery}," 108 | f" client_id={self._client_identifier!r}, spa_id={self._spa_identifier!r}," 109 | f" spa_name={self._spa_name} )" 110 | ) 111 | -------------------------------------------------------------------------------- /src/geckolib/automation/light.py: -------------------------------------------------------------------------------- 1 | """Gecko Lights.""" 2 | 3 | from __future__ import annotations 4 | 5 | from abc import abstractmethod 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from geckolib.automation.power import GeckoPower 9 | from geckolib.const import GeckoConstants 10 | 11 | if TYPE_CHECKING: 12 | from geckolib.automation.async_facade import GeckoAsyncFacade 13 | 14 | 15 | class GeckoLight(GeckoPower): 16 | """The base class of lights.""" 17 | 18 | def __init__(self, facade: GeckoAsyncFacade, name: str, key: str) -> None: 19 | """Initialize the light class.""" 20 | super().__init__(facade, name, key) 21 | self.device_class = GeckoConstants.DEVICE_CLASS_LIGHT 22 | 23 | @property 24 | @abstractmethod 25 | def is_on(self) -> bool: 26 | """Determine if the light is on or not.""" 27 | raise NotImplementedError 28 | 29 | @property 30 | @abstractmethod 31 | def state(self) -> str: 32 | """Get the state of the light.""" 33 | raise NotImplementedError 34 | 35 | async def async_turn_on(self, **kwargs: Any) -> None: 36 | """Turn the light ON, but does nothing if it is already ON.""" 37 | raise NotImplementedError 38 | 39 | async def async_turn_off(self) -> None: 40 | """Turn the device OFF, but does nothing if it is already OFF.""" 41 | raise NotImplementedError 42 | 43 | def __str__(self) -> str: 44 | """Stringize.""" 45 | return f"{self.name}: {self.state}" 46 | 47 | @property 48 | def monitor(self) -> str: 49 | """Get a monitor string.""" 50 | return f"{self.key}: {self.state}" 51 | 52 | 53 | class GeckoLightLi(GeckoLight): 54 | """Class for the Li light.""" 55 | 56 | def __init__(self, facade: GeckoAsyncFacade) -> None: 57 | """Initialize the Li light.""" 58 | super().__init__(facade, "Light", "LI") 59 | if "LI" in self.facade.spa.struct.connections: 60 | self.set_availability(is_available=True) 61 | 62 | if self.is_available: 63 | self._state_accessor = self.facade.spa.accessors["UdLi"] 64 | self._state_accessor.watch(self._on_change) 65 | 66 | @property 67 | def is_on(self) -> bool: 68 | """Determine if the light is on or not.""" 69 | return self.state != "OFF" 70 | 71 | @property 72 | def state(self) -> str: 73 | """Get the state of the light.""" 74 | return self._state_accessor.value 75 | 76 | async def async_turn_on(self, **_kwargs: Any) -> None: 77 | """Turn the light ON, but does nothing if it is already ON.""" 78 | if self.is_on: 79 | return 80 | await self._state_accessor.async_set_value("HI") 81 | 82 | async def async_turn_off(self, **_kwargs: Any) -> None: 83 | """Turn the light OFF, but does nothing if it is already OFF.""" 84 | if not self.is_on: 85 | return 86 | await self._state_accessor.async_set_value("OFF") 87 | 88 | 89 | class GeckoLightL120(GeckoLight): 90 | """Class for the L120 light.""" 91 | 92 | def __init__(self, facade: GeckoAsyncFacade) -> None: 93 | """Initialize the L120 light.""" 94 | super().__init__(facade, "Light 2", "L120") 95 | if "L120" in self.facade.spa.struct.connections: 96 | self.set_availability(is_available=True) 97 | 98 | if self.is_available: 99 | self._state_accessor = self.facade.spa.accessors["UdL120"] 100 | self._state_accessor.watch(self._on_change) 101 | 102 | @property 103 | def is_on(self) -> bool: 104 | """Determine if the light is on or not.""" 105 | return self.state != "OFF" 106 | 107 | @property 108 | def state(self) -> str: 109 | """Get the state of the light.""" 110 | return self._state_accessor.value 111 | 112 | async def async_turn_on(self, **_kwargs: Any) -> None: 113 | """Turn the light ON, but does nothing if it is already ON.""" 114 | if self.is_on: 115 | return 116 | await self._state_accessor.async_set_value("ON") 117 | 118 | async def async_turn_off(self, **_kwargs: Any) -> None: 119 | """Turn the light OFF, but does nothing if it is already OFF.""" 120 | if not self.is_on: 121 | return 122 | await self._state_accessor.async_set_value("OFF") 123 | -------------------------------------------------------------------------------- /src/geckolib/automation/reminders.py: -------------------------------------------------------------------------------- 1 | """Gecko Reminders.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from datetime import UTC, datetime 7 | from typing import TYPE_CHECKING 8 | 9 | from geckolib.driver import GeckoReminderType 10 | 11 | from .base import GeckoAutomationFacadeBase 12 | 13 | if TYPE_CHECKING: 14 | from geckolib.automation.async_facade import GeckoAsyncFacade 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class GeckoReminders(GeckoAutomationFacadeBase): 20 | """Reminders management.""" 21 | 22 | class Reminder: 23 | """A single reminder instance.""" 24 | 25 | def __init__(self, rem: tuple[GeckoReminderType, int]) -> None: 26 | """Initialize a single reminder.""" 27 | self._reminder_type: GeckoReminderType = rem[0] 28 | self._days: int = rem[1] 29 | 30 | @property 31 | def reminder_type(self) -> GeckoReminderType: 32 | """Get the reminder type.""" 33 | return self._reminder_type 34 | 35 | @property 36 | def description(self) -> str: 37 | """Get the reminder description.""" 38 | return GeckoReminderType.to_string(self.reminder_type) 39 | 40 | @property 41 | def days(self) -> int: 42 | """Get the remaining days.""" 43 | return self._days 44 | 45 | def set_days(self, days: int) -> None: 46 | """Set the remaining days.""" 47 | self._days = days 48 | 49 | @property 50 | def monitor(self) -> str: 51 | """Get the monitor string.""" 52 | return f"{datetime.now(tz=UTC)}" 53 | 54 | def __str__(self) -> str: 55 | """Stringize the reminder.""" 56 | return ( 57 | f"{self.description} due in {self.days} days" 58 | if self.days > 0 59 | else f"{self.description} due today" 60 | if self.days == 0 61 | else f"{self.description} overdue by {-self.days} days" 62 | ) 63 | 64 | def __init__(self, facade: GeckoAsyncFacade) -> None: 65 | """Initialize the reminders class.""" 66 | super().__init__(facade, "Reminders", "REMINDERS") 67 | 68 | self._all_reminders: list[GeckoReminders.Reminder] = [] 69 | self._reminders_handler = None 70 | self._last_update = None 71 | if facade.spa.struct.is_spa_pack: 72 | self.set_availability(is_available=True) 73 | 74 | @property 75 | def reminders(self) -> list[Reminder]: 76 | """Return active reminders.""" 77 | return [ 78 | reminder 79 | for reminder in self._all_reminders 80 | if reminder.reminder_type != GeckoReminderType.INVALID 81 | ] 82 | 83 | def get_reminder( 84 | self, reminder_type: GeckoReminderType 85 | ) -> GeckoReminders.Reminder | None: 86 | """Get the reminder of the specified type, or None if not found.""" 87 | for reminder in self._all_reminders: 88 | if reminder.reminder_type == reminder_type: 89 | return reminder 90 | return None 91 | 92 | async def set_reminder(self, reminder_type: GeckoReminderType, days: int) -> None: 93 | """Set the remaining days for the specified reminder type.""" 94 | for reminder in self._all_reminders: 95 | if reminder.reminder_type == reminder_type: 96 | reminder.set_days(days) 97 | await self.facade.spa.async_set_reminders( 98 | [ 99 | (reminder.reminder_type, reminder.days) 100 | for reminder in self._all_reminders 101 | ] 102 | ) 103 | self._on_change(self) 104 | 105 | @property 106 | def last_update(self) -> datetime | None: 107 | """Time of last reminder update.""" 108 | return self._last_update 109 | 110 | def change_reminders(self, reminders: list[tuple]) -> None: 111 | """Call from async facade to update active reminders.""" 112 | self._last_update = datetime.now(tz=UTC) 113 | self._all_reminders = [ 114 | GeckoReminders.Reminder(reminder) for reminder in reminders 115 | ] 116 | self._on_change(self) 117 | 118 | def __str__(self) -> str: 119 | """Stringize the class.""" 120 | if self.reminders is None: 121 | return f"{self.name}: Waiting..." 122 | return f"{self.name}: {self.reminders}" 123 | -------------------------------------------------------------------------------- /src/geckolib/__init__.py: -------------------------------------------------------------------------------- 1 | """Library to communicate with Gecko Alliance products via the in.touch2 module.""" 2 | 3 | import logging 4 | 5 | from ._version import VERSION 6 | from .async_locator import GeckoAsyncLocator 7 | from .async_spa import GeckoAsyncSpa 8 | from .async_spa_descriptor import GeckoAsyncSpaDescriptor 9 | from .async_spa_manager import GeckoAsyncSpaMan 10 | from .async_taskman import GeckoAsyncTaskMan 11 | from .async_tasks import AsyncTasks 12 | from .automation import ( 13 | BainUltra, 14 | GeckoAsyncFacade, 15 | GeckoAutomationBase, 16 | GeckoAutomationFacadeBase, 17 | GeckoBinarySensor, 18 | GeckoBlower, 19 | GeckoBubbleGenerator, 20 | GeckoButton, 21 | GeckoErrorSensor, 22 | GeckoHeatPump, 23 | GeckoInGrid, 24 | GeckoInMix, 25 | GeckoInMixSynchro, 26 | GeckoInMixZone, 27 | GeckoKeypad, 28 | GeckoLight, 29 | GeckoNumber, 30 | GeckoPump, 31 | GeckoReminders, 32 | GeckoSensor, 33 | GeckoSwitch, 34 | GeckoWaterCare, 35 | GeckoWaterfall, 36 | GeckoWaterHeater, 37 | GeckoWaterHeaterAbstract, 38 | MrSteam, 39 | ) 40 | from .config import GeckoConfig 41 | from .const import GeckoConstants 42 | from .driver import ( 43 | GeckoAsyncPartialStatusBlockProtocolHandler, 44 | GeckoAsyncStructure, 45 | GeckoBoolStructAccessor, 46 | GeckoByteStructAccessor, 47 | GeckoConfigFileProtocolHandler, 48 | GeckoEnumStructAccessor, 49 | GeckoGetChannelProtocolHandler, 50 | GeckoGetWatercareModeProtocolHandler, 51 | GeckoGetWatercareScheduleListProtocolHandler, 52 | GeckoHelloProtocolHandler, 53 | GeckoPackCommandProtocolHandler, 54 | GeckoPacketProtocolHandler, 55 | GeckoPingProtocolHandler, 56 | GeckoRemindersProtocolHandler, 57 | GeckoReminderType, 58 | GeckoSetWatercareModeProtocolHandler, 59 | GeckoStatusBlockProtocolHandler, 60 | GeckoStructAccessor, 61 | GeckoUdpProtocolHandler, 62 | GeckoUpdateFirmwareProtocolHandler, 63 | GeckoVersionProtocolHandler, 64 | GeckoWatercareScheduleManager, 65 | GeckoWordStructAccessor, 66 | Observable, 67 | ) 68 | from .spa_events import GeckoSpaEvent 69 | from .spa_state import GeckoSpaState 70 | from .utils import CUI, GeckoShell, GeckoSimulator, GeckoSnapshot 71 | 72 | # Module logger, uses the library name (at this time it was geckolib) and it 73 | # is silent unless required ... 74 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 75 | 76 | __version__ = VERSION 77 | 78 | __all__ = [ 79 | "CUI", 80 | "VERSION", 81 | "AsyncTasks", 82 | "BainUltra", 83 | "GeckoAsyncFacade", 84 | "GeckoAsyncLocator", 85 | "GeckoAsyncPartialStatusBlockProtocolHandler", 86 | "GeckoAsyncSpa", 87 | "GeckoAsyncSpaDescriptor", 88 | "GeckoAsyncSpaMan", 89 | "GeckoAsyncStructure", 90 | "GeckoAsyncTaskMan", 91 | "GeckoAutomationBase", 92 | "GeckoAutomationFacadeBase", 93 | "GeckoBinarySensor", 94 | "GeckoBlower", 95 | "GeckoBoolStructAccessor", 96 | "GeckoBubbleGenerator", 97 | "GeckoButton", 98 | "GeckoByteStructAccessor", 99 | "GeckoConfig", 100 | "GeckoConfigFileProtocolHandler", 101 | "GeckoConstants", 102 | "GeckoEnumStructAccessor", 103 | "GeckoErrorSensor", 104 | "GeckoGetChannelProtocolHandler", 105 | "GeckoGetWatercareModeProtocolHandler", 106 | "GeckoGetWatercareScheduleListProtocolHandler", 107 | "GeckoHeatPump", 108 | "GeckoHelloProtocolHandler", 109 | "GeckoInGrid", 110 | "GeckoInMix", 111 | "GeckoInMixSynchro", 112 | "GeckoInMixZone", 113 | "GeckoKeypad", 114 | "GeckoLight", 115 | "GeckoNumber", 116 | "GeckoPackCommandProtocolHandler", 117 | "GeckoPacketProtocolHandler", 118 | "GeckoPingProtocolHandler", 119 | "GeckoPump", 120 | "GeckoReminderType", 121 | "GeckoReminders", 122 | "GeckoRemindersProtocolHandler", 123 | "GeckoSensor", 124 | "GeckoSetWatercareModeProtocolHandler", 125 | "GeckoShell", 126 | "GeckoSimulator", 127 | "GeckoSnapshot", 128 | "GeckoSpaEvent", 129 | "GeckoSpaState", 130 | "GeckoStatusBlockProtocolHandler", 131 | "GeckoStructAccessor", 132 | "GeckoSwitch", 133 | "GeckoUdpProtocolHandler", 134 | "GeckoUpdateFirmwareProtocolHandler", 135 | "GeckoVersionProtocolHandler", 136 | "GeckoWaterCare", 137 | "GeckoWaterHeater", 138 | "GeckoWaterHeaterAbstract", 139 | "GeckoWatercareScheduleManager", 140 | "GeckoWaterfall", 141 | "GeckoWordStructAccessor", 142 | "MrSteam", 143 | "Observable", 144 | ] 145 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/packcommand.py: -------------------------------------------------------------------------------- 1 | """Gecko SPACK/PACKS handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from geckolib.config import GeckoConfig 10 | from geckolib.driver.accessor import GeckoStructAccessor 11 | 12 | from .packet import GeckoPacketProtocolHandler 13 | 14 | SPACK_VERB = b"SPACK" 15 | PACKS_VERB = b"PACKS" 16 | PACK_COMMAND_KEY_PRESS = 57 17 | PACK_COMMAND_SET_VALUE = 70 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | class GeckoPackCommandProtocolHandler(GeckoPacketProtocolHandler): 23 | """Pack Command handler.""" 24 | 25 | @staticmethod 26 | def set_value( # noqa: PLR0913 27 | seq: int, 28 | pack_type: int, 29 | config_version: int, 30 | log_version: int, 31 | pos: int, 32 | length: int, 33 | data: Any, 34 | **kwargs: Any, 35 | ) -> GeckoPackCommandProtocolHandler: 36 | """Generate a SetValue command.""" 37 | return GeckoPackCommandProtocolHandler( 38 | content=b"".join( 39 | [ 40 | SPACK_VERB, 41 | struct.pack( 42 | ">BBBBBBH", 43 | seq, 44 | pack_type, 45 | 5 + length, 46 | PACK_COMMAND_SET_VALUE, 47 | config_version, 48 | log_version, 49 | pos, 50 | ), 51 | GeckoStructAccessor.pack_data(length, data), 52 | ] 53 | ), 54 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS * 3, 55 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 56 | **kwargs, 57 | ) 58 | 59 | @staticmethod 60 | def keypress( 61 | seq: int, pack_type: int, key: int, **kwargs: Any 62 | ) -> GeckoPackCommandProtocolHandler: 63 | """Generate a KeyPress command.""" 64 | return GeckoPackCommandProtocolHandler( 65 | content=b"".join( 66 | [ 67 | SPACK_VERB, 68 | struct.pack( 69 | ">BBBBB", seq, pack_type, 2, PACK_COMMAND_KEY_PRESS, key 70 | ), 71 | ] 72 | ), 73 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS * 3, 74 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 75 | **kwargs, 76 | ) 77 | 78 | @staticmethod 79 | def response(**kwargs: Any) -> GeckoPackCommandProtocolHandler: 80 | """Generate a response.""" 81 | return GeckoPackCommandProtocolHandler(content=PACKS_VERB, **kwargs) 82 | 83 | def __init__(self, **kwargs: Any) -> None: 84 | """Initialize the class.""" 85 | super().__init__(**kwargs) 86 | self.pack_type = None 87 | self.is_key_press = False 88 | self.keycode = None 89 | self.is_set_value = False 90 | self.position = None 91 | self.length = None 92 | self.new_data = None 93 | 94 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 95 | """Can we handle this verb.""" 96 | return received_bytes.startswith((SPACK_VERB, PACKS_VERB)) 97 | 98 | def handle(self, received_bytes: bytes, _sender: tuple) -> None: 99 | """Handle the verb.""" 100 | remainder = received_bytes[5:] 101 | if received_bytes.startswith(PACKS_VERB): 102 | self._should_remove_handler = True 103 | return 104 | # Otherwise must be SPACK, so work out what is going on ... 105 | self._sequence, self.pack_type, self.length, command = struct.unpack( 106 | ">BBBB", remainder[0:4] 107 | ) 108 | if command == PACK_COMMAND_KEY_PRESS: 109 | if self.length == 2: # noqa: PLR2004 110 | self.is_key_press = True 111 | self.is_set_value = False 112 | self.keycode = struct.unpack(">B", remainder[4:])[0] 113 | else: 114 | _LOGGER.warning("SPACK key press command incorrect length") 115 | elif command == PACK_COMMAND_SET_VALUE: 116 | self.is_set_value = True 117 | self.is_key_press = False 118 | self.length -= 5 119 | config_version, log_version, self.position = struct.unpack( 120 | ">BBH", remainder[4:8] 121 | ) 122 | self.new_data = remainder[8:] 123 | else: 124 | _LOGGER.warning("Unhandled SPACK command %d", command) 125 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inclear-32k-cfg-2.py: -------------------------------------------------------------------------------- 1 | """GeckoConfigStruct - A class to manage the ConfigStruct for 'inClear-32K v2'.""" # noqa: N999 2 | 3 | from . import ( 4 | GeckoAsyncStructure, 5 | GeckoByteStructAccessor, 6 | GeckoEnumStructAccessor, 7 | GeckoStructAccessor, 8 | GeckoWordStructAccessor, 9 | ) 10 | 11 | 12 | class GeckoConfigStruct: 13 | """Config Struct Class.""" 14 | 15 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 16 | """Initialize the config struct class.""" 17 | self.struct = struct_ 18 | 19 | @property 20 | def version(self) -> int: 21 | """Get the config struct class version.""" 22 | return 2 23 | 24 | @property 25 | def output_keys(self) -> list[str]: 26 | """Output keys property.""" 27 | return [] 28 | 29 | @property 30 | def accessors(self) -> dict[str, GeckoStructAccessor]: 31 | """The structure accessors.""" 32 | return { 33 | "inClear-32K-Mode": GeckoEnumStructAccessor( 34 | self.struct, 35 | "ConfigStructure/All/inClear-32K-Mode", 36 | 512, 37 | None, 38 | ["OFF", "ON"], 39 | None, 40 | None, 41 | "ALL", 42 | ), 43 | "inClear-32K-MaintenanceLevel": GeckoByteStructAccessor( 44 | self.struct, 45 | "ConfigStructure/All/inClear-32K-MaintenanceLevel", 46 | 513, 47 | "ALL", 48 | ), 49 | "inClear-32K-BoostLevel": GeckoByteStructAccessor( 50 | self.struct, "ConfigStructure/All/inClear-32K-BoostLevel", 514, "ALL" 51 | ), 52 | "inClear-32K-MaxCellCurrent": GeckoWordStructAccessor( 53 | self.struct, 54 | "ConfigStructure/All/inClear-32K-MaxCellCurrent", 55 | 515, 56 | "ALL", 57 | ), 58 | "inClear-32K-MaxMaintenanceLevel": GeckoWordStructAccessor( 59 | self.struct, 60 | "ConfigStructure/All/inClear-32K-MaxMaintenanceLevel", 61 | 517, 62 | "ALL", 63 | ), 64 | "inClear-32K-ErrDelayAfterReset": GeckoWordStructAccessor( 65 | self.struct, 66 | "ConfigStructure/All/inClear-32K-ErrDelayAfterReset", 67 | 519, 68 | "ALL", 69 | ), 70 | "inClear-32K-Boost1Durx6Minutes": GeckoWordStructAccessor( 71 | self.struct, 72 | "ConfigStructure/All/inClear-32K-Boost1Durx6Minutes", 73 | 521, 74 | "ALL", 75 | ), 76 | "inClear-32K-Boost2Durx6Minutes": GeckoWordStructAccessor( 77 | self.struct, 78 | "ConfigStructure/All/inClear-32K-Boost2Durx6Minutes", 79 | 523, 80 | "ALL", 81 | ), 82 | "inClear-32K-Boost3Durx6Minutes": GeckoWordStructAccessor( 83 | self.struct, 84 | "ConfigStructure/All/inClear-32K-Boost3Durx6Minutes", 85 | 525, 86 | "ALL", 87 | ), 88 | "inClear-32K-Boost4Durx6Minutes": GeckoWordStructAccessor( 89 | self.struct, 90 | "ConfigStructure/All/inClear-32K-Boost4Durx6Minutes", 91 | 527, 92 | "ALL", 93 | ), 94 | "inClear-32K-Boost5Durx6Minutes": GeckoWordStructAccessor( 95 | self.struct, 96 | "ConfigStructure/All/inClear-32K-Boost5Durx6Minutes", 97 | 529, 98 | "ALL", 99 | ), 100 | "inClear-32K-Boost6Durx6Minutes": GeckoWordStructAccessor( 101 | self.struct, 102 | "ConfigStructure/All/inClear-32K-Boost6Durx6Minutes", 103 | 531, 104 | "ALL", 105 | ), 106 | "inClear-32K-Boost7Durx6Minutes": GeckoWordStructAccessor( 107 | self.struct, 108 | "ConfigStructure/All/inClear-32K-Boost7Durx6Minutes", 109 | 533, 110 | "ALL", 111 | ), 112 | "inClear-32K-Boost8Durx6Minutes": GeckoWordStructAccessor( 113 | self.struct, 114 | "ConfigStructure/All/inClear-32K-Boost8Durx6Minutes", 115 | 535, 116 | "ALL", 117 | ), 118 | "inClear-32K-ValidRemoteFlow": GeckoWordStructAccessor( 119 | self.struct, 120 | "ConfigStructure/All/inClear-32K-ValidRemoteFlow", 121 | 537, 122 | "ALL", 123 | ), 124 | } 125 | -------------------------------------------------------------------------------- /src/geckolib/automation/mrsteam.py: -------------------------------------------------------------------------------- 1 | """Mr.Steam class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.automation.heater import GeckoWaterHeaterAbstract 9 | from geckolib.automation.number import GeckoNumber 10 | from geckolib.automation.sensors import GeckoSensor 11 | from geckolib.automation.switch import GeckoSwitch 12 | from geckolib.const import GeckoConstants 13 | 14 | if TYPE_CHECKING: 15 | from geckolib.automation.async_facade import GeckoAsyncFacade 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class MrSteam(GeckoWaterHeaterAbstract): 21 | """Mr.Steam support class.""" 22 | 23 | def __init__(self, facade: GeckoAsyncFacade) -> None: 24 | """Initialize the MrSteam class.""" 25 | super().__init__(facade, "Steamer", "MRSTEAM") 26 | if not facade.spa.struct.is_mr_steam: 27 | return 28 | 29 | self.set_availability(is_available=True) 30 | 31 | self.onoff = GeckoSwitch( 32 | facade, 33 | GeckoConstants.KEY_MRSTEAM_USER_MODE, 34 | ( 35 | "On/Off", 36 | GeckoConstants.KEY_MRSTEAM_USER_MODE, 37 | GeckoConstants.DEVICE_CLASS_SWITCH, 38 | ), 39 | ) 40 | self.onoff.watch(self._on_change) 41 | 42 | self.aroma = GeckoSwitch( 43 | facade, 44 | GeckoConstants.KEY_MRSTEAM_USER_AROMA, 45 | ( 46 | "Aroma", 47 | GeckoConstants.KEY_MRSTEAM_USER_AROMA, 48 | GeckoConstants.DEVICE_CLASS_SWITCH, 49 | ), 50 | ) 51 | self.aroma.watch(self._on_change) 52 | 53 | self.chroma = GeckoSwitch( 54 | facade, 55 | GeckoConstants.KEY_MRSTEAM_USER_CHROMA, 56 | ( 57 | "Chroma", 58 | GeckoConstants.KEY_MRSTEAM_USER_CHROMA, 59 | GeckoConstants.DEVICE_CLASS_SWITCH, 60 | ), 61 | ) 62 | self.chroma.watch(self._on_change) 63 | 64 | self.setpoint_accessor = self._spa.accessors[ 65 | GeckoConstants.KEY_MRSTEAM_USER_SETPOINT_G 66 | ] 67 | self.setpoint_accessor.watch(self._on_change) 68 | self.temp_unt_accesor = self._spa.accessors[GeckoConstants.KEY_TEMP_UNITS] 69 | self.min_temp_accessor = self._spa.accessors[GeckoConstants.KEY_MIN_SETPOINT_G] 70 | self.max_temp_accessor = self._spa.accessors[GeckoConstants.KEY_MAX_SETPOINT_G] 71 | 72 | self.user_runtime = GeckoNumber( 73 | facade, "Runtime", GeckoConstants.KEY_MRSTEAM_USER_RUNTIME, "min" 74 | ) 75 | self.user_runtime.watch(self._on_change) 76 | if GeckoConstants.KEY_MRSTEAM_MAX_RUNTIME in self._spa.accessors: 77 | max_runtime = self._spa.accessors[GeckoConstants.KEY_MRSTEAM_MAX_RUNTIME] 78 | self.user_runtime.native_max_value = max_runtime.value 79 | 80 | self.remaining_runtime = GeckoSensor( 81 | facade, 82 | "Remaining Runtime", 83 | self._spa.accessors[GeckoConstants.KEY_MRSTEAM_REMAINING_RUNTIME], 84 | "min", 85 | ) 86 | self.remaining_runtime.watch(self._on_change) 87 | 88 | @property 89 | def switches(self) -> list[GeckoSwitch]: 90 | """Get a list of all the switches.""" 91 | if self.is_available: 92 | return [self.onoff, self.aroma, self.chroma] 93 | return [] 94 | 95 | @property 96 | def current_operation(self) -> str: 97 | """Get the current operation.""" 98 | if not self.onoff.is_on: 99 | return "Turned off" 100 | return f"Running, {self.remaining_runtime.state}m remaining" 101 | 102 | @property 103 | def temperature_unit(self) -> str: 104 | """Get the temperature unit.""" 105 | if self.temp_unt_accesor.value == "C": 106 | return self.TEMP_CELCIUS 107 | return self.TEMP_FARENHEIGHT 108 | 109 | @property 110 | def current_temperature(self) -> float: 111 | """Get the current temperature.""" 112 | return self.setpoint_accessor.value 113 | 114 | @property 115 | def target_temperature(self) -> float: 116 | """Get the target temperature.""" 117 | return self.setpoint_accessor.value 118 | 119 | @property 120 | def min_temp(self) -> float: 121 | """Get the min temp.""" 122 | return self.min_temp_accessor.value 123 | 124 | @property 125 | def max_temp(self) -> float: 126 | """Get the max temp.""" 127 | return self.max_temp_accessor.value 128 | 129 | async def async_set_target_temperature(self, new_temperature: float) -> None: 130 | """Set the target temperature of the water.""" 131 | await self.setpoint_accessor.async_set_value(new_temperature) 132 | -------------------------------------------------------------------------------- /src/geckolib/spa_events.py: -------------------------------------------------------------------------------- 1 | """Connection events issued during spa discovery/connection.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Coroutine 6 | from enum import Enum 7 | from typing import Any 8 | 9 | 10 | class GeckoSpaEvent(Enum): 11 | """Events triggered during locating and connection status of spas.""" 12 | 13 | # Spa manager ------------------------------------------------------- 14 | SPA_MAN_ENTER = 1 15 | """Spa manager was initialized""" 16 | SPA_MAN_EXIT = 2 17 | """Spa manager was exited, exc_info= exception if present""" 18 | 19 | SPA_NOT_FOUND = 5 20 | """Specified spa was not found on the network, spa_address= and 21 | spa_identifier= have more information""" 22 | 23 | # Locating spas ----------------------------------------------------- 24 | LOCATING_STARTED = 100 25 | """Locating spas on the network has started""" 26 | 27 | LOCATING_DISCOVERED_SPA = 150 28 | """A spa was located, spa_descriptor= contains the details """ 29 | 30 | LOCATING_FINISHED = 199 31 | """Locating spas on the network has finished, spa_descriptors= 32 | contains a list (possibly empty) of the spas that were found""" 33 | 34 | # Connecting to spas ------------------------------------------------ 35 | CONNECTION_STARTED = 200 36 | """Connecting to a spa on the network has started""" 37 | 38 | CONNECTION_GOT_FIRMWARE_VERSION = 201 39 | """Connection has in.touch2 firmware versions, **kwargs has details""" 40 | 41 | CONNECTION_GOT_CHANNEL = 202 42 | """Connection has channel and RF strength. **kwargs has details""" 43 | 44 | CONNECTION_GOT_CONFIG_FILES = 203 45 | """Connection has got config files and versions. **kwargs has details""" 46 | 47 | CONNECTION_INITIAL_DATA_BLOCK_REQUEST = 204 48 | """Connection has asked for initial data block""" 49 | 50 | CONNECTION_SPA_COMPLETE = 250 51 | """Connection has completed for this spa, the facade can be constructed""" 52 | 53 | CONNECTION_CANNOT_FIND_LOG_VERSION = 295 54 | """During connection, the log version of the spa pack count not be 55 | found. **kwargs has details""" 56 | 57 | CONNECTION_CANNOT_FIND_CONFIG_VERSION = 296 58 | """During connection, the config version of the spa pack count not be 59 | found. **kwargs has details""" 60 | 61 | CONNECTION_CANNOT_FIND_SPA_PACK = 297 62 | """During connection, the calculated spa pack could not be found. 63 | **kwargs has details""" 64 | 65 | CONNECTION_PROTOCOL_RETRY_TIME_EXCEEDED = 298 66 | """During connection, the retry time was exceeded for part of the 67 | protocol handshake""" 68 | 69 | CONNECTION_FINISHED = 299 70 | """Connecting to a spa on the network has finished, facade= 71 | has the connected facade or None if some error occurred""" 72 | 73 | # Running spa ------------------------------------------------------ 74 | RUNNING_PING_RECEIVED = 300 75 | """Running spa received a ping response""" 76 | RUNNING_PING_MISSED = 301 77 | """Running spa missed a ping response""" 78 | RUNNING_PING_NO_RESPONSE = 302 79 | """Running spa not responding to pings """ 80 | RUNNING_SPA_DISCONNECTED = 303 81 | """Running spa was disconnected""" 82 | RUNNING_SPA_WATER_CARE_ERROR = 304 83 | """A running spa will sometimes get an unprompted watercare error""" 84 | RUNNING_SPA_PACK_REFRESHED = 305 85 | """A running spa will update the spapack data and radio strength periodically""" 86 | RUNNING_SPA_NEEDS_RELOAD = 306 87 | """A running spa has detected that it needs a complete reload""" 88 | 89 | # Events targeted at clients, to determine when things can be shown 90 | # or hidden in the UI 91 | CLIENT_HAS_STATUS_SENSOR = 401 92 | """The spa manager has the status sensor""" 93 | CLIENT_HAS_RECONNECT_BUTTON = 402 94 | """The spa manager has the reconnect button""" 95 | CLIENT_HAS_PING_SENSOR = 403 96 | """The spa manager has the ping sensor""" 97 | 98 | CLIENT_FACADE_IS_READY = 420 99 | """The facade is ready for use.""" 100 | CLIENT_FACADE_TEARDOWN = 421 101 | """Facade is being torn down.""" 102 | 103 | # Error event that SpaMan can retry without ------------------------ 104 | # intervention 105 | 106 | # Terminal states that require subclasses to ------------------------ 107 | # let go of the facade and all automation objects and reconnect 108 | ERROR_TOO_MANY_RF_ERRORS = 500 109 | """The spa has had too many RF errors""" 110 | 111 | ERROR_PROTOCOL_RETRY_TIME_EXCEEDED = 501 112 | """Protocol retry time was exceeded during normal operations""" 113 | 114 | ERROR_RF_ERROR = 502 115 | """The in.touch EN module can't communicate with the CO module""" 116 | 117 | CallBack = Callable[..., Coroutine[Any, Any, None]] 118 | """Typedef for event callbacks, located here so we can be DRY""" 119 | -------------------------------------------------------------------------------- /src/geckolib/driver/packs/inmix-cfg-1.py: -------------------------------------------------------------------------------- 1 | """GeckoConfigStruct - A class to manage the ConfigStruct for 'InMix v1'.""" # noqa: N999 2 | 3 | from . import ( 4 | GeckoAsyncStructure, 5 | GeckoEnumStructAccessor, 6 | GeckoStructAccessor, 7 | ) 8 | 9 | 10 | class GeckoConfigStruct: 11 | """Config Struct Class.""" 12 | 13 | def __init__(self, struct_: GeckoAsyncStructure) -> None: 14 | """Initialize the config struct class.""" 15 | self.struct = struct_ 16 | 17 | @property 18 | def version(self) -> int: 19 | """Get the config struct class version.""" 20 | return 1 21 | 22 | @property 23 | def output_keys(self) -> list[str]: 24 | """Output keys property.""" 25 | return [] 26 | 27 | @property 28 | def accessors(self) -> dict[str, GeckoStructAccessor]: 29 | """The structure accessors.""" 30 | return { 31 | "InMix-Zone1Led": GeckoEnumStructAccessor( 32 | self.struct, 33 | "ConfigStructure/InMix-Zone1Led", 34 | 592, 35 | 0, 36 | ["RGB", "WHITE"], 37 | None, 38 | 2, 39 | None, 40 | ), 41 | "InMix-Zone1Type": GeckoEnumStructAccessor( 42 | self.struct, 43 | "ConfigStructure/InMix-Zone1Type", 44 | 592, 45 | 1, 46 | ["NORMAL", "STATUS"], 47 | None, 48 | 2, 49 | None, 50 | ), 51 | "InMix-Zone1Supply": GeckoEnumStructAccessor( 52 | self.struct, 53 | "ConfigStructure/InMix-Zone1Supply", 54 | 592, 55 | 2, 56 | ["12V", "5V"], 57 | None, 58 | 2, 59 | None, 60 | ), 61 | "InMix-Zone2Led": GeckoEnumStructAccessor( 62 | self.struct, 63 | "ConfigStructure/InMix-Zone2Led", 64 | 593, 65 | 0, 66 | ["RGB", "WHITE"], 67 | None, 68 | 2, 69 | None, 70 | ), 71 | "InMix-Zone2Type": GeckoEnumStructAccessor( 72 | self.struct, 73 | "ConfigStructure/InMix-Zone2Type", 74 | 593, 75 | 1, 76 | ["NORMAL", "STATUS"], 77 | None, 78 | 2, 79 | None, 80 | ), 81 | "InMix-Zone2Supply": GeckoEnumStructAccessor( 82 | self.struct, 83 | "ConfigStructure/InMix-Zone2Supply", 84 | 593, 85 | 2, 86 | ["12V", "5V"], 87 | None, 88 | 2, 89 | None, 90 | ), 91 | "InMix-Zone3Led": GeckoEnumStructAccessor( 92 | self.struct, 93 | "ConfigStructure/InMix-Zone3Led", 94 | 594, 95 | 0, 96 | ["RGB", "WHITE"], 97 | None, 98 | 2, 99 | None, 100 | ), 101 | "InMix-Zone3Type": GeckoEnumStructAccessor( 102 | self.struct, 103 | "ConfigStructure/InMix-Zone3Type", 104 | 594, 105 | 1, 106 | ["NORMAL", "STATUS"], 107 | None, 108 | 2, 109 | None, 110 | ), 111 | "InMix-Zone3Supply": GeckoEnumStructAccessor( 112 | self.struct, 113 | "ConfigStructure/InMix-Zone3Supply", 114 | 594, 115 | 2, 116 | ["12V", "5V"], 117 | None, 118 | 2, 119 | None, 120 | ), 121 | "InMix-Zone4Led": GeckoEnumStructAccessor( 122 | self.struct, 123 | "ConfigStructure/InMix-Zone4Led", 124 | 595, 125 | 0, 126 | ["RGB", "WHITE"], 127 | None, 128 | 2, 129 | None, 130 | ), 131 | "InMix-Zone4Type": GeckoEnumStructAccessor( 132 | self.struct, 133 | "ConfigStructure/InMix-Zone4Type", 134 | 595, 135 | 1, 136 | ["NORMAL", "STATUS"], 137 | None, 138 | 2, 139 | None, 140 | ), 141 | "InMix-Zone4Supply": GeckoEnumStructAccessor( 142 | self.struct, 143 | "ConfigStructure/InMix-Zone4Supply", 144 | 595, 145 | 2, 146 | ["12V", "5V"], 147 | None, 148 | 2, 149 | None, 150 | ), 151 | } 152 | -------------------------------------------------------------------------------- /src/geckolib/automation/switch.py: -------------------------------------------------------------------------------- 1 | """Automation switches.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.automation.power import GeckoPower 9 | from geckolib.const import GeckoConstants 10 | 11 | from .base import GeckoAutomationFacadeBase 12 | from .sensors import GeckoSensor 13 | 14 | if TYPE_CHECKING: 15 | from geckolib.automation.async_facade import GeckoAsyncFacade 16 | from geckolib.driver.accessor import GeckoEnumStructAccessor 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class GeckoSwitch(GeckoPower): 22 | """A switch can turn something on or off, and can report the current state.""" 23 | 24 | def __init__(self, facade: GeckoAsyncFacade, key: str, props: tuple) -> None: 25 | """Props is a tuple of (name, keypad_button, state_key, device_class).""" 26 | super().__init__(facade, props[0], key) 27 | if key in self._spa.accessors: 28 | self._accessor = self._spa.accessors[key] 29 | else: 30 | self._accessor = self._spa.accessors[props[1]] 31 | self._state_sensor = GeckoSensor(facade, f"{props[0]} State", self._accessor) 32 | self._state_sensor.watch(self._on_change) 33 | self.device_class = props[2] 34 | 35 | @property 36 | def is_on(self) -> bool: 37 | """True if the device is ON, False otherwise.""" 38 | if self._accessor.accessor_type == GeckoConstants.SPA_PACK_STRUCT_BOOL_TYPE: 39 | return self._state_sensor.state 40 | return self._state_sensor.state != "OFF" 41 | 42 | async def async_turn_on(self) -> None: 43 | """Turn the device ON, but does nothing if it is already ON.""" 44 | _LOGGER.debug("%s async turn ON", self.name) 45 | if self.is_on: 46 | _LOGGER.debug("%s request to turn ON ignored, it's already on!", self.name) 47 | return 48 | _LOGGER.debug("Set async state on accessor") 49 | if self._accessor.accessor_type == GeckoConstants.SPA_PACK_STRUCT_BOOL_TYPE: 50 | await self._accessor.async_set_value(True) # noqa: FBT003 51 | else: 52 | await self._accessor.async_set_value("ON") 53 | 54 | async def async_turn_off(self) -> None: 55 | """Turn the device OFF, but does nothing if it is already OFF.""" 56 | _LOGGER.debug("%s async turn OFF", self.name) 57 | if not self.is_on: 58 | _LOGGER.debug( 59 | "%s request to turn OFF ignored, it's already off!", self.name 60 | ) 61 | return 62 | _LOGGER.debug("Set async state on accessor") 63 | if self._accessor.accessor_type == GeckoConstants.SPA_PACK_STRUCT_BOOL_TYPE: 64 | await self._accessor.async_set_value(False) # noqa: FBT003 65 | else: 66 | await self._accessor.async_set_value("OFF") 67 | 68 | def state_sensor(self) -> GeckoSensor: 69 | """Get the state sensor.""" 70 | return self._state_sensor 71 | 72 | @property 73 | def state(self) -> str: 74 | """Get the switch state.""" 75 | if self._accessor.accessor_type == GeckoConstants.SPA_PACK_STRUCT_BOOL_TYPE: 76 | return "ON" if self._state_sensor.state else "OFF" 77 | return self._state_sensor.state 78 | 79 | def __str__(self) -> str: 80 | """Stringize.""" 81 | return f"{self.name}: {self._state_sensor.state}" 82 | 83 | @property 84 | def monitor(self) -> str: 85 | """Get a monitor string.""" 86 | return f"{self.key}: {self._state_sensor.state}" 87 | 88 | 89 | class GeckoStandby(GeckoAutomationFacadeBase): 90 | """Standby switch.""" 91 | 92 | def __init__(self, facade: GeckoAsyncFacade) -> None: 93 | """Initialize the standby switch.""" 94 | super().__init__(facade, "Standby", GeckoConstants.KEY_STANDBY) 95 | self._accessor: GeckoEnumStructAccessor = self._spa.accessors[ 96 | GeckoConstants.KEY_STANDBY 97 | ] 98 | self._accessor.watch(self._on_change) 99 | 100 | @property 101 | def is_on(self) -> bool: 102 | """True if the device is ON, False otherwise.""" 103 | return self._accessor.value == "OFF" 104 | 105 | async def async_turn_on(self) -> None: 106 | """Turn on standby mode.""" 107 | await self._accessor.async_set_value("OFF") 108 | 109 | async def async_turn_off(self) -> None: 110 | """Turn off standby mode.""" 111 | await self._accessor.async_set_value("NOT_SET") 112 | 113 | @property 114 | def state(self) -> str: 115 | """Get the standby state.""" 116 | return f"{self.is_on}" 117 | 118 | def __str__(self) -> str: 119 | """Stringize the class.""" 120 | return f"{self.name}: {self.is_on}" 121 | 122 | @property 123 | def monitor(self) -> str: 124 | """Get the monitor string.""" 125 | return f"{self.key}: {self.is_on}" 126 | -------------------------------------------------------------------------------- /src/geckolib/automation/bainultra.py: -------------------------------------------------------------------------------- 1 | """Bain Ultra class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from geckolib.automation.heater import GeckoWaterHeaterAbstract 9 | from geckolib.automation.number import GeckoNumber 10 | from geckolib.automation.select import GeckoSelect 11 | from geckolib.automation.switch import GeckoSwitch 12 | from geckolib.const import GeckoConstants 13 | 14 | if TYPE_CHECKING: 15 | from geckolib.automation.async_facade import GeckoAsyncFacade 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class BainUltra(GeckoWaterHeaterAbstract): 21 | """Bain Ultra support class.""" 22 | 23 | def __init__(self, facade: GeckoAsyncFacade) -> None: 24 | """Initialize the Bain Ultra class.""" 25 | super().__init__(facade, "Bath", "BAIN") 26 | if not facade.spa.struct.is_bain_ultra: 27 | return 28 | 29 | self.set_availability(is_available=True) 30 | 31 | self.onoff = GeckoSwitch( 32 | facade, 33 | GeckoConstants.KEY_BAIN_POWER_STATE, 34 | ( 35 | "On/Off", 36 | GeckoConstants.KEY_BAIN_POWER_STATE, 37 | GeckoConstants.DEVICE_CLASS_SWITCH, 38 | ), 39 | ) 40 | self.onoff.watch(self._on_change) 41 | # The spa pack didn't set Read/Write on the power property, but the BU client 42 | # wrote to it anyway, so we will too... 43 | self.facade.spa.accessors[GeckoConstants.KEY_BAIN_POWER_STATE].set_read_write( 44 | "ALL" 45 | ) 46 | 47 | self.geysair = GeckoSwitch( 48 | facade, 49 | GeckoConstants.KEY_BAIN_GEYSAIR, 50 | ( 51 | "Geysair", 52 | GeckoConstants.KEY_BAIN_GEYSAIR, 53 | GeckoConstants.DEVICE_CLASS_BLOWER, 54 | ), 55 | ) 56 | self.geysair.watch(self._on_change) 57 | # The spa pack didn't set Read/Write on the geysair property, but the BU client 58 | # wrote to it anyway, so we will too... 59 | self.facade.spa.accessors[GeckoConstants.KEY_BAIN_GEYSAIR].set_read_write("ALL") 60 | 61 | self.backrest = GeckoSelect( 62 | facade, "Heated Backrest", GeckoConstants.KEY_BAIN_HEATER1 63 | ) 64 | self.backrest.watch(self._on_change) 65 | self.backrest2 = GeckoSelect( 66 | facade, "2nd Heated Backrest", GeckoConstants.KEY_BAIN_HEATER2 67 | ) 68 | self.backrest2.watch(self._on_change) 69 | 70 | self.bath_runtime = GeckoNumber( 71 | facade, "Duration", GeckoConstants.KEY_BAIN_DURATION, "min" 72 | ) 73 | self.bath_runtime.native_max_value = 30.0 74 | self.bath_runtime.watch(self._on_change) 75 | self.bath_intensity = GeckoNumber( 76 | facade, "Intensity", GeckoConstants.KEY_BAIN_INTENSITY, "%" 77 | ) 78 | self.bath_intensity.watch(self._on_change) 79 | 80 | self.drying_cycle = GeckoSelect( 81 | facade, "Drying Cycle", GeckoConstants.KEY_BAIN_DRYING_CYCLE 82 | ) 83 | self.drying_cycle.watch(self._on_change) 84 | self.drying_cycle_delay = GeckoNumber( 85 | facade, 86 | "Drying Cycle Delay", 87 | GeckoConstants.KEY_BAIN_DRYING_CYCLE_DELAY, 88 | "min", 89 | ) 90 | self.drying_cycle_delay.native_step = 5.0 91 | self.drying_cycle_delay.watch(self._on_change) 92 | 93 | self.drying_cycle_hour = GeckoNumber( 94 | facade, "Drying Cycle Hour", GeckoConstants.KEY_BAIN_DRYING_CYCLE_HOUR, "hr" 95 | ) 96 | self.drying_cycle_hour.native_max_value = 23.0 97 | self.drying_cycle_hour.watch(self._on_change) 98 | 99 | self.drying_cycle_minute = GeckoNumber( 100 | facade, 101 | "Drying Cycle Minute", 102 | GeckoConstants.KEY_BAIN_DRYING_CYCLE_MINUTE, 103 | "min", 104 | ) 105 | self.drying_cycle_minute.native_max_value = 59.0 106 | self.drying_cycle_minute.native_step = 5.0 107 | self.drying_cycle_minute.watch(self._on_change) 108 | 109 | self.chroma = GeckoSelect(facade, "Chroma", GeckoConstants.KEY_BAIN_CHROMA) 110 | has_chromo = self.facade.spa.accessors[GeckoConstants.KEY_BAIN_HAS_CHROMO] 111 | self.chroma.set_availability(is_available=has_chromo.value == "DELUXE") 112 | 113 | @property 114 | def switches(self) -> list[GeckoSwitch]: 115 | """Get a list of all the switches.""" 116 | if self.is_available: 117 | return [self.onoff, self.geysair] 118 | return [] 119 | 120 | @property 121 | def selects(self) -> list[GeckoSelect]: 122 | """Get a list of all the selects.""" 123 | if self.is_available: 124 | return [self.backrest, self.backrest2] 125 | return [] 126 | 127 | @property 128 | def current_operation(self) -> str: 129 | """Get the current operation.""" 130 | if not self.onoff.is_on: 131 | return "Turned off" 132 | return f"Running, {self.bath_runtime} remaining" 133 | -------------------------------------------------------------------------------- /src/geckolib/automation/sensors.py: -------------------------------------------------------------------------------- 1 | """Gecko automation support for sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from geckolib.driver import GeckoBoolStructAccessor 9 | from geckolib.driver.accessor import GeckoStructAccessor 10 | 11 | from .base import GeckoAutomationFacadeBase 12 | 13 | if TYPE_CHECKING: 14 | from geckolib.automation.async_facade import GeckoAsyncFacade 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class GeckoSensorBase(GeckoAutomationFacadeBase): 20 | """Base sensor allows non-accessor sensors to be implemented.""" 21 | 22 | def __init__( 23 | self, facade: GeckoAsyncFacade, name: str, device_class: str | None = None 24 | ) -> None: 25 | """Initialize the sensor base.""" 26 | super().__init__(facade, name, name.upper()) 27 | self._device_class = device_class 28 | 29 | @property 30 | def state(self) -> Any | None: 31 | """The state of the sensor.""" 32 | return None 33 | 34 | @property 35 | def unit_of_measurement(self) -> str | None: 36 | """The unit of measurement for the sensor, or None.""" 37 | return None 38 | 39 | @property 40 | def device_class(self) -> str | None: 41 | """The device class.""" 42 | return self._device_class 43 | 44 | def __repr__(self) -> str: 45 | """Get a string representation.""" 46 | return f"{self.name}: {self.state}" 47 | 48 | 49 | ######################################################################################## 50 | class GeckoSensor(GeckoSensorBase): 51 | """ 52 | Sensors wrapper. 53 | 54 | Take accessors state with extra units and device 55 | class properties 56 | """ 57 | 58 | def __init__( 59 | self, 60 | facade: GeckoAsyncFacade, 61 | name: str, 62 | accessor: GeckoStructAccessor, 63 | unit_accessor: GeckoStructAccessor | str | None = None, 64 | device_class: str | None = None, 65 | ) -> None: 66 | """Initialize the sensor class.""" 67 | super().__init__(facade, name, device_class) 68 | self._accessor = accessor 69 | # Bubble up change notification 70 | accessor.watch(self._on_change) 71 | self._unit_of_measurement_accessor = unit_accessor 72 | if isinstance(self._unit_of_measurement_accessor, GeckoStructAccessor): 73 | unit_accessor.watch(self._on_change) 74 | 75 | @property 76 | def state(self) -> Any: 77 | """The state of the sensor.""" 78 | return self._accessor.value 79 | 80 | @property 81 | def unit_of_measurement(self) -> str | None: 82 | """The unit of measurement for the sensor, or None.""" 83 | if isinstance(self._unit_of_measurement_accessor, GeckoStructAccessor): 84 | return self._unit_of_measurement_accessor.value 85 | return self._unit_of_measurement_accessor 86 | 87 | @property 88 | def accessor(self) -> GeckoStructAccessor: 89 | """Access the accessor member.""" 90 | return self._accessor 91 | 92 | @property 93 | def monitor(self) -> str: 94 | """Get monitor string.""" 95 | return f"{self.accessor.tag}: {self.state}" 96 | 97 | 98 | ######################################################################################## 99 | class GeckoBinarySensor(GeckoSensor): 100 | """Binary sensors only have two states.""" 101 | 102 | @property 103 | def is_on(self) -> bool: 104 | """Determine if the sensor is on or not.""" 105 | state = self.state 106 | if isinstance(state, bool): 107 | return state 108 | if state == "": 109 | return False 110 | return state != "OFF" 111 | 112 | def __repr__(self) -> str: 113 | """Get string representation.""" 114 | return f"{self.name}: {self.is_on}" 115 | 116 | 117 | ######################################################################################## 118 | class GeckoErrorSensor(GeckoSensorBase): 119 | """Error sensor aggregates all the error keys into a comma separated text string.""" 120 | 121 | def __init__( 122 | self, facade: GeckoAsyncFacade, device_class: str | None = None 123 | ) -> None: 124 | """Initialise the error sensor class.""" 125 | super().__init__(facade, "Error Sensor", device_class) 126 | self._state = "No errors or warnings" 127 | 128 | # Listen for changes to any of the error spapack accessors 129 | for accessor_key in facade.spa.struct.error_keys: 130 | accessor = facade.spa.struct.accessors[accessor_key] 131 | accessor.watch(self.update_state) 132 | 133 | # Force initial state 134 | self.update_state() 135 | 136 | @property 137 | def state(self) -> str: 138 | """The state of the sensor.""" 139 | return self._state 140 | 141 | def update_state( 142 | self, _sender: Any = None, _old_value: Any = None, _new_value: Any = None 143 | ) -> None: 144 | """Update the state.""" 145 | self._state = "" 146 | 147 | active_errors = [ 148 | accessor 149 | for accessor_key, accessor in self.facade.spa.struct.accessors.items() 150 | if accessor_key in self.facade.spa.struct.error_keys 151 | and isinstance(accessor, GeckoBoolStructAccessor) 152 | and accessor.value is True 153 | ] 154 | 155 | if active_errors: 156 | self._state = ", ".join(err.tag for err in active_errors) 157 | _LOGGER.debug("Error sensor state is %s", self.state) 158 | else: 159 | self._state = "None" 160 | 161 | self._on_change(None, None, None) 162 | -------------------------------------------------------------------------------- /tests/test_protocol_watercare.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the UDP protocol handlers.""" # noqa: INP001 2 | 3 | # ruff: noqa: E501 4 | 5 | import unittest 6 | import unittest.mock 7 | 8 | from context import ( 9 | GeckoGetWatercareModeProtocolHandler, 10 | GeckoGetWatercareScheduleListProtocolHandler, 11 | GeckoSetWatercareModeProtocolHandler, 12 | GeckoWatercareScheduleManager, 13 | ) 14 | 15 | PARMS = (1, 2, b"SRCID", b"DESTID") 16 | 17 | 18 | class TestGeckoGetWatercareModeHandler(unittest.TestCase): 19 | """Get Watercare handler tests.""" 20 | 21 | def test_send_construct_get(self) -> None: 22 | handler = GeckoGetWatercareModeProtocolHandler.get(1, parms=PARMS) 23 | self.assertEqual( 24 | handler.send_bytes, 25 | b"DESTIDSRCID" 26 | b"GETWC\x01", 27 | ) 28 | 29 | def test_send_construct_response(self) -> None: 30 | handler = GeckoGetWatercareModeProtocolHandler.get_response(3, parms=PARMS) 31 | self.assertEqual( 32 | handler.send_bytes, 33 | b"DESTIDSRCID" 34 | b"WCGET\x03", 35 | ) 36 | 37 | def test_recv_can_handle(self) -> None: 38 | handler = GeckoGetWatercareModeProtocolHandler() 39 | self.assertTrue(handler.can_handle(b"GETWC", PARMS)) 40 | self.assertTrue(handler.can_handle(b"WCGET", PARMS)) 41 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 42 | 43 | def test_recv_handle_request(self) -> None: 44 | handler = GeckoGetWatercareModeProtocolHandler() 45 | handler.handle(b"GETWC\x01", PARMS) 46 | self.assertFalse(handler.should_remove_handler) 47 | self.assertEqual(handler._sequence, 1) 48 | 49 | def test_recv_handle_response(self) -> None: 50 | handler = GeckoGetWatercareModeProtocolHandler() 51 | handler.handle(b"WCGET\x03", PARMS) 52 | self.assertTrue(handler.should_remove_handler) 53 | self.assertEqual(handler.mode, 3) 54 | 55 | 56 | class TestGeckoSetWatercareModeHandler(unittest.TestCase): 57 | """Set watercare mode handler tests.""" 58 | 59 | def test_send_construct_request(self) -> None: 60 | handler = GeckoSetWatercareModeProtocolHandler.set(1, 2, parms=PARMS) 61 | self.assertEqual( 62 | handler.send_bytes, 63 | b"DESTIDSRCID" 64 | b"SETWC\x01\x02", 65 | ) 66 | 67 | def test_send_construct_response(self) -> None: 68 | handler = GeckoSetWatercareModeProtocolHandler.set_response(2, parms=PARMS) 69 | self.assertEqual( 70 | handler.send_bytes, 71 | b"DESTIDSRCID" 72 | b"WCSET\x02", 73 | ) 74 | 75 | def test_recv_can_handle(self) -> None: 76 | handler = GeckoSetWatercareModeProtocolHandler() 77 | self.assertTrue(handler.can_handle(b"SETWC", PARMS)) 78 | self.assertTrue(handler.can_handle(b"WCSET", PARMS)) 79 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 80 | 81 | def test_recv_handle_request(self) -> None: 82 | handler = GeckoSetWatercareModeProtocolHandler() 83 | handler.handle(b"SETWC\x01\x02", PARMS) 84 | self.assertFalse(handler.should_remove_handler) 85 | self.assertEqual(handler._sequence, 1) 86 | self.assertEqual(handler.mode, 2) 87 | 88 | def test_recv_handle_response(self) -> None: 89 | handler = GeckoSetWatercareModeProtocolHandler() 90 | handler.handle(b"WCSET\x02", PARMS) 91 | self.assertTrue(handler.should_remove_handler) 92 | self.assertEqual(handler.mode, 2) 93 | 94 | 95 | class TestGeckoGetWatercareScheduleListHandler(unittest.TestCase): 96 | """Get Watercare schedule list tests.""" 97 | 98 | def test_send_construct_request(self) -> None: 99 | handler = GeckoGetWatercareScheduleListProtocolHandler.get(1, parms=PARMS) 100 | self.assertEqual( 101 | handler.send_bytes, 102 | b"DESTIDSRCID" 103 | b"REQWC\x01", 104 | ) 105 | 106 | def test_send_construct_response(self) -> None: 107 | handler = GeckoGetWatercareScheduleListProtocolHandler.get_response( 108 | GeckoWatercareScheduleManager(), parms=PARMS 109 | ) 110 | self.assertEqual( 111 | handler.send_bytes, 112 | b"DESTIDSRCID" 113 | b"WCREQ\x00\x00\x00\x01\x00\x00\x06\x00\x00\x00\x00\x02\x01\x00" 114 | b"\x01\x05" 115 | b"\x06\x00\x12\x00\x03\x01\x00\x00\x06\x06\x00\x12\x00\x04\x01\x00" 116 | b"\x01\x05\x00\x00\x00\x00", 117 | ) 118 | 119 | def test_recv_can_handle(self) -> None: 120 | handler = GeckoGetWatercareScheduleListProtocolHandler() 121 | self.assertTrue(handler.can_handle(b"REQWC", PARMS)) 122 | self.assertTrue(handler.can_handle(b"WCREQ", PARMS)) 123 | self.assertFalse(handler.can_handle(b"OTHER", PARMS)) 124 | 125 | def test_recv_handle_request(self) -> None: 126 | handler = GeckoGetWatercareScheduleListProtocolHandler() 127 | handler.handle(b"REQWC\x01", PARMS) 128 | self.assertFalse(handler.should_remove_handler) 129 | self.assertEqual(handler._sequence, 1) 130 | 131 | def test_recv_handle_response(self) -> None: 132 | handler = GeckoGetWatercareScheduleListProtocolHandler() 133 | handler.handle(b"WCREQ\x02", PARMS) 134 | self.assertTrue(handler.should_remove_handler) 135 | 136 | 137 | if __name__ == "__main__": 138 | unittest.main() 139 | -------------------------------------------------------------------------------- /src/geckolib/driver/protocol/reminders.py: -------------------------------------------------------------------------------- 1 | """Gecko REQRM/RMREQ handlers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from enum import IntEnum 8 | from typing import Any 9 | 10 | from geckolib.config import GeckoConfig 11 | 12 | from .packet import GeckoPacketProtocolHandler 13 | 14 | REQRM_VERB = b"REQRM" # Request all reminders 15 | RMREQ_VERB = b"RMREQ" # Response with all 10 reminders 16 | SETRM_VERB = b"SETRM" # Set all 10 reminders 17 | RMSET_VERB = b"RMSET" # Ack for the reminder set 18 | 19 | RESPONSE_FORMAT = ">BBB" 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class GeckoReminderType(IntEnum): 25 | """Reminder type class.""" 26 | 27 | INVALID = 0 28 | RINSE_FILTER = 1 29 | CLEAN_FILTER = 2 30 | CHANGE_WATER = 3 31 | CHECK_SPA = 4 32 | CHANGE_OZONATOR = 5 33 | CHANGE_VISION_CARTRIDGE = 6 34 | 35 | @staticmethod 36 | def to_string(the_type: GeckoReminderType) -> str: # noqa: PLR0911 37 | """Converet enum to string.""" 38 | if the_type == GeckoReminderType.INVALID: 39 | return "Invalid" 40 | if the_type == GeckoReminderType.RINSE_FILTER: 41 | return "RinseFilter" 42 | if the_type == GeckoReminderType.CLEAN_FILTER: 43 | return "CleanFilter" 44 | if the_type == GeckoReminderType.CHANGE_WATER: 45 | return "ChangeWater" 46 | if the_type == GeckoReminderType.CHECK_SPA: 47 | return "CheckSpa" 48 | if the_type == GeckoReminderType.CHANGE_OZONATOR: 49 | return "ChangeOzonator" 50 | if the_type == GeckoReminderType.CHANGE_VISION_CARTRIDGE: 51 | return "ChangeVisionCartridge" 52 | # Technically unreachable code here 53 | return "Unhandled" 54 | 55 | 56 | class GeckoRemindersProtocolHandler(GeckoPacketProtocolHandler): 57 | """Reminders protocol handler.""" 58 | 59 | @staticmethod 60 | def request(seq: int, **kwargs: Any) -> GeckoRemindersProtocolHandler: 61 | """Generate request.""" 62 | return GeckoRemindersProtocolHandler( 63 | content=b"".join([REQRM_VERB, struct.pack(">B", seq)]), 64 | timeout=GeckoConfig.PROTOCOL_TIMEOUT_IN_SECONDS, 65 | on_retry_failed=GeckoPacketProtocolHandler.default_retry_failed_handler, 66 | **kwargs, 67 | ) 68 | 69 | @staticmethod 70 | def req_response( 71 | reminders: list[tuple[GeckoReminderType, int]], **kwargs: Any 72 | ) -> GeckoRemindersProtocolHandler: 73 | """Generate response handler.""" 74 | return GeckoRemindersProtocolHandler( 75 | content=b"".join( 76 | [RMREQ_VERB] 77 | + [ 78 | struct.pack(" GeckoRemindersProtocolHandler: 89 | """Generate a set command.""" 90 | return GeckoRemindersProtocolHandler( 91 | content=b"".join( 92 | [SETRM_VERB, struct.pack(">B", seq)] 93 | + [ 94 | struct.pack(" GeckoRemindersProtocolHandler: 105 | """Generate a set response.""" 106 | return GeckoRemindersProtocolHandler(content=b"".join([RMSET_VERB]), **kwargs) 107 | 108 | def __init__(self, **kwargs: Any) -> None: 109 | """Initialize the reminders protocol handler class.""" 110 | super().__init__(**kwargs) 111 | self.reminders: list[tuple[GeckoReminderType, int]] = [] 112 | self.is_request: bool = False 113 | 114 | def can_handle(self, received_bytes: bytes, _sender: tuple) -> bool: 115 | """Can we handle this verb.""" 116 | return received_bytes.startswith( 117 | (REQRM_VERB, RMREQ_VERB, SETRM_VERB, RMSET_VERB) 118 | ) 119 | 120 | def _extract_reminders(self, remainder: bytes) -> None: 121 | rest = remainder 122 | while len(rest) > 0: 123 | (t, days, _push, rest) = struct.unpack(f" None: 130 | """Handle the verb.""" 131 | remainder = received_bytes[5:] 132 | self.is_request = False 133 | self.reminders = [] 134 | 135 | if received_bytes.startswith(REQRM_VERB): 136 | self._sequence = struct.unpack(">B", remainder[0:1])[0] 137 | self.is_request = True 138 | return # Stay in the handler list 139 | 140 | if received_bytes.startswith(SETRM_VERB): 141 | self._sequence = struct.unpack(">B", remainder[0:1])[0] 142 | self._extract_reminders(remainder[1:]) 143 | return # Stay in the handler list 144 | 145 | if received_bytes.startswith(RMSET_VERB): 146 | pass 147 | 148 | # Otherwise must be RMREQ 149 | if received_bytes.startswith(RMREQ_VERB): 150 | self._extract_reminders(remainder) 151 | 152 | self._should_remove_handler = True 153 | --------------------------------------------------------------------------------