Skip to content
Merged

Dev #10

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
151 changes: 117 additions & 34 deletions custom_components/pytap/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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)),
)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/pytap/pytap/core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
76 changes: 73 additions & 3 deletions custom_components/pytap/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Comment on lines +54 to +66
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_coerce_restored_state_value can return non-finite floats (e.g., 'nan', 'inf') and will raise in the readings_today path (int(float('nan')) throws). Add a finite check (e.g., math.isfinite) after parsing, and for readings_today only coerce when the value is a whole number to avoid silently truncating strings like '42.9'.

Copilot uses AI. Check for mistakes.
return numeric_value


@dataclass(frozen=True, kw_only=True)
class PyTapSensorEntityDescription(SensorEntityDescription):
"""Describes a PyTap sensor entity."""
Expand Down Expand Up @@ -360,13 +378,32 @@ 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:
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:
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:
Expand Down Expand Up @@ -464,13 +501,46 @@ 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

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:
Expand Down
Loading