Skip to content

Commit 5d000da

Browse files
committed
Add automated tests and switch to poetry.
1 parent 9e9f99d commit 5d000da

15 files changed

Lines changed: 1291 additions & 8 deletions

.github/workflows/publisher.yaml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,37 @@ name: Publisher
33
on:
44
push:
55
branches:
6-
- dev
6+
- '**'
77
tags:
8-
- v*
8+
- '*'
99

1010
jobs:
11+
tests:
12+
runs-on: ubuntu-latest
13+
name: Test
14+
steps:
15+
- name: Check out the repository
16+
uses: actions/checkout@v6.0.2
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v6
20+
with:
21+
python-version: '3.12'
22+
23+
- name: Install dependencies
24+
run: |
25+
python -m pip install --upgrade pip
26+
python -m pip install poetry
27+
python -m poetry install --with dev --no-interaction --no-ansi
28+
29+
- name: Run tests
30+
run: python -m poetry run pytest
31+
1132
publish:
1233
runs-on: ubuntu-latest
1334
name: Publish
35+
needs: tests
36+
if: ${{ (github.ref_type == 'branch' && github.ref_name == 'dev') || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) }}
1437
steps:
1538
- name: Check out the repository
1639
uses: actions/checkout@v6.0.2

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,19 @@ Depends on Home Assistant for presence and weather information.
1919
2. Open the menu (****) and choose **Repositories**.
2020
3. Add <https://github.com/lexbrugman/ha-apps> as a repository.
2121
4. Find **Evohome Helper** in the store and install it.
22+
23+
24+
## Development
25+
26+
This project uses [Poetry](https://python-poetry.org/) for dependency management.
27+
28+
```bash
29+
poetry install --with dev
30+
poetry run pytest
31+
```
32+
33+
Run the service locally with:
34+
35+
```bash
36+
poetry run python src/main.py
37+
```

poetry.lock

Lines changed: 435 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[tool.poetry]
2+
package-mode = false
3+
4+
[tool.poetry.dependencies]
5+
python = ">=3.10,<4.0"
6+
evohomeclient = "0.3.9"
7+
requests = "2.32.5"
8+
9+
[tool.poetry.group.dev.dependencies]
10+
pytest = "9.0.2"
11+
freezegun = "1.5.5"
12+
13+
[build-system]
14+
requires = ["poetry-core>=1.0.0"]
15+
build-backend = "poetry.core.masonry.api"
16+
17+
[tool.pytest.ini_options]
18+
pythonpath = ["src"]

src/evohome_helper/evohome.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ def __init__(self, mode, status):
2828
def get_all(cls):
2929
return set(cls)
3030

31+
@classmethod
32+
def get_by_status(cls, status_code: int):
33+
for status in cls:
34+
if status.status == status_code:
35+
return status
36+
37+
return None
38+
3139
@classmethod
3240
def get_by_mode(cls, mode: str):
3341
for status in cls:

src/evohome_helper/func_tools.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def function_wrapper(*args, **kwargs):
3939
**kwargs
4040
)
4141

42+
function_wrapper.cache_clear = _cache_call.cache_clear
43+
4244
return function_wrapper
4345

4446
return function_decorator

src/evohome_helper/presence.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def is_someone_home():
1919
def is_in_away_grace_period():
2020
for entity_id in settings.HOMEASSISTANT_PRESENCE_ENTITIES:
2121
seconds_since_last_seen = _get_data(entity_id).get("seconds_since_last_seen")
22-
if seconds_since_last_seen and seconds_since_last_seen <= settings.PRESENCE_LAST_HOME_GRACE_TIME:
22+
if seconds_since_last_seen is not None and seconds_since_last_seen <= settings.PRESENCE_LAST_HOME_GRACE_TIME:
2323
return True
2424

2525
return False

src/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
from logging import config as log_config
88

9+
from evohome_helper import evohome
10+
from evohome_helper import presence
11+
912
log_config.fileConfig(os.path.join(os.path.dirname(__file__), "logging.conf"))
1013
logger = logging.getLogger(__name__)
1114

@@ -41,9 +44,6 @@ def set_thermostat_mode():
4144

4245

4346
if __name__ == "__main__":
44-
from evohome_helper import evohome
45-
from evohome_helper import presence
46-
4747
while True:
4848
try:
4949
set_thermostat_mode()

src/requirements.txt

Lines changed: 0 additions & 2 deletions
This file was deleted.

tests/conftest.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import sys
2+
import types
3+
from dataclasses import dataclass, field
4+
from unittest.mock import Mock
5+
6+
import pytest
7+
8+
9+
def _switchpoint(time_of_day, heat_setpoint):
10+
return {"TimeOfDay": time_of_day, "heatSetpoint": heat_setpoint}
11+
12+
13+
def _day_schedule(day_of_week, switchpoints):
14+
return {"DayOfWeek": day_of_week, "Switchpoints": switchpoints}
15+
16+
17+
def _switch_point_reference(day_of_week, time_of_day):
18+
return {"DayOfWeek": day_of_week, "TimeOfDay": time_of_day}
19+
20+
21+
if "settings" not in sys.modules:
22+
settings = types.SimpleNamespace(
23+
EVOHOME_USERNAME="user",
24+
EVOHOME_PASSWORD="pass",
25+
EVOHOME_LOCATION_NAME="Home",
26+
EVOHOME_OFF_TEMP_THRESHOLD=5,
27+
EVOHOME_AWAY_MODE="away",
28+
AUTO_ECO_ENABLED=True,
29+
AUTO_ECO_OUTSIDE_TEMP_THRESHOLD=14,
30+
AUTO_ECO_INSIDE_TEMP_DIFF=2,
31+
PRESENCE_HEATING_SCHEDULE_GRACE_TIME=1800,
32+
PRESENCE_LAST_HOME_GRACE_TIME=1200,
33+
HOMEASSISTANT_URL="http://ha.local",
34+
HOMEASSISTANT_TOKEN="token",
35+
HOMEASSISTANT_AUTO_ECO_WEATHER_ENTITY="weather.home",
36+
HOMEASSISTANT_PRESENCE_ENTITIES=["person.a", "person.b"],
37+
)
38+
sys.modules["settings"] = settings
39+
40+
41+
if "evohomeclient2" not in sys.modules:
42+
class DummyBaseClient:
43+
def __init__(self, *_args, **_kwargs):
44+
self.locations = []
45+
46+
def installation(self):
47+
return None
48+
49+
sys.modules["evohomeclient2"] = types.SimpleNamespace(EvohomeClient=DummyBaseClient)
50+
51+
52+
@dataclass
53+
class FakeZone:
54+
name: str = "zone"
55+
activeFaults: list = field(default_factory=list)
56+
setpointStatus: dict = field(default_factory=lambda: {"setpointMode": "FollowSchedule", "targetHeatTemperature": 20})
57+
temperatureStatus: dict = field(default_factory=lambda: {"temperature": 19})
58+
_schedule: dict = field(default_factory=lambda: {"DailySchedules": []})
59+
60+
def schedule(self):
61+
return self._schedule
62+
63+
def set_schedule(self, daily_schedules):
64+
self._schedule = {"DailySchedules": daily_schedules}
65+
66+
def set_weekly_schedule(self, setpoint=20, time_of_day="07:00:00"):
67+
self._schedule = {
68+
"DailySchedules": [
69+
_day_schedule(day, [_switchpoint(time_of_day, setpoint)])
70+
for day in range(7)
71+
]
72+
}
73+
74+
75+
@dataclass
76+
class FakeControlSystem:
77+
mode: str = "Auto"
78+
zones: dict = field(default_factory=dict)
79+
systemId: str = "system-1"
80+
def __post_init__(self):
81+
self.systemModeStatus = {"mode": self.mode}
82+
self.set_status = Mock(side_effect=self._set_status)
83+
84+
def _set_status(self, status):
85+
from evohome_helper.evohome import ThermostatStatus
86+
87+
next_status = ThermostatStatus.get_by_status(status)
88+
if next_status is not None:
89+
self.systemModeStatus["mode"] = next_status.mode
90+
91+
92+
@dataclass
93+
class FakeGateway:
94+
control_systems: dict
95+
96+
97+
@dataclass
98+
class FakeLocation:
99+
name: str = "Home"
100+
gateways: dict = field(default_factory=dict)
101+
102+
103+
@dataclass
104+
class FakeEvohomeClient:
105+
locations: list = field(default_factory=list)
106+
installation_calls: int = 0
107+
108+
def installation(self):
109+
self.installation_calls += 1
110+
111+
def get_location(self, name):
112+
self.installation()
113+
for location in self.locations:
114+
if location.name == name:
115+
return location
116+
return None
117+
118+
119+
@pytest.fixture
120+
def evohome_factory():
121+
class Factory:
122+
@staticmethod
123+
def weekly_schedule(setpoint=20, time_of_day="07:00:00"):
124+
return {
125+
"DailySchedules": [
126+
Factory.day_schedule(day, [Factory.switchpoint(time_of_day, setpoint)])
127+
for day in range(7)
128+
]
129+
}
130+
131+
@staticmethod
132+
def day_schedule(day_of_week, switchpoints):
133+
return _day_schedule(day_of_week, switchpoints)
134+
135+
@staticmethod
136+
def switchpoint(time_of_day, heat_setpoint):
137+
return _switchpoint(time_of_day, heat_setpoint)
138+
139+
@staticmethod
140+
def switch_point_reference(day_of_week, time_of_day):
141+
return _switch_point_reference(day_of_week, time_of_day)
142+
143+
@staticmethod
144+
def zone(**kwargs):
145+
return FakeZone(**kwargs)
146+
147+
@staticmethod
148+
def control_system(mode="Auto", zones=None, system_id="system-1"):
149+
return FakeControlSystem(mode=mode, zones=zones or {}, systemId=system_id)
150+
151+
@staticmethod
152+
def location(*, name="Home", control_systems=None, gateway_id="gateway-1"):
153+
return FakeLocation(name=name, gateways={gateway_id: FakeGateway(control_systems=control_systems or {})})
154+
155+
@staticmethod
156+
def complete_state(*, location_name="Home", zone_mode="FollowSchedule", system_mode="Auto", with_fault=False, setpoint=21):
157+
zone = FakeZone(
158+
name="Living",
159+
setpointStatus={"setpointMode": zone_mode, "targetHeatTemperature": setpoint},
160+
temperatureStatus={"temperature": 20},
161+
activeFaults=["fault"] if with_fault else [],
162+
)
163+
control = FakeControlSystem(mode=system_mode, zones={"z1": zone}, systemId="sys-1")
164+
location = FakeLocation(name=location_name, gateways={"g1": FakeGateway(control_systems={"c1": control})})
165+
return {"location": location, "control_system": control, "zone": zone}
166+
167+
return Factory
168+
169+
170+
@pytest.fixture
171+
def evohome_client(evohome_factory):
172+
from evohome_helper import evohome
173+
174+
def _build(*, location_name="Home", **state_kwargs):
175+
state = evohome_factory.complete_state(location_name=location_name, **state_kwargs)
176+
client = FakeEvohomeClient(locations=[state["location"]])
177+
evohome._evohome_client = client
178+
return client, state
179+
180+
yield _build
181+
evohome._evohome_client = None
182+
183+
184+
@pytest.fixture
185+
def patch_evohome_client_class(monkeypatch):
186+
from evohome_helper import evohome
187+
188+
def _apply(client_class):
189+
monkeypatch.setattr("evohome_helper.evohome.EvohomeClient", client_class)
190+
monkeypatch.setattr("evohome_helper.evohome.sleep", lambda _seconds: None)
191+
evohome._evohome_client = None
192+
193+
yield _apply
194+
evohome._evohome_client = None
195+
196+
197+
@pytest.fixture(autouse=True)
198+
def reset_cached_state():
199+
from evohome_helper import presence, weather
200+
201+
presence.last_known_presence_state.clear()
202+
203+
presence._get_data.cache_clear()
204+
weather.get_temperature.cache_clear()
205+
206+

0 commit comments

Comments
 (0)