Version 0.3.1 · Last updated: March 2026
This document captures the current implementation state of the PyTap Home Assistant custom component. It describes what has been built, how each module works, the design decisions made during development, and the test coverage in place.
For the high-level architecture and design rationale, see architecture.md.
- Implementation Summary
- File Inventory
- Module Details
- const.py — Constants
- manifest.json — Integration Metadata
- config_flow.py — Configuration Flow
- coordinator.py — Data Coordinator
- energy.py — Energy Accumulation
- sensor.py — Sensor Platform
- diagnostics.py — Diagnostics Platform
- __init__.py — Integration Lifecycle
- strings.json / translations — UI Strings
- Config Flow UX Design
- Data Flow
- Testing
- Design Decisions & Trade-offs
- Known Deviations from Architecture
- Development History
- Future Work
PyTap is a Home Assistant custom component that passively monitors Tigo TAP solar energy systems. It connects to a Tigo gateway over TCP, parses the proprietary RS-485 bus protocol in real time using an embedded Python parser library, and exposes per-optimizer sensor entities in Home Assistant.
Key characteristics of the current implementation:
| Aspect | Implementation |
|---|---|
| Integration type | Hub (integration_type: "hub") |
| Data delivery | Push-based streaming (iot_class: "local_push") |
| 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 |
custom_components/pytap/
├── __init__.py # ~187 lines — Integration lifecycle (setup, teardown, migration, options listener)
├── config_flow.py # 369 lines — Menu-driven config & options flows
├── const.py # ~28 lines — Domain, config keys, defaults, energy tuning constants
├── coordinator.py # ~1016 lines — Push-based DataUpdateCoordinator
├── diagnostics.py # ~46 lines — Diagnostics download (config entry diagnostics)
├── energy.py # ~80 lines — Pure trapezoidal energy accumulation helpers
├── manifest.json # 13 lines — HA integration metadata
├── sensor.py # ~572 lines — 12 sensor entity types, CoordinatorEntity pattern
├── strings.json # ~100 lines — UI strings (source of truth)
├── translations/
│ └── en.json # ~100 lines — English translations (mirrors strings.json)
└── pytap/ # Embedded protocol parser library (persistence decoupled)
├── api.py # Public API: connect(), create_parser(), parse_bytes()
└── core/
├── parser.py # Protocol parser: bytes → events
├── types.py # Protocol constants & frame types
├── events.py # Event dataclasses (PowerReportEvent, etc.)
├── state.py # SlotClock, NodeTableBuilder, PersistentState (to_dict/from_dict)
├── source.py # TcpSource, SerialSource
├── crc.py # CRC-16-CCITT
└── barcode.py # Tigo barcode encode/decode
tests/
├── conftest.py # 14 lines — Auto-enable custom integrations fixture
├── test_config_flow.py # 548 lines — 16 config flow tests
├── test_coordinator_persistence.py # ~1100 lines — 36 coordinator & persistence tests
├── test_diagnostics.py # ~172 lines — 4 diagnostics platform tests
├── test_energy.py # ~170 lines — 13 pure energy accumulation tests
├── test_migration.py # ~300 lines — 11 entity migration tests
└── test_sensor.py # ~875 lines — 32 sensor platform tests
docs/
├── architecture.md # 656 lines — Architecture & design document
└── implementation.md # This file
Defines all integration-wide constants in a single location:
DOMAIN = "pytap"
DEFAULT_PORT = 502 # Tigo gateway default TCP port
# Config entry data keys
CONF_MODULES = "modules" # List of module dicts in ConfigEntry.data
CONF_MODULE_STRING = "string" # Required string/group label
CONF_MODULE_NAME = "name" # User-friendly optimizer name
CONF_MODULE_BARCODE = "barcode" # Tigo hardware barcode (stable ID)
CONF_MODULE_PEAK_POWER = "peak_power" # Peak panel power in Wp
# Defaults
DEFAULT_PEAK_POWER = 455 # Wp (watts peak) — STC rating
# Reconnection tuning
RECONNECT_TIMEOUT = 60 # Seconds of silence → reconnect
RECONNECT_DELAY = 5 # Pause between reconnection attempts
RECONNECT_RETRIES = 0 # 0 = infinite retriesThese constants are imported by every other module in the integration.
{
"domain": "pytap",
"name": "PyTap",
"codeowners": ["@azebro"],
"config_flow": true,
"documentation": "https://github.com/azebro/pytap",
"integration_type": "hub",
"iot_class": "local_push",
"issue_tracker": "https://github.com/azebro/pytap/issues",
"requirements": [],
"version": "0.3.0"
}Key choices:
integration_type: "hub"— One gateway entry manages multiple downstream optimizer devices.iot_class: "local_push"— Data streams from the gateway in real time; no polling interval.requirements: []— The pytap parser library is embedded, not installed from PyPI.
369 lines implementing a menu-driven config flow and a full options flow.
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Step: user │────►│ Step: modules_ │────►│ Step: add_ │
│ (host/port)│ │ menu (menu) │◄────│ module (form)│
└─────────────┘ │ │ └──────────────┘
│ ► Add module │
│ ► Finish setup │──── CREATE_ENTRY
└──────────────────┘
-
async_step_user— Collectshost(required) andport(default 502). Sets a unique ID ofhost:portand aborts if already configured. Performs a non-blocking TCP connection test — warns on failure but always proceeds to the modules menu. -
async_step_modules_menu— Shows a menu with two options: "Add a module" and "Finish setup". Displays the current module list via_modules_description(). If the user selects "Finish" with no modules added, the menu re-displays (guard against empty config). -
async_step_add_module— Form with three fields:- String group (
string) — Optional grouping label (e.g., "A", "East"). - Name (
name) — Required user-friendly label (e.g., "Panel_01"). - Barcode (
barcode) — Required Tigo hardware barcode. - Peak power (
peak_power) — Optional peak panel power in Wp (default: 455). Used to calculate performance percentage.
Validation:
- Name must be non-empty →
missing_nameerror on the name field. - Barcode must be non-empty →
missing_barcodeerror on the barcode field. - Barcode must match pattern
^[0-9A-Fa-f]-[0-9A-Fa-f]{1,7}[A-Za-z]$→invalid_barcodeerror. - Barcode must not duplicate an already-added module →
duplicate_barcodeerror. - On success, appends the module dict and returns to the modules menu.
- String group (
-
async_step_finish— Creates the config entry with{host, port, modules: [...]}.
┌──────────────┐ ┌──────────────┐
│ Step: init │────►│ add_module │
│ (menu) │◄────│ (form) │
│ │ └──────────────┘
│ ► Add │ ┌──────────────┐
│ ► Remove │────►│ remove_module│
│ ► Save │◄────│ (dropdown) │
└──────────────┘ └──────────────┘
│
▼ (done)
UPDATE_ENTRY
async_step_init— Menu with "Add a module", "Remove a module", "Save and close".async_step_add_module— Same form and validation as the config flow version.async_step_remove_module— Dropdown (vol.In) built dynamically from the current module list showing"Name (Barcode)"labels. Selecting one removes it and returns to the menu.- "Save and close" — Updates
ConfigEntry.datawith the modified module list, triggering an integration reload.
validate_barcode(barcode)— Regex validation against_BARCODE_PATTERN.validate_connection(hass, data)— RunsTcpSource.connect()in the executor. Used for advisory connection testing only._modules_description(modules)— Builds a Markdown-formatted summary of the module list for display in menu descriptions.
Four custom HomeAssistantError subclasses: CannotConnect, InvalidAuth, InvalidModuleFormat, InvalidBarcodeFormat.
~1016 lines implementing PyTapDataUpdateCoordinator, the core runtime engine.
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_datapreserves accumulator continuity fordaily_energy,total_energy, andreadings_today.node_snapshotspreserves the latest known measurement payload (power/voltage/current/temperature/duty/rssi/performance andlast_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.
def __init__(self, hass, entry):
# Extract config
self._host = entry.data[CONF_HOST]
self._port = entry.data.get(CONF_PORT, DEFAULT_PORT)
self._modules = entry.data.get(CONF_MODULES, [])
# Build barcode allowlist and lookup table
self._configured_barcodes = {m[CONF_MODULE_BARCODE] for m in self._modules ...}
self._module_lookup = {m[CONF_MODULE_BARCODE]: m for m in self._modules ...}
# Barcode ↔ node_id mapping (learned at runtime)
self._barcode_to_node = {}
self._node_to_barcode = {}
# Discovery tracking for unconfigured barcodes
self._discovered_barcodes = set()
# Initialize data structure
self.data = {
"gateways": {},
"nodes": {}, # barcode → {power, voltage_in, ...}
"counters": {}, # parser frame counters
"discovered_barcodes": [],
}async_start_listener()
├── creates background task → _async_listen()
│ └── executor job → _listen()
└── schedules midnight reset timer → _schedule_midnight_reset()
async_stop_listener()
└── sets _stop_event (threading.Event)
└── cancels midnight reset timer
└── acquires _source_lock, closes _source (unblocks socket.read)
└── awaits task with asyncio.timeout(5)
-
_stop_event— Athreading.Event(notasyncio.Event) so it can be set from the main thread and checked from the executor thread without cross-loop issues. -
_source_lock— Athreading.Lockprotecting_sourceaccess. Both_listen()(executor) andasync_stop_listener()(main loop via executor) need to touch_source; the lock prevents races. -
_async_listen()— Async wrapper that callshass.async_add_executor_job(self._listen). This is necessary becauseasync_create_background_taskexpects a coroutine, not a Future. -
_listen()— Blocking loop running in the executor thread:- Creates a
Parserand connects aTcpSource(under_source_lock). - Checks
_stop_eventimmediately after connect — if set during connect, exits cleanly. - Reads 4096-byte chunks in a loop.
- Feeds bytes to the parser, getting back a list of
Eventobjects. - For each event individually, calls
_process_event()— if it returnsTrue(data changed), pushes an update to HA viahass.loop.call_soon_threadsafe(self.async_set_updated_data, ...). This per-event push avoids micro-batching. - Monitors for silence timeouts (
RECONNECT_TIMEOUT). - On error/timeout, closes the source (under
_source_lock), waitsRECONNECT_DELAYseconds, and retries. - Sleep during reconnect delay uses 0.1s increments checking
_stop_eventfor fast shutdown.
- Creates a
def _process_event(self, event) -> bool:
"""Returns True if data was changed."""
if isinstance(event, PowerReportEvent):
return self._handle_power_report(event)
elif isinstance(event, InfrastructureEvent):
return self._handle_infrastructure(event)
elif isinstance(event, TopologyEvent):
return self._handle_topology(event)
elif isinstance(event, StringEvent):
# Logged at DEBUG, not stored
return False_handle_power_report(event) → bool:
- Resolves
barcode— directly from event, or via_node_to_barcodemapping. - If barcode is unknown, logs at DEBUG and returns
False. - If barcode is not in
_configured_barcodes(allowlist), logs discovery at INFO and returnsFalse. - Upserts into
self.data["nodes"][barcode]with all power fields pluslast_updatetimestamp. ReturnsTrue.
The data dict stored per node:
{
"gateway_id": int,
"node_id": int,
"barcode": str,
"name": str, # from user config
"string": str, # from user config
"voltage_in": float,
"voltage_out": float,
"current_in": float,
"current_out": float,
"power": float,
"peak_power": int,
"performance": float, # (power / peak_power) × 100
"temperature": float,
"dc_dc_duty_cycle": float, # 0.0–1.0
"rssi": int,
"daily_energy_wh": float, # accumulated daily Wh (trapezoidal)
"total_energy_wh": float, # lifetime accumulated Wh
"readings_today": int, # power-report count since midnight
"daily_reset_date": str, # ISO date of last daily reset
"last_update": str, # ISO 8601
}_handle_infrastructure(event) → bool:
- Replaces
self.data["gateways"]with the event's gateway dict. - Rebuilds the
barcode ↔ node_idbidirectional mapping from scratch. - Logs newly discovered unconfigured barcodes at INFO level.
- Differentiates the first infrastructure event in a session:
- If the event has no barcodes (node table not yet received), logs an INFO explaining that resolution will activate once the gateway sends the full node table.
- If the event has barcodes, logs a WARNING with the match count.
- On subsequent events with changed mappings, logs updated match counts and lists any configured barcodes still missing from the node table.
- Triggers a persist (via
_schedule_save) when mappings change or when new unconfigured barcodes are discovered. - Returns
Trueif gateway data changed.
_handle_topology(event) → bool:
- Attaches topology data to the matching node (by resolving
node_id→ barcode). - Returns
Trueif data was attached to a configured node.
When an unconfigured barcode is seen for the first time, the coordinator logs:
INFO: Discovered unconfigured Tigo optimizer barcode: A-9999999Z
(gateway=1, node=55). Add it to your PyTap module list to start tracking.
The _discovered_barcodes set ensures each barcode is logged only once. The sorted list is also exposed in self.data["discovered_barcodes"] for potential diagnostics use.
def reload_modules(self, modules):
"""Rebuild allowlist and lookup from updated module config."""
self._configured_barcodes = {m[CONF_MODULE_BARCODE] for m in modules ...}
self._module_lookup = {m[CONF_MODULE_BARCODE]: m for m in modules ...}Called when the options flow updates the module list. After updating the allowlist, checks whether any newly-configured barcodes already have a known node mapping from previous infrastructure events. If so, creates a placeholder entry in self.data["nodes"] with module metadata (name, string group) so sensor entities can bind immediately without waiting for the next power report. Logs which barcodes were resolved from saved state and which are still pending.
All persistent state is consolidated into a single HA Store, written via homeassistant.helpers.storage.Store (version 2) as <config>/.storage/pytap_<entry_id>_coordinator. The store contains:
barcode_to_node— Barcode↔node_id mappings learned from infrastructure events.discovered_barcodes— Set of unconfigured barcodes seen on the bus.parser_state— Serialised parser infrastructure state (gateway identities, versions, node tables) viaPersistentState.to_dict().energy_data— Per-barcode accumulator state (daily_energy_wh,daily_reset_date,total_energy_wh,readings_today,last_power_w,last_reading_ts).
On startup, coordinator state is loaded from the HA Store (via _async_load_coordinator_state), including the parser's PersistentState which is deserialized via PersistentState.from_dict(). The parser receives a shared PersistentState object and mutates it in memory — the parser never performs file I/O. The coordinator schedules debounced saves (10-second delay) when mappings or infrastructure change, and flushes immediately on shutdown.
The _init_mappings_from_parser method pre-populates barcode↔node mappings from the parser's infrastructure on reconnect. Parser mappings take precedence when non-empty; when the parser state has no node table (first run), the coordinator-saved mappings are preserved as fallback.
Pure-logic module implementing trapezoidal energy integration. Intentionally HA-independent so it can be unit-tested without coordinator or event-loop setup.
EnergyAccumulator dataclass — Per-barcode mutable state: daily_energy_wh, total_energy_wh, daily_reset_date, last_power_w, last_reading_ts, readings_today.
EnergyUpdateResult dataclass — Immutable result metadata per accumulation step: increment_wh, discarded_gap_during_production.
accumulate_energy(acc, power, now, …) → EnergyUpdateResult:
- Clamps power to non-negative.
- Resets
daily_energy_whandreadings_todayto zero on date change. - If a previous reading exists and the interval is within the gap threshold, applies trapezoidal integration:
((prev_power + power) / 2) × (Δt / 3600). - Flags intervals exceeding the gap threshold during production as discarded.
- Unconditionally increments
readings_todayand updateslast_power_w/last_reading_ts.
The coordinator calls accumulate_energy() from _handle_power_report and merges the result into the node data dict.
In addition to the lazy date-check inside accumulate_energy(), the coordinator runs a proactive midnight reset timer (_schedule_midnight_reset / _perform_midnight_reset). This ensures daily sensors (daily_energy, readings_today) zero at exactly midnight local time, even when no power reports arrive overnight (typical for solar installations). The timer uses hass.loop.call_later() and reschedules itself after each reset.
Implements per-optimizer sensors and aggregate sensors using the CoordinatorEntity pattern.
SENSOR_DESCRIPTIONS = (
PyTapSensorEntityDescription(key="performance", value_key="performance",
unit="%", state_class=MEASUREMENT),
PyTapSensorEntityDescription(key="power", value_key="power",
unit=UnitOfPower.WATT, device_class=POWER),
PyTapSensorEntityDescription(key="voltage_in", value_key="voltage_in",
unit=UnitOfElectricPotential.VOLT, device_class=VOLTAGE),
PyTapSensorEntityDescription(key="voltage_out", value_key="voltage_out",
unit=UnitOfElectricPotential.VOLT, device_class=VOLTAGE),
PyTapSensorEntityDescription(key="current_in", value_key="current_in",
unit=UnitOfElectricCurrent.AMPERE, device_class=CURRENT),
PyTapSensorEntityDescription(key="current_out", value_key="current_out",
unit=UnitOfElectricCurrent.AMPERE, device_class=CURRENT),
PyTapSensorEntityDescription(key="temperature", value_key="temperature",
unit=UnitOfTemperature.CELSIUS, device_class=TEMPERATURE),
PyTapSensorEntityDescription(key="dc_dc_duty_cycle", value_key="dc_dc_duty_cycle",
unit="%", state_class=MEASUREMENT),
PyTapSensorEntityDescription(key="rssi", value_key="rssi",
unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SIGNAL_STRENGTH),
PyTapSensorEntityDescription(key="daily_energy", value_key="daily_energy_wh",
unit=UnitOfEnergy.WATT_HOUR, device_class=ENERGY, state_class=TOTAL),
PyTapSensorEntityDescription(key="total_energy", value_key="total_energy_wh",
unit=UnitOfEnergy.WATT_HOUR, device_class=ENERGY, state_class=TOTAL_INCREASING),
PyTapSensorEntityDescription(key="readings_today", value_key="readings_today",
state_class=TOTAL, entity_category=DIAGNOSTIC),
)Power/electrical sensors use SensorStateClass.MEASUREMENT; energy sensors use TOTAL/TOTAL_INCREASING for HA long-term statistics and energy dashboard compatibility.
async_setup_entry() creates entities deterministically from the config:
for module_config in modules:
barcode = module_config.get(CONF_MODULE_BARCODE, "")
if not barcode:
continue # Skip modules without barcode
for description in SENSOR_DESCRIPTIONS:
entities.append(PyTapSensor(coordinator, description, module_config, entry))For two modules on two strings, entity creation is:
- Per-optimizer:
2 × 12 = 24 - Per-string aggregate:
2 × 4 = 8 - Installation aggregate:
4 - Total:
36
Inherits CoordinatorEntity[PyTapDataUpdateCoordinator] and SensorEntity.
Identity:
unique_id:"{DOMAIN}_{barcode}_{sensor_key}"(e.g.,pytap_A-1234567B_power)has_entity_name = True
Device grouping:
DeviceInfo(
identifiers={(DOMAIN, barcode)},
name=f"Tigo TS4 {module_name}",
manufacturer="Tigo Energy",
model="TS4",
serial_number=barcode,
)All 12 sensors for the same barcode are grouped under one device.
Availability:
Returns True only when coordinator.data["nodes"][barcode] exists (i.e., at least one PowerReportEvent has been received for this optimizer). There is no unavailable timeout — sensors hold their last received value indefinitely.
Value updates (_handle_coordinator_update):
- Reads from
coordinator.data["nodes"][barcode][value_key]. - Special case:
dc_dc_duty_cycleis converted from 0.0–1.0 to percentage (* 100). - Calls
self.async_write_ha_state()to push the update.
Extra state attributes:
string_group— from user config (if set).last_update— ISO timestamp from coordinator data.gateway_id— the gateway this optimizer communicates through.
Implements the HA diagnostics download endpoint via async_get_config_entry_diagnostics(). HA auto-discovers this module — no PLATFORMS entry is needed.
Redaction: Uses async_redact_data with TO_REDACT = {CONF_HOST} to strip the host IP from the config entry snapshot. Port and all other data remain visible.
Payload structure:
config_entry— Redacted config entry dict.counters— Internal event counters from coordinator data.gateways— Gateway identity data.discovered_barcodes— Unconfigured barcodes seen on the bus.nodes— Per-barcode summary (last_update, gateway_id, node_id, daily_energy_wh, total_energy_wh, readings_today).- Plus all keys from
coordinator.get_diagnostics_data()(node_mappings, connection_state, energy_state), also redacted.
The node summaries intentionally omit raw power/voltage fields to keep the diagnostics download focused on integration health rather than instantaneous electrical data.
Handles integration lifecycle, config entry migration, and legacy entity cleanup.
CONFIG_ENTRY_VERSION = 4:
v1 → v2: voltage/current split to_in/_outv2 → v3: module string labels became mandatory (defaulted to"Default"during migration)v3 → v4: peak power added to module config (defaulted to 455 Wp during migration)
Handles config entry version migration:
- v1 → v2: Updates
entry.versionto 2. - v2 → v3: Ensures each module has a non-empty string label, defaulting missing/empty values to
"Default". - v3 → v4: Adds
peak_powerto each module, defaulting toDEFAULT_PEAK_POWER(455 Wp) for modules that don't have it.
- Cleans up legacy entity unique IDs from pre-v0.2.0 via
_async_cleanup_legacy_entities(). - Creates
PyTapDataUpdateCoordinator(hass, entry). - Calls
coordinator.async_config_entry_first_refresh()— validates initialization (does not block on data since this is push-based). - Calls
coordinator.async_start_listener()— launches the background streaming task. - Registers
coordinator.async_stop_listenerviaentry.async_on_unload()— ensures the listener is stopped on HA shutdown or entry unload. - Stores coordinator in
hass.data[DOMAIN][entry.entry_id]. - Forwards platform setup (
Platform.SENSOR). - Registers
_async_update_optionsas an update listener.
Removes orphaned entity registry entries left over from the voltage/current → voltage_in/out, current_in/out rename:
- Iterates configured modules and checks for entities with old unique IDs (
pytap_BARCODE_voltage,pytap_BARCODE_current). - Removes matching entries from the entity registry.
- Logs the count of cleaned-up entities.
Reloads the entire integration when options change, causing a full teardown/setup cycle that picks up the modified module list.
- Unloads platforms.
- Stops the coordinator's background listener (also covered by
async_on_unloadbut called explicitly for the non-shutdown unload path). - Removes the coordinator from
hass.data.
Defines all user-facing text for the config flow and options flow in structured JSON.
Config flow steps:
user— "Connect to Tigo Gateway" with host/port fields and descriptions.modules_menu— "Configure Modules" menu with{modules_list}and{error}placeholders.add_module— "Add Module" form with string/name/barcode fields and detailed descriptions.finish— "Finish Setup" (terminal step).
Options flow steps:
init— "PyTap Options" menu with add/remove/done options.add_module— Same fields as config flow.remove_module— Dropdown withremove_barcodeselector.
Error strings: cannot_connect, invalid_barcode, missing_string, missing_name, missing_barcode, duplicate_barcode, no_modules, unknown.
Abort reasons: already_configured.
translations/en.json mirrors strings.json exactly.
The config flow uses a menu-driven approach where users add optimizer modules one at a time through individual form fields.
The menu-driven form is used because it improves usability and validation:
- Lower error rate — Each field is validated directly.
- Per-field errors — Validation issues are shown on the relevant field.
- Better discoverability — Users are guided through the expected inputs.
- Inline help — Each field can include a focused description.
1. User enters host and port
→ Non-blocking connection test (warns but proceeds if unreachable)
2. Modules menu appears:
"Modules (0): No modules added yet."
[Add a module] [Finish setup]
3. User clicks "Add a module" → form appears:
String group: [___________] (required)
Name: [___________] (required)
Barcode: [___________] (required)
4. On submit, if valid:
→ Returns to menu with updated list
"Modules (1):
1. string=A / Panel_01 / A-1234567B"
[Add a module] [Finish setup]
5. User repeats step 3-4 as needed, then clicks "Finish setup"
→ Config entry created
The TCP connection test in step 1 is non-blocking: if the gateway is unreachable (common during initial setup when the gateway may not be powered on), the flow logs a warning and proceeds to the modules menu. This prevents the common frustration of being unable to complete configuration when the gateway is temporarily offline.
Tigo Gateway (TCP port 502)
│
│ Raw bytes (RS-485 protocol frames)
▼
TcpSource.read(4096) [executor thread]
│
▼
Parser.feed(bytes) → list[Event] [executor thread]
│
├── PowerReportEvent
├── InfrastructureEvent
├── TopologyEvent
└── StringEvent
│
▼
FOR EACH event: [executor thread]
coordinator._process_event(event) → bool
│
├── data changed (True)?
│ ├── Barcode in allowlist? YES → merge into data["nodes"][barcode]
│ │ NO → log discovery, discard
│ │
│ ▼
│ hass.loop.call_soon_threadsafe( [→ main event loop]
│ coordinator.async_set_updated_data, data
│ )
│
└── no change (False) → skip push
│
▼
CoordinatorEntity._handle_coordinator_update() [main event loop]
│
├── Read from data["nodes"][barcode][value_key]
├── Convert duty cycle → percentage (if applicable)
└── async_write_ha_state()
│
▼
Home Assistant frontend / automations / history
- Framework: pytest with
pytest-homeassistant-custom-component - Async mode:
asyncio_mode = auto(inpytest.ini) - Fixture:
auto_enable_custom_integrations(inconftest.py) enables loading fromcustom_components/.
Representative tests:
| Test | What it verifies |
|---|---|
test_step_user_shows_form |
Initial step renders host/port form with no errors |
test_user_step_proceeds_to_menu |
Submitting host/port advances to modules_menu |
test_user_step_proceeds_even_without_connection |
Failed TCP test still proceeds (non-blocking) |
test_full_flow_add_one_module |
Complete flow: user → menu → add → menu → finish = CREATE_ENTRY |
test_full_flow_add_two_modules |
Two add_module cycles produce entry with 2 modules |
test_add_module_invalid_barcode |
Invalid barcode format shows error on barcode field |
test_add_module_missing_name |
Empty name shows error on name field |
test_add_module_duplicate_barcode |
Duplicate barcode shows error on barcode field |
All tests mock validate_connection to avoid real TCP connections.
| Test | What it verifies |
|---|---|
test_sensor_entities_created |
2 modules, 2 strings create 36 entities including aggregate sensors |
test_sensor_unique_ids |
IDs include per-optimizer and aggregate unique ID formats |
test_sensor_available_with_data |
Sensor available when node data exists |
test_sensor_unavailable_without_data |
Sensor unavailable when data dict is empty |
test_sensor_skips_modules_without_barcode |
Modules with empty barcode don't create entities |
test_sensor_device_info |
Device identifiers, manufacturer, model, serial_number |
test_sensor_descriptions_count |
Exactly 12 sensor descriptions defined |
test_energy_sensor_descriptions |
Daily/total energy sensor metadata and state classes |
test_daily_energy_last_reset |
Daily energy exposes last_reset from daily_reset_date |
test_string_daily_energy_sums |
String daily aggregate sums constituent daily_energy_wh |
test_installation_total_energy_sums_all |
Installation total aggregate sums constituent total_energy_wh |
test_string_aggregate_device_info |
String aggregate uses virtual string device metadata |
test_installation_aggregate_device_info |
Installation aggregate uses installation virtual device metadata |
test_performance_sensor_value |
Per-optimizer performance sensor exposes stored percentage |
test_string_performance_weighted |
String aggregate uses capacity-weighted formula |
test_installation_performance_partial_data |
Aggregate performance includes only reporting nodes |
test_installation_performance_unavailable_without_data |
Aggregate performance unavailable when no nodes report power |
test_performance_sensor_zero_power |
Power=0W produces performance=0.0% |
test_readings_today_sensor_metadata |
readings_today has TOTAL state class and DIAGNOSTIC category |
test_readings_today_value_and_last_reset |
Value read from node data, last_reset from daily_reset_date |
test_performance_sensor_above_100 |
Power > peak produces >100% (no clamping) |
Tests use MagicMock(spec=PyTapDataUpdateCoordinator) to avoid real coordinator initialization.
Coverage includes coordinator initialization, barcode mapping restoration and purging, deferred power-report handling before infrastructure, save/load behavior, parser-state restore/fallback, stop-flush behavior, energy-data persistence (energy_data save/load with daily reset on new day), and proactive midnight reset behavior (zeroing daily accumulators, skip-if-already-reset, timer rescheduling, and cancellation on stop).
tests/test_diagnostics.py validates the diagnostics download endpoint:
| Test | What it verifies |
|---|---|
test_config_entry_diagnostics_redacts_host |
Host is redacted, port and barcodes are visible |
test_config_entry_diagnostics_includes_unredacted_barcodes |
Discovered barcodes pass through unredacted |
test_config_entry_diagnostics_fresh_install |
Empty coordinator (no data) doesn't raise |
test_config_entry_diagnostics_all_keys_present |
All expected top-level keys present, energy_state/discovered_barcodes pass-through |
tests/test_energy.py validates trapezoidal integration in isolation across baseline behavior, nominal interval integration, gap handling during production, overnight gaps, daily resets with preserved total accumulation, readings_today incrementing and daily-reset behaviour, and related edge cases.
| Test | What it verifies |
|---|---|
test_removes_old_voltage_and_current_entities |
Legacy voltage/current entity registry entries removed on setup |
test_does_not_touch_new_entities |
New _in/_out entities are not affected by cleanup |
test_no_op_when_no_legacy_entities |
No errors when no legacy entities exist |
test_migrates_v1_to_v2 |
Config entry version migrates forward to current version |
test_migrate_v2_to_v3_empty_strings |
Empty/missing module strings are defaulted during migration |
test_migrate_v2_to_v3_existing_strings |
Existing string labels preserved during migration |
test_migrate_v2_to_v3_mixed |
Only missing string labels defaulted in mixed lists |
test_migrate_v3_to_v4_adds_peak_power |
Modules without peak_power get DEFAULT_PEAK_POWER |
test_migrate_v3_to_v4_preserves_peak_power |
Existing peak_power values not overwritten |
test_migrate_v3_to_v4_mixed |
Mixed modules: missing gets default, existing preserved |
test_already_current_version |
Current-version entries pass through migration unchanged |
The embedded parser library has its own test suite:
| Test File | Coverage |
|---|---|
test_parser.py |
Byte-level protocol parsing with captured data |
test_types.py |
Protocol type construction and field validation |
test_crc.py |
CRC-16 calculation against known vectors |
test_barcode.py |
Barcode encode/decode round-trips |
test_api.py |
Public API function surface tests |
# Run all integration tests
python3 -m pytest tests/ -vv --tb=short
# Run parser library tests
python3 -m pytest custom_components/pytap/pytap/tests/ -vv
# Lint
python3 -m ruff check custom_components/pytap/Chosen: Individual form-per-module with a menu loop.
Trade-off: More config flow steps for users with many modules, but significantly better UX:
- Per-field validation with targeted error messages.
- Required field (string group) is clearly labeled.
- Guided field-by-field entry for string, name, and barcode.
- Matches HA's native form conventions.
Chosen: TCP connection test warns on failure but does not block the flow.
Rationale: Users often configure integrations when the target device is offline (e.g., solar gateway powered off at night). Blocking on connection would prevent saving a valid configuration. The integration will connect when the gateway becomes available.
Chosen: Background streaming task with call_soon_threadsafe dispatch.
Trade-off: More complex than a simple update_interval-based coordinator, but:
- Sub-second latency vs. polling interval latency.
- No redundant requests — only new data is processed.
- Matches the bus protocol's inherent push nature.
Chosen: Run blocking _listen() in HA's executor via async_add_executor_job, wrapped in an _async_listen() coroutine.
Rationale: The pytap library uses blocking socket.recv(). Rewriting the library for asyncio would add complexity without benefit. The executor bridge is clean: the library remains portable and testable outside HA.
Chosen: All entity IDs, device identifiers, and data dict keys use the Tigo barcode.
Rationale: node_id is a transient 16-bit integer that can change across gateway restarts. Barcodes are hardware-burned identifiers that never change, making them suitable as stable unique IDs.
Chosen: Entities are created at setup from the configured module list. No dynamic entity creation from bus events.
Rationale:
- Prevents phantom entities from neighboring installations on the same RS-485 bus.
- Enables dashboard/automation setup before first data arrives.
- User-defined names instead of opaque IDs.
- Consistent with the taptap HA add-on approach.
Chosen: _async_update_options triggers a full async_reload rather than incremental entity updates.
Trade-off: Brief disruption during reload, but guarantees entities match the new config exactly. Adding/removing modules changes the entity set, which is simplest to handle via full reload.
The architecture document (architecture.md) was written during initial design and has not been fully updated for the menu-driven config flow. Notable differences:
| Aspect | Architecture Doc | Actual Implementation |
|---|---|---|
| Config flow modules step | Legacy flow variants | Menu-driven: host/port → modules_menu → add_module loop → finish |
| Module input format | Legacy text input formats | Individual form fields per module |
| Options flow | Described as text-based reconfiguration | Menu with add/remove/done actions |
| Gateway device registration | Described as separate DeviceInfo | Not yet implemented (sensors have device info per optimizer only) |
via_device on nodes |
Linked to gateway device | Not implemented (no gateway device yet) |
| Unavailable timeout | Described as configurable via options | Removed — sensors hold last value indefinitely |
| Sensor count | Historical counts | 12 per optimizer, plus string/installation aggregate sensors |
| Config entry version | Not mentioned | v4 with v1→v2, v2→v3, and v3→v4 migration steps |
| Threading primitives | Not specified | threading.Event + threading.Lock (not asyncio.Event) |
| Diagnostics platform | Mentioned for discovered barcodes | Implemented via diagnostics.py config-entry download |
Created docs/architecture.md capturing the full design: system context, module responsibilities, data flow, threading model, entity model, and configuration schema.
Updated the architecture to remove auto-discovery in favor of user-configured barcodes, inspired by the taptap HA add-on.
Implemented all core files:
const.py,manifest.json— Constants and metadata.config_flow.py— Menu-driven flow (host/port → modules menu → add/remove modules).coordinator.py— Push-based streaming with barcode filtering.sensor.py— 12 per-optimizer sensor types plus aggregate sensor platform entities.__init__.py— Lifecycle management.strings.json,translations/en.json— UI strings.- Test suite — 14 tests passing.
Discovered that the TCP connection test in step 1 blocked the flow when no gateway was available (the common case during development). Changed the connection test to non-blocking: it warns but always proceeds to the modules step.
Refined the config flow to the current menu-driven approach:
- Individual
add_moduleform with string/name/barcode fields. - Menu loop for adding multiple modules.
- Options flow with add/remove/done menu.
- Per-field error reporting (errors shown on the specific field).
- Dropdown-based module removal in options flow.
- All tests rewritten for the new flow pattern (16 tests passing).
Created this implementation document capturing all development work to date.
- Fixed micro-batching:
_listen()was callingasync_set_updated_dataonce per TCP read chunk. Changed to push per-event — each event that changes data triggers its ownasync_set_updated_datacall. _process_event()and all handler methods now returnboolindicating whether data changed.- Removed
UNAVAILABLE_TIMEOUTconstant and all related logic. Sensors now hold their last received value indefinitely (no forcedNoneafter timeout). - Updated
source.py:TcpSource.read()now raisesOSError("Socket is closed")when the socket isNoneandConnectionResetErroron peer close, instead of silently returningb''.
- Split
voltage→voltage_in/voltage_outandcurrent→current_in/current_out, then expanded per-optimizer sensor count to 10 with daily/total energy. - Added entity registry cleanup in
_async_cleanup_legacy_entities()to remove orphanedvoltage/currententities from pre-v0.2.0 installs. - Bumped config entry version to 2 and added
async_migrate_entry()for v1→v2 migration. - Bumped manifest version to 0.2.0.
- Added 5 entity migration tests.
- Changed
_stop_eventfromasyncio.Eventtothreading.Event— the former is not thread-safe across loops and caused HA shutdown to hang. - Added
_source_lock(threading.Lock) to protect concurrent_sourceaccess between the executor thread and the main loop's stop path. async_stop_listener()now usesasyncio.timeout(5)to prevent indefinite blocking if the listener task doesn't exit.- Registered
coordinator.async_stop_listenerviaentry.async_on_unload()so the listener is stopped on HA shutdown/reload, not just explicit unload. - Added 17 coordinator persistence and lifecycle tests.
- Node table sentinel tolerance — Fixed parser
_handle_node_table_commandto tolerate trailing bytes on the end-of-table sentinel page (entries_count=0). The gateway commonly sends padding/CRC bytes after the zero count, which was previously rejected as "corrupt", preventing the node table from completing and barcodes from being resolved. - Trailing-byte tolerance on data pages — Data pages with more bytes than expected now parse the declared entries and ignore trailing bytes (changed strict equality check to minimum-length check).
- First infrastructure event differentiation —
_handle_infrastructurenow distinguishes between infra events with and without barcodes. The first event often arrives from gateway identity/version discovery before the node table is received. Previously this logged a misleading "0/N matched" WARNING; now it logs an INFO explaining that resolution will activate once the node table arrives. - Configured barcode mismatch logging — Infrastructure events now log which specific configured barcodes are NOT found in the node table, helping users identify typos or incorrect barcodes.
- Discovery persistence fix — Discovered (unconfigured) barcodes from infrastructure events now properly trigger
_schedule_save(). Previously only mapping changes triggered saves, leaving discovered barcodes unpersisted. - Coordinator-saved mapping preservation —
_init_mappings_from_parsernow preserves coordinator-saved barcode↔node mappings when the parser state is empty (merge instead of replace). This prevents previously-learned mappings from being wiped on reconnect when the parser state file has no node table. - Instant barcode resolution on module add —
reload_modulesnow checks if newly-added barcodes already exist in the saved barcode↔node mapping and pre-populates placeholder node data so sensor entities can bind immediately without waiting for the next power report.
- Node address bit-15 flag masking — Fixed parser
_handle_node_table_commandto mask node addresses to 15 bits (& 0x7FFF) when parsingNODE_TABLE_RESPONSEentries. Bit 15 of theNodeAddressin node table entries is a protocol flag (indicating router/repeater status), not part of the node ID. Two nodes with barcodes4-D39A3ESand4-D39CB6Rwere observed with raw addresses0x8019(32793) and0x801A(32794) instead of the expected 25 and 26. Without masking, node table keys did not match the 15-bit node IDs used in power reports, causing those nodes' power data to be unresolvable to barcodes. - Debug logging for flagged nodes — When bit 15 is detected on a node address, the parser now emits a
DEBUG-level log with the raw and masked values for protocol analysis. - Test added —
test_node_table_bit15_flag_maskedintest_parser.pyverifies that addresses0x8019/0x801Aresolve to node IDs 25/26 and not 32793/32794.
- Consolidated storage — Merged the parser's raw JSON state file and the coordinator's HA Store into a single
homeassistant.helpers.storage.Store(version 2). The store now holds barcode↔node mappings, discovered barcodes, and parser infrastructure state (PersistentState.to_dict()). This eliminates raw file I/O, ensures proper HA backup inclusion, and enables automatic cleanup on config entry removal. - Parser decoupled from file I/O —
Parser.__init__now accepts an optionalPersistentStateobject instead of astate_filepath. The parser mutates the state in memory; the coordinator owns persistence. PersistentStateserialization — Replacedsave(path)/load(path)file I/O methods withto_dict()/from_dict()for JSON-compatible serialization via the HA Store.create_parser()API updated — Acceptspersistent_state: Optional[PersistentState]instead ofstate_file: str | Path | None.- CLI removed — Deleted
pytap/cli/module,pytap/setup.py, andpytap.egg-info/. The library is now embedded-only, used exclusively through the HA integration coordinator. observe()function removed — The blocking streaming loop with callback was only used by the CLI. Callers now usecreate_parser()+connect()+ manualfeed()loop.- Store version bumped to 2 — Added migration path from v1 (barcode mappings + discovered barcodes only) to v2 (adds
parser_state). - Tests updated — Replaced
_state_file_pathassertions with_persistent_statechecks, addedtest_load_restores_parser_stateandtest_load_handles_corrupt_parser_state. 51 tests passing. - Documentation updated — All docs (README, architecture, implementation, API reference) updated to reflect the consolidated storage model and removed CLI.
- Added aggregate sensors for each string and for the full installation (
power,daily_energy,total_energy). - Added
PyTapAggregateSensorand dedicated virtual devices (Tigo String <name>,Tigo Installation). - Made module
stringmandatory in config and options flow (missing_stringvalidation). - Bumped config entry version to 3 with
v2 → v3migration defaulting missing strings to"Default". - Added aggregate and migration tests to validate sums, IDs, availability, metadata, and migration behavior.
- Added
CONF_MODULE_PEAK_POWERandDEFAULT_PEAK_POWER(455 Wp) constants toconst.py. - Extended
ADD_MODULE_SCHEMAinconfig_flow.pywith optionalpeak_powerfield (vol.Range(min=1, max=1000)). - Updated
_modules_descriptionto display peak power per module. - Bumped
PyTapConfigFlow.VERSIONandCONFIG_ENTRY_VERSIONto 4, aligning the previously mismatched versions. - Added v3→v4 migration in
async_migrate_entryto backfillDEFAULT_PEAK_POWERon existing modules. - Extended
coordinator._handle_power_reportto computeperformance = (power / peak_power) × 100with defensive parsing and fallback for invalid peak_power values. - Added
peak_powerandperformanceto coordinator node data dict andreload_modulesplaceholder. - Added
performancesensor description to all three description tuples (SENSOR_DESCRIPTIONS,STRING_SENSOR_DESCRIPTIONS,INSTALLATION_SENSOR_DESCRIPTIONS). - Implemented capacity-weighted aggregate performance in
PyTapAggregateSensor._handle_coordinator_update(sensor-side). - Updated entity count formula:
M × 11 + S × 4 + 4(wasM × 10 + S × 3 + 3). - Added 3 sensor translation keys (
performance,string_performance,installation_performance) andpeak_powerconfig field tostrings.jsonandtranslations/en.json. - Added tests: v3→v4 migration (3 tests), config flow peak_power (2 tests), sensor performance (6 tests), coordinator performance (3 tests).
- Bumped
manifest.jsonversion to 0.3.0. - 99 tests passing, ruff clean.
- Added
custom_components/pytap/diagnostics.pywithasync_get_config_entry_diagnostics. - Added coordinator
get_diagnostics_data()snapshot including node mappings, connection state, and per-barcode accumulator summary. - Redacted host/IP in diagnostics output while preserving barcodes for troubleshooting.
- Added per-optimizer
readings_todayas a diagnostic sensor (SensorStateClass.TOTAL, unitless). - Extended
EnergyAccumulatorwithreadings_today, increment-on-report, and reset-on-new-day behavior. - Persisted
readings_todayin coordinator store load/save, including date-rollover reset on restore. - Added tests in
tests/test_diagnostics.pyand extended sensor/coordinator/energy tests. - Full suite status after feature: 105 tests passing, ruff clean.
- Bug fix: Daily sensors (
daily_energy,readings_today) previously reset lazily on the first power report after midnight. Since solar panels produce no data overnight, the reset occurred at a random time in the morning when the first reading arrived. - Added
_schedule_midnight_reset()and_perform_midnight_reset()to the coordinator. Acall_latertimer on the HA event loop fires at exactly midnight local time, zeroes all daily accumulators, updates node data, pushes the update to sensors, and reschedules itself for the next midnight. async_start_listener()now schedules the midnight reset timer at startup.async_stop_listener()cancels the midnight reset timer on shutdown.- Added 4 tests in
TestMidnightReset: zeroing daily values, skip-if-already-reset, timer rescheduling, and cancellation on stop.
Items identified but not yet implemented:
- Gateway device registration — Create a device per gateway for the
via_devicehierarchy. - Binary sensors — Node connectivity and gateway online status.
- HACS distribution — Package with
hacs.jsonfor one-click installation.