|
| 1 | +"""Optional PV3300 BMS reads: bounded, runtime-scoped and never carried forward.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import asyncio |
| 6 | +from dataclasses import dataclass |
| 7 | +from typing import Callable |
| 8 | + |
| 9 | +from ..payload.modbus import ModbusError |
| 10 | +from ..payload.register_decode import decode_block |
| 11 | + |
| 12 | +READ_TIMEOUT = 3.0 |
| 13 | +RETRY_DELAY = 60.0 |
| 14 | +UNSUPPORTED_RETRY_DELAY = 300.0 |
| 15 | +STATE_KEY = "must_bms_read" |
| 16 | + |
| 17 | + |
| 18 | +@dataclass |
| 19 | +class BmsReadState: |
| 20 | + transport: object |
| 21 | + inverter: object |
| 22 | + last_clock: float |
| 23 | + retry_at: float = 0.0 |
| 24 | + status: str = "not_checked" |
| 25 | + |
| 26 | + |
| 27 | +def decode_bms(words: list[int], schema) -> dict[str, object]: |
| 28 | + """Decode the vendor's 109..113 block without inventing a battery state.""" |
| 29 | + if len(words) != 5 or any(type(word) is not int or not 0 <= word <= 65535 for word in words): |
| 30 | + raise ModbusError("invalid_bms_block") |
| 31 | + # The vendor decoder ignores the reserved word at 112 in its no-data test. |
| 32 | + if all(words[index] == 0 for index in (0, 1, 2, 4)): |
| 33 | + return {} |
| 34 | + values = decode_block(109, words, schema.spec_set("bms"), all_ones_unavailable=True) |
| 35 | + values = {key: value for key, value in values.items() if value is not None} |
| 36 | + # SOC is signed in protocol 6422/1916. Never clamp invalid/FFFF to 0/100. |
| 37 | + if not 0 <= values["battery_soc"] <= 100: |
| 38 | + values.pop("battery_soc") |
| 39 | + # An entirely unpopulated all-ones block is not a valid -0.1 A sample. |
| 40 | + if all(words[index] == 65535 for index in (0, 1, 2, 4)): |
| 41 | + return {} |
| 42 | + return values |
| 43 | + |
| 44 | + |
| 45 | +async def async_read_bms(session, transport, inverter, schema, runtime_state: dict, clock: Callable[[], float]): |
| 46 | + """Read once after core telemetry; failure removes only optional values. |
| 47 | +
|
| 48 | + Successful samples are not cached: MUST returns FULL snapshots, so a failed |
| 49 | + read or negative-cache hit cannot label an old SOC as fresh. Negative facts |
| 50 | + are temporary and scoped to this runtime binding, never the shared driver. |
| 51 | + """ |
| 52 | + now = clock() |
| 53 | + state = runtime_state.get(STATE_KEY) |
| 54 | + if ( |
| 55 | + type(state) is not BmsReadState |
| 56 | + or state.transport is not transport |
| 57 | + or state.inverter is not inverter |
| 58 | + or now < state.last_clock |
| 59 | + ): |
| 60 | + state = BmsReadState(transport, inverter, now) |
| 61 | + runtime_state[STATE_KEY] = state |
| 62 | + values = {} |
| 63 | + if now >= state.retry_at: |
| 64 | + delay = RETRY_DELAY |
| 65 | + try: |
| 66 | + async with asyncio.timeout(READ_TIMEOUT): |
| 67 | + words = await session.read_holding(109, 5) |
| 68 | + except ConnectionError: |
| 69 | + # A positive link failure still belongs to normal runtime recovery. |
| 70 | + raise |
| 71 | + except Exception as exc: |
| 72 | + if getattr(transport, "connected", True) is False: |
| 73 | + raise ConnectionError("must_bms_connection_lost") from exc |
| 74 | + if isinstance(exc, ModbusError) and str(exc) in {"exception_code:1", "exception_code:2"}: |
| 75 | + state.status = "unsupported" |
| 76 | + delay = UNSUPPORTED_RETRY_DELAY |
| 77 | + else: |
| 78 | + state.status = "timeout" if isinstance(exc, TimeoutError) else "invalid_response" |
| 79 | + else: |
| 80 | + try: |
| 81 | + values = decode_bms(words, schema) |
| 82 | + except ModbusError: |
| 83 | + state.status = "invalid_response" |
| 84 | + else: |
| 85 | + state.status = "ok" if "battery_soc" in values else "invalid_soc" if values else "no_data" |
| 86 | + if values: |
| 87 | + delay = 0.0 |
| 88 | + state.retry_at = clock() + delay |
| 89 | + state.last_clock = clock() |
| 90 | + return values, { |
| 91 | + "must_bms_status": state.status, |
| 92 | + "must_bms_retry_after_seconds": round(max(0, state.retry_at - state.last_clock), 3), |
| 93 | + } |
0 commit comments