From 28740d9dbfd4aae941b416c6dc656929ca05569b Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Mon, 23 Feb 2026 19:48:44 +0000 Subject: [PATCH 1/8] refactor: change log level from warning to info for barcode resolution and node table completion --- custom_components/pytap/coordinator.py | 6 +++--- custom_components/pytap/pytap/core/parser.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/pytap/coordinator.py b/custom_components/pytap/coordinator.py index a0e0317..4a7e3ff 100644 --- a/custom_components/pytap/coordinator.py +++ b/custom_components/pytap/coordinator.py @@ -606,7 +606,7 @@ def _handle_infrastructure(self, event: InfrastructureEvent) -> bool: configured_missing = self._configured_barcodes - set(new_barcode_to_node) if first_infra_with_nodes: - _LOGGER.warning( + _LOGGER.info( "First node table this session — barcode " "resolution now active. %d/%d configured barcodes matched " "in node table.", @@ -620,14 +620,14 @@ def _handle_infrastructure(self, event: InfrastructureEvent) -> bool: ", ".join(sorted(configured_missing)), ) elif mappings_changed and new_barcode_to_node: - _LOGGER.warning( + _LOGGER.info( "Barcode mappings updated — %d/%d configured barcodes now " "matched in node table.", len(configured_matched), len(self._configured_barcodes), ) if configured_missing: - _LOGGER.warning( + _LOGGER.info( "Configured barcodes still NOT found in node table: %s", ", ".join(sorted(configured_missing)), ) diff --git a/custom_components/pytap/pytap/core/parser.py b/custom_components/pytap/pytap/core/parser.py index a5f2d55..5a7c64d 100644 --- a/custom_components/pytap/pytap/core/parser.py +++ b/custom_components/pytap/pytap/core/parser.py @@ -850,7 +850,7 @@ def _handle_node_table_command( builder = self._node_table_builders.setdefault(gw_id, NodeTableBuilder()) result = builder.push(start_address, entries) if result is not None: - logger.warning( + logger.info( "NODE_TABLE complete for gw=%d: %d nodes resolved", gw_id, len(result), From 3ab6429b69da63db496d60ff74184c7a026b8577 Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Tue, 24 Feb 2026 20:27:31 +0000 Subject: [PATCH 2/8] Add PV Production Dashboard and Template Sensors - Implemented a new Lovelace dashboard for PV production with two views: 1. PV Panel Layout displaying daily energy production per panel. 2. Panel Connectivity showing the number of readings received today per panel. - Created template sensors for max-value aggregation of daily energy and readings across all panels, improving performance by offloading calculations to the server-side. - Enhanced unit tests for sensor state restoration, ensuring proper handling of restored states and fallback mechanisms during component startup. --- README.md | 5 + custom_components/pytap/coordinator.py | 145 ++++-- custom_components/pytap/sensor.py | 46 ++ dashboards/pv_production.md | 146 ++++++ dashboards/pv_production_dashboard.yaml | 571 ++++++++++++++++++++++++ dashboards/tigo.yaml | 48 ++ docs/implementation.md | 8 + planning/future_considerations.md | 3 +- tests/test_coordinator_persistence.py | 61 +++ tests/test_sensor.py | 190 +++++++- 10 files changed, 1187 insertions(+), 36 deletions(-) create mode 100644 dashboards/pv_production.md create mode 100644 dashboards/pv_production_dashboard.yaml create mode 100644 dashboards/tigo.yaml diff --git a/README.md b/README.md index 72b8bb3..ec53bbb 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ Please note that this integration requires RS485 to TCP converter that needs to - **Persistent barcode mapping** — Discovered barcodes and node mappings are saved across restarts. When you add a previously-discovered barcode, it resolves instantly without waiting for the next gateway enumeration. +- **Restart-safe availability** — Last known node readings are persisted and + restored on startup, so sensors stay available with the most recent value + even before the first live frame arrives (for example after a night restart). - **Energy accumulation** — Trapezoidal Wh integration with daily reset semantics and monotonic lifetime totals. - **No external dependencies** — The protocol parser library is fully embedded; nothing to install from PyPI. - **Options flow** — Add or remove optimizer modules at any time without reconfiguring. @@ -156,6 +159,8 @@ PyTap Coordinator PyTap uses a background listener thread that streams data from the gateway, parses protocol frames, and dispatches events to the Home Assistant event loop. Only events matching your configured barcodes create or update sensor entities. +On startup, PyTap restores persisted coordinator state (barcode mappings, energy accumulators, and last node snapshots). Sensor entities also use Home Assistant restore fallback if needed, so a restart during low/no production does not force entities into unavailable when prior data exists. + --- ## Development diff --git a/custom_components/pytap/coordinator.py b/custom_components/pytap/coordinator.py index 4a7e3ff..5831e3b 100644 --- a/custom_components/pytap/coordinator.py +++ b/custom_components/pytap/coordinator.py @@ -654,6 +654,75 @@ def _handle_topology(self, event: TopologyEvent) -> bool: # Persistence helpers # ------------------------------------------------------------------- + def _build_node_payload( + self, + barcode: str, + module_meta: dict[str, Any], + node_id: int | None, + ) -> dict[str, Any]: + """Build a baseline node payload for restored/startup state.""" + return { + "gateway_id": None, + "node_id": node_id, + "barcode": barcode, + "name": module_meta.get(CONF_MODULE_NAME, barcode), + "string": module_meta.get(CONF_MODULE_STRING, ""), + "peak_power": module_meta.get(CONF_MODULE_PEAK_POWER, DEFAULT_PEAK_POWER), + "voltage_in": None, + "voltage_out": None, + "current_in": None, + "current_out": None, + "power": None, + "performance": None, + "temperature": None, + "dc_dc_duty_cycle": None, + "rssi": None, + "daily_energy_wh": 0.0, + "total_energy_wh": 0.0, + "readings_today": 0, + "daily_reset_date": "", + "last_update": None, + } + + def _merge_snapshot_into_node( + self, + node_payload: dict[str, Any], + snapshot: dict[str, Any], + ) -> None: + """Merge persisted node snapshot fields into a baseline node payload.""" + for key in ( + "gateway_id", + "node_id", + "voltage_in", + "voltage_out", + "current_in", + "current_out", + "power", + "performance", + "temperature", + "dc_dc_duty_cycle", + "rssi", + "daily_energy_wh", + "total_energy_wh", + "readings_today", + "daily_reset_date", + "last_update", + "topology", + ): + if key in snapshot: + node_payload[key] = snapshot[key] + + def _merge_energy_into_node( + self, + node_payload: dict[str, Any], + acc: EnergyAccumulator, + ) -> None: + """Merge persisted accumulator values into a node payload.""" + node_payload["daily_energy_wh"] = round(acc.daily_energy_wh, 2) + node_payload["total_energy_wh"] = round(acc.total_energy_wh, 2) + node_payload["readings_today"] = acc.readings_today + node_payload["daily_reset_date"] = acc.daily_reset_date + async def _async_load_coordinator_state(self) -> None: """Load all persisted state (barcode mappings, discovered barcodes, parser state) from HA Store.""" try: @@ -714,49 +783,35 @@ async def _async_load_coordinator_state(self) -> None: readings_today=readings_today, ) - # Pre-populate coordinator.data["nodes"] for configured barcodes - # that have persisted energy state. This makes energy values - # available to sensors immediately on startup instead of waiting - # for the first live power report (which would otherwise cause a - # visible drop while RestoreSensor is the only fallback). + # Restore last known node snapshots for configured barcodes, and + # merge energy accumulator values where available. + node_snapshots = stored.get("node_snapshots", {}) for barcode in self._configured_barcodes: + snapshot = node_snapshots.get(barcode) acc = self._energy_state.get(barcode) - if acc is None: + if not isinstance(snapshot, dict) and acc is None: continue + module_meta = self._module_lookup.get(barcode, {}) node_id = self._barcode_to_node.get(barcode) - self.data["nodes"][barcode] = { - "gateway_id": None, - "node_id": node_id, - "barcode": barcode, - "name": module_meta.get(CONF_MODULE_NAME, barcode), - "string": module_meta.get(CONF_MODULE_STRING, ""), - "peak_power": module_meta.get( - CONF_MODULE_PEAK_POWER, DEFAULT_PEAK_POWER - ), - "voltage_in": None, - "voltage_out": None, - "current_in": None, - "current_out": None, - "power": None, - "performance": None, - "temperature": None, - "dc_dc_duty_cycle": None, - "rssi": None, - "daily_energy_wh": round(acc.daily_energy_wh, 2), - "total_energy_wh": round(acc.total_energy_wh, 2), - "readings_today": acc.readings_today, - "daily_reset_date": acc.daily_reset_date, - "last_update": None, - } + node_payload = self._build_node_payload(barcode, module_meta, node_id) + + if isinstance(snapshot, dict): + self._merge_snapshot_into_node(node_payload, snapshot) + + if acc is not None: + self._merge_energy_into_node(node_payload, acc) + + self.data["nodes"][barcode] = node_payload _LOGGER.info( "Restored coordinator state: %d barcode mappings, %d discovered barcodes, " - "%d gateway identities, %d energy states", + "%d gateway identities, %d energy states, %d node snapshots", len(barcode_to_node), len(discovered), len(self._persistent_state.gateway_identities), len(self._energy_state), + len(node_snapshots), ) async def _async_save_coordinator_state(self) -> None: @@ -783,6 +838,34 @@ async def _async_save_coordinator_state(self) -> None: } for barcode, acc in self._energy_state.items() }, + "node_snapshots": { + barcode: { + key: value + for key, value in node.items() + if key + in { + "gateway_id", + "node_id", + "voltage_in", + "voltage_out", + "current_in", + "current_out", + "power", + "performance", + "temperature", + "dc_dc_duty_cycle", + "rssi", + "daily_energy_wh", + "total_energy_wh", + "readings_today", + "daily_reset_date", + "last_update", + "topology", + } + } + for barcode, node in self.data["nodes"].items() + if barcode in self._configured_barcodes and isinstance(node, dict) + }, } try: await self._store.async_save(data) diff --git a/custom_components/pytap/sensor.py b/custom_components/pytap/sensor.py index 6f9b857..b2aece3 100644 --- a/custom_components/pytap/sensor.py +++ b/custom_components/pytap/sensor.py @@ -22,6 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -49,6 +51,22 @@ _LOGGER = logging.getLogger(__name__) +def _coerce_restored_state_value(raw_state: str, sensor_key: str) -> int | float | None: + """Convert a restored state string to a native numeric sensor value.""" + if raw_state in (STATE_UNKNOWN, STATE_UNAVAILABLE, "None", "none", ""): + return None + + try: + numeric_value = float(raw_state) + except (TypeError, ValueError): + return None + + if sensor_key == "readings_today": + return int(numeric_value) + + return numeric_value + + @dataclass(frozen=True, kw_only=True) class PyTapSensorEntityDescription(SensorEntityDescription): """Describes a PyTap sensor entity.""" @@ -361,12 +379,26 @@ async def async_added_to_hass(self) -> None: """Restore last known native value from Home Assistant state cache.""" await super().async_added_to_hass() if self.coordinator.data.get("nodes", {}).get(self._barcode) is not None: + self._handle_coordinator_update() return if restored := await self.async_get_last_sensor_data(): if restored.native_value is not None: self._attr_native_value = restored.native_value self._restored_native_value = True + return + + restored_state = await self.async_get_last_state() + if restored_state is None: + return + + native_value = _coerce_restored_state_value( + restored_state.state, + self.entity_description.key, + ) + if native_value is not None: + self._attr_native_value = native_value + self._restored_native_value = True @property def available(self) -> bool: @@ -465,12 +497,26 @@ async def async_added_to_hass(self) -> None: nodes = self.coordinator.data.get("nodes", {}) if any(nodes.get(barcode) is not None for barcode in self._barcodes): + self._handle_coordinator_update() return if restored := await self.async_get_last_sensor_data(): if restored.native_value is not None: self._attr_native_value = restored.native_value self._restored_native_value = True + return + + restored_state = await self.async_get_last_state() + if restored_state is None: + return + + native_value = _coerce_restored_state_value( + restored_state.state, + self.entity_description.key, + ) + if native_value is not None: + self._attr_native_value = native_value + self._restored_native_value = True @property def available(self) -> bool: diff --git a/dashboards/pv_production.md b/dashboards/pv_production.md new file mode 100644 index 0000000..bc64123 --- /dev/null +++ b/dashboards/pv_production.md @@ -0,0 +1,146 @@ +# PV Production Dashboard + +Home Assistant Lovelace dashboard displaying the physical solar panel layout with dynamic status coloring. + +## Files + +| File | Purpose | +|------|---------| +| [`pv_production_dashboard.yaml`](pv_production_dashboard.yaml) | Lovelace dashboard (2 views, 571 lines) | +| [`tigo.yaml`](tigo.yaml) | HA template sensors for max-value aggregation | + +## Prerequisites + +**HACS frontend components:** + +- **[custom:button-card](https://github.com/custom-cards/button-card)** — tile rendering with JS templates for dynamic styling +- **[custom:layout-card](https://github.com/thomasloven/lovelace-layout-card)** — CSS Grid layout for precise column positioning + +**Template sensors** — include `tigo.yaml` in your HA configuration (e.g. via `template: !include tigo.yaml` or a packages directory). It provides: + +| Sensor | Entity ID | Purpose | +|--------|-----------|---------| +| Tigo Max Daily Energy | `sensor.tigo_max_daily_energy` | Highest `_daily_energy` across all 24 panels (kWh) | +| Tigo Max Readings Today | `sensor.tigo_max_readings_today` | Highest `_readings_today` across all 24 panels | + +These are computed server-side with Jinja2 templates so each button-card only needs a single `states[]` lookup instead of scanning all 24 sensors in JavaScript. + +## Views + +### 1. PV Panel Layout (`/pv-layout`) + +Shows daily energy production per panel. Each tile displays the panel name and `sensor.tigo_ts4__daily_energy` value. + +**Color scale:** Transparent → Green. Green overlay alpha scales from 0 to 0.7 relative to `sensor.tigo_max_daily_energy`. An inner green glow intensifies with production. + +### 2. Panel Connectivity (`/panel-connectivity`) + +Shows the number of readings received today per panel using `sensor.tigo_ts4__readings_today`. + +**Color scale:** Red → Green. HSL hue scales 0° (red) → 120° (green) relative to `sensor.tigo_max_readings_today`. Low-connectivity panels are immediately visible in red. + +## Physical Layout + +Both views mirror the actual roof panel arrangement using a 26-column CSS grid. Panels have two orientations matching real hardware: + +- **Landscape (3:2)** — C2, C3 (top row) +- **Portrait (2:3)** — all other panels + +``` + col 11 col 14 + ┌──────────┐ ┌──────────┐ + Row 1 │ C2 (L) │ │ C3 (L) │ + └──────────┘ └──────────┘ + col 9 col 11 col 13 col 15 col 17 + ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ + Row 2 │C4│ │C10│ │C9 │ │C8 │ │C7 │ + └──┘ └──┘ └──┘ └──┘ └──┘ + + col 4 col 6 col 8 col 10 col 12 col 14 col 16 col 18 col 20 col 22 + ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ + Row 3 │C1│ │C6│ │D6│ │D7│ │D8│ │D9│ │C11│ + └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ + ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ + │D11│ │D5│ │C12│ │D4│ │D3│ │D1│ │D2│ │C5│ │C13│ │D10│ + └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ + Row 4 +``` + +- **Top section** (`grid-layout`): Row 1 has C2/C3 centered (landscape); Row 2 has C4, C10, C9, C8, C7 (portrait) +- **Bottom section** (`grid-layout`): Row 3 has C1–C11 main array; Row 4 extends left with D11/D5 and continues through D10 + +## Panels (24 total) + +| String C | String D | +|----------------|----------------| +| C1, C2, C3 | D1, D2, D3 | +| C4, C5, C6 | D4, D5, D6 | +| C7, C8, C9 | D7, D8, D9 | +| C10, C11, C12 | D10, D11 | +| C13 | | + +## Tile Rendering + +Each tile is a `custom:button-card` with a layered CSS background that simulates a solar panel: + +1. **Color overlay** — dynamic green (page 1) or hue-shifted (page 2) based on performance percentage +2. **Glass reflection** — diagonal gradient from white highlight to dark shadow +3. **Cell grid lines** — horizontal + vertical `repeating-linear-gradient` at 20% intervals +4. **Dark base** — `#0a111a` + +A dynamic `box-shadow` adds an inner glow whose intensity and color match the performance metric. + +### Page 1 color formula (Energy) + +```javascript +const max = parseFloat(states['sensor.tigo_max_daily_energy']?.state) || 1; +const pct = Math.min(Math.max(val, 0) / max, 1); +const alpha = pct * 0.7; +// Green overlay: rgba(0, 220, 50, alpha) +// Inner glow: rgba(0, 255, 50, 0.8 * pct) +``` + +### Page 2 color formula (Connectivity) + +```javascript +const max = parseFloat(states['sensor.tigo_max_readings_today']?.state) || 1; +const pct = Math.min(Math.max(val, 0) / max, 1); +const hue = Math.round(pct * 120); // 0°=red → 120°=green +// Color overlay: hsla(hue, 80%, 50%, 0.55) +// Inner glow: hsla(hue, 80%, 50%, 0.7) +``` + +## YAML Structure + +YAML anchors keep the file DRY: + +| Anchor | View | Orientation | Purpose | +|--------|------|-------------|---------| +| `&tile_landscape` | Energy | Landscape (3:2) | C2, C3 tiles | +| `&tile_portrait` | Energy | Portrait (2:3) | All other tiles | +| `&conn_tile_landscape` | Connectivity | Landscape (3:2) | C2, C3 tiles (red→green) | +| `&conn_tile_portrait` | Connectivity | Portrait (2:3) | All other tiles (red→green) | + +Both views use the same structural approach: + +1. **Top roof** — `custom:layout-card` with `grid-template-columns: repeat(26, 1fr)`, 6px gap +2. **Spacer** — blank `button-card` (20px height) +3. **Bottom roof** — second `custom:layout-card` with the same 26-column grid + +Each panel card specifies its exact position via `view_layout: { grid-row, grid-column }`, with portrait tiles spanning 2 columns and landscape tiles spanning 3. + +## tigo.yaml — Template Sensors + +Server-side Jinja2 template sensors that aggregate all 24 panel values: + +```yaml +template: + - sensor: + - name: "Tigo Max Daily Energy" # sensor.tigo_max_daily_energy + state: "{{ [all 24 _daily_energy states] | map('float', 0) | max }}" + + - name: "Tigo Max Readings Today" # sensor.tigo_max_readings_today + state: "{{ [all 24 _readings_today states] | map('float', 0) | max }}" +``` + +This offloads the max-value computation from the frontend (previously each of the 48 button-cards scanned all 24 sensors in JS) to a single HA template sensor that updates automatically. diff --git a/dashboards/pv_production_dashboard.yaml b/dashboards/pv_production_dashboard.yaml new file mode 100644 index 0000000..d0157d4 --- /dev/null +++ b/dashboards/pv_production_dashboard.yaml @@ -0,0 +1,571 @@ +title: PV Production +views: + - title: PV Panel Layout + path: pv-layout + panel: true + cards: + - type: vertical-stack + cards: + # ════════════════════════════════════════════════════════ + # TOP ROOF — 26-Column Grid (Forces panels to render smaller) + # ════════════════════════════════════════════════════════ + - type: custom:layout-card + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(26, 1fr) + grid-gap: 6px + margin: 0 + cards: + # Row 1: C2 & C3 (Landscape, 3 cols each, perfectly centered) + - type: custom:button-card + entity: sensor.tigo_ts4_c2_daily_energy + name: C2 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 11 / 14 } + styles: &tile_landscape + card: + - aspect-ratio: 3/2 + - border-radius: 4px + - border: "1px solid #3b4252" + - background: > + [[[ + const max = parseFloat(states['sensor.tigo_max_daily_energy']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const alpha = pct * 0.7; + return `linear-gradient(rgba(0, 220, 50, ${alpha}), rgba(0, 220, 50, ${alpha})), linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%), repeating-linear-gradient(0deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), repeating-linear-gradient(90deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), #0a111a`; + ]]] + - box-shadow: > + [[[ + const max = parseFloat(states['sensor.tigo_max_daily_energy']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + return `0 4px 8px rgba(0,0,0,0.5), inset 0 0 ${15 * pct}px rgba(0, 255, 50, ${0.8 * pct})`; + ]]] + name: + - font-size: 1.0em + - font-weight: 800 + - color: white + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + state: + - font-size: 0.85em + - font-weight: 600 + - color: "#e5e9f0" + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + + - type: custom:button-card + entity: sensor.tigo_ts4_c3_daily_energy + name: C3 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 14 / 17 } + styles: *tile_landscape + + # Row 2: C4-C7 (Portrait, 2 cols each) + - type: custom:button-card + entity: sensor.tigo_ts4_c4_daily_energy + name: C4 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 9 / 11 } + styles: &tile_portrait + card: + - aspect-ratio: 2/3 + - border-radius: 4px + - border: "1px solid #3b4252" + - background: > + [[[ + const max = parseFloat(states['sensor.tigo_max_daily_energy']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const alpha = pct * 0.7; + return `linear-gradient(rgba(0, 220, 50, ${alpha}), rgba(0, 220, 50, ${alpha})), linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%), repeating-linear-gradient(0deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), repeating-linear-gradient(90deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), #0a111a`; + ]]] + - box-shadow: > + [[[ + const max = parseFloat(states['sensor.tigo_max_daily_energy']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + return `0 4px 8px rgba(0,0,0,0.5), inset 0 0 ${15 * pct}px rgba(0, 255, 50, ${0.8 * pct})`; + ]]] + name: + - font-size: 1.0em + - font-weight: 800 + - color: white + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + state: + - font-size: 0.85em + - font-weight: 600 + - color: "#e5e9f0" + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + + - type: custom:button-card + entity: sensor.tigo_ts4_c10_daily_energy + name: C10 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 11 / 13 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c9_daily_energy + name: C9 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 13 / 15 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c8_daily_energy + name: C8 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 15 / 17 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c7_daily_energy + name: C7 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 17 / 19 } + styles: *tile_portrait + + # Smaller Spacer + - type: custom:button-card + color_type: blank-card + styles: + card: + [height: 20px, background: none, box-shadow: none, border: none] + + # ════════════════════════════════════════════════════════ + # BOTTOM ROOF + # ════════════════════════════════════════════════════════ + - type: custom:layout-card + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(26, 1fr) + grid-gap: 6px + margin: 0 + cards: + # Row 3 + - type: custom:button-card + entity: sensor.tigo_ts4_c1_daily_energy + name: C1 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 8 / 10 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c6_daily_energy + name: C6 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 10 / 12 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d6_daily_energy + name: D6 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 12 / 14 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d7_daily_energy + name: D7 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 14 / 16 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d8_daily_energy + name: D8 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 16 / 18 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d9_daily_energy + name: D9 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 18 / 20 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c11_daily_energy + name: C11 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 20 / 22 } + styles: *tile_portrait + + # Row 4 + - type: custom:button-card + entity: sensor.tigo_ts4_d11_daily_energy + name: D11 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 4 / 6 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d5_daily_energy + name: D5 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 6 / 8 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c12_daily_energy + name: C12 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 8 / 10 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d4_daily_energy + name: D4 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 10 / 12 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d3_daily_energy + name: D3 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 12 / 14 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d1_daily_energy + name: D1 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 14 / 16 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d2_daily_energy + name: D2 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 16 / 18 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c5_daily_energy + name: C5 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 18 / 20 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c13_daily_energy + name: C13 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 20 / 22 } + styles: *tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d10_daily_energy + name: D10 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 22 / 24 } + styles: *tile_portrait + + # ═══════════════════════════════════════════════════════════ + # PAGE 2 — Panel Connectivity + # ═══════════════════════════════════════════════════════════ + - title: Panel Connectivity + path: panel-connectivity + panel: true + cards: + - type: vertical-stack + cards: + - type: custom:layout-card + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(26, 1fr) + grid-gap: 6px + margin: 0 + cards: + - type: custom:button-card + entity: sensor.tigo_ts4_c2_readings_today + name: C2 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 11 / 14 } + styles: &conn_tile_landscape + card: + - aspect-ratio: 3/2 + - border-radius: 4px + - border: "1px solid #3b4252" + - background: > + [[[ + const max = parseFloat(states['sensor.tigo_max_readings_today']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const hue = Math.round(pct * 120); + const alpha = 0.55; + return `linear-gradient(hsla(${hue}, 80%, 50%, ${alpha}), hsla(${hue}, 80%, 50%, ${alpha})), linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%), repeating-linear-gradient(0deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), repeating-linear-gradient(90deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), #0a111a`; + ]]] + - box-shadow: > + [[[ + const max = parseFloat(states['sensor.tigo_max_readings_today']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const hue = Math.round(pct * 120); + return `0 4px 8px rgba(0,0,0,0.5), inset 0 0 12px hsla(${hue}, 80%, 50%, 0.7)`; + ]]] + name: + - font-size: 1.0em + - font-weight: 800 + - color: white + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + state: + - font-size: 0.85em + - font-weight: 600 + - color: "#e5e9f0" + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + + - type: custom:button-card + entity: sensor.tigo_ts4_c3_readings_today + name: C3 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 14 / 17 } + styles: *conn_tile_landscape + + - type: custom:button-card + entity: sensor.tigo_ts4_c4_readings_today + name: C4 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 9 / 11 } + styles: &conn_tile_portrait + card: + - aspect-ratio: 2/3 + - border-radius: 4px + - border: "1px solid #3b4252" + - background: > + [[[ + const max = parseFloat(states['sensor.tigo_max_readings_today']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const hue = Math.round(pct * 120); + const alpha = 0.55; + return `linear-gradient(hsla(${hue}, 80%, 50%, ${alpha}), hsla(${hue}, 80%, 50%, ${alpha})), linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%), repeating-linear-gradient(0deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), repeating-linear-gradient(90deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), #0a111a`; + ]]] + - box-shadow: > + [[[ + const max = parseFloat(states['sensor.tigo_max_readings_today']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const hue = Math.round(pct * 120); + return `0 4px 8px rgba(0,0,0,0.5), inset 0 0 12px hsla(${hue}, 80%, 50%, 0.7)`; + ]]] + name: + - font-size: 1.0em + - font-weight: 800 + - color: white + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + state: + - font-size: 0.85em + - font-weight: 600 + - color: "#e5e9f0" + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + + - type: custom:button-card + entity: sensor.tigo_ts4_c10_readings_today + name: C10 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 11 / 13 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c9_readings_today + name: C9 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 13 / 15 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c8_readings_today + name: C8 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 15 / 17 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c7_readings_today + name: C7 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 17 / 19 } + styles: *conn_tile_portrait + + - type: custom:button-card + color_type: blank-card + styles: + card: + [height: 20px, background: none, box-shadow: none, border: none] + + - type: custom:layout-card + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(26, 1fr) + grid-gap: 6px + margin: 0 + cards: + - type: custom:button-card + entity: sensor.tigo_ts4_c1_readings_today + name: C1 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 8 / 10 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c6_readings_today + name: C6 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 10 / 12 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d6_readings_today + name: D6 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 12 / 14 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d7_readings_today + name: D7 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 14 / 16 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d8_readings_today + name: D8 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 16 / 18 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d9_readings_today + name: D9 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 18 / 20 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c11_readings_today + name: C11 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 20 / 22 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d11_readings_today + name: D11 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 4 / 6 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d5_readings_today + name: D5 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 6 / 8 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c12_readings_today + name: C12 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 8 / 10 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d4_readings_today + name: D4 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 10 / 12 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d3_readings_today + name: D3 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 12 / 14 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d1_readings_today + name: D1 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 14 / 16 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d2_readings_today + name: D2 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 16 / 18 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c5_readings_today + name: C5 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 18 / 20 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c13_readings_today + name: C13 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 20 / 22 } + styles: *conn_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d10_readings_today + name: D10 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 22 / 24 } + styles: *conn_tile_portrait diff --git a/dashboards/tigo.yaml b/dashboards/tigo.yaml new file mode 100644 index 0000000..1d90dd5 --- /dev/null +++ b/dashboards/tigo.yaml @@ -0,0 +1,48 @@ +template: + - sensor: + # ══════════════════════════════════════════════════════ + # 1. Max Energy Sensor (For Page 1) + # ══════════════════════════════════════════════════════ + - name: "Tigo Max Daily Energy" + unique_id: tigo_max_daily_energy + unit_of_measurement: "kWh" + icon: mdi:solar-panel-large + state: > + {% set energy = [ + states('sensor.tigo_ts4_c1_daily_energy'), states('sensor.tigo_ts4_c2_daily_energy'), + states('sensor.tigo_ts4_c3_daily_energy'), states('sensor.tigo_ts4_c4_daily_energy'), + states('sensor.tigo_ts4_c5_daily_energy'), states('sensor.tigo_ts4_c6_daily_energy'), + states('sensor.tigo_ts4_c7_daily_energy'), states('sensor.tigo_ts4_c8_daily_energy'), + states('sensor.tigo_ts4_c9_daily_energy'), states('sensor.tigo_ts4_c10_daily_energy'), + states('sensor.tigo_ts4_c11_daily_energy'), states('sensor.tigo_ts4_c12_daily_energy'), + states('sensor.tigo_ts4_c13_daily_energy'), states('sensor.tigo_ts4_d1_daily_energy'), + states('sensor.tigo_ts4_d2_daily_energy'), states('sensor.tigo_ts4_d3_daily_energy'), + states('sensor.tigo_ts4_d4_daily_energy'), states('sensor.tigo_ts4_d5_daily_energy'), + states('sensor.tigo_ts4_d6_daily_energy'), states('sensor.tigo_ts4_d7_daily_energy'), + states('sensor.tigo_ts4_d8_daily_energy'), states('sensor.tigo_ts4_d9_daily_energy'), + states('sensor.tigo_ts4_d10_daily_energy'), states('sensor.tigo_ts4_d11_daily_energy') + ] %} + {{ energy | map('float', 0) | max }} + + # ══════════════════════════════════════════════════════ + # 2. Max Readings Sensor (For Page 2) + # ══════════════════════════════════════════════════════ + - name: "Tigo Max Readings Today" + unique_id: tigo_max_readings_today + icon: mdi:check-network + state: > + {% set readings = [ + states('sensor.tigo_ts4_c1_readings_today'), states('sensor.tigo_ts4_c2_readings_today'), + states('sensor.tigo_ts4_c3_readings_today'), states('sensor.tigo_ts4_c4_readings_today'), + states('sensor.tigo_ts4_c5_readings_today'), states('sensor.tigo_ts4_c6_readings_today'), + states('sensor.tigo_ts4_c7_readings_today'), states('sensor.tigo_ts4_c8_readings_today'), + states('sensor.tigo_ts4_c9_readings_today'), states('sensor.tigo_ts4_c10_readings_today'), + states('sensor.tigo_ts4_c11_readings_today'), states('sensor.tigo_ts4_c12_readings_today'), + states('sensor.tigo_ts4_c13_readings_today'), states('sensor.tigo_ts4_d1_readings_today'), + states('sensor.tigo_ts4_d2_readings_today'), states('sensor.tigo_ts4_d3_readings_today'), + states('sensor.tigo_ts4_d4_readings_today'), states('sensor.tigo_ts4_d5_readings_today'), + states('sensor.tigo_ts4_d6_readings_today'), states('sensor.tigo_ts4_d7_readings_today'), + states('sensor.tigo_ts4_d8_readings_today'), states('sensor.tigo_ts4_d9_readings_today'), + states('sensor.tigo_ts4_d10_readings_today'), states('sensor.tigo_ts4_d11_readings_today') + ] %} + {{ readings | map('float', 0) | max }} diff --git a/docs/implementation.md b/docs/implementation.md index 8b63910..7d3c3f1 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -45,6 +45,7 @@ PyTap is a Home Assistant custom component that passively monitors Tigo TAP sola | Entity creation | Deterministic from user-configured barcode list (no auto-discovery) | | Config flow | Menu-driven: add modules one at a time via individual form fields | | Threading model | Blocking parser in executor thread, bridged to async event loop | +| Restart behavior | Coordinator restores last node snapshots + energy state; sensors use restore fallback to remain available when historical data exists | | External dependencies | None — parser library embedded, stdlib only | | Sensor types | 12 per optimizer + aggregate sensors per string and per installation (performance, power, daily energy, total energy) | | Test coverage | Expanded integration + parser coverage, including aggregate sensor, performance, and v3→v4 migration behavior | @@ -223,6 +224,13 @@ Four custom `HomeAssistantError` subclasses: `CannotConnect`, `InvalidAuth`, `In Inherits from `DataUpdateCoordinator[dict[str, Any]]`. Despite using the coordinator pattern, this is a **push-based** integration — `_async_update_data()` simply returns the current data dict without polling. +Persistence now includes both `energy_data` and `node_snapshots`: + +- `energy_data` preserves accumulator continuity for `daily_energy`, `total_energy`, and `readings_today`. +- `node_snapshots` preserves the latest known measurement payload (power/voltage/current/temperature/duty/rssi/performance and `last_update`) for configured barcodes. + +At startup, the coordinator restores snapshot data first and overlays normalized energy accumulator values, so entities can publish their last known readings immediately while waiting for new live frames. + #### Initialization ```python diff --git a/planning/future_considerations.md b/planning/future_considerations.md index 9205114..320ff01 100644 --- a/planning/future_considerations.md +++ b/planning/future_considerations.md @@ -46,7 +46,7 @@ Status: ✅ Implemented in Feature 5. 5.2 **Per-sensor readings counter** — Each optimizer now has a `readings_today` daily meter (`SensorStateClass.TOTAL`, diagnostic category) to support per-module connectivity troubleshooting. -## 6. Binary Sensor Platform - low priority +## 6. Binary Sensor Platform - will not implement Add binary sensors for node connectivity (available/unavailable based on `last_update` age) and gateway online status. @@ -60,5 +60,4 @@ Status: ✅ Implemented in v1.0.0. ## 8. Configuration - Add ability to bulk load devices -- Add ability to modify barcodes - Make energy gap threshold configurable via options flow (currently hardcoded at 120 s) diff --git a/tests/test_coordinator_persistence.py b/tests/test_coordinator_persistence.py index 72b99a5..7c6e1e1 100644 --- a/tests/test_coordinator_persistence.py +++ b/tests/test_coordinator_persistence.py @@ -274,6 +274,42 @@ async def test_load_handles_missing_energy_data(self, hass: HomeAssistant) -> No assert coordinator._discovered_barcodes == {"X-9999999Z"} assert coordinator._energy_state == {} + async def test_load_restores_node_snapshot_without_energy( + self, hass: HomeAssistant + ) -> None: + """Node snapshot should restore live fields even without energy_data.""" + entry = _make_entry(hass) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + + stored_data = { + "barcode_to_node": {"A-1234567B": 10}, + "discovered_barcodes": [], + "node_snapshots": { + "A-1234567B": { + "gateway_id": 1, + "node_id": 10, + "voltage_in": 35.2, + "voltage_out": 34.8, + "current_in": 8.5, + "current_out": 8.4, + "power": 299.2, + "performance": 65.76, + "temperature": 42.0, + "dc_dc_duty_cycle": 0.95, + "rssi": -65, + "last_update": "2026-02-24T20:00:00", + } + }, + } + coordinator._store.async_load = AsyncMock(return_value=stored_data) + + await coordinator._async_load_coordinator_state() + + node = coordinator.data["nodes"]["A-1234567B"] + assert node["power"] == 299.2 + assert node["voltage_in"] == 35.2 + assert node["last_update"] == "2026-02-24T20:00:00" + class TestSaveCoordinatorState: """Test _async_save_coordinator_state persists data.""" @@ -296,6 +332,28 @@ async def test_save_writes_all_data(self, hass: HomeAssistant) -> None: last_reading_ts=datetime.now(), ) } + coordinator.data["nodes"]["A-1234567B"] = { + "gateway_id": 1, + "node_id": 10, + "barcode": "A-1234567B", + "name": "Panel_01", + "string": "A", + "peak_power": 455, + "voltage_in": 35.2, + "voltage_out": 34.8, + "current_in": 8.5, + "current_out": 8.4, + "power": 299.2, + "performance": 65.76, + "temperature": 42.0, + "dc_dc_duty_cycle": 0.95, + "rssi": -65, + "daily_energy_wh": 1.25, + "total_energy_wh": 100.75, + "readings_today": 9, + "daily_reset_date": datetime.now().date().isoformat(), + "last_update": "2026-02-24T20:00:00", + } coordinator._unsaved_changes = True coordinator._store.async_save = AsyncMock() @@ -308,7 +366,10 @@ async def test_save_writes_all_data(self, hass: HomeAssistant) -> None: assert saved["discovered_barcodes"] == ["X-9999999Z"] assert "parser_state" in saved assert "energy_data" in saved + assert "node_snapshots" in saved assert "A-1234567B" in saved["energy_data"] + assert "A-1234567B" in saved["node_snapshots"] + assert saved["node_snapshots"]["A-1234567B"]["power"] == 299.2 assert saved["energy_data"]["A-1234567B"]["readings_today"] == 9 assert coordinator._unsaved_changes is False diff --git a/tests/test_sensor.py b/tests/test_sensor.py index ff3b220..cc801a3 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -5,8 +5,17 @@ import pytest from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import CONF_HOST, CONF_PORT, EntityCategory, UnitOfEnergy -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EntityCategory, + STATE_UNAVAILABLE, + UnitOfEnergy, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.pytap.const import ( CONF_MODULE_BARCODE, @@ -19,7 +28,11 @@ DOMAIN, ) from custom_components.pytap.coordinator import PyTapDataUpdateCoordinator -from custom_components.pytap.sensor import SENSOR_DESCRIPTIONS, async_setup_entry +from custom_components.pytap.sensor import ( + SENSOR_DESCRIPTIONS, + _coerce_restored_state_value, + async_setup_entry, +) MOCK_MODULES = [ @@ -873,3 +886,174 @@ async def test_no_string_aggregates_when_no_modules(hass: HomeAssistant) -> None await async_setup_entry(hass, entry, lambda e: entities.extend(e)) assert entities == [] + + +def test_coerce_restored_state_value_float_sensor() -> None: + """Restored state should coerce to float for standard numeric sensors.""" + assert _coerce_restored_state_value("299.2", "power") == 299.2 + + +def test_coerce_restored_state_value_int_sensor() -> None: + """Readings-today restore should coerce to int.""" + assert _coerce_restored_state_value("42", "readings_today") == 42 + + +def test_coerce_restored_state_value_ignores_unavailable() -> None: + """Unavailable/unknown restore values should be ignored.""" + assert _coerce_restored_state_value("unknown", "power") is None + assert _coerce_restored_state_value("unavailable", "power") is None + + +async def test_restart_restores_snapshot_before_live_stream( + hass: HomeAssistant, +) -> None: + """After restart, sensors should be available from restored snapshot data.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_MODULES: [MOCK_MODULES[0]], + }, + entry_id="restart_restore_entry", + title="PyTap (192.168.1.100)", + version=4, + ) + entry.add_to_hass(hass) + + today = "2026-02-24" + stored_state = { + "barcode_to_node": {"A-1234567B": 10}, + "discovered_barcodes": [], + "energy_data": { + "A-1234567B": { + "daily_energy_wh": 12.5, + "daily_reset_date": today, + "total_energy_wh": 2000.0, + "readings_today": 55, + "last_power_w": 299.2, + "last_reading_ts": "2026-02-24T20:00:00", + } + }, + "node_snapshots": { + "A-1234567B": { + "gateway_id": 1, + "node_id": 10, + "voltage_in": 35.2, + "voltage_out": 34.8, + "current_in": 8.5, + "current_out": 8.4, + "power": 299.2, + "performance": 65.76, + "temperature": 42.0, + "dc_dc_duty_cycle": 0.95, + "rssi": -65, + "last_update": "2026-02-24T20:00:00", + } + }, + } + + with ( + patch( + "custom_components.pytap.coordinator._MigratingStore.async_load", + new=AsyncMock(return_value=stored_state), + ), + patch( + "custom_components.pytap.coordinator.PyTapDataUpdateCoordinator._listen", + return_value=None, + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + + power_entity_id = ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{DOMAIN}_A-1234567B_power" + ) + assert power_entity_id is not None + power_state = hass.states.get(power_entity_id) + assert power_state is not None + assert power_state.state != STATE_UNAVAILABLE + assert float(power_state.state) == 299.2 + + installation_power_entity_id = ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{DOMAIN}_{entry.entry_id}_installation_power" + ) + assert installation_power_entity_id is not None + installation_power_state = hass.states.get(installation_power_entity_id) + assert installation_power_state is not None + assert installation_power_state.state != STATE_UNAVAILABLE + assert float(installation_power_state.state) == 299.2 + + +async def test_restart_uses_restore_entity_fallback_without_snapshot( + hass: HomeAssistant, +) -> None: + """When no snapshot exists, startup should restore values from HA state cache.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_MODULES: [MOCK_MODULES[0]], + }, + entry_id="restart_restore_fallback_entry", + title="PyTap (192.168.1.100)", + version=4, + ) + entry.add_to_hass(hass) + + stored_state = { + "barcode_to_node": {}, + "discovered_barcodes": [], + } + + with ( + patch( + "custom_components.pytap.coordinator._MigratingStore.async_load", + new=AsyncMock(return_value=stored_state), + ), + patch( + "custom_components.pytap.coordinator.PyTapDataUpdateCoordinator._listen", + return_value=None, + ), + patch( + "custom_components.pytap.sensor.PyTapSensor.async_get_last_sensor_data", + new=AsyncMock(return_value=None), + ), + patch( + "custom_components.pytap.sensor.PyTapAggregateSensor.async_get_last_sensor_data", + new=AsyncMock(return_value=None), + ), + patch( + "custom_components.pytap.sensor.PyTapSensor.async_get_last_state", + new=AsyncMock(return_value=State("sensor.fake_module", "123.4")), + ), + patch( + "custom_components.pytap.sensor.PyTapAggregateSensor.async_get_last_state", + new=AsyncMock(return_value=State("sensor.fake_aggregate", "456.7")), + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + + power_entity_id = ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{DOMAIN}_A-1234567B_power" + ) + assert power_entity_id is not None + power_state = hass.states.get(power_entity_id) + assert power_state is not None + assert power_state.state != STATE_UNAVAILABLE + assert float(power_state.state) == 123.4 + + installation_power_entity_id = ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{DOMAIN}_{entry.entry_id}_installation_power" + ) + assert installation_power_entity_id is not None + installation_power_state = hass.states.get(installation_power_entity_id) + assert installation_power_state is not None + assert installation_power_state.state != STATE_UNAVAILABLE + assert float(installation_power_state.state) == 456.7 From fff5b81d11499f38a3b21aa45fb3c100ac34deb0 Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Wed, 4 Mar 2026 17:25:39 +0000 Subject: [PATCH 3/8] feat: update PV Production Dashboard to include D12 and D13 sensors for daily energy and readings --- dashboards/pv_production.md | 26 +- dashboards/pv_production_dashboard.yaml | 330 ++++++++++++++++++++++++ dashboards/tigo.yaml | 31 ++- 3 files changed, 372 insertions(+), 15 deletions(-) diff --git a/dashboards/pv_production.md b/dashboards/pv_production.md index bc64123..1fe5f72 100644 --- a/dashboards/pv_production.md +++ b/dashboards/pv_production.md @@ -20,8 +20,8 @@ Home Assistant Lovelace dashboard displaying the physical solar panel layout wit | Sensor | Entity ID | Purpose | |--------|-----------|---------| -| Tigo Max Daily Energy | `sensor.tigo_max_daily_energy` | Highest `_daily_energy` across all 24 panels (kWh) | -| Tigo Max Readings Today | `sensor.tigo_max_readings_today` | Highest `_readings_today` across all 24 panels | +| Tigo Max Daily Energy | `sensor.tigo_max_daily_energy` | Highest `_daily_energy` across all 26 panels (kWh) | +| Tigo Max Readings Today | `sensor.tigo_max_readings_today` | Highest `_readings_today` across all 26 panels | These are computed server-side with Jinja2 templates so each button-card only needs a single `states[]` lookup instead of scanning all 24 sensors in JavaScript. @@ -47,14 +47,14 @@ Both views mirror the actual roof panel arrangement using a 26-column CSS grid. - **Portrait (2:3)** — all other panels ``` - col 11 col 14 - ┌──────────┐ ┌──────────┐ - Row 1 │ C2 (L) │ │ C3 (L) │ - └──────────┘ └──────────┘ - col 9 col 11 col 13 col 15 col 17 - ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ - Row 2 │C4│ │C10│ │C9 │ │C8 │ │C7 │ - └──┘ └──┘ └──┘ └──┘ └──┘ + col 11 col 14 col 21 + ┌──────────┐ ┌──────────┐ ┌──┐ + Row 1 │ C2 (L) │ │ C3 (L) │ │D12│ + └──────────┘ └──────────┘ └──┘ + col 9 col 11 col 13 col 15 col 17 col 21 + ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ + Row 2 │C4│ │C10│ │C9 │ │C8 │ │C7 │ │D13│ + └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ col 4 col 6 col 8 col 10 col 12 col 14 col 16 col 18 col 20 col 22 ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ @@ -66,10 +66,10 @@ Both views mirror the actual roof panel arrangement using a 26-column CSS grid. Row 4 ``` -- **Top section** (`grid-layout`): Row 1 has C2/C3 centered (landscape); Row 2 has C4, C10, C9, C8, C7 (portrait) +- **Top section** (`grid-layout`): Row 1 has C2/C3 centered (landscape) + D12 (portrait, south-facing, separated by spacer); Row 2 has C4, C10, C9, C8, C7 (portrait) + D13 (portrait, south-facing) - **Bottom section** (`grid-layout`): Row 3 has C1–C11 main array; Row 4 extends left with D11/D5 and continues through D10 -## Panels (24 total) +## Panels (26 total) | String C | String D | |----------------|----------------| @@ -77,7 +77,7 @@ Both views mirror the actual roof panel arrangement using a 26-column CSS grid. | C4, C5, C6 | D4, D5, D6 | | C7, C8, C9 | D7, D8, D9 | | C10, C11, C12 | D10, D11 | -| C13 | | +| C13 | D12, D13 | ## Tile Rendering diff --git a/dashboards/pv_production_dashboard.yaml b/dashboards/pv_production_dashboard.yaml index d0157d4..57a4736 100644 --- a/dashboards/pv_production_dashboard.yaml +++ b/dashboards/pv_production_dashboard.yaml @@ -132,6 +132,23 @@ views: view_layout: { grid-row: 2, grid-column: 17 / 19 } styles: *tile_portrait + # D12 & D13 — South-facing, separated by spacer gap + - type: custom:button-card + entity: sensor.tigo_ts4_d12_daily_energy + name: D12 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 21 / 24 } + styles: *tile_landscape + + - type: custom:button-card + entity: sensor.tigo_ts4_d13_daily_energy + name: D13 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 21 / 24 } + styles: *tile_landscape + # Smaller Spacer - type: custom:button-card color_type: blank-card @@ -421,6 +438,23 @@ views: view_layout: { grid-row: 2, grid-column: 17 / 19 } styles: *conn_tile_portrait + # D12 & D13 — South-facing, separated by spacer gap + - type: custom:button-card + entity: sensor.tigo_ts4_d12_readings_today + name: D12 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 21 / 24 } + styles: *conn_tile_landscape + + - type: custom:button-card + entity: sensor.tigo_ts4_d13_readings_today + name: D13 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 21 / 24 } + styles: *conn_tile_landscape + - type: custom:button-card color_type: blank-card styles: @@ -569,3 +603,299 @@ views: show_icon: false view_layout: { grid-row: 2, grid-column: 22 / 24 } styles: *conn_tile_portrait + + # ═══════════════════════════════════════════════════════════ + # PAGE 3 — Panel Power (Live) + # ═══════════════════════════════════════════════════════════ + - title: Panel Power + path: panel-power + panel: true + cards: + - type: vertical-stack + cards: + - type: custom:layout-card + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(26, 1fr) + grid-gap: 6px + margin: 0 + cards: + - type: custom:button-card + entity: sensor.tigo_ts4_c2_power + name: C2 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 11 / 14 } + styles: &pwr_tile_landscape + card: + - aspect-ratio: 3/2 + - border-radius: 4px + - border: "1px solid #3b4252" + - background: > + [[[ + const max = parseFloat(states['sensor.tigo_max_power']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const alpha = pct * 0.7; + return `linear-gradient(rgba(0, 220, 50, ${alpha}), rgba(0, 220, 50, ${alpha})), linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%), repeating-linear-gradient(0deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), repeating-linear-gradient(90deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), #0a111a`; + ]]] + - box-shadow: > + [[[ + const max = parseFloat(states['sensor.tigo_max_power']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + return `0 4px 8px rgba(0,0,0,0.5), inset 0 0 ${15 * pct}px rgba(0, 255, 50, ${0.8 * pct})`; + ]]] + name: + - font-size: 1.0em + - font-weight: 800 + - color: white + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + state: + - font-size: 0.85em + - font-weight: 600 + - color: "#e5e9f0" + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + + - type: custom:button-card + entity: sensor.tigo_ts4_c3_power + name: C3 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 14 / 17 } + styles: *pwr_tile_landscape + + - type: custom:button-card + entity: sensor.tigo_ts4_c4_power + name: C4 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 9 / 11 } + styles: &pwr_tile_portrait + card: + - aspect-ratio: 2/3 + - border-radius: 4px + - border: "1px solid #3b4252" + - background: > + [[[ + const max = parseFloat(states['sensor.tigo_max_power']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + const alpha = pct * 0.7; + return `linear-gradient(rgba(0, 220, 50, ${alpha}), rgba(0, 220, 50, ${alpha})), linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.6) 100%), repeating-linear-gradient(0deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), repeating-linear-gradient(90deg, transparent, transparent 18%, rgba(255,255,255,0.1) 18%, rgba(255,255,255,0.1) 20%), #0a111a`; + ]]] + - box-shadow: > + [[[ + const max = parseFloat(states['sensor.tigo_max_power']?.state) || 1; + const val = parseFloat(entity.state) || 0; + const pct = Math.min(Math.max(val, 0) / max, 1); + return `0 4px 8px rgba(0,0,0,0.5), inset 0 0 ${15 * pct}px rgba(0, 255, 50, ${0.8 * pct})`; + ]]] + name: + - font-size: 1.0em + - font-weight: 800 + - color: white + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + state: + - font-size: 0.85em + - font-weight: 600 + - color: "#e5e9f0" + - text-shadow: "1px 1px 2px rgba(0,0,0,0.8)" + + - type: custom:button-card + entity: sensor.tigo_ts4_c10_power + name: C10 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 11 / 13 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c9_power + name: C9 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 13 / 15 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c8_power + name: C8 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 15 / 17 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c7_power + name: C7 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 17 / 19 } + styles: *pwr_tile_portrait + + # D12 & D13 — South-facing, separated by spacer gap + - type: custom:button-card + entity: sensor.tigo_ts4_d12_power + name: D12 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 21 / 24 } + styles: *pwr_tile_landscape + + - type: custom:button-card + entity: sensor.tigo_ts4_d13_power + name: D13 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 21 / 24 } + styles: *pwr_tile_landscape + + - type: custom:button-card + color_type: blank-card + styles: + card: + [height: 20px, background: none, box-shadow: none, border: none] + + - type: custom:layout-card + layout_type: custom:grid-layout + layout: + grid-template-columns: repeat(26, 1fr) + grid-gap: 6px + margin: 0 + cards: + - type: custom:button-card + entity: sensor.tigo_ts4_c1_power + name: C1 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 8 / 10 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c6_power + name: C6 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 10 / 12 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d6_power + name: D6 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 12 / 14 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d7_power + name: D7 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 14 / 16 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d8_power + name: D8 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 16 / 18 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d9_power + name: D9 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 18 / 20 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c11_power + name: C11 + show_state: true + show_icon: false + view_layout: { grid-row: 1, grid-column: 20 / 22 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d11_power + name: D11 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 4 / 6 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d5_power + name: D5 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 6 / 8 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c12_power + name: C12 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 8 / 10 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d4_power + name: D4 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 10 / 12 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d3_power + name: D3 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 12 / 14 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d1_power + name: D1 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 14 / 16 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d2_power + name: D2 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 16 / 18 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c5_power + name: C5 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 18 / 20 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_c13_power + name: C13 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 20 / 22 } + styles: *pwr_tile_portrait + + - type: custom:button-card + entity: sensor.tigo_ts4_d10_power + name: D10 + show_state: true + show_icon: false + view_layout: { grid-row: 2, grid-column: 22 / 24 } + styles: *pwr_tile_portrait diff --git a/dashboards/tigo.yaml b/dashboards/tigo.yaml index 1d90dd5..611d395 100644 --- a/dashboards/tigo.yaml +++ b/dashboards/tigo.yaml @@ -20,7 +20,8 @@ template: states('sensor.tigo_ts4_d4_daily_energy'), states('sensor.tigo_ts4_d5_daily_energy'), states('sensor.tigo_ts4_d6_daily_energy'), states('sensor.tigo_ts4_d7_daily_energy'), states('sensor.tigo_ts4_d8_daily_energy'), states('sensor.tigo_ts4_d9_daily_energy'), - states('sensor.tigo_ts4_d10_daily_energy'), states('sensor.tigo_ts4_d11_daily_energy') + states('sensor.tigo_ts4_d10_daily_energy'), states('sensor.tigo_ts4_d11_daily_energy'), + states('sensor.tigo_ts4_d12_daily_energy'), states('sensor.tigo_ts4_d13_daily_energy') ] %} {{ energy | map('float', 0) | max }} @@ -43,6 +44,32 @@ template: states('sensor.tigo_ts4_d4_readings_today'), states('sensor.tigo_ts4_d5_readings_today'), states('sensor.tigo_ts4_d6_readings_today'), states('sensor.tigo_ts4_d7_readings_today'), states('sensor.tigo_ts4_d8_readings_today'), states('sensor.tigo_ts4_d9_readings_today'), - states('sensor.tigo_ts4_d10_readings_today'), states('sensor.tigo_ts4_d11_readings_today') + states('sensor.tigo_ts4_d10_readings_today'), states('sensor.tigo_ts4_d11_readings_today'), + states('sensor.tigo_ts4_d12_readings_today'), states('sensor.tigo_ts4_d13_readings_today') ] %} {{ readings | map('float', 0) | max }} + + # ══════════════════════════════════════════════════════ + # 3. Max Power Sensor (For Page 3) + # ══════════════════════════════════════════════════════ + - name: "Tigo Max Power" + unique_id: tigo_max_power + unit_of_measurement: "W" + icon: mdi:flash + state: > + {% set power = [ + states('sensor.tigo_ts4_c1_power'), states('sensor.tigo_ts4_c2_power'), + states('sensor.tigo_ts4_c3_power'), states('sensor.tigo_ts4_c4_power'), + states('sensor.tigo_ts4_c5_power'), states('sensor.tigo_ts4_c6_power'), + states('sensor.tigo_ts4_c7_power'), states('sensor.tigo_ts4_c8_power'), + states('sensor.tigo_ts4_c9_power'), states('sensor.tigo_ts4_c10_power'), + states('sensor.tigo_ts4_c11_power'), states('sensor.tigo_ts4_c12_power'), + states('sensor.tigo_ts4_c13_power'), states('sensor.tigo_ts4_d1_power'), + states('sensor.tigo_ts4_d2_power'), states('sensor.tigo_ts4_d3_power'), + states('sensor.tigo_ts4_d4_power'), states('sensor.tigo_ts4_d5_power'), + states('sensor.tigo_ts4_d6_power'), states('sensor.tigo_ts4_d7_power'), + states('sensor.tigo_ts4_d8_power'), states('sensor.tigo_ts4_d9_power'), + states('sensor.tigo_ts4_d10_power'), states('sensor.tigo_ts4_d11_power'), + states('sensor.tigo_ts4_d12_power'), states('sensor.tigo_ts4_d13_power') + ] %} + {{ power | map('float', 0) | max }} From 93b7ca1f767eaf763d20ead6497c4ee88add84f1 Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Thu, 5 Mar 2026 07:56:30 +0000 Subject: [PATCH 4/8] fix: replace datetime.now() with dt_util.now() for consistent date handling in tests --- tests/test_coordinator_persistence.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_coordinator_persistence.py b/tests/test_coordinator_persistence.py index 7c6e1e1..9cfb989 100644 --- a/tests/test_coordinator_persistence.py +++ b/tests/test_coordinator_persistence.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from custom_components.pytap.const import ( CONF_MODULE_BARCODE, @@ -203,7 +204,7 @@ async def test_load_restores_energy_data(self, hass: HomeAssistant) -> None: "energy_data": { "A-1234567B": { "daily_energy_wh": 10.5, - "daily_reset_date": datetime.now().date().isoformat(), + "daily_reset_date": dt_util.now().date().isoformat(), "total_energy_wh": 1234.5, "readings_today": 17, "last_power_w": 250.0, @@ -250,7 +251,7 @@ async def test_load_resets_daily_energy_on_new_day( assert acc.daily_energy_wh == 0.0 assert acc.readings_today == 0 assert acc.total_energy_wh == 555.0 - assert acc.daily_reset_date == datetime.now().date().isoformat() + assert acc.daily_reset_date == dt_util.now().date().isoformat() async def test_load_handles_missing_energy_data(self, hass: HomeAssistant) -> None: """Missing energy_data key should leave energy state empty.""" @@ -325,7 +326,7 @@ async def test_save_writes_all_data(self, hass: HomeAssistant) -> None: coordinator._energy_state = { "A-1234567B": EnergyAccumulator( daily_energy_wh=1.25, - daily_reset_date=datetime.now().date().isoformat(), + daily_reset_date=dt_util.now().date().isoformat(), total_energy_wh=100.75, readings_today=9, last_power_w=250.0, @@ -351,7 +352,7 @@ async def test_save_writes_all_data(self, hass: HomeAssistant) -> None: "daily_energy_wh": 1.25, "total_energy_wh": 100.75, "readings_today": 9, - "daily_reset_date": datetime.now().date().isoformat(), + "daily_reset_date": dt_util.now().date().isoformat(), "last_update": "2026-02-24T20:00:00", } coordinator._unsaved_changes = True @@ -842,7 +843,7 @@ async def test_load_prepopulates_node_data(self, hass: HomeAssistant) -> None: entry = _make_entry(hass) coordinator = PyTapDataUpdateCoordinator(hass, entry) - today = datetime.now().date().isoformat() + today = dt_util.now().date().isoformat() stored_data = { "barcode_to_node": {"A-1234567B": 10}, "discovered_barcodes": [], @@ -876,7 +877,7 @@ async def test_prepopulated_node_has_none_for_live_fields( entry = _make_entry(hass) coordinator = PyTapDataUpdateCoordinator(hass, entry) - today = datetime.now().date().isoformat() + today = dt_util.now().date().isoformat() stored_data = { "barcode_to_node": {}, "discovered_barcodes": [], @@ -912,7 +913,7 @@ async def test_prepopulation_skips_unconfigured_barcodes( entry = _make_entry(hass) coordinator = PyTapDataUpdateCoordinator(hass, entry) - today = datetime.now().date().isoformat() + today = dt_util.now().date().isoformat() stored_data = { "barcode_to_node": {}, "discovered_barcodes": [], @@ -949,7 +950,7 @@ async def test_power_report_overwrites_prepopulated_data( entry = _make_entry(hass) coordinator = PyTapDataUpdateCoordinator(hass, entry) - today = datetime.now().date().isoformat() + today = dt_util.now().date().isoformat() stored_data = { "barcode_to_node": {"A-1234567B": 10}, "discovered_barcodes": [], From 0bc3cffcdfe6139ecbdb89d3b576743a59a92f60 Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Thu, 5 Mar 2026 07:57:32 +0000 Subject: [PATCH 5/8] Update custom_components/pytap/sensor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- custom_components/pytap/sensor.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/custom_components/pytap/sensor.py b/custom_components/pytap/sensor.py index b2aece3..f157edd 100644 --- a/custom_components/pytap/sensor.py +++ b/custom_components/pytap/sensor.py @@ -378,9 +378,14 @@ def __init__( async def async_added_to_hass(self) -> None: """Restore last known native value from Home Assistant state cache.""" await super().async_added_to_hass() - if self.coordinator.data.get("nodes", {}).get(self._barcode) is not None: - self._handle_coordinator_update() - return + nodes = self.coordinator.data.get("nodes", {}) + node = nodes.get(self._barcode) + # Only short-circuit to coordinator data if this sensor has a non-None value. + if node is not None: + coordinator_value = node.get(self.entity_description.key) + if coordinator_value is not None: + self._handle_coordinator_update() + return if restored := await self.async_get_last_sensor_data(): if restored.native_value is not None: From 5c5c38c8fe56660d2686bd94e91d4d551d772c75 Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Thu, 5 Mar 2026 07:58:11 +0000 Subject: [PATCH 6/8] Update custom_components/pytap/sensor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- custom_components/pytap/sensor.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/custom_components/pytap/sensor.py b/custom_components/pytap/sensor.py index f157edd..ee83739 100644 --- a/custom_components/pytap/sensor.py +++ b/custom_components/pytap/sensor.py @@ -501,7 +501,26 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() nodes = self.coordinator.data.get("nodes", {}) - if any(nodes.get(barcode) is not None for barcode in self._barcodes): + # Only short-circuit to live data if at least one node already has + # a non-None value for the aggregate's underlying field. This avoids + # overwriting a restored value with None from placeholder node dicts. + value_key = getattr(self.entity_description, "value_key", None) + if self.entity_description.key == "performance": + has_fresh_data = any( + (node := nodes.get(barcode)) is not None + and node.get("power") is not None + for barcode in self._barcodes + ) + elif value_key: + has_fresh_data = any( + (node := nodes.get(barcode)) is not None + and node.get(value_key) is not None + for barcode in self._barcodes + ) + else: + has_fresh_data = False + + if has_fresh_data: self._handle_coordinator_update() return From f501d153f75da801096f01fe17fd32b82641b1e1 Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Thu, 5 Mar 2026 07:58:29 +0000 Subject: [PATCH 7/8] Update dashboards/pv_production.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dashboards/pv_production.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboards/pv_production.md b/dashboards/pv_production.md index 1fe5f72..89ab76b 100644 --- a/dashboards/pv_production.md +++ b/dashboards/pv_production.md @@ -6,7 +6,7 @@ Home Assistant Lovelace dashboard displaying the physical solar panel layout wit | File | Purpose | |------|---------| -| [`pv_production_dashboard.yaml`](pv_production_dashboard.yaml) | Lovelace dashboard (2 views, 571 lines) | +| [`pv_production_dashboard.yaml`](pv_production_dashboard.yaml) | Lovelace dashboard (3 views, ~901 lines) | | [`tigo.yaml`](tigo.yaml) | HA template sensors for max-value aggregation | ## Prerequisites From f4e712dbe7ebf7c6eb523f80ec96eee3e9d534bd Mon Sep 17 00:00:00 2001 From: Adam Zebrowski Date: Thu, 5 Mar 2026 07:58:41 +0000 Subject: [PATCH 8/8] Update dashboards/pv_production.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dashboards/pv_production.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboards/pv_production.md b/dashboards/pv_production.md index 89ab76b..19d5a7e 100644 --- a/dashboards/pv_production.md +++ b/dashboards/pv_production.md @@ -23,7 +23,7 @@ Home Assistant Lovelace dashboard displaying the physical solar panel layout wit | Tigo Max Daily Energy | `sensor.tigo_max_daily_energy` | Highest `_daily_energy` across all 26 panels (kWh) | | Tigo Max Readings Today | `sensor.tigo_max_readings_today` | Highest `_readings_today` across all 26 panels | -These are computed server-side with Jinja2 templates so each button-card only needs a single `states[]` lookup instead of scanning all 24 sensors in JavaScript. +These are computed server-side with Jinja2 templates so each button-card only needs a single `states[]` lookup instead of scanning all 26 sensors in JavaScript. ## Views