Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
gazoodle committed Feb 5, 2025
2 parents 034aaf3 + 04ef819 commit e561711
Show file tree
Hide file tree
Showing 281 changed files with 67,111 additions and 28,641 deletions.
3 changes: 2 additions & 1 deletion .devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@
"ffmpeg",
"libturbojpeg0",
"libpcap-dev",
"iputils-ping",
"iputils-ping"
]
}
},
"postCreateCommand": "pip install -r requirements.txt",
"runArgs": [
"-v",
"${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh",
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: "Lint"

on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main, dev ]


jobs:
ruff:
name: "Ruff"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout the repository"
uses: "actions/[email protected]"

- name: "Set up Python"
uses: actions/[email protected]
with:
python-version: "3.13"
cache: "pip"

- name: "Install requirements"
run: python3 -m pip install -r requirements.txt

- name: "Run"
run: python3 -m ruff check .

- name: "Test"
run: pytest
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.13"]

steps:
- uses: actions/checkout@v4
Expand Down
88 changes: 45 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,53 +1,18 @@
# GeckoLib

Library to interface with Gecko Alliance spa pack systems via in.touch2
Async library to interface with Gecko Alliance spa pack systems via in.touch2

Written from the ground up using info gleaned from Wireshark captures to sniff
the conversation between the iOS app and the inTouch2 home transmitter.

Designed to be used by home automation systems such as Home Assistant or openHAB

_This library is currently in Alpha, meaning that there may be large changes
_This library is now Beta, meaning that there are unlikely to be major changes
in library shape, class definitions, behaviours etc as I client it in my ongoing
Home Assistant integration. This has now been released, in preview, and can be
found at https://github.com/gazoodle/gecko-home-assistant, or from HACS by adding
a new integration and seaching for Gecko_

## WARNING
This is the last version of this library that will support sync and async. The
next release will remove the sync API as it's too convoluted to support both in
the same codebase now.

# Async support

The core of the library has been rewritten to be async based. This is for several
reasons;

1. Home Assistant, my main client of the library prefers this pattern. I'd like to
get away from the "can't connect", "not supported" pattern and have the spa
connect immediately to the facade (which will do the handshake to the actual spa
asynchronously so that connection state can be shown in the UI if required).
This will improve HA startup performance and allow me to control
the retry connection pattern in the library without having to burden the HA
integration with this (HA doesn't like protocol in integrations)
2. I've done loads of multi-threaded programming in my life and think I'm familiar
with almost all kinds of problems this brings, but ... why bother when this isn't
necessary
3. While trying to implement a feature that supports occasionally disconnected
spas without generating reams of logging, I realized that I was fighting against
the previous architecture, so it's time to refactor that.
4. Every day is a school day. I've not seriously explored Python's async support :-)

Currently this isn't a breaking change, the sync library still has the functionality
that it always had (albeit with some major refactoring). There is a completely parallel
API and class set to support async clients.

I'll update the HA integration to use the async version as it's much faster to start
and it has more functionality. I know there are other automation clients using this
library, so the sync API and classes will stay here for a while until those clients have
had a chance to use the new async code, but I will deprecate them at some point,
probably when the library goes to v1.0.0

# Installation

This library is hosted on PyPI and can be installed with the following command-line
Expand Down Expand Up @@ -305,11 +270,6 @@ https://github.com/gazoodle/geckolib/tree/main/sample. Only works on Linux
The best example of use is in the Home Assistant integration which can be
found here https://github.com/gazoodle/gecko-home-assistant

# Sync API Usage

**WARNING** Sync functionality will be removed in a future release,
examples removed from README

# Acknowledgements

- Inspired by https://github.com/chicago6061/in.touch2.
Expand All @@ -334,11 +294,24 @@ https://www.gnu.org/licenses/gpl-3.0.html
- Add ability to set hours so we can implement a crude clock sync mechanism
- Look into getting shell & simulator using async API so that there are no
internal dependencies on the sync code any longer
- Move to pytest unit test framework
- Move to pytest unit test framework (replace all unittest fixtures and custom asserts)
- Use snapshots to generate some specific tests
- Build some documentation
- Add coverage to GitHub package workflow
- API set_config_mode needs to be per device rather than global
- Need to develop a way to force reconnection if certain accessors change, the intouch2 application
will do this if certain values are changed, and using the simulator snapshots, I've noticed that
the HA integration can get confused leading to possible values beiing posted to the wrong locations.
- Move localizable strings so that HA can handle itself

## Done/Fixed in 1.0.0
- Breaking change, removed all sync APIs
- Require Python 3.13 as minimum version
- Made unit tests pass after sync API removal
- Refactor a bit of the protocol stack to DRY out some code
- Added "Spa In Use" sensor
- Added useful diagnostic functionality to shell and simulator
- Added support for external heat sources

## Done/Fixed in 0.4.20
- Remove deprecated constant, it's only available in Python 3.13 from warnings, we can re-add it
Expand Down Expand Up @@ -602,6 +575,35 @@ https://www.gnu.org/licenses/gpl-3.0.html
- Automation interface added
- Timeout retry of command to make it more robust on busy networks

# Thoughts and musings

The current facade control mechanism was based on my first experience with a single spa, and not much
community feedback which has now changed, I now think that I need to update it following a greater
understanding.

The first thing is that the intouch2 app only ever seems to send KEYPAD commands, and these are
handled by the spa and seem to be converted into UserDemand changes.

When the UserDemand property changes, the spa also seems to make other changes to the data structures
such as setting pump run times, light run times and so on, all of which we can see in the client
apps and the simulator.

I have recently done quite a bit of work in the simulator and client to remove the old sync code
and in doing so, I have refactored a good chunk of the base and got a better idea on how to improve
it.

Sometimes the intouch2 app shows the state of devices based on the timers for some reason, at
least if I change the UserDemand for the time then the app UI updates, but other times it responds
to when the UdP1/UdLi status changes. This needs more investigation!

In the current code, the possible values for the user demands is a direct copy of the info
that is in the SpaStruct.xml file, but if that was modified based on the information in the
Config accessors, then the facade could be more aware of single speed, two speed and variable
speed pumps that seem to be supported.

I've also recently updated the spa pack generator to include the structures for inMix and other
Gecko products so that I can look into how to drive those devices too.

# Version

Using Semantic versioning https://semver.org/
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

[tool.black]
target-version = ["py311", "py312", "py313"]
target-version = ["py313"]
exclude = 'generated'
line-length = 88

Expand All @@ -26,3 +26,4 @@ not_skip = '__init__.py'
sections = ['FUTURE','STDLIB','THIRDPARTY','FIRSTPARTY','LOCALFOLDER']
default_section = 'THIRDPARTY'
#known_first_party = custom_components.gecko

3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pip>=21.3.1
ruff==0.9.1
requests==2.32.3
requests==2.32.3
pytest-asyncio==0.25.2
7 changes: 5 additions & 2 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml

target-version = "py312"
target-version = "py313"

[lint]
select = [
Expand All @@ -23,4 +23,7 @@ fixture-parentheses = false
keep-runtime-typing = true

[lint.mccabe]
max-complexity = 25
max-complexity = 25

[lint.per-file-ignores]
"tests/test_*.py" = ["PT009", "D102", "SLF001", "S101", "PLR2004"]
2 changes: 1 addition & 1 deletion sample/abstract_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async def process_input(self) -> None:
_LOGGER.debug("Input loop cancelled")
raise

except: # noqa
except:
_LOGGER.exception("Exception in input loop")
raise

Expand Down
7 changes: 3 additions & 4 deletions sample/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import configparser
import logging
from typing import Optional

# Configuration file constants
CONFIG_FILE = "sample.ini"
Expand Down Expand Up @@ -31,7 +30,7 @@ def save(self) -> None:
self._config.write(configfile)

@property
def spa_id(self) -> Optional[str]:
def spa_id(self) -> str | None:
return self._config[CK_DEFAULT].get(CK_SPA_ID, None)

def set_spa_id(self, spa_id) -> None:
Expand All @@ -41,7 +40,7 @@ def set_spa_id(self, spa_id) -> None:
self._config[CK_DEFAULT][CK_SPA_ID] = spa_id

@property
def spa_address(self) -> Optional[str]:
def spa_address(self) -> str | None:
return self._config[CK_DEFAULT].get(CK_SPA_ADDR, None)

def set_spa_address(self, spa_address) -> None:
Expand All @@ -51,7 +50,7 @@ def set_spa_address(self, spa_address) -> None:
self._config[CK_DEFAULT][CK_SPA_ADDR] = spa_address

@property
def spa_name(self) -> Optional[str]:
def spa_name(self) -> str | None:
return self._config[CK_DEFAULT].get(CK_SPA_NAME, None)

def set_spa_name(self, spa_name) -> None:
Expand Down
5 changes: 3 additions & 2 deletions sample/context_sample.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
""" Fix the path """
"""Fix the path"""

import os
import sys

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))

from geckolib import * # noqa: E402, F401, F403
from geckolib import * # noqa: F403
80 changes: 42 additions & 38 deletions sample/cui.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ def make_display(self) -> None:
for reminder in self.facade.reminders_manager.reminders:
lines.append(f"{reminder}")
lines.append(f"{self.facade.water_care}")
if self.facade.heatpump is not None:
lines.append(f"{self.facade.heatpump}")
if self.facade.ingrid is not None:
lines.append(f"{self.facade.ingrid}")
for sensor in [
*self.facade.sensors,
*self.facade.binary_sensors,
Expand All @@ -172,45 +176,45 @@ def make_display(self) -> None:
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append(f"{self.facade.error_sensor}")
lines.append(f"{self.facade.spa_in_use_sensor}")

elif self.spa_state == GeckoSpaState.LOCATED_SPAS:
if self.spa_descriptors is not None:
# If the _spas property is available, that means we've got
# a list of spas that we can choose from
for idx, spa in enumerate(self.spa_descriptors, start=1):
lines.append(f"{idx}. {spa.name} at {spa.ipaddress}")
self._commands[f"{idx}"] = (self._select_spa, spa)

if len(self.spa_descriptors) == 0:
lines.append("No spas were found on your network")

elif self.spa_state == GeckoSpaState.CONNECTED:
lines.append(f"{self.spa_name} connecting")
lines.append(f"{self.ping_sensor}")
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append("")

else:
if self.spa_state == GeckoSpaState.LOCATED_SPAS:
if self.spa_descriptors is not None:
# If the _spas property is available, that means we've got
# a list of spas that we can choose from
for idx, spa in enumerate(self.spa_descriptors, start=1):
lines.append(f"{idx}. {spa.name} at {spa.ipaddress}")
self._commands[f"{idx}"] = (self._select_spa, spa)

if len(self.spa_descriptors) == 0:
lines.append("No spas were found on your network")

elif self.spa_state == GeckoSpaState.CONNECTED:
lines.append(f"{self.spa_name} connecting")
lines.append(f"{self.ping_sensor}")
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append("")

elif self.spa_state == GeckoSpaState.ERROR_RF_FAULT:
lines.append(f"{self.spa_name} not ready")
lines.append(f"{self.ping_sensor}")
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append("")
lines.append(
"Lost contact with your spa, it looks as if it is turned off"
)

elif self.spa_state == GeckoSpaState.ERROR_PING_MISSED:
lines.append(f"{self.spa_name} not ready")
lines.append(f"{self.ping_sensor}")
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append("")
lines.append(
"Lost contact with your intouch2 module, please investigate"
)
elif self.spa_state == GeckoSpaState.ERROR_RF_FAULT:
lines.append(f"{self.spa_name} not ready")
lines.append(f"{self.ping_sensor}")
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append("")
lines.append(
"Lost contact with your spa, it looks as if it is turned off"
)

elif self.spa_state == GeckoSpaState.ERROR_PING_MISSED:
lines.append(f"{self.spa_name} not ready")
lines.append(f"{self.ping_sensor}")
lines.append(f"{self.radio_sensor}")
lines.append(f"{self.channel_sensor}")
lines.append("")
lines.append(
"Lost contact with your intouch2 module, please investigate"
)

lines.append("")

Expand Down
7 changes: 7 additions & 0 deletions scripts/test
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash

set -e

cd "$(dirname "$0")/../tests"
pytest
cd "$(dirname "$0")"
Loading

0 comments on commit e561711

Please sign in to comment.