Skip to content

Commit 39f429a

Browse files
authored
Merge pull request #53 from traktore-org/develop
v1.0.8: Platinum quality scale — 803 tests, CI pipeline, beginner docs
2 parents 3b511a1 + 4e79e11 commit 39f429a

17 files changed

Lines changed: 2023 additions & 35 deletions

.github/workflows/tests.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [develop, main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.12", "3.13"]
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install pytest pytest-asyncio pytest-cov freezegun homeassistant
28+
29+
- name: Set up importable package structure
30+
run: |
31+
# Create custom_components structure outside the checkout
32+
# so pytest doesn't try to collect __init__.py from repo root
33+
mkdir -p /tmp/ha-config/custom_components
34+
cp -r $GITHUB_WORKSPACE /tmp/ha-config/custom_components/solar_energy_management
35+
36+
- name: Run tests with coverage
37+
working-directory: /tmp/ha-config/custom_components/solar_energy_management
38+
env:
39+
PYTHONPATH: /tmp/ha-config
40+
run: |
41+
python -m pytest tests/ \
42+
--cov=custom_components.solar_energy_management \
43+
--cov-report=term-missing \
44+
--cov-report=xml:$GITHUB_WORKSPACE/coverage.xml \
45+
-v --tb=short
46+
47+
- name: Upload coverage report
48+
if: always()
49+
uses: actions/upload-artifact@v4
50+
with:
51+
name: coverage-py${{ matrix.python-version }}
52+
path: coverage.xml
53+
54+
- name: Coverage summary
55+
if: always()
56+
working-directory: /tmp/ha-config/custom_components/solar_energy_management
57+
env:
58+
PYTHONPATH: /tmp/ha-config
59+
run: |
60+
python -m pytest tests/ \
61+
--cov=custom_components.solar_energy_management \
62+
--cov-report=term \
63+
-q --no-header 2>&1 | tail -30 || true

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Solar Energy Management (SEM)
66

77
[![GitHub Release][releases-shield]][releases]
8+
[![Tests](https://github.com/traktore-org/sem-community/actions/workflows/tests.yml/badge.svg)](https://github.com/traktore-org/sem-community/actions/workflows/tests.yml)
89
[![GitHub Activity][commits-shield]][commits]
910
[![License][license-shield]](LICENSE)
1011
[![hacs][hacsbadge]][hacs]
@@ -44,6 +45,8 @@ SEM monitors your solar production, battery, grid, and EV charger every 10 secon
4445

4546
## Installation
4647

48+
> **New to custom integrations?** See the [Step-by-Step Setup Guide](docs/SETUP_GUIDE.md) for a beginner-friendly walkthrough with checklist and FAQ.
49+
4750
### Via HACS (Recommended)
4851

4952
1. Open **HACS** > **Integrations** > **Custom repositories**

coordinator/coordinator.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
167167
# Phase 8: Consumption/solar predictor (#3)
168168
self._predictor = ConsumptionPredictor()
169169

170+
# Per-cycle caches (initialized here, populated in _async_update_data)
171+
self._cycle_forecast = None
172+
self._cycle_vehicle_soc: Optional[float] = None
173+
170174
# EV stall detection for self-healing
171175
self._ev_stalled_since: Optional[float] = None
172176

@@ -313,6 +317,19 @@ async def _async_update_data(self) -> Dict[str, Any]:
313317
self._observer_mode = observer_state.state == "on"
314318

315319
try:
320+
# Per-cycle caches — avoid redundant lookups within one 10s cycle (#52)
321+
self._cycle_forecast = self._forecast_reader.read_forecast()
322+
# Cache vehicle SOC (read in both _async_update_data and _determine_charging_strategy)
323+
_vehicle_soc_entity = self.config.get("vehicle_soc_entity", "")
324+
self._cycle_vehicle_soc = None
325+
if _vehicle_soc_entity:
326+
_soc_state = self.hass.states.get(_vehicle_soc_entity)
327+
if _soc_state and _soc_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
328+
try:
329+
self._cycle_vehicle_soc = float(_soc_state.state)
330+
except (ValueError, TypeError):
331+
pass
332+
316333
# Step 1: Read power values from sensors
317334
power = self._sensor_reader.read_power()
318335

@@ -505,15 +522,9 @@ async def _async_update_data(self) -> Dict[str, Any]:
505522
solar = lifetime.get("total_solar_kwh", 0)
506523
result["lifetime_ev_solar_share"] = round(solar / total * 100, 1) if total > 0 else 0
507524

508-
# Vehicle SOC (if configured)
509-
vehicle_soc_entity = self.config.get("vehicle_soc_entity", "")
510-
if vehicle_soc_entity:
511-
soc_state = self.hass.states.get(vehicle_soc_entity)
512-
if soc_state and soc_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
513-
try:
514-
result["vehicle_soc"] = float(soc_state.state)
515-
except (ValueError, TypeError):
516-
pass
525+
# Vehicle SOC (from per-cycle cache)
526+
if self._cycle_vehicle_soc is not None:
527+
result["vehicle_soc"] = self._cycle_vehicle_soc
517528

518529
# EV departure time (if configured via input_datetime entity)
519530
departure_entity = self.config.get("ev_departure_time_entity", "")
@@ -561,7 +572,7 @@ async def _update_analytics_phases(
561572
# Forecast (Phase 0.3)
562573
forecast_data = ForecastSensorData()
563574
try:
564-
forecast = self._forecast_reader.read_forecast()
575+
forecast = self._cycle_forecast
565576
if forecast.available:
566577
forecast_data.forecast_today_kwh = forecast.forecast_today_kwh
567578
forecast_data.forecast_tomorrow_kwh = forecast.forecast_tomorrow_kwh
@@ -877,14 +888,7 @@ def _determine_charging_strategy(self, power: PowerReadings, energy: Any) -> tup
877888
ev_battery_capacity = self.config.get("ev_battery_capacity_kwh", 40)
878889
ev_target_soc = self.config.get("ev_target_soc", 80)
879890

880-
vehicle_soc = None
881-
if vehicle_soc_entity:
882-
soc_state = self.hass.states.get(vehicle_soc_entity)
883-
if soc_state and soc_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
884-
try:
885-
vehicle_soc = float(soc_state.state)
886-
except (ValueError, TypeError):
887-
pass
891+
vehicle_soc = self._cycle_vehicle_soc
888892

889893
if vehicle_soc is not None:
890894
# SOC-based: remaining = (target_soc - current_soc) / 100 * capacity
@@ -965,7 +969,7 @@ def _determine_charging_strategy(self, power: PowerReadings, energy: Any) -> tup
965969
if power.battery_soc >= buffer_soc:
966970
# Use forecast if available to check if surplus alone is enough
967971
try:
968-
forecast = self._forecast_reader.read_forecast()
972+
forecast = self._cycle_forecast
969973
if forecast.available:
970974
surplus_factor = 0.5
971975
estimated_surplus = forecast.forecast_remaining_today_kwh * surplus_factor
@@ -999,7 +1003,7 @@ def _determine_charging_strategy(self, power: PowerReadings, energy: Any) -> tup
9991003
)
10001004
reason = f"Zone 2: SOC={power.battery_soc:.0f}% in [{priority_soc}%..{buffer_soc}%) — surplus only"
10011005
try:
1002-
forecast = self._forecast_reader.read_forecast()
1006+
forecast = self._cycle_forecast
10031007
if forecast.available:
10041008
surplus_factor = 0.5
10051009
estimated_surplus = forecast.forecast_remaining_today_kwh * surplus_factor
@@ -1055,7 +1059,7 @@ def _build_charging_context(
10551059
# Use EV budget (with battery redirect) instead of surplus-style available_power
10561060
forecast_remaining = 0
10571061
try:
1058-
forecast = self._forecast_reader.read_forecast()
1062+
forecast = self._cycle_forecast
10591063
if forecast.available:
10601064
forecast_remaining = forecast.forecast_remaining_today_kwh
10611065
except Exception:

0 commit comments

Comments
 (0)