diff --git a/.env b/.env index 5436e5c..4e1acd8 100644 --- a/.env +++ b/.env @@ -1,3 +1,42 @@ SERIAL_PORT='/dev/ttyXRUSB0' MODBUS_TCP_GW_IP='192.168.1.140' MODBUS_TCP_GW_PORT='8899' +MOSQUITTO_IP=mosquitto.hs.mfis.net +MOSQUITTO_PORT=1883 +CERBO_AC_OUT_TOPIC=N/48e7da878d35/vebus/276/Ac/Out +CERBO_AC_ACTIVEIN_TOPIC=N/48e7da878d35/vebus/276/Ac/ActiveIn +# Enable a virtual Maxem solar meter slave (default address 001) fed from Cerbo MQTT PV topics. +CERBO_ENABLE_PV_SLAVE=1 +# RTU slave address for the virtual PV meter used by Maxem autoconfig (typically 001). +CERBO_PV_TARGET_SLAVE=1 +# Comma-separated PV power topics (W). Values are summed into realtime total PV production. +CERBO_PV_TOPICS=N/48e7da878d35/solarcharger/283/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/1/P +# How to fill phase power registers (0x5B16..0x5B1B): +# - activein: use Cerbo Ac/ActiveIn L1/L2/L3 watts (can go negative on export). +# - acout: derive watts from ABB phase voltage * Cerbo Ac/Out phase current (non-negative, often best for fuse/load behavior). +# - abb: keep ABB phase power words unchanged (passthrough). +CERBO_PHASE_POWER_SOURCE=activein +# When 1, enforce non-negative phase-power writes: +# - activein mode: first net exports against imports across phases, then clamp to >=0. +# - other modes: clamp each phase to >=0. +CERBO_FORCE_NONNEGATIVE_PHASE_POWER=1 +# When 1, publish MQTT snapshot only after a coherent 3-phase frame is seen for both ActiveIn and Ac/Out. +CERBO_COHERENT_PHASE_FRAMES=1 +# Max allowed L1/L2/L3 timestamp skew (seconds) inside a coherent frame; frame is skipped if exceeded. +CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS=1.5 + +SYNTHETIC_HOME_STATE_FILE=/tmp/modbus-softsplit-state.json + +## Feature flags +# 1 = default to dry-run mode (no RTU open, preview logs only); 0 = normal live mode. +DRY_RUN_MAXEM_HOME=0 +# INFO by default; use DEBUG to print ABB/Cerbo preview lines. +LOG_LEVEL=DEBUG +# 1 enables verbose paho MQTT protocol wire logs (CONNECT/SUBSCRIBE/PUBLISH). Keep 0 for readable DEBUG logs. +CERBO_MQTT_PROTOCOL_DEBUG=0 +# DEBUG snapshot spam control: 0 disables per-snapshot debug lines; >0 logs at most once per interval in seconds. +CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS=0 +# Suppress only known one-byte RTU noise from modbus_tk logs (no protocol behavior change). +SUPPRESS_SHORT_RTU_REQUEST_LOGS=1 +# Loop heartbeat interval in seconds (0 disables heartbeats). +STATUS_LOG_INTERVAL_SECONDS=30 diff --git a/.gitignore b/.gitignore index dd78f6f..d80cf5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.secrets-agents +mcp.json *.mod # Byte-compiled / optimized / DLL files @@ -16,7 +18,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..04c4d2e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "cortex-nexus-gateway": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://127.0.0.1:18000/mcp", + "--allow-http" + ] + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a8752e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +This repository uses the Documentation and Memory Hygiene policy in +[`docs/policies/documentation-and-memory-hygiene.md`](docs/policies/documentation-and-memory-hygiene.md). + +When a project has a root `.mcp.json` of `mcp.json` file, treat that policy as active. + +Read before meaningful work: + +- `AGENTS.md` +- `docs/policies/documentation-and-memory-hygiene.md` +- relevant project docs such as `README.md`, `docs/`, `docs/adr/`, `docs/archive/`, and `runbooks/` + +Follow the policy when updating code, docs, configuration, or durable memory. + +For repository-specific durable knowledge, write or update the matching +document under `docs/` in the same task and treat it as the canonical record. If an appropriate doc does not yet exist, create it. This is YOUR memory and you can manage it in the best way you yourself can utilize it in the future. +Use external memory only as a supplemental reminder, not as the sole source of +truth for this repository. + +Extra tools, skills, personas, roles, and access to third party platforms is available to you via the MCP servers listed in `*mcp.json` file in the project root. If a connector is not configured it is probably exposed to you via these MCP servers. Use them. diff --git a/README.md b/README.md index 721a5f3..2a87e13 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ the two "virtual" Slave/Client devices, in essence, create 2 new data buses (one and allow each of the physical Master/Server devices to have exclusive communication to the "virtual" slaves on these 2 new "virtual" buses. +This architecture is intentionally generic and can be adapted for other Modbus device families; ABB meter mapping is the +current implementation profile, not a hard project limitation. + ![screenshot](/layout.png?raw=true) ## Notes on this implementation: @@ -34,8 +37,82 @@ Energy hardware while the other exposes itself via modbus-rtu by way of a USB to mentioned earlier does not support modbus-tcp so it was necessary to wire it directly and provide access to the virtual client devices via modbus-rtu using the USB to RS-485 converter. - This code should be easily read and converted in the situation where you want to have multiple RTU based Server devices -or all Server devices support modbus-tcp. The easy to use modbus-tk project makes it quite easy to quickly adjust to +or all Server devices support modbus-tcp. The easy to use modbus-tk project makes it quite easy to quickly adjust to your specific situation. +- The Maxem Home rewrite path is opt-in via `--dry-run-maxem-home`. When that flag is set, the service leaves the RTU + serial adapter unopened and logs the instantaneous-power rewrite it would apply to `instantaneous_values` instead of + writing values into the RTU slave. This keeps the RTU serving path quiet while we inspect the intended rewrite + behavior. +- You can also set `DRY_RUN_MAXEM_HOME=1` in `.env` to make dry-run the default without changing service/unit args. +- In dry-run mode the Maxem preview is intentionally short and easy to compare against dashboards: + `ABB source: X W`, `Cerbo Usage to Maxem: Y W`, and `Cerbo Phase Watts to Maxem: L1=..., L2=..., L3=...`. + When PV slave emulation is enabled, dry-run also logs `Cerbo PV to Maxem (slave 001): ...`. + These lines are emitted at `DEBUG` level (set `LOG_LEVEL=DEBUG` when you want them). +- The active rewrite story now reads directly from Victron CerboGX MQTT (read-only) and rewrites selected words in + ABB `instantaneous_values` while mirroring all other words verbatim. + - `active_power_total` (`0x5B14/0x5B15`) is sourced from `Ac/ActiveIn` total watts. + - `active_power_l1/l2/l3` (`0x5B16..0x5B1B`) follow `CERBO_PHASE_POWER_SOURCE`. + - `current_l1/l2/l3/n` (`0x5B0C..0x5B13`) are sourced from `Ac/Out` phase currents. + - `Ac/ActiveIn` values may be positive or negative; `Ac/Out` currents are clamped to non-negative values. + - `CERBO_PHASE_POWER_SOURCE` can pivot phase-power behavior without code edits: + - `activein` uses Cerbo `Ac/ActiveIn` per-phase watts. + When `CERBO_FORCE_NONNEGATIVE_PHASE_POWER=1`, exports are first netted against imports across phases, then clamped to `>=0`. + - `acout` derives phase watts from ABB phase voltages and Cerbo `Ac/Out` phase currents. + - `abb` leaves phase-power words unchanged from ABB passthrough. + - `CERBO_COHERENT_PHASE_FRAMES=1` publishes snapshots only after complete 3-phase updates for both + `Ac/ActiveIn` and `Ac/Out`, reducing mixed-time phase combinations. + - Optional virtual PV meter emulation for Maxem slave `001` is available via: + - `CERBO_ENABLE_PV_SLAVE=1` + - `CERBO_PV_TARGET_SLAVE=1` + - `CERBO_PV_TOPICS=` + The PV total is summed from those topics and written to the `instantaneous_values` active-power words on slave `001`. + Phase power/current words for slave `001` are synthesized coherently from that total (equal split by phase, amps from ABB phase voltage). + This keeps Maxem home/grid power semantics aligned with grid import/export while preserving AC-out current safety inputs + used for EV phase protection. +- The dry-run logger prints one semantic line before the preview values so it is obvious that the preview is the + ABB instantaneous register block being rewritten from Cerbo MQTT. +- Register bundle captures and replay previews live under `tools/`. The dump helper defaults to `instantaneous_values` + only, and the replay helper prints the same preview lines without touching the RTU adapter. +- Preview semantics are simple: + - `ABB source` is the decoded instantaneous active-power total from ABB. + - `Cerbo Usage to Maxem` is the rewrite total watts from `Ac/ActiveIn`. + - `Cerbo Phase Watts to Maxem` are per-phase rewrite watts from `Ac/ActiveIn`. + - `Cerbo Phase Currents to Maxem` are per-phase + neutral current amps from `Ac/Out`. +- Core application modules now live under `lib/` so the repo root stays focused on the entrypoint, tools, + and docs. + +## Development workflow + +- Test files use numbered prefixes like `tests/10_test_maxem_home_usage.py` and `tests/20_test_dump_and_replay.py` so + related suites can grow in a predictable order. +- `pytest.ini` is configured to collect only numbered test files from `tests/`. +- The quickest local checks are `python3 -m pytest` and `python3 -m py_compile` on the touched modules. +- For the register tooling, run `python3 tools/dump_register_block.py --help` and + `python3 tools/replay_maxem_preview.py --help` before using real captures. +- `python3 tools/dump_register_block.py` defaults to the ABB `instantaneous_values` block; pass `--register` to add + extra blocks only when you truly need them. +- `python3 tools/replay_maxem_preview.py --bundle ` prints the same short preview line format the dry-run + runtime uses (`ABB source` plus rewrite lines) from a captured bundle. +- `python3 tools/inspect_instantaneous_payload.py --bundle ` unpacks the key ABB instantaneous fields and shows + source vs rewritten values plus the exact word addresses that changed. +- `python3 main.py --trace-instantaneous-payload` enables the same field-level diff in live runtime logs so we can + verify exactly what is being written without changing default behavior. +- Cerbo MQTT poller is read-only and subscribes to: + - `CERBO_AC_OUT_TOPIC` (default `N/48e7da878d35/vebus/276/Ac/Out`) + - `CERBO_AC_ACTIVEIN_TOPIC` (default `N/48e7da878d35/vebus/276/Ac/ActiveIn`) + - `CERBO_PV_TOPICS` (default: three configured `solarcharger/.../Pv/.../P` topics; summed for virtual PV meter power) +- At `DEBUG` level the runtime logs snapshot updates from MQTT and preview lines (`ABB source` / `Cerbo ... to Maxem`). +- To keep DEBUG readable by default: + - `CERBO_MQTT_PROTOCOL_DEBUG=0` suppresses raw paho wire logs (`Sending CONNECT`, `Received PUBLISH`, etc). + - `CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS=0` suppresses per-message snapshot debug spam. + - Set `CERBO_MQTT_PROTOCOL_DEBUG=1` and/or `CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS=` only for deep MQTT troubleshooting. +- Startup logs print effective Cerbo source settings and where values came from (`env`, `.env`, or defaults). +- The main loop now handles `Ctrl-C` cleanly in one interrupt and stops the poller and servers without a traceback. +- Canonical project-specific runtime rules and durable decisions live in + `docs/decisions/project-conventions.md`. +- Long-run logging/observability tuning is controlled with: + - `SUPPRESS_SHORT_RTU_REQUEST_LOGS=1` (default) to suppress only `invalid request: Request length is invalid 1`. + - `STATUS_LOG_INTERVAL_SECONDS=30` (default) to emit periodic heartbeat summaries instead of per-loop update spam. ## Tested Hardware This has been tested with the Exar USB to RS-485 adapter and with the Waveshare CAN Hat (CANbus and RS-485 add-on) for diff --git a/docs/Install-slave-probes.png b/docs/Install-slave-probes.png new file mode 100644 index 0000000..992bd07 Binary files /dev/null and b/docs/Install-slave-probes.png differ diff --git a/docs/Maxem MX Home 4 handleiding.pdf b/docs/Maxem MX Home 4 handleiding.pdf new file mode 100644 index 0000000..5fa4310 Binary files /dev/null and b/docs/Maxem MX Home 4 handleiding.pdf differ diff --git a/docs/decisions/project-conventions.md b/docs/decisions/project-conventions.md new file mode 100644 index 0000000..d398228 --- /dev/null +++ b/docs/decisions/project-conventions.md @@ -0,0 +1,114 @@ +# Project Conventions + +This document is the canonical home for durable, project-specific decisions and +operator-facing assumptions for `modbus-softsplit`. + +## Active Rewrite Path + +- The Maxem rewrite path reads source data directly from Victron CerboGX MQTT + (read-only), not from Domoticz. +- MQTT broker defaults: + - host `mosquitto.hs.mfis.net` + - port `1883` + - active-in topic base `N/48e7da878d35/vebus/276/Ac/ActiveIn` + - ac-out topic base `N/48e7da878d35/vebus/276/Ac/Out` + - pv topic list (`CERBO_PV_TOPICS`) defaults to: + - `N/48e7da878d35/solarcharger/283/Pv/0/P` + - `N/48e7da878d35/solarcharger/282/Pv/0/P` + - `N/48e7da878d35/solarcharger/282/Pv/1/P` +- Power rewrite source: + - `Ac/ActiveIn/L1|L2|L3/P` -> active power total + per phase words. + - Values may be positive or negative. +- `CERBO_PHASE_POWER_SOURCE` controls phase-power words (`0x5B16..0x5B1B`): + - `activein` -> phase power from Cerbo `Ac/ActiveIn` (signed). + - `acout` -> phase power derived from ABB phase voltages and Cerbo `Ac/Out` currents. + - `abb` -> phase power passthrough from ABB (no rewrite on phase words). +- `CERBO_FORCE_NONNEGATIVE_PHASE_POWER=1` enforces non-negative phase-power writes: + - in `activein` mode, export is netted against import across phases first, then clamped to `>=0`. + - in other modes, each phase is clamped independently to `>=0`. +- Current rewrite source: + - `Ac/Out/L1|L2|L3/I` -> phase current words. + - `Ac/Out/N/I` -> neutral current word when present. + - AC-out currents are clamped to `>= 0` before encoding. +- `CERBO_COHERENT_PHASE_FRAMES=1` requires full 3-phase refresh for both power and current + inputs before publishing a new rewrite snapshot. +- `CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS` bounds acceptable timestamp skew across + phase updates to reduce mixed-time phase combinations. +- The ABB target register block is `instantaneous_values`. +- Rewritten words in that block are: + - `0x5B0C/0x5B0D`, `0x5B0E/0x5B0F`, `0x5B10/0x5B11`, `0x5B12/0x5B13` + current L1/L2/L3/N from Cerbo `Ac/Out`. + - `0x5B14/0x5B15` active power total from Cerbo `Ac/ActiveIn`. + - `0x5B16/0x5B17`, `0x5B18/0x5B19`, `0x5B1A/0x5B1B` + active power per-phase from `CERBO_PHASE_POWER_SOURCE`. +- All other words in `instantaneous_values` and all other Maxem register blocks + are mirrored unchanged from the ABB source. +- Optional PV meter emulation (`CERBO_ENABLE_PV_SLAVE=1`) publishes a virtual + Maxem-compatible slave (default address `001` via `CERBO_PV_TARGET_SLAVE`): + - all Maxem register blocks mirror ABB source values by default. + - in `instantaneous_values`, slave `001` rewrites: + - `0x5B14/0x5B15` to summed PV watts from `CERBO_PV_TOPICS`. + - `0x5B16..0x5B1B` to an equal 3-phase split of that total. + - `0x5B0C..0x5B13` to derived non-negative phase currents from + rewritten phase watts and ABB phase voltages; neutral current is `0`. +- Preview logs should stay short and verifiable: + - `ABB source: X W` + - `Cerbo Usage to Maxem: Y W` + - `Cerbo Phase Watts to Maxem: L1=..., L2=..., L3=...` + - `Cerbo Phase Currents to Maxem: L1=..., L2=..., L3=..., N=...` +- These preview lines are logged at `DEBUG` level and are intended as opt-in + operator diagnostics (`LOG_LEVEL=DEBUG`). +- The live RTU path should emit the same short preview when the instantaneous + rewrite value changes so operators can verify the actual write path without + enabling dry-run mode. + +## Register Tooling + +- `tools/dump_register_block.py` defaults to `instantaneous_values` only. +- `tools/replay_maxem_preview.py` replays the instantaneous-power preview from a + captured bundle. +- `tools/inspect_instantaneous_payload.py` is the deep-dive tool for unpacking + ABB source vs rewritten payload values and showing exactly which + `instantaneous_values` words changed. +- `docs/runbooks/maxem-live-validation.md` is the canonical operational + checklist for long-running live validation. +- `docs/Maxem MX Home 4 handleiding.pdf` is installation/wiring guidance and + useful for system behavior context, but does not replace ABB register mapping + references. +- `total_accumulators` is legacy prototype output and is not part of the active + Maxem rewrite story. + +## Runtime Guardrails + +- `--dry-run-maxem-home` is opt-in and must not open the RTU serial adapter. +- `DRY_RUN_MAXEM_HOME=1` in `.env` is the default-mode toggle equivalent to + starting with `--dry-run-maxem-home`. +- `--trace-instantaneous-payload` is opt-in and intended for diagnostics only. +- Cerbo MQTT subscriptions must remain read-only; do not publish/control from + this runtime. +- MQTT callbacks must stay lightweight and non-blocking; RTU serving path must + remain timing-safe. +- Startup should log effective Cerbo MQTT source settings and source precedence + (`env` vs `.env` vs defaults). +- Startup should log effective PV slave settings: + `CERBO_ENABLE_PV_SLAVE`, `CERBO_PV_TARGET_SLAVE`, and `CERBO_PV_TOPICS`. +- `CERBO_MQTT_PROTOCOL_DEBUG=0` should remain default so DEBUG logs stay operator-readable; enable only during MQTT wire troubleshooting. +- `CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS=0` should remain default to prevent per-message snapshot log flooding. +- The serving loop should remain timing-safe for the RTU client. +- High-volume loop status logs should be periodic instead of per-cycle to avoid + unnecessary log I/O overhead (`STATUS_LOG_INTERVAL_SECONDS`, default `30`). +- RTU protocol validation must remain strict; do not reinterpret malformed + short frames as valid requests. +- `SUPPRESS_SHORT_RTU_REQUEST_LOGS=1` (default) suppresses only the specific + known-noise line `invalid request: Request length is invalid 1` at logging + time, without changing RTU frame parsing behavior. + +## Documentation and Memory Hygiene + +- Project-specific durable knowledge belongs in `docs/` first. +- Prefer `docs/decisions/` for stable implementation rules and `docs/runbooks/` + for operational procedures. +- External memory notes are supplemental and should not be the only durable + record for this repository. +- When project behavior changes, update the relevant repo docs in the same task + so future runs do not need to infer intent from chat history. diff --git a/docs/maxem-5-installatie-1.png b/docs/maxem-5-installatie-1.png new file mode 100644 index 0000000..f3e4e46 Binary files /dev/null and b/docs/maxem-5-installatie-1.png differ diff --git a/docs/maxem-5-installatie-2-solar-tip.png b/docs/maxem-5-installatie-2-solar-tip.png new file mode 100644 index 0000000..ebbf521 Binary files /dev/null and b/docs/maxem-5-installatie-2-solar-tip.png differ diff --git a/docs/policies/documentation-and-memory-hygiene.md b/docs/policies/documentation-and-memory-hygiene.md new file mode 100644 index 0000000..4e4b7c8 --- /dev/null +++ b/docs/policies/documentation-and-memory-hygiene.md @@ -0,0 +1,148 @@ +# Documentation and Memory Hygiene + +## Scope + +This policy applies to any project where this policy document is present. + +The policy is active when any file in the project root has a filename containing +the string `mcp.json`. + +Examples of activating root filenames include: + +- `.mcp.json` +- `mcp.json` +- `local.mcp.json` +- `project-mcp.json` +- `example.mcp.json.backup` + +## Compact Gateway Digest + +Use this short form by default to keep prompts small: + +- Read relevant project documentation before meaningful work. +- Treat current implementation, tests, and active documentation as truth. +- Update documentation in the same task when behavior changes. +- Write a short checkpoint before handoff on long tasks. +- Do not rely only on chat history. + +## Rule + +Keep code, documentation, and durable project memory aligned with the actual +project state. + +## Start Of Task + +Before meaningful work: + +1. Read relevant project documentation if it has not already been read in the + current session. +2. Check for project instructions, such as: + - `README.md` + - `AGENTS.md` + - `CLAUDE.md` + - `codex.md` + - `CONTRIBUTING.md` + - `docs/` + - `adr/` + - `runbooks/` +3. Check whether persistent memory or memory-capable tools are available. +4. Load relevant project memory if available. + +Do not rely only on chat history. + +## During Work + +When behavior, commands, architecture, configuration, dependencies, workflows, +tools, or assumptions change: + +1. Update the relevant documentation in the same task. +2. For repository-specific durable knowledge, prefer a matching document under + `docs/` and keep it as the canonical project record. + If an appropriate doc does not yet exist, create it. This is YOUR memory and you can manage it in the best way you yourself can utilize it in the future. +3. Edit existing documentation in place. +4. Remove or replace stale information. +5. Avoid appending duplicate or contradictory notes. +6. Update persistent memory only when the knowledge is cross-project or a + supplemental reminder is genuinely useful; do not use memory as the sole + durable record for repository-specific behavior. + +Documentation should describe the current truth, not the history of how the task +evolved. + +## Before Context Compaction Or Handoff + +Before the context window becomes too full, write a durable checkpoint to +documentation, memory, or a task log. For project-specific state, prefer a +repo-local checkpoint in `docs/` so future runs can re-read the canonical +project record without depending on chat history or external memory. + +Include only: + +- current goal +- completed work +- files changed +- decisions made +- commands or tests run +- known issues +- next step + +After resuming, re-read the checkpoint, relevant documentation, memory, and +changed files before continuing. + +## End Of Task + +Before declaring work complete: + +1. Check whether documentation changed or became stale. +2. Update documentation to match the final implementation. +3. Update persistent memory if available and relevant, but keep repo docs as + the canonical record for repository-specific decisions. +4. Report what documentation and memory were updated. + +Project-specific durable knowledge should always be reflected in the repo docs +before handoff. External memory may be used as a supplemental reminder, but it +is not the canonical record for this repository. + +If no documentation or memory updates were needed, say so. + +## Memory Rules + +Store durable project knowledge only. + +Good memory candidates: + +- architecture decisions +- recurring commands +- project conventions +- release workflows +- tool usage +- stable constraints +- organization-specific practices + +Do not store: + +- secrets +- credentials +- temporary debugging notes +- speculation +- stale details +- personal data unless explicitly required + +## Conflict Resolution + +When sources disagree, prefer: + +1. current implementation +2. tests +3. active documentation +4. configuration +5. persistent memory +6. task logs +7. chat history + +Resolve known contradictions before finishing. + +## Completion Standard + +A task is not complete until implementation, documentation, and durable memory +are consistent. diff --git a/docs/runbooks/maxem-live-validation.md b/docs/runbooks/maxem-live-validation.md new file mode 100644 index 0000000..65b7c6a --- /dev/null +++ b/docs/runbooks/maxem-live-validation.md @@ -0,0 +1,112 @@ +# Maxem Live Validation Runbook + +## Goal + +Validate that Maxem sees: + +- Home/grid power driven by Cerbo `Ac/ActiveIn` rewrite values. +- Phase current safety behavior driven by Cerbo `Ac/Out` current signals. +- (Optional) Solar meter slave `001` powered by summed Cerbo `solarcharger/.../Pv/.../P` topics. + +## Preconditions + +- Upstream ABB source is reachable via Modbus TCP gateway. +- Cerbo MQTT broker is reachable. +- Maxem RTU link is stable. +- Environment variables are configured (`MOSQUITTO_*`, `CERBO_*`, `MODBUS_TCP_GW_*`, `SERIAL_PORT`). +- If phase sign behavior is under investigation, explicitly record: + - `CERBO_PHASE_POWER_SOURCE` + - `CERBO_FORCE_NONNEGATIVE_PHASE_POWER` + - `CERBO_COHERENT_PHASE_FRAMES` + - `CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS` +- If PV virtual meter is enabled, explicitly record: + - `CERBO_ENABLE_PV_SLAVE` + - `CERBO_PV_TARGET_SLAVE` + - `CERBO_PV_TOPICS` + +## Step 1: Offline Sanity Capture + +Capture current source + rewrite-source values: + +```bash +python3 tools/dump_register_block.py --output /tmp/maxem-bundle-live.json +``` + +Inspect source vs rewrite: + +```bash +python3 tools/inspect_instantaneous_payload.py --bundle /tmp/maxem-bundle-live.json +``` + +Expected: + +- `changed_words` includes: + - `0x5B0C..0x5B13` (current L1/L2/L3/N from Cerbo `Ac/Out`) + - `0x5B14, 0x5B15` (total power) + - `0x5B16..0x5B1B` (phase L1/L2/L3 power) +- Voltage fields remain unchanged unless source changed. +- Power rewrite values can be positive or negative (Cerbo `Ac/ActiveIn`). +- Current rewrite values are clamped to non-negative (Cerbo `Ac/Out`). + +## Step 2: Live Runtime with Trace + +Run live with field-level trace: + +```bash +LOG_LEVEL=DEBUG python3 -u main.py --trace-instantaneous-payload +``` + +Watch for: + +- Preview lines: + - `ABB source: ...` + - `Cerbo Usage to Maxem: ...` + - `Cerbo Phase Watts to Maxem: ...` + - `Cerbo Phase Currents to Maxem: ...` +- Trace lines indicating only intended words changed in `instantaneous_values`. +- Optional debug line `Cerbo MQTT snapshot updated...` should show coherent + ActiveIn phase power + Out phase current snapshots. +- Startup logs should print effective Cerbo broker/topic settings and source precedence. + +## Step 3: UI Cross-Check + +In Maxem UI/app, compare: + +- Home usage tile/summary. +- Grid phase usage chart/values. +- Charger power. + +During known scenarios (for example: charging from battery/solar with low net grid import), verify Home and Grid values align with desired interpretation. + +When PV virtual meter is enabled: + +- Confirm Maxem autoconfig finds kWh meter address `001`. +- Confirm logs show `Cerbo PV to Maxem (slave 001): ...`. +- Confirm only slave `001` instantaneous words are rewritten for PV semantics; other blocks remain mirrored. + +## Step 4: Long-Run Observation + +Run for an extended window (for example 2-8 hours) and monitor: + +- Stability of RTU updates. +- No runaway exception loops. +- No unexpected expansion of rewritten words. +- Behavior during MQTT transient failures (warnings should not stop serving loop). + +## Known Observability Notes + +- `invalid request: Request length is invalid 1` messages may originate from malformed external Modbus client traffic and are not automatically a rewrite fault. +- By default the runtime suppresses only that exact 1-byte RTU noise line + (`SUPPRESS_SHORT_RTU_REQUEST_LOGS=1`). Set `SUPPRESS_SHORT_RTU_REQUEST_LOGS=0` + when you need raw-wire troubleshooting logs. +- Loop status logs are emitted as heartbeat summaries (default every 30s). Tune + with `STATUS_LOG_INTERVAL_SECONDS`. +- ABB sentinels such as `0xFFFF` may decode as `n/a` in diagnostics. + +## Exit Criteria + +Validation pass is considered successful when: + +- Rewritten words match design (`0x5B0C..0x5B1B` for current+active-power fields only). +- Maxem dashboard behavior aligns with intended Home/Grid semantics across multiple load conditions. +- Service remains stable over long-running periods. diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..d1ece42 --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,2 @@ +"""Core package for modbus-softsplit.""" + diff --git a/lib/maxem_home_usage.py b/lib/maxem_home_usage.py new file mode 100644 index 0000000..50f59fd --- /dev/null +++ b/lib/maxem_home_usage.py @@ -0,0 +1,1012 @@ +from __future__ import annotations + +import json +import logging +import threading +import time +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +from .synthetic_home import DomoticzClient, DomoticzReading, RegisterCapture +import paho.mqtt.client as mqtt + +INSTANTANEOUS_VALUES_REGISTER_NAME = "instantaneous_values" +INSTANTANEOUS_VALUES_REGISTER_ADDRESS = 0x5B00 +INSTANTANEOUS_VALUES_REGISTER_LENGTH = 66 + +INSTANTANEOUS_VOLTAGE_L1_REGISTER_ADDRESS = 0x5B00 +INSTANTANEOUS_VOLTAGE_L2_REGISTER_ADDRESS = 0x5B02 +INSTANTANEOUS_VOLTAGE_L3_REGISTER_ADDRESS = 0x5B04 +INSTANTANEOUS_VOLTAGE_PHASE_OFFSETS = ( + INSTANTANEOUS_VOLTAGE_L1_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_VOLTAGE_L2_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_VOLTAGE_L3_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, +) +INSTANTANEOUS_VOLTAGE_PHASE_SCALE = 0.1 +INSTANTANEOUS_VOLTAGE_PHASE_LENGTH = 2 + +INSTANTANEOUS_ACTIVE_POWER_TOTAL_REGISTER_ADDRESS = 0x5B14 +INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET = INSTANTANEOUS_ACTIVE_POWER_TOTAL_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS +INSTANTANEOUS_ACTIVE_POWER_TOTAL_REGISTER_LENGTH = 2 +INSTANTANEOUS_ACTIVE_POWER_TOTAL_SCALE = 0.01 +INSTANTANEOUS_ACTIVE_POWER_L1_REGISTER_ADDRESS = 0x5B16 +INSTANTANEOUS_ACTIVE_POWER_L2_REGISTER_ADDRESS = 0x5B18 +INSTANTANEOUS_ACTIVE_POWER_L3_REGISTER_ADDRESS = 0x5B1A +INSTANTANEOUS_ACTIVE_POWER_PHASE_OFFSETS = ( + INSTANTANEOUS_ACTIVE_POWER_L1_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_ACTIVE_POWER_L2_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_ACTIVE_POWER_L3_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, +) +INSTANTANEOUS_ACTIVE_POWER_PHASE_LENGTH = 2 +INSTANTANEOUS_CURRENT_L1_REGISTER_ADDRESS = 0x5B0C +INSTANTANEOUS_CURRENT_L2_REGISTER_ADDRESS = 0x5B0E +INSTANTANEOUS_CURRENT_L3_REGISTER_ADDRESS = 0x5B10 +INSTANTANEOUS_CURRENT_N_REGISTER_ADDRESS = 0x5B12 +INSTANTANEOUS_CURRENT_PHASE_OFFSETS = ( + INSTANTANEOUS_CURRENT_L1_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_CURRENT_L2_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_CURRENT_L3_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS, +) +INSTANTANEOUS_CURRENT_N_OFFSET = INSTANTANEOUS_CURRENT_N_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS +INSTANTANEOUS_CURRENT_REGISTER_LENGTH = 2 +INSTANTANEOUS_CURRENT_SCALE = 0.01 + + +@dataclass(frozen=True) +class InstantaneousFieldSpec: + name: str + address: int + scale: float + unit: str + signed: bool + register_length: int = 2 + + @property + def offset(self) -> int: + return self.address - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + + +INSTANTANEOUS_FIELD_SPECS: tuple[InstantaneousFieldSpec, ...] = ( + InstantaneousFieldSpec("voltage_l1_n", 0x5B00, 0.1, "V", False), + InstantaneousFieldSpec("voltage_l2_n", 0x5B02, 0.1, "V", False), + InstantaneousFieldSpec("voltage_l3_n", 0x5B04, 0.1, "V", False), + InstantaneousFieldSpec("current_l1", 0x5B0C, 0.01, "A", False), + InstantaneousFieldSpec("current_l2", 0x5B0E, 0.01, "A", False), + InstantaneousFieldSpec("current_l3", 0x5B10, 0.01, "A", False), + InstantaneousFieldSpec("current_n", 0x5B12, 0.01, "A", False), + InstantaneousFieldSpec("active_power_total", 0x5B14, 0.01, "W", True), + InstantaneousFieldSpec("active_power_l1", 0x5B16, 0.01, "W", True), + InstantaneousFieldSpec("active_power_l2", 0x5B18, 0.01, "W", True), + InstantaneousFieldSpec("active_power_l3", 0x5B1A, 0.01, "W", True), +) + + +@dataclass(frozen=True) +class DomoticzUsageSnapshot: + sequence: int + reading: DomoticzReading | None + phase_usage_watts: tuple[float, float, float] | None = None + use_signed_net_power: bool = False + use_signed_net_phase_power: bool = False + + @property + def grid_import_watts(self) -> float | None: + if self.reading is None: + return None + return max(self.reading.import_watts, 0.0) + + @property + def grid_net_watts(self) -> float | None: + if self.reading is None: + return None + return float(self.reading.import_watts) - float(self.reading.export_watts) + + @property + def rewrite_usage_watts(self) -> float | None: + if self.reading is None: + return None + if self.use_signed_net_power: + return self.grid_net_watts + return self.grid_import_watts + + @property + def usage_watts(self) -> float | None: + return self.rewrite_usage_watts + + +class DomoticzUsageCache: + def __init__( + self, + *, + use_signed_net_power: bool = False, + use_signed_net_phase_power: bool = False, + ) -> None: + self._lock = threading.Lock() + self._sequence = 0 + self._reading: DomoticzReading | None = None + self._phase_usage_watts: tuple[float, float, float] | None = None + self._use_signed_net_power = bool(use_signed_net_power) + self._use_signed_net_phase_power = bool(use_signed_net_phase_power) + + def update( + self, + reading: DomoticzReading, + *, + phase_usage_watts: tuple[float, float, float] | None = None, + ) -> DomoticzUsageSnapshot: + with self._lock: + self._sequence += 1 + self._reading = reading + if phase_usage_watts is not None: + self._phase_usage_watts = tuple(float(value) for value in phase_usage_watts) + return DomoticzUsageSnapshot( + sequence=self._sequence, + reading=self._reading, + phase_usage_watts=self._phase_usage_watts, + use_signed_net_power=self._use_signed_net_power, + use_signed_net_phase_power=self._use_signed_net_phase_power, + ) + + def snapshot(self) -> DomoticzUsageSnapshot: + with self._lock: + return DomoticzUsageSnapshot( + sequence=self._sequence, + reading=self._reading, + phase_usage_watts=self._phase_usage_watts, + use_signed_net_power=self._use_signed_net_power, + use_signed_net_phase_power=self._use_signed_net_phase_power, + ) + + +class DomoticzUsagePoller(threading.Thread): + def __init__( + self, + client: DomoticzClient, + cache: DomoticzUsageCache, + *, + phase_l1_idx: int = 26, + phase_l2_idx: int = 24, + phase_l3_idx: int = 25, + phase_export_l1_idx: int | None = None, + phase_export_l2_idx: int | None = None, + phase_export_l3_idx: int | None = None, + use_signed_net_power: bool = False, + use_signed_net_phase_power: bool = False, + poll_interval_seconds: float = 5.0, + logger: logging.Logger | None = None, + ) -> None: + super().__init__(name="domoticz-usage-poller", daemon=True) + self._client = client + self._cache = cache + self._phase_import_indices = ( + int(phase_l1_idx), + int(phase_l2_idx), + int(phase_l3_idx), + ) + self._phase_export_indices = ( + self._normalize_optional_idx(phase_export_l1_idx), + self._normalize_optional_idx(phase_export_l2_idx), + self._normalize_optional_idx(phase_export_l3_idx), + ) + self._use_signed_net_power = bool(use_signed_net_power) + self._use_signed_net_phase_power = bool(use_signed_net_phase_power) + self._poll_interval_seconds = max(poll_interval_seconds, 0.1) + self._logger = logger or logging.getLogger(__name__) + self._stop_event = threading.Event() + self._warned_missing_phase_export_indices = False + + def stop(self) -> None: + self._stop_event.set() + + @staticmethod + def _normalize_optional_idx(value: int | None) -> int | None: + if value in (None, "", 0): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed <= 0: + return None + return parsed + + def _has_phase_export_indices(self) -> bool: + return all(value is not None for value in self._phase_export_indices) + + def _build_requested_indices(self) -> tuple[int, ...]: + indices = [self._client.grid_idx, *self._phase_import_indices] + if self._use_signed_net_phase_power and self._has_phase_export_indices(): + indices.extend(int(value) for value in self._phase_export_indices if value is not None) + return tuple(indices) + + def run(self) -> None: + if not self._client.enabled: + self._logger.info("Domoticz polling is disabled; Maxem preview will stay on the last known reading.") + return + + while not self._stop_event.is_set(): + try: + requested_indices = self._build_requested_indices() + self._logger.debug( + "Domoticz batch request: %s", + self._client.url_for_indices(requested_indices), + ) + devices = self._client.fetch_devices(requested_indices) + + reading = self._client.fetch_reading_from_device(devices[self._client.grid_idx]) + phase_import_watts = tuple( + self._client.data_watts_from_device(devices[phase_idx]) + for phase_idx in self._phase_import_indices + ) + phase_usage_watts = phase_import_watts + if self._use_signed_net_phase_power: + if self._has_phase_export_indices(): + phase_export_watts = tuple( + self._client.data_watts_from_device(devices[phase_idx]) + for phase_idx in self._phase_export_indices + if phase_idx is not None + ) + phase_usage_watts = tuple( + float(phase_import_watts[index]) - float(phase_export_watts[index]) + for index in range(3) + ) + elif not self._warned_missing_phase_export_indices: + self._logger.warning( + "DOMOTICZ_USE_SIGNED_NET_PHASE_POWER is enabled but one or more DOMOTICZ_PHASE_EXPORT_*_IDX values are missing; " + "falling back to unsigned phase import values." + ) + self._warned_missing_phase_export_indices = True + + snapshot = self._cache.update(reading, phase_usage_watts=phase_usage_watts) + self._logger.debug( + ( + "Domoticz usage snapshot updated: sequence=%s signed_total=%s signed_phase=%s " + "grid_import_watts=%.0f grid_export_watts=%.0f grid_rewrite_watts=%.0f " + "phase_watts=(%.0f, %.0f, %.0f)" + ), + snapshot.sequence, + int(snapshot.use_signed_net_power), + int(self._use_signed_net_phase_power), + max(reading.import_watts, 0.0), + max(reading.export_watts, 0.0), + snapshot.rewrite_usage_watts or 0.0, + snapshot.phase_usage_watts[0] if snapshot.phase_usage_watts else 0.0, + snapshot.phase_usage_watts[1] if snapshot.phase_usage_watts else 0.0, + snapshot.phase_usage_watts[2] if snapshot.phase_usage_watts else 0.0, + ) + except Exception as exc: # pragma: no cover - defensive log path + self._logger.warning("Domoticz poll failed: %s", exc) + + self._stop_event.wait(self._poll_interval_seconds) + + +@dataclass(frozen=True) +class CerboMqttSnapshot: + sequence: int + ac_in_phase_watts: tuple[float, float, float] | None = None + ac_in_total_watts: float | None = None + ac_out_phase_currents: tuple[float, float, float] | None = None + ac_out_current_n: float | None = None + pv_total_watts: float | None = None + source_label: str = "Cerbo" + + @property + def rewrite_usage_watts(self) -> float | None: + return self.ac_in_total_watts + + @property + def phase_usage_watts(self) -> tuple[float, float, float] | None: + return self.ac_in_phase_watts + + @property + def phase_current_amps(self) -> tuple[float, float, float] | None: + return self.ac_out_phase_currents + + @property + def current_n_amps(self) -> float | None: + return self.ac_out_current_n + + +class CerboMqttCache: + def __init__(self) -> None: + self._lock = threading.Lock() + self._sequence = 0 + self._ac_in_phase_watts: tuple[float, float, float] | None = None + self._ac_in_total_watts: float | None = None + self._ac_out_phase_currents: tuple[float, float, float] | None = None + self._ac_out_current_n: float | None = None + self._pv_total_watts: float | None = None + + def update( + self, + *, + ac_in_phase_watts: tuple[float, float, float], + ac_out_phase_currents: tuple[float, float, float], + ac_out_current_n: float | None, + pv_total_watts: float | None = None, + ) -> CerboMqttSnapshot: + with self._lock: + self._sequence += 1 + self._ac_in_phase_watts = tuple(float(value) for value in ac_in_phase_watts) + self._ac_in_total_watts = float(sum(self._ac_in_phase_watts)) + self._ac_out_phase_currents = tuple(max(float(value), 0.0) for value in ac_out_phase_currents) + self._ac_out_current_n = None if ac_out_current_n is None else max(float(ac_out_current_n), 0.0) + self._pv_total_watts = None if pv_total_watts is None else max(float(pv_total_watts), 0.0) + return CerboMqttSnapshot( + sequence=self._sequence, + ac_in_phase_watts=self._ac_in_phase_watts, + ac_in_total_watts=self._ac_in_total_watts, + ac_out_phase_currents=self._ac_out_phase_currents, + ac_out_current_n=self._ac_out_current_n, + pv_total_watts=self._pv_total_watts, + ) + + def snapshot(self) -> CerboMqttSnapshot: + with self._lock: + return CerboMqttSnapshot( + sequence=self._sequence, + ac_in_phase_watts=self._ac_in_phase_watts, + ac_in_total_watts=self._ac_in_total_watts, + ac_out_phase_currents=self._ac_out_phase_currents, + ac_out_current_n=self._ac_out_current_n, + pv_total_watts=self._pv_total_watts, + ) + + +class CerboMqttPoller(threading.Thread): + def __init__( + self, + *, + broker_host: str, + broker_port: int, + ac_out_topic_base: str, + ac_active_in_topic_base: str, + pv_power_topics: Sequence[str] | None, + cache: CerboMqttCache, + protocol_debug: bool = False, + snapshot_debug_interval_seconds: float = 0.0, + coherent_phase_frames: bool = True, + coherent_phase_frame_max_skew_seconds: float = 1.5, + logger: logging.Logger | None = None, + ) -> None: + super().__init__(name="cerbo-mqtt-poller", daemon=True) + self._broker_host = str(broker_host).strip() + self._broker_port = int(broker_port) + self._ac_out_topic_base = ac_out_topic_base.rstrip("/") + self._ac_active_in_topic_base = ac_active_in_topic_base.rstrip("/") + self._cache = cache + self._logger = logger or logging.getLogger(__name__) + self._stop_event = threading.Event() + self._protocol_debug = bool(protocol_debug) + self._snapshot_debug_interval_seconds = max(float(snapshot_debug_interval_seconds), 0.0) + self._next_snapshot_log_time = 0.0 + self._coherent_phase_frames = bool(coherent_phase_frames) + self._coherent_phase_frame_max_skew_seconds = max(float(coherent_phase_frame_max_skew_seconds), 0.0) + self._phase_in_watts: list[float | None] = [None, None, None] + self._phase_out_currents: list[float | None] = [None, None, None] + self._phase_in_update_times: list[float] = [0.0, 0.0, 0.0] + self._phase_out_update_times: list[float] = [0.0, 0.0, 0.0] + self._phase_in_updated_mask = 0 + self._phase_out_updated_mask = 0 + self._current_n: float | None = None + self._pv_power_topics = self._normalize_pv_topics(pv_power_topics) + self._pv_topic_values: dict[str, float | None] = {topic: None for topic in self._pv_power_topics} + self._client = mqtt.Client(client_id=f"modbus-softsplit-{int(time.time())}", protocol=mqtt.MQTTv311) + if self._protocol_debug: + self._client.enable_logger(self._logger) + self._client.on_connect = self._on_connect + self._client.on_message = self._on_message + self._client.on_disconnect = self._on_disconnect + + @staticmethod + def _normalize_pv_topics(topics: Sequence[str] | None) -> tuple[str, ...]: + if not topics: + return () + + normalized: list[str] = [] + seen: set[str] = set() + for topic in topics: + cleaned = str(topic or "").strip().rstrip("/") + if not cleaned or cleaned in seen: + continue + normalized.append(cleaned) + seen.add(cleaned) + return tuple(normalized) + + def stop(self) -> None: + self._stop_event.set() + try: + self._client.disconnect() + except Exception: + pass + + def _on_connect(self, client, userdata, flags, rc): + if rc != 0: + self._logger.warning("Cerbo MQTT connect failed: rc=%s", rc) + return + + subscriptions = [ + (f"{self._ac_active_in_topic_base}/L1/P", 0), + (f"{self._ac_active_in_topic_base}/L2/P", 0), + (f"{self._ac_active_in_topic_base}/L3/P", 0), + (f"{self._ac_out_topic_base}/L1/I", 0), + (f"{self._ac_out_topic_base}/L2/I", 0), + (f"{self._ac_out_topic_base}/L3/I", 0), + (f"{self._ac_out_topic_base}/N/I", 0), + ] + for pv_topic in self._pv_power_topics: + subscriptions.append((pv_topic, 0)) + for topic, qos in subscriptions: + client.subscribe(topic, qos=qos) + if self._pv_power_topics: + self._logger.info( + "Cerbo MQTT connected to %s:%s; subscribed read-only to Ac/ActiveIn, Ac/Out, and %d PV topic(s).", + self._broker_host, + self._broker_port, + len(self._pv_power_topics), + ) + else: + self._logger.info( + "Cerbo MQTT connected to %s:%s; subscribed read-only to Ac/ActiveIn and Ac/Out topics.", + self._broker_host, + self._broker_port, + ) + + def _on_disconnect(self, client, userdata, rc): + if self._stop_event.is_set(): + return + self._logger.warning("Cerbo MQTT disconnected unexpectedly: rc=%s", rc) + + @staticmethod + def _payload_value(payload: bytes) -> float: + raw = payload.decode("utf-8", "replace") + parsed = json.loads(raw) + if not isinstance(parsed, Mapping) or "value" not in parsed: + raise ValueError(f"Unexpected MQTT payload shape: {raw!r}") + return float(parsed["value"]) + + def _update_phase_slot(self, topic: str, value: float) -> bool: + if topic in self._pv_topic_values: + self._pv_topic_values[topic] = max(float(value), 0.0) + return True + + now = time.monotonic() + for phase_index, phase_name in enumerate(("L1", "L2", "L3")): + if topic.endswith(f"/{phase_name}/P"): + self._phase_in_watts[phase_index] = float(value) + self._phase_in_update_times[phase_index] = now + self._phase_in_updated_mask |= 1 << phase_index + return True + if topic.endswith(f"/{phase_name}/I"): + self._phase_out_currents[phase_index] = max(float(value), 0.0) + self._phase_out_update_times[phase_index] = now + self._phase_out_updated_mask |= 1 << phase_index + return True + if topic.endswith("/N/I"): + self._current_n = max(float(value), 0.0) + return True + return False + + def _current_pv_total_watts(self) -> float | None: + if not self._pv_topic_values: + return None + values = list(self._pv_topic_values.values()) + if any(value is None for value in values): + return None + return float(sum(float(value) for value in values)) + + def _has_baseline_values(self) -> bool: + return not any(v is None for v in self._phase_in_watts) and not any(v is None for v in self._phase_out_currents) + + def _has_full_coherent_frame(self) -> bool: + full_mask = 0b111 + return self._phase_in_updated_mask == full_mask and self._phase_out_updated_mask == full_mask + + def _phase_skew_exceeds_threshold(self) -> bool: + if self._coherent_phase_frame_max_skew_seconds <= 0.0: + return False + + phase_in_skew = max(self._phase_in_update_times) - min(self._phase_in_update_times) + phase_out_skew = max(self._phase_out_update_times) - min(self._phase_out_update_times) + return ( + phase_in_skew > self._coherent_phase_frame_max_skew_seconds + or phase_out_skew > self._coherent_phase_frame_max_skew_seconds + ) + + def _ready_for_publish(self) -> bool: + if not self._has_baseline_values(): + return False + if not self._coherent_phase_frames: + return True + if not self._has_full_coherent_frame(): + return False + if self._phase_skew_exceeds_threshold(): + return False + return True + + def _on_message(self, client, userdata, msg): + try: + value = self._payload_value(msg.payload) + except Exception as exc: + self._logger.warning("Cerbo MQTT payload parse failed for topic=%s: %s", msg.topic, exc) + return + + if not self._update_phase_slot(msg.topic, value): + return + + if not self._ready_for_publish(): + return + + snapshot = self._cache.update( + ac_in_phase_watts=( + float(self._phase_in_watts[0]), + float(self._phase_in_watts[1]), + float(self._phase_in_watts[2]), + ), + ac_out_phase_currents=( + float(self._phase_out_currents[0]), + float(self._phase_out_currents[1]), + float(self._phase_out_currents[2]), + ), + ac_out_current_n=self._current_n, + pv_total_watts=self._current_pv_total_watts(), + ) + if self._coherent_phase_frames: + self._phase_in_updated_mask = 0 + self._phase_out_updated_mask = 0 + + if self._snapshot_debug_interval_seconds <= 0.0: + return + + now = time.monotonic() + if now < self._next_snapshot_log_time: + return + self._next_snapshot_log_time = now + self._snapshot_debug_interval_seconds + + current_n_text = "n/a" if snapshot.current_n_amps is None else f"{snapshot.current_n_amps:.2f}" + self._logger.debug( + ( + "Cerbo MQTT snapshot updated: sequence=%s ac_in_total_watts=%.2f ac_in_phase_watts=(%.2f, %.2f, %.2f) " + "ac_out_phase_currents=(%.2f, %.2f, %.2f) ac_out_current_n=%s pv_total_watts=%s" + ), + snapshot.sequence, + snapshot.rewrite_usage_watts or 0.0, + snapshot.phase_usage_watts[0] if snapshot.phase_usage_watts else 0.0, + snapshot.phase_usage_watts[1] if snapshot.phase_usage_watts else 0.0, + snapshot.phase_usage_watts[2] if snapshot.phase_usage_watts else 0.0, + snapshot.phase_current_amps[0] if snapshot.phase_current_amps else 0.0, + snapshot.phase_current_amps[1] if snapshot.phase_current_amps else 0.0, + snapshot.phase_current_amps[2] if snapshot.phase_current_amps else 0.0, + current_n_text, + "n/a" if snapshot.pv_total_watts is None else f"{snapshot.pv_total_watts:.2f}", + ) + + def run(self) -> None: + if not self._broker_host: + self._logger.info("Cerbo MQTT polling disabled: empty broker host.") + return + + try: + self._client.connect(self._broker_host, self._broker_port, 60) + self._client.loop_start() + while not self._stop_event.wait(0.2): + pass + except Exception as exc: # pragma: no cover - defensive log path + self._logger.warning("Cerbo MQTT poller failed: %s", exc) + finally: + try: + self._client.loop_stop() + except Exception: + pass + try: + self._client.disconnect() + except Exception: + pass + + +def _format_watts(value: float) -> str: + if abs(value - round(value)) < 1e-9: + return f"{int(round(value)):,} W" + return f"{value:,.2f} W" + + +def _format_value(value: float | None, unit: str) -> str: + if value is None: + return "n/a" + if abs(value - round(value)) < 1e-9: + return f"{int(round(value)):,} {unit}" + return f"{value:,.2f} {unit}" + + +def describe_instantaneous_preview_basis() -> str: + return ( + "Preview basis: active_power_total (0x5B14/0x5B15) is rewritten from Cerbo Ac/ActiveIn total watts. " + "active_power_l1/l2/l3 (0x5B16..0x5B1B) follow CERBO_PHASE_POWER_SOURCE mode (activein, acout-derived, or abb passthrough). " + "current_l1/l2/l3/n (0x5B0C..0x5B13) are rewritten from Cerbo Ac/Out phase currents with non-negative clamp. " + "All other registers in instantaneous_values are copied verbatim from the ABB source." + ) + + +def _decode_scaled_value( + register_values: Sequence[int], + *, + offset: int, + scale: float, + signed: bool, + register_length: int = 2, +) -> float | None: + if len(register_values) < offset + register_length: + return None + + high_word = int(register_values[offset]) & 0xFFFF + low_word = int(register_values[offset + 1]) & 0xFFFF + raw = (high_word << 16) | low_word + + raw_bit_count = register_length * 16 + invalid_unsigned = (1 << raw_bit_count) - 1 + invalid_signed = (1 << (raw_bit_count - 1)) - 1 + if not signed and raw == invalid_unsigned: + return None + if signed and raw == invalid_signed: + return None + + if signed and raw & (1 << (raw_bit_count - 1)): + raw -= 1 << raw_bit_count + return raw * scale + + +def decode_signed_scaled_watts(register_values: Sequence[int], *, offset: int = INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET) -> float | None: + return _decode_scaled_value( + register_values, + offset=offset, + scale=INSTANTANEOUS_ACTIVE_POWER_TOTAL_SCALE, + signed=True, + register_length=INSTANTANEOUS_ACTIVE_POWER_TOTAL_REGISTER_LENGTH, + ) + + +def decode_instantaneous_fields(register_values: Sequence[int]) -> dict[str, float | None]: + decoded: dict[str, float | None] = {} + for spec in INSTANTANEOUS_FIELD_SPECS: + decoded[spec.name] = _decode_scaled_value( + register_values, + offset=spec.offset, + scale=spec.scale, + signed=spec.signed, + register_length=spec.register_length, + ) + return decoded + + +def derive_phase_watts_from_currents( + source_values: Sequence[int], + phase_current_amps: tuple[float, float, float] | None, + *, + fallback_phase_voltage_volts: float = 230.0, +) -> tuple[float, float, float] | None: + if phase_current_amps is None: + return None + + derived_watts: list[float] = [] + for phase_index, phase_offset in enumerate(INSTANTANEOUS_VOLTAGE_PHASE_OFFSETS): + voltage = _decode_scaled_value( + source_values, + offset=phase_offset, + scale=INSTANTANEOUS_VOLTAGE_PHASE_SCALE, + signed=False, + register_length=INSTANTANEOUS_VOLTAGE_PHASE_LENGTH, + ) + if voltage is None or voltage <= 0.0: + voltage = float(fallback_phase_voltage_volts) + amps = max(float(phase_current_amps[phase_index]), 0.0) + derived_watts.append(float(voltage) * amps) + + return (derived_watts[0], derived_watts[1], derived_watts[2]) + + +def net_signed_phase_watts_to_nonnegative_import( + phase_watts: tuple[float, float, float] | None, +) -> tuple[float, float, float] | None: + if phase_watts is None: + return None + + phase_values = tuple(float(value) for value in phase_watts) + phase_import = [max(value, 0.0) for value in phase_values] + total_import = sum(phase_import) + if total_import <= 0.0: + return (0.0, 0.0, 0.0) + + total_export = -sum(min(value, 0.0) for value in phase_values) + if total_export <= 0.0: + return (phase_import[0], phase_import[1], phase_import[2]) + + net_import = max(total_import - total_export, 0.0) + if net_import <= 0.0: + return (0.0, 0.0, 0.0) + + scale = net_import / total_import + return ( + phase_import[0] * scale, + phase_import[1] * scale, + phase_import[2] * scale, + ) + + +def split_total_watts_evenly(total_watts: float | None) -> tuple[float, float, float] | None: + if total_watts is None: + return None + + clamped_total_watts = max(float(total_watts), 0.0) + per_phase = clamped_total_watts / 3.0 + return ( + per_phase, + per_phase, + clamped_total_watts - (2.0 * per_phase), + ) + + +def derive_phase_currents_from_watts( + source_values: Sequence[int], + phase_watts: tuple[float, float, float] | None, + *, + fallback_phase_voltage_volts: float = 230.0, +) -> tuple[float, float, float] | None: + if phase_watts is None: + return None + + derived_currents: list[float] = [] + for phase_index, phase_offset in enumerate(INSTANTANEOUS_VOLTAGE_PHASE_OFFSETS): + voltage = _decode_scaled_value( + source_values, + offset=phase_offset, + scale=INSTANTANEOUS_VOLTAGE_PHASE_SCALE, + signed=False, + register_length=INSTANTANEOUS_VOLTAGE_PHASE_LENGTH, + ) + if voltage is None or voltage <= 0.0: + voltage = float(fallback_phase_voltage_volts) + watts = max(float(phase_watts[phase_index]), 0.0) + derived_currents.append(watts / float(voltage)) + return ( + derived_currents[0], + derived_currents[1], + derived_currents[2], + ) + + +def encode_signed_scaled_watts( + value_watts: float, + *, + allow_negative: bool = False, +) -> tuple[int, int]: + raw = int(round(float(value_watts) / INSTANTANEOUS_ACTIVE_POWER_TOTAL_SCALE)) + if allow_negative: + raw = max(min(raw, 0x7FFFFFFF), -0x80000000) + payload = raw.to_bytes(4, byteorder="big", signed=True) + else: + raw = max(min(raw, 0x7FFFFFFF), 0) + payload = raw.to_bytes(4, byteorder="big", signed=False) + return ( + int.from_bytes(payload[:2], byteorder="big"), + int.from_bytes(payload[2:], byteorder="big"), + ) + + +def encode_unsigned_scaled_amps(value_amps: float) -> tuple[int, int]: + raw = int(round(max(float(value_amps), 0.0) / INSTANTANEOUS_CURRENT_SCALE)) + raw = max(min(raw, 0xFFFFFFFF), 0) + payload = raw.to_bytes(4, byteorder="big", signed=False) + return ( + int.from_bytes(payload[:2], byteorder="big"), + int.from_bytes(payload[2:], byteorder="big"), + ) + + +def rewrite_instantaneous_values( + source_values: Sequence[int], + *, + usage_watts: float, + phase_usage_watts: tuple[float, float, float] | None = None, + phase_current_amps: tuple[float, float, float] | None = None, + current_n_amps: float | None = None, + allow_negative: bool = False, + allow_negative_phase: bool | None = None, +) -> tuple[int, ...]: + values = list(int(value) & 0xFFFF for value in source_values) + if len(values) < INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET + INSTANTANEOUS_ACTIVE_POWER_TOTAL_REGISTER_LENGTH: + return tuple(values) + + high_word, low_word = encode_signed_scaled_watts(usage_watts, allow_negative=allow_negative) + values[INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET] = high_word + values[INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET + 1] = low_word + + phase_allow_negative = allow_negative if allow_negative_phase is None else bool(allow_negative_phase) + if phase_usage_watts is not None: + for phase_index, phase_offset in enumerate(INSTANTANEOUS_ACTIVE_POWER_PHASE_OFFSETS): + if len(values) < phase_offset + INSTANTANEOUS_ACTIVE_POWER_PHASE_LENGTH: + continue + phase_high_word, phase_low_word = encode_signed_scaled_watts( + phase_usage_watts[phase_index], + allow_negative=phase_allow_negative, + ) + values[phase_offset] = phase_high_word + values[phase_offset + 1] = phase_low_word + + if phase_current_amps is not None: + for phase_index, phase_offset in enumerate(INSTANTANEOUS_CURRENT_PHASE_OFFSETS): + if len(values) < phase_offset + INSTANTANEOUS_CURRENT_REGISTER_LENGTH: + continue + phase_high_word, phase_low_word = encode_unsigned_scaled_amps(phase_current_amps[phase_index]) + values[phase_offset] = phase_high_word + values[phase_offset + 1] = phase_low_word + + if current_n_amps is not None and len(values) >= INSTANTANEOUS_CURRENT_N_OFFSET + INSTANTANEOUS_CURRENT_REGISTER_LENGTH: + n_high_word, n_low_word = encode_unsigned_scaled_amps(current_n_amps) + values[INSTANTANEOUS_CURRENT_N_OFFSET] = n_high_word + values[INSTANTANEOUS_CURRENT_N_OFFSET + 1] = n_low_word + + return tuple(values) + + +def rewrite_pv_instantaneous_values( + source_values: Sequence[int], + *, + pv_total_watts: float | None, +) -> tuple[int, ...]: + phase_watts = split_total_watts_evenly(pv_total_watts) + phase_currents = derive_phase_currents_from_watts(source_values, phase_watts) + total_watts = 0.0 if pv_total_watts is None else max(float(pv_total_watts), 0.0) + return rewrite_instantaneous_values( + source_values, + usage_watts=total_watts, + phase_usage_watts=phase_watts, + phase_current_amps=phase_currents, + current_n_amps=0.0, + allow_negative=False, + allow_negative_phase=False, + ) + + +def _snapshot_rewrite_usage_watts(snapshot: Any | None) -> float | None: + if snapshot is None: + return None + return getattr(snapshot, "rewrite_usage_watts", None) + + +def _snapshot_phase_usage_watts(snapshot: Any | None) -> tuple[float, float, float] | None: + if snapshot is None: + return None + return getattr(snapshot, "phase_usage_watts", None) + + +def _snapshot_phase_current_amps(snapshot: Any | None) -> tuple[float, float, float] | None: + if snapshot is None: + return None + return getattr(snapshot, "phase_current_amps", None) + + +def _snapshot_current_n_amps(snapshot: Any | None) -> float | None: + if snapshot is None: + return None + return getattr(snapshot, "current_n_amps", None) + + +def _snapshot_source_label(snapshot: Any | None) -> str: + if snapshot is None: + return "Cerbo" + explicit_label = getattr(snapshot, "source_label", None) + if explicit_label not in (None, ""): + return str(explicit_label) + if hasattr(snapshot, "reading"): + return "DZ" + return "Cerbo" + + +def preview_signature( + capture: RegisterCapture, + *, + snapshot: Any | None, +) -> tuple[Any, ...]: + usage_watts = _snapshot_rewrite_usage_watts(snapshot) + sequence = getattr(snapshot, "sequence", None) if snapshot else None + phase_usage_watts = _snapshot_phase_usage_watts(snapshot) + phase_currents = _snapshot_phase_current_amps(snapshot) + current_n = _snapshot_current_n_amps(snapshot) + return ( + capture.target_slave, + capture.source_slave, + capture.register_name, + capture.address, + capture.address_length, + capture.source_values, + sequence, + usage_watts, + phase_usage_watts, + phase_currents, + current_n, + ) + + +def format_instantaneous_preview_lines( + capture: RegisterCapture, + *, + snapshot: Any | None, +) -> list[str]: + if capture.target_slave != 100 or capture.register_name != INSTANTANEOUS_VALUES_REGISTER_NAME: + return [] + + source_watts = decode_signed_scaled_watts(capture.source_values) + usage_watts = _snapshot_rewrite_usage_watts(snapshot) + source_label = _snapshot_source_label(snapshot) + + if source_watts is None or usage_watts is None: + return [ + "ABB source: awaiting baseline", + f"{source_label} Usage to Maxem: awaiting baseline", + ] + + lines = [ + f"ABB source: {_format_watts(source_watts)}", + f"{source_label} Usage to Maxem: {_format_watts(usage_watts)}", + ] + phase_usage_watts = _snapshot_phase_usage_watts(snapshot) + if phase_usage_watts is not None: + lines.append( + f"{source_label} Phase Watts to Maxem: " + f"L1={_format_watts(phase_usage_watts[0])}, " + f"L2={_format_watts(phase_usage_watts[1])}, " + f"L3={_format_watts(phase_usage_watts[2])}" + ) + phase_current_amps = _snapshot_phase_current_amps(snapshot) + current_n_amps = _snapshot_current_n_amps(snapshot) + if phase_current_amps is not None: + lines.append( + f"{source_label} Phase Currents to Maxem: " + f"L1={_format_value(phase_current_amps[0], 'A')}, " + f"L2={_format_value(phase_current_amps[1], 'A')}, " + f"L3={_format_value(phase_current_amps[2], 'A')}, " + f"N={_format_value(current_n_amps, 'A')}" + ) + return lines + + +def changed_instantaneous_words( + source_values: Sequence[int], + rewritten_values: Sequence[int], +) -> list[int]: + changed_addresses: list[int] = [] + max_words = min(len(source_values), len(rewritten_values)) + for offset in range(max_words): + source_word = int(source_values[offset]) & 0xFFFF + rewritten_word = int(rewritten_values[offset]) & 0xFFFF + if source_word != rewritten_word: + changed_addresses.append(INSTANTANEOUS_VALUES_REGISTER_ADDRESS + offset) + return changed_addresses + + +def format_instantaneous_diff_lines( + source_values: Sequence[int], + rewritten_values: Sequence[int], +) -> list[str]: + source_decoded = decode_instantaneous_fields(source_values) + rewritten_decoded = decode_instantaneous_fields(rewritten_values) + lines: list[str] = [] + + for spec in INSTANTANEOUS_FIELD_SPECS: + source_value = source_decoded.get(spec.name) + rewritten_value = rewritten_decoded.get(spec.name) + changed_marker = "" + if source_value is not None and rewritten_value is not None: + if abs(source_value - rewritten_value) > 1e-9: + changed_marker = " [changed]" + lines.append( + f"{spec.name}: {_format_value(source_value, spec.unit)} -> {_format_value(rewritten_value, spec.unit)}{changed_marker}" + ) + + changed_addresses = changed_instantaneous_words(source_values, rewritten_values) + if changed_addresses: + changed_words_text = ", ".join(f"0x{address:04X}" for address in changed_addresses) + else: + changed_words_text = "none" + lines.append(f"changed_words: {changed_words_text}") + return lines diff --git a/lib/register_capture_tools.py b/lib/register_capture_tools.py new file mode 100644 index 0000000..544523d --- /dev/null +++ b/lib/register_capture_tools.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + +from .register_maps import MAXEM_HOLDING_REGISTERS +from .maxem_home_usage import ( + DomoticzUsageSnapshot, + INSTANTANEOUS_VALUES_REGISTER_NAME, + format_instantaneous_preview_lines, +) +from .synthetic_home import ( + DomoticzReading, + RegisterCapture, + register_capture_from_dict, + register_capture_to_dict, +) + +BUNDLE_FORMAT_VERSION = 1 + + +def capture_register_blocks( + tcp_master, + source_slaves: Iterable[int], + register_names: Sequence[str], + *, + read_holding_registers: int, +) -> list[RegisterCapture]: + captures: list[RegisterCapture] = [] + for source_slave in source_slaves: + for register_name in register_names: + address, address_length = MAXEM_HOLDING_REGISTERS[register_name] + values = tcp_master.execute(source_slave, read_holding_registers, address, address_length) + captures.append( + RegisterCapture( + target_slave=source_slave, + source_slave=source_slave, + register_name=register_name, + address=address, + address_length=address_length, + source_values=tuple(int(value) for value in values or ()), + ) + ) + return captures + + +def build_dump_bundle( + captures: Sequence[RegisterCapture], + *, + modbus_tcp_gateway: str, + modbus_tcp_port: int, + request_source_slaves: Sequence[int], + request_register_names: Sequence[str], + domoticz_reading: DomoticzReading | None, + domoticz_url: str | None, + domoticz_grid_idx: int | None, + domoticz_use_signed_net_power: bool = False, + domoticz_use_signed_net_phase_power: bool = False, + domoticz_phase_import_watts: Sequence[float] | None = None, + domoticz_phase_export_watts: Sequence[float] | None = None, + domoticz_phase_usage_watts: Sequence[float] | None = None, +) -> dict[str, object]: + return { + "bundle_format_version": BUNDLE_FORMAT_VERSION, + "captured_at": datetime.now(timezone.utc).isoformat(), + "request": { + "source_slaves": [int(slave) for slave in request_source_slaves], + "register_names": list(request_register_names), + }, + "modbus_tcp_gateway": { + "host": modbus_tcp_gateway, + "port": modbus_tcp_port, + }, + "domoticz": { + "url": domoticz_url, + "grid_idx": domoticz_grid_idx, + "use_signed_net_power": bool(domoticz_use_signed_net_power), + "use_signed_net_phase_power": bool(domoticz_use_signed_net_phase_power), + "reading": domoticz_reading.to_dict() if domoticz_reading else None, + "phase_import_watts": [float(value) for value in domoticz_phase_import_watts] if domoticz_phase_import_watts else None, + "phase_export_watts": [float(value) for value in domoticz_phase_export_watts] if domoticz_phase_export_watts else None, + "phase_usage_watts": [float(value) for value in domoticz_phase_usage_watts] if domoticz_phase_usage_watts else None, + }, + "captures": [register_capture_to_dict(capture) for capture in captures], + } + + +def dump_bundle_text(bundle: Mapping[str, object]) -> str: + return json.dumps(bundle, indent=2, sort_keys=True) + "\n" + + +def write_dump_bundle(bundle: Mapping[str, object], *, output_path: str | None = None) -> None: + serialized = dump_bundle_text(bundle) + if output_path: + Path(output_path).write_text(serialized, encoding="utf-8") + else: + print(serialized, end="") + + +def load_dump_bundle(bundle_path: str | Path) -> dict[str, Any]: + payload = json.loads(Path(bundle_path).read_text(encoding="utf-8")) + if not isinstance(payload, Mapping): + raise TypeError("Capture bundle must be a JSON object") + bundle = dict(payload) + + version = int(bundle.get("bundle_format_version", 0) or 0) + if version != BUNDLE_FORMAT_VERSION: + raise ValueError(f"Unsupported bundle format version: {version}") + + return bundle + + +def bundle_captures(bundle: Mapping[str, Any]) -> list[RegisterCapture]: + raw_captures = bundle.get("captures", []) + if not isinstance(raw_captures, Sequence): + raise TypeError("Capture bundle captures field must be a sequence") + return [register_capture_from_dict(capture) for capture in raw_captures] + + +def bundle_domoticz_reading(bundle: Mapping[str, Any]) -> DomoticzReading | None: + domoticz_block = bundle.get("domoticz", {}) + if not isinstance(domoticz_block, Mapping): + raise TypeError("Capture bundle domoticz field must be a mapping") + + reading = domoticz_block.get("reading") + if reading in (None, ""): + return None + if not isinstance(reading, Mapping): + raise TypeError("Capture bundle domoticz.reading field must be a mapping") + return DomoticzReading.from_dict(reading) + + +def build_replay_snapshot( + bundle: Mapping[str, Any], +) -> DomoticzUsageSnapshot: + reading = bundle_domoticz_reading(bundle) + domoticz_block = bundle.get("domoticz", {}) + use_signed_net_power = False + use_signed_net_phase_power = False + phase_usage_watts = None + if isinstance(domoticz_block, Mapping): + use_signed_net_power = bool(domoticz_block.get("use_signed_net_power", False)) + use_signed_net_phase_power = bool(domoticz_block.get("use_signed_net_phase_power", False)) + raw_phase_values = domoticz_block.get("phase_usage_watts") + if isinstance(raw_phase_values, Sequence) and len(raw_phase_values) == 3: + phase_usage_watts = tuple(float(value) for value in raw_phase_values) + sequence = 1 if reading is not None else 0 + return DomoticzUsageSnapshot( + sequence=sequence, + reading=reading, + phase_usage_watts=phase_usage_watts, + use_signed_net_power=use_signed_net_power, + use_signed_net_phase_power=use_signed_net_phase_power, + ) + + +def build_replay_preview_lines( + bundle: Mapping[str, Any], + *, + snapshot: DomoticzUsageSnapshot, +) -> list[str]: + captures = bundle_captures(bundle) + for capture in captures: + if capture.target_slave == 100 and capture.register_name == INSTANTANEOUS_VALUES_REGISTER_NAME: + return format_instantaneous_preview_lines(capture, snapshot=snapshot) + + return [] diff --git a/lib/register_maps.py b/lib/register_maps.py new file mode 100644 index 0000000..00b6a10 --- /dev/null +++ b/lib/register_maps.py @@ -0,0 +1,19 @@ +MAXEM_HOLDING_REGISTERS = { + "total_accumulators": (0x5000, 44), + "by_tariff": (0x5170, 58), + "per_phase": (0x5460, 108), + "instantaneous_values": (0x5b00, 66), + "inputs_outpus": (0x6300, 32), + "data_identification": (0x8900, 96), + "misc": (0x8A07, 30), + "settings": (0x8C04, 8), +} + +VICTRON_HOLDING_REGISTERS = { + "hw_version": (0x8960, 6), + "fw_version": (0x8908, 8), + "serial": (0x8900, 2), + "usage": (0x5B00, 48), + "line_import_export": (0x5460, 24), + "total_import_export": (0x5000, 8), +} diff --git a/lib/synthetic_home.py b/lib/synthetic_home.py new file mode 100644 index 0000000..b5fae14 --- /dev/null +++ b/lib/synthetic_home.py @@ -0,0 +1,603 @@ +"""Legacy cumulative-energy helpers kept for historical reference. + +The active Maxem rewrite path is watts-based and lives in +lib.maxem_home_usage.py. +""" + +from __future__ import annotations + +import json +import logging +import re +import threading +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Mapping, Sequence +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +_NUMBER_RE = re.compile(r"[-+]?\d+(?:\.\d+)?") + + +def _optional_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _optional_int(value: Any, default: int = 0) -> int: + if value in (None, ""): + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _coerce_float(payload: Mapping[str, Any], field: str) -> float: + value = payload.get(field) + if value is None: + raise KeyError(f"Domoticz payload is missing required field: {field}") + + match = _NUMBER_RE.search(str(value).replace(",", "")) + if not match: + raise ValueError(f"Could not parse numeric value for {field!r}: {value!r}") + + return float(match.group(0)) + + +def _state_to_dict(state: "SyntheticHomeState") -> dict[str, Any]: + return { + "previous_import_kwh": state.previous_import_kwh, + "previous_export_kwh": state.previous_export_kwh, + "synthetic_home_kwh": state.synthetic_home_kwh, + "sequence": state.sequence, + "last_update": state.last_update, + "last_reason": state.last_reason, + } + + +@dataclass(frozen=True) +class RegisterCapture: + target_slave: int + source_slave: int + register_name: str + address: int + address_length: int + source_values: tuple[int, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "target_slave": self.target_slave, + "source_slave": self.source_slave, + "register_name": self.register_name, + "address": self.address, + "address_length": self.address_length, + "source_values": list(self.source_values), + } + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "RegisterCapture": + raw_values = payload.get("source_values", []) + if not isinstance(raw_values, Sequence): + raise TypeError("Register capture source_values must be a sequence") + + return cls( + target_slave=int(payload.get("target_slave", 0) or 0), + source_slave=int(payload.get("source_slave", 0) or 0), + register_name=str(payload.get("register_name", "")), + address=int(payload.get("address", 0) or 0), + address_length=int(payload.get("address_length", 0) or 0), + source_values=tuple(int(value) for value in raw_values), + ) + + +def register_capture_to_dict(capture: RegisterCapture) -> dict[str, Any]: + return capture.to_dict() + + +def register_capture_from_dict(payload: Mapping[str, Any]) -> RegisterCapture: + return RegisterCapture.from_dict(payload) + + +@dataclass(frozen=True) +class DomoticzReading: + import_kwh: float + export_kwh: float + import_watts: float + export_watts: float + last_update: str | None = None + + @classmethod + def from_payload(cls, payload: Mapping[str, Any]) -> "DomoticzReading": + candidate: Mapping[str, Any] + if "result" in payload and isinstance(payload["result"], list) and payload["result"]: + candidate = payload["result"][0] + else: + candidate = payload + + if not isinstance(candidate, Mapping): + raise TypeError("Domoticz payload must be a mapping") + + return cls( + import_kwh=_coerce_float(candidate, "Counter"), + export_kwh=_coerce_float(candidate, "CounterDeliv"), + import_watts=_coerce_float(candidate, "Usage"), + export_watts=_coerce_float(candidate, "UsageDeliv"), + last_update=str(candidate.get("LastUpdate")) if candidate.get("LastUpdate") else None, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "import_kwh": self.import_kwh, + "export_kwh": self.export_kwh, + "import_watts": self.import_watts, + "export_watts": self.export_watts, + "last_update": self.last_update, + } + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "DomoticzReading": + return cls( + import_kwh=float(payload.get("import_kwh", 0.0) or 0.0), + export_kwh=float(payload.get("export_kwh", 0.0) or 0.0), + import_watts=float(payload.get("import_watts", 0.0) or 0.0), + export_watts=float(payload.get("export_watts", 0.0) or 0.0), + last_update=str(payload.get("last_update")) if payload.get("last_update") else None, + ) + + +@dataclass(frozen=True) +class SyntheticHomeState: + previous_import_kwh: float | None = None + previous_export_kwh: float | None = None + synthetic_home_kwh: float = 0.0 + sequence: int = 0 + last_update: str | None = None + last_reason: str = "uninitialized" + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "SyntheticHomeState": + return cls( + previous_import_kwh=_optional_float(payload.get("previous_import_kwh")), + previous_export_kwh=_optional_float(payload.get("previous_export_kwh")), + synthetic_home_kwh=_optional_float(payload.get("synthetic_home_kwh")) or 0.0, + sequence=_optional_int(payload.get("sequence"), 0), + last_update=str(payload.get("last_update")) if payload.get("last_update") else None, + last_reason=str(payload.get("last_reason", "uninitialized") or "uninitialized"), + ) + + def to_dict(self) -> dict[str, Any]: + return _state_to_dict(self) + + +@dataclass(frozen=True) +class SyntheticHomeSnapshot: + changed: bool + reason: str + sequence: int + previous_import_kwh: float | None + previous_export_kwh: float | None + current_import_kwh: float | None + current_export_kwh: float | None + import_delta_kwh: float + export_delta_kwh: float + synthetic_delta_kwh: float + synthetic_home_kwh: float + last_update: str | None + last_reason: str + + +def register_capture_signature( + capture: RegisterCapture, + *, + snapshot: SyntheticHomeSnapshot | None = None, +) -> tuple[Any, ...]: + if snapshot is None: + return ( + capture.target_slave, + capture.source_slave, + capture.register_name, + capture.address, + capture.address_length, + capture.source_values, + ) + + return ( + capture.target_slave, + capture.source_slave, + capture.register_name, + capture.address, + capture.address_length, + capture.source_values, + snapshot.sequence, + snapshot.synthetic_home_kwh, + snapshot.reason, + ) + + +def format_register_preview_lines( + capture: RegisterCapture, + *, + snapshot: SyntheticHomeSnapshot | None = None, +) -> list[str]: + if capture.target_slave != 100 or capture.register_name != "total_accumulators": + return [] + + if snapshot is None: + return [ + "Legacy ABB source: awaiting baseline", + "Legacy DZ Rewrite to Maxem: awaiting baseline", + ] + + if snapshot.current_import_kwh is None: + abb_source_line = "Legacy ABB source: awaiting baseline" + else: + abb_source_line = f"Legacy ABB source: {snapshot.current_import_kwh:.3f} kWh" + + if snapshot.synthetic_home_kwh is None: + rewrite_line = "Legacy DZ Rewrite to Maxem: awaiting baseline" + else: + rewrite_line = f"Legacy DZ Rewrite to Maxem: {snapshot.synthetic_home_kwh:.3f} kWh" + + return [abb_source_line, rewrite_line] + + +def format_register_preview( + capture: RegisterCapture, + *, + snapshot: SyntheticHomeSnapshot | None = None, +) -> str: + return "\n".join(format_register_preview_lines(capture, snapshot=snapshot)) + + +def format_total_accumulators_preview( + *, + register_name: str, + address: int, + address_length: int, + source_slave: int, + source_values: Sequence[int], + snapshot: SyntheticHomeSnapshot, +) -> str: + capture = RegisterCapture( + target_slave=source_slave, + source_slave=source_slave, + register_name=register_name, + address=address, + address_length=address_length, + source_values=tuple(int(value) for value in source_values), + ) + return format_register_preview(capture, snapshot=snapshot) + + +def describe_total_accumulators_preview_basis() -> str: + return ( + "Preview basis: retired cumulative-energy prototype output. The active v1 story now lives in " + "lib.maxem_home_usage.py and rewrites selected ABB instantaneous current/power words from Cerbo MQTT. " + "This helper remains only for historical reference." + ) + + +class SyntheticHomeTracker: + def __init__( + self, + state_path: str | Path, + *, + allow_export_decrement: bool = False, + logger: logging.Logger | None = None, + ) -> None: + self._state_path = Path(state_path) + self._allow_export_decrement = allow_export_decrement + self._logger = logger or logging.getLogger(__name__) + self._lock = threading.Lock() + self._state = self._load_state() + + def snapshot(self) -> SyntheticHomeSnapshot: + with self._lock: + return self._snapshot_for_state( + self._state, + changed=False, + reason=self._state.last_reason, + ) + + def observe(self, reading: DomoticzReading) -> SyntheticHomeSnapshot: + with self._lock: + state = self._state + + if state.previous_import_kwh is None or state.previous_export_kwh is None: + new_state = replace( + state, + previous_import_kwh=reading.import_kwh, + previous_export_kwh=reading.export_kwh, + sequence=state.sequence + 1, + last_update=reading.last_update or state.last_update, + last_reason="baseline", + ) + self._state = new_state + self._persist_locked() + return self._snapshot_for_state( + new_state, + observed=reading, + import_delta_kwh=0.0, + export_delta_kwh=0.0, + synthetic_delta_kwh=0.0, + changed=True, + reason="baseline", + ) + + if reading.import_kwh == state.previous_import_kwh and reading.export_kwh == state.previous_export_kwh: + return self._snapshot_for_state( + state, + observed=reading, + changed=False, + reason="unchanged", + ) + + if ( + reading.import_kwh < state.previous_import_kwh + or reading.export_kwh < state.previous_export_kwh + ): + return self._snapshot_for_state( + state, + observed=reading, + changed=False, + reason="ignored_backwards_jump", + ) + + import_delta_kwh = reading.import_kwh - state.previous_import_kwh + export_delta_kwh = reading.export_kwh - state.previous_export_kwh + + if self._allow_export_decrement: + synthetic_next = max(state.synthetic_home_kwh + import_delta_kwh - export_delta_kwh, 0.0) + else: + synthetic_next = max(state.synthetic_home_kwh + max(import_delta_kwh - export_delta_kwh, 0.0), 0.0) + + synthetic_delta_kwh = synthetic_next - state.synthetic_home_kwh + new_reason = "advanced" if synthetic_delta_kwh > 0 else "flat" + new_state = replace( + state, + previous_import_kwh=reading.import_kwh, + previous_export_kwh=reading.export_kwh, + synthetic_home_kwh=synthetic_next, + sequence=state.sequence + 1, + last_update=reading.last_update or state.last_update, + last_reason=new_reason, + ) + self._state = new_state + self._persist_locked() + return self._snapshot_for_state( + new_state, + observed=reading, + import_delta_kwh=import_delta_kwh, + export_delta_kwh=export_delta_kwh, + synthetic_delta_kwh=synthetic_delta_kwh, + changed=True, + reason=new_reason, + ) + + def _load_state(self) -> SyntheticHomeState: + try: + raw = self._state_path.read_text(encoding="utf-8") + except FileNotFoundError: + return SyntheticHomeState() + except OSError as exc: + self._logger.warning("Could not read synthetic home state from %s: %s", self._state_path, exc) + return SyntheticHomeState() + + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + self._logger.warning("Could not parse synthetic home state from %s: %s", self._state_path, exc) + return SyntheticHomeState() + + if not isinstance(payload, Mapping): + self._logger.warning("Synthetic home state file %s did not contain a mapping; starting fresh", self._state_path) + return SyntheticHomeState() + + return SyntheticHomeState.from_dict(payload) + + def _persist_locked(self) -> None: + try: + self._state_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self._state_path.with_name(f"{self._state_path.name}.tmp") + tmp_path.write_text( + json.dumps(self._state.to_dict(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + tmp_path.replace(self._state_path) + except OSError as exc: + self._logger.warning("Could not persist synthetic home state to %s: %s", self._state_path, exc) + + def _snapshot_for_state( + self, + state: SyntheticHomeState, + *, + observed: DomoticzReading | None = None, + import_delta_kwh: float = 0.0, + export_delta_kwh: float = 0.0, + synthetic_delta_kwh: float = 0.0, + changed: bool, + reason: str, + ) -> SyntheticHomeSnapshot: + return SyntheticHomeSnapshot( + changed=changed, + reason=reason, + sequence=state.sequence, + previous_import_kwh=state.previous_import_kwh, + previous_export_kwh=state.previous_export_kwh, + current_import_kwh=observed.import_kwh if observed else state.previous_import_kwh, + current_export_kwh=observed.export_kwh if observed else state.previous_export_kwh, + import_delta_kwh=import_delta_kwh, + export_delta_kwh=export_delta_kwh, + synthetic_delta_kwh=synthetic_delta_kwh, + synthetic_home_kwh=state.synthetic_home_kwh, + last_update=state.last_update, + last_reason=state.last_reason, + ) + + +class DomoticzClient: + def __init__(self, base_url: str, grid_idx: int, *, timeout_seconds: float = 1.0) -> None: + self._base_url = base_url.rstrip("/") + self._grid_idx = grid_idx + self._timeout_seconds = timeout_seconds + + @property + def enabled(self) -> bool: + return bool(self._base_url) + + @property + def url(self) -> str: + return f"{self._base_url}/json.htm?type=devices&rid={self._grid_idx}" + + @property + def grid_idx(self) -> int: + return int(self._grid_idx) + + def url_for_idx(self, rid: int) -> str: + return f"{self._base_url}/json.htm?type=devices&rid={int(rid)}" + + @staticmethod + def _normalize_indices(rids: Sequence[int]) -> tuple[int, ...]: + normalized: list[int] = [] + seen: set[int] = set() + for raw_rid in rids: + rid = int(raw_rid) + if rid <= 0 or rid in seen: + continue + normalized.append(rid) + seen.add(rid) + + if not normalized: + raise ValueError("At least one positive Domoticz IDX is required") + return tuple(normalized) + + def url_for_indices(self, rids: Sequence[int]) -> str: + normalized = self._normalize_indices(rids) + joined = ",".join(str(rid) for rid in normalized) + return f"{self._base_url}/json.htm?type=devices&rid={joined}" + + def fetch_payload( + self, + *, + rid: int | None = None, + rids: Sequence[int] | None = None, + ) -> Mapping[str, Any]: + if not self.enabled: + raise RuntimeError("Domoticz client is disabled because no base URL was configured") + + if rid is not None and rids is not None: + raise ValueError("Use either rid or rids, not both") + + if rids is not None: + target_url = self.url_for_indices(rids) + else: + target_url = self.url if rid is None else self.url_for_idx(rid) + request = Request(target_url, headers={"Accept": "application/json", "User-Agent": "modbus-softsplit/1.0"}) + with urlopen(request, timeout=self._timeout_seconds) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, Mapping): + raise TypeError("Domoticz payload must be a mapping") + return payload + + @staticmethod + def _result_candidates(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]: + result = payload.get("result") + if isinstance(result, list) and result: + return [candidate for candidate in result if isinstance(candidate, Mapping)] + if any(field in payload for field in ("idx", "Data", "Usage", "Counter")): + return [payload] + return [] + + def fetch_devices(self, rids: Sequence[int]) -> dict[int, Mapping[str, Any]]: + normalized = self._normalize_indices(rids) + payload = self.fetch_payload(rids=normalized) + candidates = self._result_candidates(payload) + + devices: dict[int, Mapping[str, Any]] = {} + for candidate in candidates: + raw_idx = candidate.get("idx") + try: + idx = int(raw_idx) + except (TypeError, ValueError): + if len(normalized) == 1: + idx = normalized[0] + else: + continue + if idx in normalized and idx not in devices: + devices[idx] = candidate + + if len(normalized) == 1 and not devices and candidates: + devices[normalized[0]] = candidates[0] + + missing = [rid for rid in normalized if rid not in devices] + if missing: + missing_text = ", ".join(str(rid) for rid in missing) + raise KeyError(f"Domoticz payload missing requested IDX values: {missing_text}") + return devices + + @staticmethod + def data_watts_from_device(candidate: Mapping[str, Any]) -> float: + return _coerce_float(candidate, "Data") + + @staticmethod + def fetch_reading_from_device(candidate: Mapping[str, Any]) -> DomoticzReading: + return DomoticzReading.from_payload({"result": [candidate]}) + + def fetch_data_watts_map(self, rids: Sequence[int]) -> dict[int, float]: + devices = self.fetch_devices(rids) + return {rid: self.data_watts_from_device(candidate) for rid, candidate in devices.items()} + + def fetch_data_watts(self, rid: int) -> float: + return self.fetch_data_watts_map((rid,))[int(rid)] + + def fetch_reading(self) -> DomoticzReading: + payload = self.fetch_payload() + return DomoticzReading.from_payload(payload) + + +class DomoticzPoller(threading.Thread): + def __init__( + self, + client: DomoticzClient, + tracker: SyntheticHomeTracker, + *, + poll_interval_seconds: float = 5.0, + logger: logging.Logger | None = None, + ) -> None: + super().__init__(name="domoticz-poller", daemon=True) + self._client = client + self._tracker = tracker + self._poll_interval_seconds = max(poll_interval_seconds, 0.1) + self._logger = logger or logging.getLogger(__name__) + self._stop_event = threading.Event() + + def stop(self) -> None: + self._stop_event.set() + + def run(self) -> None: + if not self._client.enabled: + self._logger.info("Domoticz polling is disabled; synthetic Maxem preview will stay on the last known snapshot.") + return + + while not self._stop_event.is_set(): + try: + reading = self._client.fetch_reading() + snapshot = self._tracker.observe(reading) + if snapshot.changed: + self._logger.debug( + "Synthetic Home tracker updated: sequence=%s reason=%s import_kwh=%.3f export_kwh=%.3f synthetic_home_kwh=%.3f", + snapshot.sequence, + snapshot.reason, + snapshot.current_import_kwh or 0.0, + snapshot.current_export_kwh or 0.0, + snapshot.synthetic_home_kwh, + ) + except (HTTPError, URLError, OSError, ValueError, TypeError, json.JSONDecodeError) as exc: + self._logger.warning("Domoticz poll failed: %s", exc) + + self._stop_event.wait(self._poll_interval_seconds) diff --git a/main.py b/main.py index d74d42c..2491c30 100755 --- a/main.py +++ b/main.py @@ -1,35 +1,381 @@ #!/usr/bin/python3 -u -import serial +import argparse import logging as logger +import os +import time + from dotenv import dotenv_values +import modbus_tk import modbus_tk.defines as cst -from modbus_tk import modbus_tcp, modbus_rtu +from modbus_tk import modbus_rtu, modbus_tcp + +import serial + +from lib.register_maps import MAXEM_HOLDING_REGISTERS, VICTRON_HOLDING_REGISTERS +from lib.maxem_home_usage import ( + CerboMqttSnapshot, + CerboMqttCache, + CerboMqttPoller, + INSTANTANEOUS_VALUES_REGISTER_NAME, + describe_instantaneous_preview_basis, + split_total_watts_evenly, + derive_phase_watts_from_currents, + format_instantaneous_diff_lines, + format_instantaneous_preview_lines, + net_signed_phase_watts_to_nonnegative_import, + preview_signature, + rewrite_pv_instantaneous_values, + rewrite_instantaneous_values, +) +from lib.synthetic_home import RegisterCapture + +_DOTENV = dotenv_values(".env") + + +def _get_setting(name, default=None): + value = os.environ.get(name) + if value not in (None, ""): + return value + + value = _DOTENV.get(name) + if value in (None, ""): + return default + + return value + + +def _get_setting_source(name: str) -> str: + env_value = os.environ.get(name) + if env_value not in (None, ""): + return "env" + dotenv_value = _DOTENV.get(name) + if dotenv_value not in (None, ""): + return ".env" + return "default" + + +def _parse_bool_setting(name: str, default: str = "0") -> bool: + raw_value = str(_get_setting(name, default)).strip().lower() + return raw_value not in {"0", "false", "no", "off", ""} + + +def _parse_csv_setting(name: str, default: str = "") -> tuple[str, ...]: + raw_value = str(_get_setting(name, default) or "") + items = [item.strip().strip("'").strip('"') for item in raw_value.split(",")] + return tuple(item for item in items if item) + + +def _normalize_phase_power_source(value: str) -> str: + normalized = str(value).strip().lower() + if normalized in {"activein", "acout", "abb"}: + return normalized + return "activein" -SERIAL_PORT = dotenv_values('.env')['SERIAL_PORT'] or "/dev/ttyXRUSB0" -MODBUS_TCP_GW = dotenv_values('.env')['MODBUS_TCP_GW_IP'] or "192.168.1.140" -MODBUS_TCP_GW_PORT = int(dotenv_values('.env')['MODBUS_TCP_GW_PORT']) or 8899 + +def _parse_args(argv=None): + parser = argparse.ArgumentParser(description="Modbus softsplit proxy") + parser.add_argument( + "--dry-run-maxem-home", + action="store_true", + default=_parse_bool_setting("DRY_RUN_MAXEM_HOME", "0"), + help=( + "Skip the RTU serial adapter and log the ABB instantaneous-power rewrite plan instead of writing " + "to the RTU slave." + ), + ) + parser.add_argument( + "--trace-instantaneous-payload", + action="store_true", + help=( + "Log detailed field-level ABB source vs rewritten instantaneous_values diagnostics " + "(for deep-dive debugging)." + ), + ) + return parser.parse_args(argv) + + +SERIAL_PORT = _get_setting("SERIAL_PORT", "/dev/ttyXRUSB0") +MODBUS_TCP_GW = _get_setting("MODBUS_TCP_GW_IP", "192.168.1.140") +MODBUS_TCP_GW_PORT = int(_get_setting("MODBUS_TCP_GW_PORT", "8899")) +MOSQUITTO_IP = _get_setting("MOSQUITTO_IP", "mosquitto.hs.mfis.net") +MOSQUITTO_PORT = int(_get_setting("MOSQUITTO_PORT", "1883")) +CERBO_AC_OUT_TOPIC = _get_setting("CERBO_AC_OUT_TOPIC", "N/48e7da878d35/vebus/276/Ac/Out") +CERBO_AC_ACTIVEIN_TOPIC = _get_setting("CERBO_AC_ACTIVEIN_TOPIC", "N/48e7da878d35/vebus/276/Ac/ActiveIn") +CERBO_PV_TOPICS = _parse_csv_setting( + "CERBO_PV_TOPICS", + "N/48e7da878d35/solarcharger/283/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/1/P", +) +CERBO_ENABLE_PV_SLAVE = _parse_bool_setting("CERBO_ENABLE_PV_SLAVE", "1") +CERBO_PV_TARGET_SLAVE = max(int(_get_setting("CERBO_PV_TARGET_SLAVE", "1")), 1) +CERBO_PHASE_POWER_SOURCE = _normalize_phase_power_source(_get_setting("CERBO_PHASE_POWER_SOURCE", "activein")) +CERBO_FORCE_NONNEGATIVE_PHASE_POWER = _parse_bool_setting("CERBO_FORCE_NONNEGATIVE_PHASE_POWER", "0") +CERBO_COHERENT_PHASE_FRAMES = _parse_bool_setting("CERBO_COHERENT_PHASE_FRAMES", "1") +CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS = max( + float(_get_setting("CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS", "1.5")), + 0.0, +) +CERBO_MQTT_PROTOCOL_DEBUG = _parse_bool_setting("CERBO_MQTT_PROTOCOL_DEBUG", "0") +CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS = max( + float(_get_setting("CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS", "0.0")), + 0.0, +) +LOG_LEVEL_NAME = str(_get_setting("LOG_LEVEL", "INFO")).strip().upper() +LOG_LEVEL = getattr(logger, LOG_LEVEL_NAME, logger.INFO) +STATUS_LOG_INTERVAL_SECONDS = max(float(_get_setting("STATUS_LOG_INTERVAL_SECONDS", "30.0")), 0.0) +SUPPRESS_SHORT_RTU_REQUEST_LOGS = _parse_bool_setting("SUPPRESS_SHORT_RTU_REQUEST_LOGS", "1") logger.basicConfig( format='%(asctime)s modbus-gw: %(message)s', - level=logger.INFO, + level=LOG_LEVEL, datefmt='%Y-%m-%d %H:%M:%S') +class _SuppressShortRtuRequestNoiseFilter(logger.Filter): + _noise_message = "invalid request: Request length is invalid 1" + + def filter(self, record: logger.LogRecord) -> bool: + if record.name != "modbus_tk": + return True + try: + return record.getMessage() != self._noise_message + except Exception: # pragma: no cover - defensive fallback + return True + + +class _LoopStatusTicker: + def __init__(self, interval_seconds: float) -> None: + self._interval_seconds = max(float(interval_seconds), 0.0) + self._next_log_time = time.monotonic() + self._interval_seconds if self._interval_seconds > 0.0 else 0.0 + self._tcp_cycles = 0 + self._rtu_cycles = 0 + self._loop_errors = 0 + + def mark_tcp_cycle(self) -> None: + self._tcp_cycles += 1 + + def mark_rtu_cycle(self) -> None: + self._rtu_cycles += 1 + + def mark_loop_error(self) -> None: + self._loop_errors += 1 + + def maybe_log(self) -> None: + if self._interval_seconds <= 0.0: + return + + now = time.monotonic() + if now < self._next_log_time: + return + + logger.info( + "Mirror loop heartbeat: tcp_cycles=%d rtu_cycles=%d loop_errors=%d", + self._tcp_cycles, + self._rtu_cycles, + self._loop_errors, + ) + self._tcp_cycles = 0 + self._rtu_cycles = 0 + self._loop_errors = 0 + self._next_log_time = now + self._interval_seconds + + +if SUPPRESS_SHORT_RTU_REQUEST_LOGS: + modbus_tk.LOGGER.addFilter(_SuppressShortRtuRequestNoiseFilter()) + + +def _stop_runtime( + tcp_master, + tcp_slave_server, + rtu_slave_server, + usage_poller, +): + if usage_poller: + usage_poller.stop() + usage_poller.join(timeout=2.0) + + if rtu_slave_server: + rtu_slave_server.stop() + + if tcp_slave_server: + tcp_slave_server.stop() + + if tcp_master: + tcp_master.close() + + +def _log_source_effective_config() -> None: + logger.info( + ( + "Cerbo MQTT source: host=%s(%s) port=%s(%s) ac_out_topic=%s(%s) ac_activein_topic=%s(%s) " + "pv_topics=%s(%s) pv_slave_enabled=%s(%s) pv_target_slave=%s(%s) " + "phase_power_source=%s(%s) clamp_negative_phase_power=%s(%s) " + "coherent_phase_frames=%s(%s) coherent_phase_frame_max_skew_seconds=%.2f(%s) " + "protocol_debug=%s(%s) snapshot_debug_interval_seconds=%.2f(%s)" + ), + MOSQUITTO_IP, + _get_setting_source("MOSQUITTO_IP"), + MOSQUITTO_PORT, + _get_setting_source("MOSQUITTO_PORT"), + CERBO_AC_OUT_TOPIC, + _get_setting_source("CERBO_AC_OUT_TOPIC"), + CERBO_AC_ACTIVEIN_TOPIC, + _get_setting_source("CERBO_AC_ACTIVEIN_TOPIC"), + ",".join(CERBO_PV_TOPICS) if CERBO_PV_TOPICS else "(none)", + _get_setting_source("CERBO_PV_TOPICS"), + int(CERBO_ENABLE_PV_SLAVE), + _get_setting_source("CERBO_ENABLE_PV_SLAVE"), + CERBO_PV_TARGET_SLAVE, + _get_setting_source("CERBO_PV_TARGET_SLAVE"), + CERBO_PHASE_POWER_SOURCE, + _get_setting_source("CERBO_PHASE_POWER_SOURCE"), + int(CERBO_FORCE_NONNEGATIVE_PHASE_POWER), + _get_setting_source("CERBO_FORCE_NONNEGATIVE_PHASE_POWER"), + int(CERBO_COHERENT_PHASE_FRAMES), + _get_setting_source("CERBO_COHERENT_PHASE_FRAMES"), + CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS, + _get_setting_source("CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS"), + int(CERBO_MQTT_PROTOCOL_DEBUG), + _get_setting_source("CERBO_MQTT_PROTOCOL_DEBUG"), + CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS, + _get_setting_source("CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS"), + ) + if CERBO_AC_OUT_TOPIC == CERBO_AC_ACTIVEIN_TOPIC: + logger.warning( + "CERBO_AC_OUT_TOPIC and CERBO_AC_ACTIVEIN_TOPIC are equal. " + "This can corrupt register intent between current and active-power rewrites." + ) + if _get_setting("CERBO_PHASE_POWER_SOURCE", "activein").strip().lower() not in {"activein", "acout", "abb"}: + logger.warning( + "Unsupported CERBO_PHASE_POWER_SOURCE=%r; using 'activein'. " + "Supported values: activein, acout, abb.", + _get_setting("CERBO_PHASE_POWER_SOURCE", "activein"), + ) + if CERBO_ENABLE_PV_SLAVE and CERBO_PV_TARGET_SLAVE in {2, 100}: + logger.warning( + "CERBO_PV_TARGET_SLAVE=%s collides with existing virtual meters (2,100); " + "PV slave emulation will be disabled at runtime.", + CERBO_PV_TARGET_SLAVE, + ) + + +def _resolve_phase_usage_watts_for_rewrite( + *, + source_values, + usage_snapshot, +): + if usage_snapshot is None: + return None + + if CERBO_PHASE_POWER_SOURCE == "abb": + phase_usage_watts = None + elif CERBO_PHASE_POWER_SOURCE == "acout": + phase_usage_watts = derive_phase_watts_from_currents( + source_values, + usage_snapshot.phase_current_amps, + ) + else: + phase_usage_watts = usage_snapshot.phase_usage_watts + + if phase_usage_watts is None: + return None + if CERBO_FORCE_NONNEGATIVE_PHASE_POWER and CERBO_PHASE_POWER_SOURCE == "activein": + # In activein mode we synthesize import-only phase words by netting + # export against import across phases first, then clamping to >= 0. + return net_signed_phase_watts_to_nonnegative_import( + tuple(float(value) for value in phase_usage_watts) + ) + if CERBO_FORCE_NONNEGATIVE_PHASE_POWER: + return tuple(max(float(value), 0.0) for value in phase_usage_watts) + return tuple(float(value) for value in phase_usage_watts) + + +def _allow_negative_phase_power_for_rewrite() -> bool: + if CERBO_FORCE_NONNEGATIVE_PHASE_POWER: + return False + return CERBO_PHASE_POWER_SOURCE == "activein" + + +def _build_preview_snapshot_for_logging( + usage_snapshot, + phase_usage_watts, +): + if usage_snapshot is None: + return None + if phase_usage_watts is None: + return usage_snapshot + return CerboMqttSnapshot( + sequence=getattr(usage_snapshot, "sequence", 0), + ac_in_phase_watts=tuple(float(value) for value in phase_usage_watts), + ac_in_total_watts=getattr(usage_snapshot, "rewrite_usage_watts", 0.0), + ac_out_phase_currents=getattr(usage_snapshot, "phase_current_amps", None), + ac_out_current_n=getattr(usage_snapshot, "current_n_amps", None), + ) + + +def _snapshot_pv_total_watts(usage_snapshot) -> float | None: + if usage_snapshot is None: + return None + pv_total_watts = getattr(usage_snapshot, "pv_total_watts", None) + if pv_total_watts is None: + return None + return max(float(pv_total_watts), 0.0) + + +def _pv_preview_signature( + capture: RegisterCapture, + *, + pv_total_watts: float | None, +) -> tuple[object, ...]: + return ( + capture.target_slave, + capture.source_slave, + capture.register_name, + capture.address, + capture.address_length, + capture.source_values, + pv_total_watts, + ) + + +def _format_pv_preview_lines( + *, + pv_target_slave: int, + pv_total_watts: float | None, +) -> list[str]: + if pv_total_watts is None: + return [f"Cerbo PV to Maxem (slave {pv_target_slave:03d}): awaiting baseline"] + + phase_watts = split_total_watts_evenly(pv_total_watts) + return [ + f"Cerbo PV to Maxem (slave {pv_target_slave:03d}): {pv_total_watts:,.2f} W", + ( + f"Cerbo PV Phase Watts to Maxem (slave {pv_target_slave:03d}): " + f"L1={phase_watts[0]:,.2f} W, L2={phase_watts[1]:,.2f} W, L3={phase_watts[2]:,.2f} W" + ), + ] + + def main(): + args = _parse_args() + dry_run_maxem_home = args.dry_run_maxem_home + trace_instantaneous_payload = args.trace_instantaneous_payload tcp_slave_server = None rtu_slave_server = None maxem_100 = None maxem_2 = None + maxem_pv = None victron_100 = None victron_2 = None + rewrite_cache = None + rewrite_poller = None + tcp_master = None + status_ticker = _LoopStatusTicker(STATUS_LOG_INTERVAL_SECONDS) + pv_slave_enabled_runtime = CERBO_ENABLE_PV_SLAVE and CERBO_PV_TARGET_SLAVE not in {2, 100} try: tcp_slave_server = modbus_tcp.TcpServer(port=502) - rtu_slave_server = modbus_rtu.RtuServer(serial.Serial(port=SERIAL_PORT, baudrate=19200, bytesize=8, parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, xonxoff=0, timeout=1)) - maxem_100 = rtu_slave_server.add_slave(100) - maxem_2 = rtu_slave_server.add_slave(2) victron_100 = tcp_slave_server.add_slave(100) victron_2 = tcp_slave_server.add_slave(2) @@ -41,84 +387,294 @@ def main(): victron_100.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) victron_2.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) - # Maxem Home compatible memory blocks - for register_name in MAXEM_HOLDING_REGISTERS: - addr = MAXEM_HOLDING_REGISTERS[register_name][0] - addr_len = MAXEM_HOLDING_REGISTERS[register_name][1] - maxem_100.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) - maxem_2.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) - tcp_slave_server.start() logger.info(f"Modbus TCP slave server started...") - rtu_slave_server.start() - logger.info(f"Modbus RTU slave server started...") + _log_source_effective_config() - except KeyboardInterrupt as _E: - tcp_slave_server.stop() - rtu_slave_server.stop() + rewrite_cache = CerboMqttCache() + rewrite_poller = CerboMqttPoller( + broker_host=MOSQUITTO_IP, + broker_port=MOSQUITTO_PORT, + ac_out_topic_base=CERBO_AC_OUT_TOPIC, + ac_active_in_topic_base=CERBO_AC_ACTIVEIN_TOPIC, + pv_power_topics=CERBO_PV_TOPICS, + cache=rewrite_cache, + protocol_debug=CERBO_MQTT_PROTOCOL_DEBUG, + snapshot_debug_interval_seconds=CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS, + coherent_phase_frames=CERBO_COHERENT_PHASE_FRAMES, + coherent_phase_frame_max_skew_seconds=CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS, + logger=logger, + ) + rewrite_poller.start() + if CERBO_ENABLE_PV_SLAVE and not pv_slave_enabled_runtime: + logger.warning( + "PV virtual meter disabled because CERBO_PV_TARGET_SLAVE=%s collides with existing slave addresses.", + CERBO_PV_TARGET_SLAVE, + ) - tcp_master = modbus_tcp.TcpMaster(host=MODBUS_TCP_GW, port=MODBUS_TCP_GW_PORT, timeout_in_sec=5.0) + if dry_run_maxem_home: + logger.info( + "Maxem Home dry-run preview enabled; the RTU serial adapter will not be opened and Maxem writes will be logged only." + ) + logger.info(describe_instantaneous_preview_basis()) + else: + rtu_slave_server = modbus_rtu.RtuServer( + serial.Serial( + port=SERIAL_PORT, + baudrate=19200, + bytesize=8, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + xonxoff=0, + timeout=1, + ) + ) + maxem_100 = rtu_slave_server.add_slave(100) + maxem_2 = rtu_slave_server.add_slave(2) + if pv_slave_enabled_runtime: + maxem_pv = rtu_slave_server.add_slave(CERBO_PV_TARGET_SLAVE) - while True: - try: - # Poll the real ABB B23 hardware slaves via network connected Modbus-TCP server (waveshare / EW-11 / etc.) - # and copy that data to the 'virtual' slaves. - # Victron - for register_name in VICTRON_HOLDING_REGISTERS: - addr = VICTRON_HOLDING_REGISTERS[register_name][0] - addr_len = VICTRON_HOLDING_REGISTERS[register_name][1] - - acload_values = tcp_master.execute(100, cst.READ_HOLDING_REGISTERS, addr, addr_len) - if acload_values: - if tcp_slave_server and victron_100: - victron_100.set_values(register_name, addr, acload_values) - tesla_values = tcp_master.execute(2, cst.READ_HOLDING_REGISTERS, addr, addr_len) - if tesla_values: - if tcp_slave_server and victron_2: - victron_2.set_values(register_name, addr, tesla_values) - logger.info(f"TCP slave data updated.") - - # Maxem + # Maxem Home compatible memory blocks for register_name in MAXEM_HOLDING_REGISTERS: addr = MAXEM_HOLDING_REGISTERS[register_name][0] addr_len = MAXEM_HOLDING_REGISTERS[register_name][1] + maxem_100.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) + maxem_2.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) + if maxem_pv is not None: + maxem_pv.add_block(register_name, cst.HOLDING_REGISTERS, addr, addr_len) + + rtu_slave_server.start() + logger.info(f"Modbus RTU slave server started...") + + tcp_master = modbus_tcp.TcpMaster(host=MODBUS_TCP_GW, port=MODBUS_TCP_GW_PORT, timeout_in_sec=5.0) + last_preview_signatures = {} + + while True: + try: + # Poll the real ABB B23 hardware slaves via network connected Modbus-TCP server (waveshare / EW-11 / etc.) + # and copy that data to the 'virtual' slaves. + # Victron + for register_name in VICTRON_HOLDING_REGISTERS: + addr = VICTRON_HOLDING_REGISTERS[register_name][0] + addr_len = VICTRON_HOLDING_REGISTERS[register_name][1] + + acload_values = tcp_master.execute(100, cst.READ_HOLDING_REGISTERS, addr, addr_len) + if acload_values: + if tcp_slave_server and victron_100: + victron_100.set_values(register_name, addr, acload_values) + tesla_values = tcp_master.execute(2, cst.READ_HOLDING_REGISTERS, addr, addr_len) + if tesla_values: + if tcp_slave_server and victron_2: + victron_2.set_values(register_name, addr, tesla_values) + status_ticker.mark_tcp_cycle() + + # Maxem + for register_name in MAXEM_HOLDING_REGISTERS: + addr = MAXEM_HOLDING_REGISTERS[register_name][0] + addr_len = MAXEM_HOLDING_REGISTERS[register_name][1] + + if dry_run_maxem_home and register_name != INSTANTANEOUS_VALUES_REGISTER_NAME: + continue + + acload_values = tcp_master.execute(100, cst.READ_HOLDING_REGISTERS, addr, addr_len) + if acload_values: + if dry_run_maxem_home: + capture = RegisterCapture( + target_slave=100, + source_slave=100, + register_name=register_name, + address=addr, + address_length=addr_len, + source_values=tuple(int(value) for value in acload_values), + ) + preview_snapshot = rewrite_cache.snapshot() if rewrite_cache is not None else None + usage_watts = preview_snapshot.rewrite_usage_watts if preview_snapshot else 0.0 + if usage_watts is None: + usage_watts = 0.0 + phase_usage_watts = _resolve_phase_usage_watts_for_rewrite( + source_values=acload_values, + usage_snapshot=preview_snapshot, + ) + phase_current_amps = preview_snapshot.phase_current_amps if preview_snapshot else None + current_n_amps = preview_snapshot.current_n_amps if preview_snapshot else None + preview_display_snapshot = _build_preview_snapshot_for_logging( + preview_snapshot, + phase_usage_watts, + ) + rewritten_values = rewrite_instantaneous_values( + acload_values, + usage_watts=usage_watts, + phase_usage_watts=phase_usage_watts, + phase_current_amps=phase_current_amps, + current_n_amps=current_n_amps, + allow_negative=True, + allow_negative_phase=_allow_negative_phase_power_for_rewrite(), + ) + preview_signature_value = preview_signature( + capture, + snapshot=preview_display_snapshot, + ) + preview_signature_key = (capture.target_slave, capture.source_slave, capture.register_name) + if preview_signature_value != last_preview_signatures.get(preview_signature_key): + for preview_line in format_instantaneous_preview_lines( + capture, + snapshot=preview_display_snapshot, + ): + logger.debug(preview_line) + if trace_instantaneous_payload: + for trace_line in format_instantaneous_diff_lines(capture.source_values, rewritten_values): + logger.info(f"trace {trace_line}") + last_preview_signatures[preview_signature_key] = preview_signature_value + + if pv_slave_enabled_runtime: + pv_total_watts = _snapshot_pv_total_watts(preview_snapshot) + pv_capture = RegisterCapture( + target_slave=CERBO_PV_TARGET_SLAVE, + source_slave=100, + register_name=register_name, + address=addr, + address_length=addr_len, + source_values=tuple(int(value) for value in acload_values), + ) + pv_rewritten_values = rewrite_pv_instantaneous_values( + acload_values, + pv_total_watts=pv_total_watts, + ) + pv_preview_signature_value = _pv_preview_signature( + pv_capture, + pv_total_watts=pv_total_watts, + ) + pv_preview_signature_key = ( + pv_capture.target_slave, + pv_capture.source_slave, + pv_capture.register_name, + ) + if pv_preview_signature_value != last_preview_signatures.get(pv_preview_signature_key): + for preview_line in _format_pv_preview_lines( + pv_target_slave=CERBO_PV_TARGET_SLAVE, + pv_total_watts=pv_total_watts, + ): + logger.debug(preview_line) + if trace_instantaneous_payload: + for trace_line in format_instantaneous_diff_lines( + pv_capture.source_values, + pv_rewritten_values, + ): + logger.info(f"trace pv {trace_line}") + last_preview_signatures[pv_preview_signature_key] = pv_preview_signature_value + elif rtu_slave_server and maxem_100: + # Rewrite only the selected instantaneous current/power words from Cerbo MQTT; + # mirror every other Maxem register block verbatim from ABB. + if register_name == INSTANTANEOUS_VALUES_REGISTER_NAME: + usage_snapshot = rewrite_cache.snapshot() if rewrite_cache is not None else None + capture = RegisterCapture( + target_slave=100, + source_slave=100, + register_name=register_name, + address=addr, + address_length=addr_len, + source_values=tuple(int(value) for value in acload_values), + ) + usage_watts = usage_snapshot.rewrite_usage_watts if usage_snapshot else 0.0 + if usage_watts is None: + usage_watts = 0.0 + phase_usage_watts = _resolve_phase_usage_watts_for_rewrite( + source_values=acload_values, + usage_snapshot=usage_snapshot, + ) + phase_current_amps = usage_snapshot.phase_current_amps if usage_snapshot else None + current_n_amps = usage_snapshot.current_n_amps if usage_snapshot else None + live_preview_snapshot = _build_preview_snapshot_for_logging( + usage_snapshot, + phase_usage_watts, + ) + rewritten_values = rewrite_instantaneous_values( + acload_values, + usage_watts=usage_watts, + phase_usage_watts=phase_usage_watts, + phase_current_amps=phase_current_amps, + current_n_amps=current_n_amps, + allow_negative=True, + allow_negative_phase=_allow_negative_phase_power_for_rewrite(), + ) + live_preview_signature = preview_signature( + capture, + snapshot=live_preview_snapshot, + ) + live_preview_signature_key = (capture.target_slave, capture.source_slave, capture.register_name) + if live_preview_signature != last_preview_signatures.get(live_preview_signature_key): + for preview_line in format_instantaneous_preview_lines( + capture, + snapshot=live_preview_snapshot, + ): + logger.debug(preview_line) + if trace_instantaneous_payload: + for trace_line in format_instantaneous_diff_lines(capture.source_values, rewritten_values): + logger.info(f"trace {trace_line}") + last_preview_signatures[live_preview_signature_key] = live_preview_signature + maxem_100.set_values(register_name, addr, rewritten_values) + + if maxem_pv is not None: + pv_total_watts = _snapshot_pv_total_watts(usage_snapshot) + pv_capture = RegisterCapture( + target_slave=CERBO_PV_TARGET_SLAVE, + source_slave=100, + register_name=register_name, + address=addr, + address_length=addr_len, + source_values=tuple(int(value) for value in acload_values), + ) + pv_rewritten_values = rewrite_pv_instantaneous_values( + acload_values, + pv_total_watts=pv_total_watts, + ) + pv_preview_signature_value = _pv_preview_signature( + pv_capture, + pv_total_watts=pv_total_watts, + ) + pv_preview_signature_key = ( + pv_capture.target_slave, + pv_capture.source_slave, + pv_capture.register_name, + ) + if pv_preview_signature_value != last_preview_signatures.get(pv_preview_signature_key): + for preview_line in _format_pv_preview_lines( + pv_target_slave=CERBO_PV_TARGET_SLAVE, + pv_total_watts=pv_total_watts, + ): + logger.debug(preview_line) + if trace_instantaneous_payload: + for trace_line in format_instantaneous_diff_lines( + pv_capture.source_values, + pv_rewritten_values, + ): + logger.info(f"trace pv {trace_line}") + last_preview_signatures[pv_preview_signature_key] = pv_preview_signature_value + maxem_pv.set_values(register_name, addr, pv_rewritten_values) + else: + maxem_100.set_values(register_name, addr, acload_values) + if maxem_pv is not None: + maxem_pv.set_values(register_name, addr, acload_values) + if dry_run_maxem_home: + continue - acload_values = tcp_master.execute(100, cst.READ_HOLDING_REGISTERS, addr, addr_len) - if acload_values: - if rtu_slave_server and maxem_100: - maxem_100.set_values(register_name, addr, acload_values) - tesla_values = tcp_master.execute(2, cst.READ_HOLDING_REGISTERS, addr, addr_len) - if tesla_values: - if rtu_slave_server and maxem_2: + tesla_values = tcp_master.execute(2, cst.READ_HOLDING_REGISTERS, addr, addr_len) + if tesla_values and rtu_slave_server and maxem_2: maxem_2.set_values(register_name, addr, tesla_values) - logger.info(f"RTU slave data updated.") - - except Exception as _E: - logger.error(f"tcp_master(error): {_E}") - tcp_master.close() - - -MAXEM_HOLDING_REGISTERS = dict({ - "total_accumulators": (0x5000, 44), - "by_tariff": (0x5170, 58), - "per_phase": (0x5460, 108), - "instantaneous_values": (0x5b00, 66), - "inputs_outpus": (0x6300, 32), - "data_identification": (0x8900, 96), - "misc": (0x8A07, 30), - "settings": (0x8c04, 8), -}) - -VICTRON_HOLDING_REGISTERS = dict({ - "hw_version": (0x8960, 6), - "fw_version": (0x8908, 8), - "serial": (0x8900, 2), - "usage": (0x5b00, 48), - "line_import_export": (0x5460, 24), - "total_import_export": (0x5000, 8), -}) + if not dry_run_maxem_home: + status_ticker.mark_rtu_cycle() + status_ticker.maybe_log() + except Exception as exc: + status_ticker.mark_loop_error() + logger.error(f"loop error: {exc}") + except KeyboardInterrupt: + logger.info("Shutdown requested via Ctrl-C; stopping cleanly...") + except Exception as exc: + logger.error(f"tcp_master(error): {exc}") + finally: + _stop_runtime(tcp_master, tcp_slave_server, rtu_slave_server, rewrite_poller) if __name__ == "__main__": main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d5b17e2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +minversion = 8.0 +testpaths = tests +python_files = [0-9][0-9]_test_*.py +addopts = -ra diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..039d26e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=8.0 diff --git a/requirements.txt b/requirements.txt index 8d0aea5..6d66e1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyserial>=3.1 modbus_tk==1.1.2 python-dotenv~=0.21.0 +paho-mqtt>=1.6.1 diff --git a/tests/10_test_maxem_home_usage.py b/tests/10_test_maxem_home_usage.py new file mode 100644 index 0000000..6c9bba7 --- /dev/null +++ b/tests/10_test_maxem_home_usage.py @@ -0,0 +1,565 @@ +import unittest +from unittest.mock import patch + +from lib.maxem_home_usage import ( + CerboMqttSnapshot, + DomoticzUsageCache, + DomoticzUsagePoller, + DomoticzUsageSnapshot, + INSTANTANEOUS_ACTIVE_POWER_L1_REGISTER_ADDRESS, + INSTANTANEOUS_ACTIVE_POWER_L2_REGISTER_ADDRESS, + INSTANTANEOUS_ACTIVE_POWER_L3_REGISTER_ADDRESS, + INSTANTANEOUS_CURRENT_L1_REGISTER_ADDRESS, + INSTANTANEOUS_CURRENT_L2_REGISTER_ADDRESS, + INSTANTANEOUS_CURRENT_L3_REGISTER_ADDRESS, + INSTANTANEOUS_CURRENT_N_REGISTER_ADDRESS, + INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET, + INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_VALUES_REGISTER_LENGTH, + INSTANTANEOUS_VALUES_REGISTER_NAME, + changed_instantaneous_words, + decode_instantaneous_fields, + decode_signed_scaled_watts, + describe_instantaneous_preview_basis, + derive_phase_currents_from_watts, + derive_phase_watts_from_currents, + encode_signed_scaled_watts, + format_instantaneous_diff_lines, + format_instantaneous_preview_lines, + net_signed_phase_watts_to_nonnegative_import, + rewrite_pv_instantaneous_values, + rewrite_instantaneous_values, + split_total_watts_evenly, +) +from lib.synthetic_home import DomoticzClient, DomoticzReading, RegisterCapture + + +def _instantaneous_capture(source_watts: float) -> RegisterCapture: + values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + high_word, low_word = encode_signed_scaled_watts(source_watts) + values[INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET] = high_word + values[INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET + 1] = low_word + return RegisterCapture( + target_slave=100, + source_slave=100, + register_name=INSTANTANEOUS_VALUES_REGISTER_NAME, + address=INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + address_length=INSTANTANEOUS_VALUES_REGISTER_LENGTH, + source_values=tuple(values), + ) + + +class MaxemHomeUsageTests(unittest.TestCase): + def test_domoticz_payload_parsing_from_result_list(self) -> None: + reading = DomoticzReading.from_payload( + { + "result": [ + { + "Counter": "64989.818", + "CounterDeliv": "6787.095", + "Usage": "2 Watt", + "UsageDeliv": "0 Watt", + "LastUpdate": "2026-05-25 15:13:57", + } + ] + } + ) + + self.assertAlmostEqual(reading.import_kwh, 64989.818) + self.assertAlmostEqual(reading.export_kwh, 6787.095) + self.assertAlmostEqual(reading.import_watts, 2.0) + self.assertAlmostEqual(reading.export_watts, 0.0) + self.assertEqual(reading.last_update, "2026-05-25 15:13:57") + + def test_instantaneous_register_round_trip_and_negative_clamp(self) -> None: + capture = _instantaneous_capture(1234.5) + + self.assertAlmostEqual(decode_signed_scaled_watts(capture.source_values), 1234.5) + + rewritten = rewrite_instantaneous_values(capture.source_values, usage_watts=-25.0) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten), 0.0) + + def test_instantaneous_register_supports_negative_rewrite_when_enabled(self) -> None: + capture = _instantaneous_capture(1234.5) + + rewritten = rewrite_instantaneous_values( + capture.source_values, + usage_watts=-25.0, + allow_negative=True, + ) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten), -25.0, places=2) + + def test_instantaneous_only_rewrites_total_field(self) -> None: + capture = _instantaneous_capture(1234.5) + source_values = list(capture.source_values) + for index in range(len(source_values)): + source_values[index] = 10_000 + index + + rewritten = rewrite_instantaneous_values(tuple(source_values), usage_watts=18.0) + + for index, value in enumerate(source_values): + if index in (INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET, INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET + 1): + continue + self.assertEqual(rewritten[index], value) + + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten), 18.0) + self.assertEqual(changed_instantaneous_words(source_values, rewritten), [0x5B14, 0x5B15]) + + def test_instantaneous_rewrites_total_and_phase_power_fields(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + for index in range(len(source_values)): + source_values[index] = 20_000 + index + + rewritten = rewrite_instantaneous_values( + tuple(source_values), + usage_watts=87.0, + phase_usage_watts=(1559.24, 1284.30, 1931.40), + ) + + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten), 87.0, places=2) + + phase_l1_offset = INSTANTANEOUS_ACTIVE_POWER_L1_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + phase_l2_offset = INSTANTANEOUS_ACTIVE_POWER_L2_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + phase_l3_offset = INSTANTANEOUS_ACTIVE_POWER_L3_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten, offset=phase_l1_offset), 1559.24, places=2) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten, offset=phase_l2_offset), 1284.30, places=2) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten, offset=phase_l3_offset), 1931.40, places=2) + + changed_words = changed_instantaneous_words(source_values, rewritten) + self.assertEqual(changed_words, [0x5B14, 0x5B15, 0x5B16, 0x5B17, 0x5B18, 0x5B19, 0x5B1A, 0x5B1B]) + + def test_instantaneous_rewrite_can_use_signed_total_with_unsigned_phase_values(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + rewritten = rewrite_instantaneous_values( + tuple(source_values), + usage_watts=-50.0, + phase_usage_watts=(-10.0, 20.0, -30.0), + allow_negative=True, + allow_negative_phase=False, + ) + + phase_l1_offset = INSTANTANEOUS_ACTIVE_POWER_L1_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + phase_l2_offset = INSTANTANEOUS_ACTIVE_POWER_L2_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + phase_l3_offset = INSTANTANEOUS_ACTIVE_POWER_L3_REGISTER_ADDRESS - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten), -50.0, places=2) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten, offset=phase_l1_offset), 0.0, places=2) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten, offset=phase_l2_offset), 20.0, places=2) + self.assertAlmostEqual(decode_signed_scaled_watts(rewritten, offset=phase_l3_offset), 0.0, places=2) + + def test_instantaneous_rewrites_current_fields_from_ac_out(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + rewritten = rewrite_instantaneous_values( + tuple(source_values), + usage_watts=0.0, + phase_usage_watts=(0.0, 0.0, 0.0), + phase_current_amps=(1.23, 2.34, 3.45), + current_n_amps=0.56, + ) + + decoded = decode_instantaneous_fields(rewritten) + self.assertAlmostEqual(decoded["current_l1"] or 0.0, 1.23, places=2) + self.assertAlmostEqual(decoded["current_l2"] or 0.0, 2.34, places=2) + self.assertAlmostEqual(decoded["current_l3"] or 0.0, 3.45, places=2) + self.assertAlmostEqual(decoded["current_n"] or 0.0, 0.56, places=2) + + changed_words = changed_instantaneous_words(source_values, rewritten) + expected = [ + INSTANTANEOUS_CURRENT_L1_REGISTER_ADDRESS + 1, + INSTANTANEOUS_CURRENT_L2_REGISTER_ADDRESS + 1, + INSTANTANEOUS_CURRENT_L3_REGISTER_ADDRESS + 1, + INSTANTANEOUS_CURRENT_N_REGISTER_ADDRESS + 1, + ] + self.assertEqual(changed_words, expected) + + def test_decode_instantaneous_fields_extracts_expected_values(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + + def set_field(address: int, scale: float, value: float, *, signed: bool) -> None: + offset = address - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + raw = int(round(value / scale)) + if signed: + raw &= 0xFFFFFFFF + raw = max(min(raw, 0xFFFFFFFF), 0) + payload = raw.to_bytes(4, byteorder="big", signed=False) + source_values[offset] = int.from_bytes(payload[:2], byteorder="big") + source_values[offset + 1] = int.from_bytes(payload[2:], byteorder="big") + + set_field(0x5B00, 0.1, 228.7, signed=False) + set_field(0x5B0C, 0.01, 2.34, signed=False) + set_field(0x5B0E, 0.01, 1.17, signed=False) + set_field(0x5B10, 0.01, 4.38, signed=False) + set_field(0x5B14, 0.01, 3470.91, signed=True) + + decoded = decode_instantaneous_fields(source_values) + + self.assertAlmostEqual(decoded["voltage_l1_n"] or 0.0, 228.7, places=1) + self.assertAlmostEqual(decoded["current_l1"] or 0.0, 2.34, places=2) + self.assertAlmostEqual(decoded["current_l2"] or 0.0, 1.17, places=2) + self.assertAlmostEqual(decoded["current_l3"] or 0.0, 4.38, places=2) + self.assertAlmostEqual(decoded["active_power_total"] or 0.0, 3470.91, places=2) + + def test_derive_phase_watts_from_currents_uses_source_voltages(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + + def set_voltage(address: int, value_volts: float) -> None: + offset = address - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + raw = int(round(value_volts / 0.1)) + payload = raw.to_bytes(4, byteorder="big", signed=False) + source_values[offset] = int.from_bytes(payload[:2], byteorder="big") + source_values[offset + 1] = int.from_bytes(payload[2:], byteorder="big") + + set_voltage(0x5B00, 230.0) + set_voltage(0x5B02, 231.0) + set_voltage(0x5B04, 232.0) + + derived = derive_phase_watts_from_currents(tuple(source_values), (10.0, 1.0, 0.5)) + + self.assertIsNotNone(derived) + self.assertAlmostEqual(derived[0], 2300.0, places=2) + self.assertAlmostEqual(derived[1], 231.0, places=2) + self.assertAlmostEqual(derived[2], 116.0, places=2) + + def test_derive_phase_watts_from_currents_falls_back_to_default_voltage(self) -> None: + source_values = [0xFFFF] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + + derived = derive_phase_watts_from_currents( + tuple(source_values), + (1.0, 2.0, 3.0), + fallback_phase_voltage_volts=230.0, + ) + + self.assertIsNotNone(derived) + self.assertAlmostEqual(derived[0], 230.0, places=2) + self.assertAlmostEqual(derived[1], 460.0, places=2) + self.assertAlmostEqual(derived[2], 690.0, places=2) + + def test_split_total_watts_evenly_distributes_sum_without_loss(self) -> None: + phase_watts = split_total_watts_evenly(1000.0) + + self.assertIsNotNone(phase_watts) + self.assertAlmostEqual(sum(phase_watts), 1000.0, places=6) + self.assertAlmostEqual(phase_watts[0], phase_watts[1], places=6) + self.assertGreaterEqual(phase_watts[2], 0.0) + + def test_derive_phase_currents_from_watts_uses_source_phase_voltages(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + + def set_voltage(address: int, value_volts: float) -> None: + offset = address - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + raw = int(round(value_volts / 0.1)) + payload = raw.to_bytes(4, byteorder="big", signed=False) + source_values[offset] = int.from_bytes(payload[:2], byteorder="big") + source_values[offset + 1] = int.from_bytes(payload[2:], byteorder="big") + + set_voltage(0x5B00, 230.0) + set_voltage(0x5B02, 231.0) + set_voltage(0x5B04, 232.0) + + currents = derive_phase_currents_from_watts( + tuple(source_values), + (230.0, 462.0, 116.0), + ) + + self.assertIsNotNone(currents) + self.assertAlmostEqual(currents[0], 1.0, places=4) + self.assertAlmostEqual(currents[1], 2.0, places=4) + self.assertAlmostEqual(currents[2], 0.5, places=4) + + def test_rewrite_pv_instantaneous_values_sets_total_phase_power_and_currents(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + + def set_voltage(address: int, value_volts: float) -> None: + offset = address - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + raw = int(round(value_volts / 0.1)) + payload = raw.to_bytes(4, byteorder="big", signed=False) + source_values[offset] = int.from_bytes(payload[:2], byteorder="big") + source_values[offset + 1] = int.from_bytes(payload[2:], byteorder="big") + + set_voltage(0x5B00, 230.0) + set_voltage(0x5B02, 230.0) + set_voltage(0x5B04, 230.0) + + rewritten = rewrite_pv_instantaneous_values( + tuple(source_values), + pv_total_watts=900.0, + ) + decoded = decode_instantaneous_fields(rewritten) + + self.assertAlmostEqual(decoded["active_power_total"] or 0.0, 900.0, places=2) + self.assertAlmostEqual(decoded["active_power_l1"] or 0.0, 300.0, places=2) + self.assertAlmostEqual(decoded["active_power_l2"] or 0.0, 300.0, places=2) + self.assertAlmostEqual(decoded["active_power_l3"] or 0.0, 300.0, places=2) + self.assertAlmostEqual(decoded["current_l1"] or 0.0, 1.30, places=2) + self.assertAlmostEqual(decoded["current_l2"] or 0.0, 1.30, places=2) + self.assertAlmostEqual(decoded["current_l3"] or 0.0, 1.30, places=2) + self.assertAlmostEqual(decoded["current_n"] or 0.0, 0.0, places=2) + + def test_net_signed_phase_watts_to_nonnegative_import_offsets_exports(self) -> None: + netted = net_signed_phase_watts_to_nonnegative_import((-350.0, 350.0, 0.0)) + + self.assertIsNotNone(netted) + self.assertAlmostEqual(netted[0], 0.0, places=6) + self.assertAlmostEqual(netted[1], 0.0, places=6) + self.assertAlmostEqual(netted[2], 0.0, places=6) + + def test_net_signed_phase_watts_to_nonnegative_import_scales_positive_phases(self) -> None: + netted = net_signed_phase_watts_to_nonnegative_import((-250.0, 270.0, 50.0)) + + self.assertIsNotNone(netted) + self.assertAlmostEqual(sum(netted), 70.0, places=6) + self.assertGreaterEqual(netted[0], 0.0) + self.assertGreaterEqual(netted[1], 0.0) + self.assertGreaterEqual(netted[2], 0.0) + + def test_decode_instantaneous_fields_treats_invalid_sentinel_words_as_none(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + current_n_offset = 0x5B12 - INSTANTANEOUS_VALUES_REGISTER_ADDRESS + source_values[current_n_offset] = 0xFFFF + source_values[current_n_offset + 1] = 0xFFFF + + decoded = decode_instantaneous_fields(source_values) + + self.assertIsNone(decoded["current_n"]) + + def test_format_instantaneous_diff_marks_only_total_power_changed(self) -> None: + capture = _instantaneous_capture(3470.91) + rewritten = rewrite_instantaneous_values(capture.source_values, usage_watts=87.0) + + lines = format_instantaneous_diff_lines(capture.source_values, rewritten) + active_total_line = [line for line in lines if line.startswith("active_power_total:")][0] + active_l1_line = [line for line in lines if line.startswith("active_power_l1:")][0] + changed_words_line = lines[-1] + + self.assertIn("[changed]", active_total_line) + self.assertNotIn("[changed]", active_l1_line) + self.assertEqual(changed_words_line, "changed_words: 0x5B14, 0x5B15") + + def test_preview_message_is_about_grid_import_watts(self) -> None: + capture = _instantaneous_capture(1234.5) + snapshot = DomoticzUsageSnapshot( + sequence=1, + reading=DomoticzReading( + import_kwh=100.0, + export_kwh=10.0, + import_watts=18.0, + export_watts=0.0, + last_update="2026-05-26 09:00:00", + ), + phase_usage_watts=(10.0, 20.0, 30.0), + ) + + message = format_instantaneous_preview_lines(capture, snapshot=snapshot) + + self.assertEqual( + message, + [ + "ABB source: 1,234.50 W", + "DZ Usage to Maxem: 18 W", + "DZ Phase Watts to Maxem: L1=10 W, L2=20 W, L3=30 W", + ], + ) + + def test_preview_message_uses_cerbo_label_and_includes_currents(self) -> None: + capture = _instantaneous_capture(1234.5) + snapshot = CerboMqttSnapshot( + sequence=1, + ac_in_phase_watts=(10.0, 20.0, 30.0), + ac_in_total_watts=60.0, + ac_out_phase_currents=(0.11, 0.22, 0.33), + ac_out_current_n=0.44, + ) + + message = format_instantaneous_preview_lines(capture, snapshot=snapshot) + + self.assertEqual( + message, + [ + "ABB source: 1,234.50 W", + "Cerbo Usage to Maxem: 60 W", + "Cerbo Phase Watts to Maxem: L1=10 W, L2=20 W, L3=30 W", + "Cerbo Phase Currents to Maxem: L1=0.11 A, L2=0.22 A, L3=0.33 A, N=0.44 A", + ], + ) + + def test_preview_basis_explains_instantaneous_power_semantics(self) -> None: + message = describe_instantaneous_preview_basis() + + self.assertIn("active_power_total (0x5B14/0x5B15)", message) + self.assertIn("CERBO_PHASE_POWER_SOURCE", message) + self.assertIn("Ac/ActiveIn", message) + self.assertIn("Ac/Out", message) + self.assertIn("non-negative clamp", message) + self.assertIn("copied verbatim", message) + + def test_domoticz_client_parses_multi_idx_payload(self) -> None: + client = DomoticzClient("http://example.invalid", 20) + payload = { + "status": "OK", + "result": [ + { + "idx": "20", + "Counter": "64989.818", + "CounterDeliv": "6787.095", + "Usage": "87 Watt", + "UsageDeliv": "15 Watt", + "LastUpdate": "2026-05-28 10:00:00", + }, + {"idx": "26", "Data": "1559.24 W"}, + {"idx": "32", "Data": "120.00 W"}, + ], + } + + with patch.object(client, "fetch_payload", return_value=payload): + devices = client.fetch_devices((20, 26, 32)) + + self.assertEqual(sorted(devices.keys()), [20, 26, 32]) + self.assertAlmostEqual(client.data_watts_from_device(devices[26]), 1559.24, places=2) + self.assertAlmostEqual(client.data_watts_from_device(devices[32]), 120.00, places=2) + + reading = client.fetch_reading_from_device(devices[20]) + self.assertAlmostEqual(reading.import_watts, 87.0) + self.assertAlmostEqual(reading.export_watts, 15.0) + + def test_usage_poller_requests_one_batched_snapshot_per_cycle(self) -> None: + class _FakeBatchClient: + enabled = True + grid_idx = 20 + + def __init__(self) -> None: + import threading + + self.calls: list[tuple[int, ...]] = [] + self.called = threading.Event() + + def fetch_devices(self, rids): + self.calls.append(tuple(int(value) for value in rids)) + self.called.set() + return { + 20: { + "idx": "20", + "Counter": "64989.818", + "CounterDeliv": "6787.095", + "Usage": "87 Watt", + "UsageDeliv": "15 Watt", + }, + 26: {"idx": "26", "Data": "1559.24 W"}, + 25: {"idx": "25", "Data": "1284.30 W"}, + 24: {"idx": "24", "Data": "1931.40 W"}, + 32: {"idx": "32", "Data": "120.00 W"}, + 31: {"idx": "31", "Data": "80.00 W"}, + 33: {"idx": "33", "Data": "50.00 W"}, + } + + @staticmethod + def url_for_indices(rids): + return "http://example.invalid/json.htm?type=devices&rid=" + ",".join(str(int(value)) for value in rids) + + @staticmethod + def data_watts_from_device(candidate): + return float(str(candidate["Data"]).split()[0]) + + @staticmethod + def fetch_reading_from_device(candidate): + return DomoticzReading.from_payload({"result": [candidate]}) + + fake_client = _FakeBatchClient() + cache = DomoticzUsageCache(use_signed_net_power=True) + poller = DomoticzUsagePoller( + fake_client, # type: ignore[arg-type] + cache, + phase_l1_idx=26, + phase_l2_idx=25, + phase_l3_idx=24, + phase_export_l1_idx=32, + phase_export_l2_idx=31, + phase_export_l3_idx=33, + use_signed_net_power=True, + use_signed_net_phase_power=True, + poll_interval_seconds=0.1, + ) + + poller.start() + self.assertTrue(fake_client.called.wait(timeout=1.0)) + poller.stop() + poller.join(timeout=1.0) + + self.assertGreaterEqual(len(fake_client.calls), 1) + self.assertEqual(fake_client.calls[0], (20, 26, 25, 24, 32, 31, 33)) + snapshot = cache.snapshot() + self.assertAlmostEqual(snapshot.rewrite_usage_watts or 0.0, 72.0, places=2) + self.assertIsNotNone(snapshot.phase_usage_watts) + self.assertAlmostEqual(snapshot.phase_usage_watts[0], 1439.24, places=2) + self.assertAlmostEqual(snapshot.phase_usage_watts[1], 1204.30, places=2) + self.assertAlmostEqual(snapshot.phase_usage_watts[2], 1881.40, places=2) + + def test_usage_poller_defaults_to_import_only_phase_values(self) -> None: + class _FakeBatchClient: + enabled = True + grid_idx = 20 + + def __init__(self) -> None: + import threading + + self.called = threading.Event() + + def fetch_devices(self, rids): + self.called.set() + return { + 20: { + "idx": "20", + "Counter": "64989.818", + "CounterDeliv": "6787.095", + "Usage": "87 Watt", + "UsageDeliv": "15 Watt", + }, + 26: {"idx": "26", "Data": "100.00 W"}, + 25: {"idx": "25", "Data": "200.00 W"}, + 24: {"idx": "24", "Data": "300.00 W"}, + 32: {"idx": "32", "Data": "90.00 W"}, + 31: {"idx": "31", "Data": "190.00 W"}, + 33: {"idx": "33", "Data": "290.00 W"}, + } + + @staticmethod + def url_for_indices(rids): + return "http://example.invalid/json.htm?type=devices&rid=" + ",".join(str(int(value)) for value in rids) + + @staticmethod + def data_watts_from_device(candidate): + return float(str(candidate["Data"]).split()[0]) + + @staticmethod + def fetch_reading_from_device(candidate): + return DomoticzReading.from_payload({"result": [candidate]}) + + fake_client = _FakeBatchClient() + cache = DomoticzUsageCache(use_signed_net_power=True, use_signed_net_phase_power=False) + poller = DomoticzUsagePoller( + fake_client, # type: ignore[arg-type] + cache, + phase_l1_idx=26, + phase_l2_idx=25, + phase_l3_idx=24, + phase_export_l1_idx=32, + phase_export_l2_idx=31, + phase_export_l3_idx=33, + use_signed_net_power=True, + use_signed_net_phase_power=False, + poll_interval_seconds=0.1, + ) + + poller.start() + self.assertTrue(fake_client.called.wait(timeout=1.0)) + poller.stop() + poller.join(timeout=1.0) + + snapshot = cache.snapshot() + self.assertIsNotNone(snapshot.phase_usage_watts) + self.assertAlmostEqual(snapshot.phase_usage_watts[0], 100.0, places=2) + self.assertAlmostEqual(snapshot.phase_usage_watts[1], 200.0, places=2) + self.assertAlmostEqual(snapshot.phase_usage_watts[2], 300.0, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/20_test_dump_and_replay.py b/tests/20_test_dump_and_replay.py new file mode 100644 index 0000000..c03629b --- /dev/null +++ b/tests/20_test_dump_and_replay.py @@ -0,0 +1,197 @@ +import io +from contextlib import redirect_stdout + +from lib.maxem_home_usage import ( + INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET, + INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + INSTANTANEOUS_VALUES_REGISTER_LENGTH, + INSTANTANEOUS_VALUES_REGISTER_NAME, + encode_signed_scaled_watts, + format_instantaneous_preview_lines, +) +from lib.register_capture_tools import ( + build_dump_bundle, + build_replay_preview_lines, + build_replay_snapshot, + bundle_captures, + capture_register_blocks, + load_dump_bundle, + write_dump_bundle, +) +from lib.synthetic_home import DomoticzReading, RegisterCapture +from tools.dump_register_block import _default_register_names +from tools.inspect_instantaneous_payload import main as inspect_instantaneous_main + + +class FakeMaster: + def __init__(self) -> None: + self.calls: list[tuple[int, int, int, int]] = [] + + def execute(self, slave: int, function_code: int, address: int, length: int): + self.calls.append((slave, function_code, address, length)) + return (slave, address, length) + + +def _sample_instantaneous_capture(source_watts: float = 1_234.5) -> RegisterCapture: + values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + high_word, low_word = encode_signed_scaled_watts(source_watts) + values[INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET] = high_word + values[INSTANTANEOUS_ACTIVE_POWER_TOTAL_OFFSET + 1] = low_word + return RegisterCapture( + target_slave=100, + source_slave=100, + register_name=INSTANTANEOUS_VALUES_REGISTER_NAME, + address=INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + address_length=INSTANTANEOUS_VALUES_REGISTER_LENGTH, + source_values=tuple(values), + ) + + +def _sample_captures(source_watts: float = 1_234.5) -> list[RegisterCapture]: + return [ + _sample_instantaneous_capture(source_watts=source_watts), + RegisterCapture( + target_slave=100, + source_slave=100, + register_name="settings", + address=0x8C04, + address_length=8, + source_values=(5, 6, 7, 8), + ), + ] + + +def test_capture_register_blocks_uses_requested_registers() -> None: + master = FakeMaster() + + captures = capture_register_blocks( + tcp_master=master, + source_slaves=[100, 2], + register_names=[INSTANTANEOUS_VALUES_REGISTER_NAME, "settings"], + read_holding_registers=3, + ) + + assert master.calls == [ + (100, 3, INSTANTANEOUS_VALUES_REGISTER_ADDRESS, INSTANTANEOUS_VALUES_REGISTER_LENGTH), + (100, 3, 0x8C04, 8), + (2, 3, INSTANTANEOUS_VALUES_REGISTER_ADDRESS, INSTANTANEOUS_VALUES_REGISTER_LENGTH), + (2, 3, 0x8C04, 8), + ] + assert len(captures) == 4 + assert captures[0].source_values == (100, INSTANTANEOUS_VALUES_REGISTER_ADDRESS, INSTANTANEOUS_VALUES_REGISTER_LENGTH) + assert captures[1].source_values == (100, 0x8C04, 8) + + +def test_dump_tool_defaults_to_instantaneous_values_only() -> None: + assert _default_register_names() == [INSTANTANEOUS_VALUES_REGISTER_NAME] + + +def test_dump_bundle_round_trips_through_json(tmp_path) -> None: + bundle = build_dump_bundle( + _sample_captures(), + modbus_tcp_gateway="192.168.1.140", + modbus_tcp_port=8899, + request_source_slaves=[100], + request_register_names=[INSTANTANEOUS_VALUES_REGISTER_NAME, "settings"], + domoticz_reading=DomoticzReading( + import_kwh=12.5, + export_kwh=3.25, + import_watts=321.0, + export_watts=0.0, + last_update="2026-05-26 09:00:00", + ), + domoticz_url="http://dz-insecure.hs.mfis.net", + domoticz_grid_idx=20, + domoticz_phase_usage_watts=(10.0, 20.0, 30.0), + ) + + bundle_path = tmp_path / "maxem-bundle.json" + write_dump_bundle(bundle, output_path=str(bundle_path)) + loaded = load_dump_bundle(bundle_path) + + assert loaded["bundle_format_version"] == 1 + assert loaded["request"]["source_slaves"] == [100] + assert loaded["request"]["register_names"] == [INSTANTANEOUS_VALUES_REGISTER_NAME, "settings"] + assert loaded["domoticz"]["reading"]["import_watts"] == 321.0 + assert loaded["domoticz"]["phase_usage_watts"] == [10.0, 20.0, 30.0] + assert bundle_captures(loaded)[0].register_name == INSTANTANEOUS_VALUES_REGISTER_NAME + + +def test_replay_preview_highlights_the_grid_import_rewrite(tmp_path) -> None: + bundle = build_dump_bundle( + _sample_captures(), + modbus_tcp_gateway="192.168.1.140", + modbus_tcp_port=8899, + request_source_slaves=[100], + request_register_names=[INSTANTANEOUS_VALUES_REGISTER_NAME, "settings"], + domoticz_reading=DomoticzReading( + import_kwh=100.0, + export_kwh=10.0, + import_watts=500.0, + export_watts=0.0, + last_update="2026-05-26 09:00:00", + ), + domoticz_url="http://dz-insecure.hs.mfis.net", + domoticz_grid_idx=20, + domoticz_phase_usage_watts=(111.0, 222.0, 333.0), + ) + + snapshot = build_replay_snapshot(bundle) + lines = build_replay_preview_lines(bundle, snapshot=snapshot) + + assert lines == [ + "ABB source: 1,234.50 W", + "DZ Usage to Maxem: 500 W", + "DZ Phase Watts to Maxem: L1=111 W, L2=222 W, L3=333 W", + ] + + +def test_register_preview_uses_mirror_mode_for_victron_slave() -> None: + capture = RegisterCapture( + target_slave=2, + source_slave=2, + register_name=INSTANTANEOUS_VALUES_REGISTER_NAME, + address=INSTANTANEOUS_VALUES_REGISTER_ADDRESS, + address_length=INSTANTANEOUS_VALUES_REGISTER_LENGTH, + source_values=(0, 1, 2, 3), + ) + + lines = format_instantaneous_preview_lines(capture, snapshot=None) + + assert lines == [] + + +def test_inspect_tool_highlights_only_total_power_words_changed(tmp_path) -> None: + bundle = build_dump_bundle( + _sample_captures(source_watts=3470.91), + modbus_tcp_gateway="192.168.1.140", + modbus_tcp_port=8899, + request_source_slaves=[100], + request_register_names=[INSTANTANEOUS_VALUES_REGISTER_NAME, "settings"], + domoticz_reading=DomoticzReading( + import_kwh=100.0, + export_kwh=10.0, + import_watts=87.0, + export_watts=0.0, + last_update="2026-05-26 09:00:00", + ), + domoticz_url="http://dz-insecure.hs.mfis.net", + domoticz_grid_idx=20, + domoticz_phase_usage_watts=(1559.24, 1284.30, 1931.40), + ) + bundle_path = tmp_path / "maxem-bundle.json" + write_dump_bundle(bundle, output_path=str(bundle_path)) + + buffer = io.StringIO() + with redirect_stdout(buffer): + exit_code = inspect_instantaneous_main(["--bundle", str(bundle_path)]) + output = buffer.getvalue() + + assert exit_code == 0 + assert "ABB source: 3,470.91 W" in output + assert "DZ Usage to Maxem: 87 W" in output + assert "DZ Phase Watts to Maxem: L1=1,559.24 W, L2=1,284.30 W, L3=1,931.40 W" in output + assert "active_power_total: 3,470.91 W -> 87 W [changed]" in output + assert "active_power_l1:" in output + assert "active_power_l1: 0 W -> 1,559.24 W [changed]" in output + assert "changed_words: 0x5B14, 0x5B15, 0x5B16, 0x5B17, 0x5B18, 0x5B19, 0x5B1A, 0x5B1B" in output diff --git a/tools/dump_register_block.py b/tools/dump_register_block.py new file mode 100644 index 0000000..0406df4 --- /dev/null +++ b/tools/dump_register_block.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import logging +import os +import sys +from typing import Sequence +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.register_capture_tools import ( + build_dump_bundle, + capture_register_blocks, + write_dump_bundle, +) +from lib.maxem_home_usage import INSTANTANEOUS_VALUES_REGISTER_NAME +from lib.synthetic_home import DomoticzClient + + +def _get_env_setting(name: str, default: str | None = None) -> str | None: + value = os.environ.get(name) + if value not in (None, ""): + return value + return default + + +def _get_env_bool(name: str, default: str = "0") -> bool: + value = str(_get_env_setting(name, default)).strip().lower() + return value not in {"0", "false", "no", "off", ""} + + +def _get_env_optional_int(name: str, default: str | None = None) -> int | None: + value = _get_env_setting(name, default) + if value in (None, ""): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed <= 0: + return None + return parsed + + +def _parse_int_list(values: Sequence[str]) -> list[int]: + return [int(value, 0) for value in values] + + +def _default_register_names() -> list[str]: + return [INSTANTANEOUS_VALUES_REGISTER_NAME] + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Dump ABB register blocks and Domoticz Usage into a replay bundle") + parser.add_argument( + "--slave", + dest="source_slaves", + action="append", + default=None, + help="Modbus source slave to dump. Repeat to capture multiple slaves. Default: 100 and 2.", + ) + parser.add_argument( + "--register", + dest="register_names", + action="append", + default=None, + help="Register block to dump. Repeat to capture additional blocks. Default: instantaneous_values only.", + ) + parser.add_argument( + "--output", + dest="output_path", + default=None, + help="Write the capture bundle to this file instead of stdout.", + ) + parser.add_argument( + "--skip-domoticz", + action="store_true", + help="Do not fetch the Domoticz reading for the bundle.", + ) + parser.add_argument( + "--domoticz-url", + default=_get_env_setting("DOMOTICZ_URL", "http://dz-insecure.hs.mfis.net"), + help="Domoticz base URL. Defaults to DOMOTICZ_URL or the repository default.", + ) + parser.add_argument( + "--domoticz-grid-idx", + type=int, + default=int(_get_env_setting("DOMOTICZ_GRID_IDX", "20")), + help="Domoticz grid device index. Defaults to DOMOTICZ_GRID_IDX or 20.", + ) + parser.add_argument( + "--domoticz-phase-l1-idx", + type=int, + default=int(_get_env_setting("DOMOTICZ_PHASE_L1_IDX", "26")), + help="Domoticz phase L1 device index. Defaults to DOMOTICZ_PHASE_L1_IDX or 26.", + ) + parser.add_argument( + "--domoticz-phase-l2-idx", + type=int, + default=int(_get_env_setting("DOMOTICZ_PHASE_L2_IDX", "24")), + help="Domoticz phase L2 device index. Defaults to DOMOTICZ_PHASE_L2_IDX or 24.", + ) + parser.add_argument( + "--domoticz-phase-l3-idx", + type=int, + default=int(_get_env_setting("DOMOTICZ_PHASE_L3_IDX", "25")), + help="Domoticz phase L3 device index. Defaults to DOMOTICZ_PHASE_L3_IDX or 25.", + ) + parser.add_argument( + "--domoticz-phase-export-l1-idx", + type=int, + default=_get_env_optional_int("DOMOTICZ_PHASE_EXPORT_L1_IDX", "32"), + help="Domoticz phase L1 export device index. Defaults to DOMOTICZ_PHASE_EXPORT_L1_IDX or 32.", + ) + parser.add_argument( + "--domoticz-phase-export-l2-idx", + type=int, + default=_get_env_optional_int("DOMOTICZ_PHASE_EXPORT_L2_IDX", "31"), + help="Domoticz phase L2 export device index. Defaults to DOMOTICZ_PHASE_EXPORT_L2_IDX or 31.", + ) + parser.add_argument( + "--domoticz-phase-export-l3-idx", + type=int, + default=_get_env_optional_int("DOMOTICZ_PHASE_EXPORT_L3_IDX", "33"), + help="Domoticz phase L3 export device index. Defaults to DOMOTICZ_PHASE_EXPORT_L3_IDX or 33.", + ) + parser.add_argument( + "--domoticz-use-signed-net-power", + action=argparse.BooleanOptionalAction, + default=_get_env_bool("DOMOTICZ_USE_SIGNED_NET_POWER", "1"), + help=( + "Encode signed net power semantics for total power in the capture bundle (Usage-UsageDeliv). " + "Defaults to DOMOTICZ_USE_SIGNED_NET_POWER or true." + ), + ) + parser.add_argument( + "--domoticz-use-signed-net-phase-power", + action=argparse.BooleanOptionalAction, + default=_get_env_bool("DOMOTICZ_USE_SIGNED_NET_PHASE_POWER", "0"), + help=( + "Encode signed net semantics for phase watts in the capture bundle (per-phase import-export). " + "Defaults to DOMOTICZ_USE_SIGNED_NET_PHASE_POWER or false." + ), + ) + parser.add_argument( + "--domoticz-timeout", + type=float, + default=float(_get_env_setting("DOMOTICZ_TIMEOUT_SECONDS", "1.0")), + help="Domoticz HTTP timeout in seconds.", + ) + parser.add_argument( + "--modbus-gw-host", + default=_get_env_setting("MODBUS_TCP_GW_IP", "192.168.1.140"), + help="Modbus TCP gateway host.", + ) + parser.add_argument( + "--modbus-gw-port", + type=int, + default=int(_get_env_setting("MODBUS_TCP_GW_PORT", "8899")), + help="Modbus TCP gateway port.", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + logging.basicConfig( + format="%(asctime)s dump-registers: %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S", + ) + + source_slaves = _parse_int_list(args.source_slaves or ["100", "2"]) + register_names = args.register_names or _default_register_names() + + from modbus_tk import modbus_tcp + import modbus_tk.defines as cst + + tcp_master = modbus_tcp.TcpMaster( + host=args.modbus_gw_host, + port=args.modbus_gw_port, + timeout_in_sec=5.0, + ) + try: + captures = capture_register_blocks( + tcp_master=tcp_master, + source_slaves=source_slaves, + register_names=register_names, + read_holding_registers=cst.READ_HOLDING_REGISTERS, + ) + + domoticz_reading = None + domoticz_phase_usage_watts = None + domoticz_phase_import_watts = None + domoticz_phase_export_watts = None + domoticz_url = None + domoticz_grid_idx = None + if not args.skip_domoticz: + domoticz_client = DomoticzClient( + args.domoticz_url, + args.domoticz_grid_idx, + timeout_seconds=args.domoticz_timeout, + ) + requested_indices = [ + int(args.domoticz_grid_idx), + int(args.domoticz_phase_l1_idx), + int(args.domoticz_phase_l2_idx), + int(args.domoticz_phase_l3_idx), + ] + export_phase_idxs = ( + args.domoticz_phase_export_l1_idx, + args.domoticz_phase_export_l2_idx, + args.domoticz_phase_export_l3_idx, + ) + if args.domoticz_use_signed_net_phase_power and all( + value is not None and int(value) > 0 for value in export_phase_idxs + ): + requested_indices.extend(int(value) for value in export_phase_idxs if value is not None) + + devices = domoticz_client.fetch_devices(requested_indices) + domoticz_reading = domoticz_client.fetch_reading_from_device(devices[int(args.domoticz_grid_idx)]) + domoticz_phase_import_watts = ( + domoticz_client.data_watts_from_device(devices[int(args.domoticz_phase_l1_idx)]), + domoticz_client.data_watts_from_device(devices[int(args.domoticz_phase_l2_idx)]), + domoticz_client.data_watts_from_device(devices[int(args.domoticz_phase_l3_idx)]), + ) + domoticz_phase_usage_watts = domoticz_phase_import_watts + if args.domoticz_use_signed_net_phase_power: + if all(value is not None and int(value) > 0 for value in export_phase_idxs): + domoticz_phase_export_watts = ( + domoticz_client.data_watts_from_device(devices[int(export_phase_idxs[0])]), + domoticz_client.data_watts_from_device(devices[int(export_phase_idxs[1])]), + domoticz_client.data_watts_from_device(devices[int(export_phase_idxs[2])]), + ) + domoticz_phase_usage_watts = tuple( + float(domoticz_phase_import_watts[index]) - float(domoticz_phase_export_watts[index]) + for index in range(3) + ) + else: + logging.warning( + "DOMOTICZ_USE_SIGNED_NET_PHASE_POWER is enabled but one or more DOMOTICZ_PHASE_EXPORT_*_IDX values are missing; " + "phase values in this bundle remain unsigned import." + ) + domoticz_url = domoticz_client.url + domoticz_grid_idx = args.domoticz_grid_idx + + bundle = build_dump_bundle( + captures, + modbus_tcp_gateway=args.modbus_gw_host, + modbus_tcp_port=args.modbus_gw_port, + request_source_slaves=source_slaves, + request_register_names=register_names, + domoticz_reading=domoticz_reading, + domoticz_url=domoticz_url, + domoticz_grid_idx=domoticz_grid_idx, + domoticz_use_signed_net_power=bool(args.domoticz_use_signed_net_power), + domoticz_use_signed_net_phase_power=bool(args.domoticz_use_signed_net_phase_power), + domoticz_phase_import_watts=domoticz_phase_import_watts, + domoticz_phase_export_watts=domoticz_phase_export_watts, + domoticz_phase_usage_watts=domoticz_phase_usage_watts, + ) + write_dump_bundle(bundle, output_path=args.output_path) + finally: + tcp_master.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/inspect_instantaneous_payload.py b/tools/inspect_instantaneous_payload.py new file mode 100644 index 0000000..af2fa34 --- /dev/null +++ b/tools/inspect_instantaneous_payload.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Sequence + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.maxem_home_usage import ( + CerboMqttSnapshot, + INSTANTANEOUS_VALUES_REGISTER_NAME, + describe_instantaneous_preview_basis, + format_instantaneous_diff_lines, + format_instantaneous_preview_lines, + rewrite_instantaneous_values, +) +from lib.register_capture_tools import ( + build_replay_snapshot, + bundle_captures, + load_dump_bundle, +) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Inspect ABB instantaneous_values payload and show exactly what words/fields change after the " + "rewrite plan is applied." + ) + ) + parser.add_argument( + "--bundle", + required=True, + help="Path to capture bundle JSON produced by tools/dump_register_block.py.", + ) + parser.add_argument( + "--usage-watts", + type=float, + default=None, + help="Override rewrite total watts from the bundle snapshot.", + ) + parser.add_argument("--phase-l1-watts", type=float, default=None, help="Override phase L1 watts.") + parser.add_argument("--phase-l2-watts", type=float, default=None, help="Override phase L2 watts.") + parser.add_argument("--phase-l3-watts", type=float, default=None, help="Override phase L3 watts.") + parser.add_argument("--phase-l1-amps", type=float, default=None, help="Override phase L1 current amps.") + parser.add_argument("--phase-l2-amps", type=float, default=None, help="Override phase L2 current amps.") + parser.add_argument("--phase-l3-amps", type=float, default=None, help="Override phase L3 current amps.") + parser.add_argument("--neutral-amps", type=float, default=None, help="Override neutral current amps.") + return parser + + +def _find_instantaneous_capture(bundle) -> object: + captures = bundle_captures(bundle) + for capture in captures: + if capture.target_slave == 100 and capture.register_name == INSTANTANEOUS_VALUES_REGISTER_NAME: + return capture + raise ValueError("Bundle does not include target_slave=100 instantaneous_values capture") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + bundle = load_dump_bundle(args.bundle) + capture = _find_instantaneous_capture(bundle) + + replay_snapshot = build_replay_snapshot(bundle) + preview_snapshot = replay_snapshot + usage_watts = getattr(preview_snapshot, "rewrite_usage_watts", None) + phase_usage_watts = getattr(preview_snapshot, "phase_usage_watts", None) + phase_current_amps = getattr(preview_snapshot, "phase_current_amps", None) + current_n_amps = getattr(preview_snapshot, "current_n_amps", None) + allow_negative = bool(getattr(preview_snapshot, "use_signed_net_power", True)) + allow_negative_phase = bool(getattr(preview_snapshot, "use_signed_net_phase_power", True)) + + if args.usage_watts is not None: + usage_watts = args.usage_watts + + override_phase_values = [args.phase_l1_watts, args.phase_l2_watts, args.phase_l3_watts] + if any(value is not None for value in override_phase_values): + if any(value is None for value in override_phase_values): + raise ValueError("Provide all three phase overrides together: --phase-l1-watts --phase-l2-watts --phase-l3-watts") + phase_usage_watts = (float(args.phase_l1_watts), float(args.phase_l2_watts), float(args.phase_l3_watts)) + + override_phase_current_values = [args.phase_l1_amps, args.phase_l2_amps, args.phase_l3_amps] + if any(value is not None for value in override_phase_current_values): + if any(value is None for value in override_phase_current_values): + raise ValueError("Provide all three current overrides together: --phase-l1-amps --phase-l2-amps --phase-l3-amps") + phase_current_amps = (float(args.phase_l1_amps), float(args.phase_l2_amps), float(args.phase_l3_amps)) + + if args.neutral_amps is not None: + current_n_amps = float(args.neutral_amps) + + if usage_watts is None: + usage_watts = float(sum(phase_usage_watts)) if phase_usage_watts is not None else 0.0 + + if ( + args.usage_watts is not None + or any(value is not None for value in override_phase_values) + or any(value is not None for value in override_phase_current_values) + or args.neutral_amps is not None + ): + preview_snapshot = CerboMqttSnapshot( + sequence=int(getattr(replay_snapshot, "sequence", 0)) + 1, + ac_in_phase_watts=phase_usage_watts, + ac_in_total_watts=float(usage_watts), + ac_out_phase_currents=phase_current_amps, + ac_out_current_n=current_n_amps, + ) + allow_negative = True + allow_negative_phase = True + + rewritten_values = rewrite_instantaneous_values( + capture.source_values, + usage_watts=usage_watts, + phase_usage_watts=phase_usage_watts, + phase_current_amps=phase_current_amps, + current_n_amps=current_n_amps, + allow_negative=allow_negative, + allow_negative_phase=allow_negative_phase, + ) + + print(describe_instantaneous_preview_basis()) + for line in format_instantaneous_preview_lines(capture, snapshot=preview_snapshot): + print(line) + if args.usage_watts is not None: + print(f"Override Usage applied: {args.usage_watts:.2f} W") + if any(value is not None for value in override_phase_values): + print( + "Override Phase Watts applied: " + f"L1={args.phase_l1_watts:.2f} W, L2={args.phase_l2_watts:.2f} W, L3={args.phase_l3_watts:.2f} W" + ) + if any(value is not None for value in override_phase_current_values): + print( + "Override Phase Currents applied: " + f"L1={args.phase_l1_amps:.2f} A, L2={args.phase_l2_amps:.2f} A, L3={args.phase_l3_amps:.2f} A" + ) + if args.neutral_amps is not None: + print(f"Override Neutral Current applied: N={args.neutral_amps:.2f} A") + + print("---- instantaneous field diff ----") + for line in format_instantaneous_diff_lines(capture.source_values, rewritten_values): + print(line) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/replay_maxem_preview.py b/tools/replay_maxem_preview.py new file mode 100644 index 0000000..9d4eb2f --- /dev/null +++ b/tools/replay_maxem_preview.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path +from typing import Sequence + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.register_capture_tools import build_replay_preview_lines, build_replay_snapshot, load_dump_bundle +from lib.maxem_home_usage import describe_instantaneous_preview_basis + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Replay a captured instantaneous-power bundle as a dry-run preview") + parser.add_argument( + "--bundle", + required=True, + help="Path to the capture bundle JSON produced by tools/dump_register_block.py.", + ) + return parser + + +def _render_bundle(bundle_path: str) -> list[str]: + bundle = load_dump_bundle(bundle_path) + snapshot = build_replay_snapshot(bundle) + return build_replay_preview_lines(bundle, snapshot=snapshot) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + logging.basicConfig( + format="%(asctime)s replay-maxem-preview: %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S", + ) + + lines = _render_bundle(args.bundle) + + if lines: + print(describe_instantaneous_preview_basis()) + + for line in lines: + print(line) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())