From 854e5064ec7cedb54f1074c8f77f89af849e8387 Mon Sep 17 00:00:00 2001 From: fboundy Date: Fri, 3 Jul 2026 14:10:34 +0100 Subject: [PATCH 1/3] Add TOU V2 cloud mapping, review fixes, docs and credits TOU V2 timed slots over SolisCloud: - Map the verified CID family 5916-5987 into the local 43707-43791 register model: slot enable bitfield, SOC/current/voltage companions, and HH:MM-HH:MM time slots composed from HA's hour/minute registers. - Lenient time parsing (single-digit fields, whitespace); empty/unset slots decode to 00:00-00:00 instead of silently never updating. Repo review fixes: - Modbus writes now execute directly and raise HomeAssistantError on failure (write queue removed; serialization is already guaranteed by the shared per-link lock). This makes dual-mode write fallback work, surfaces write errors to entities/services, and removes the leaked queue-consumer task per protocol switch. - Sensor staleness watchdog treats poll speeds as seconds, not minutes; cloud controller reports its poll interval in seconds accordingly. - Protocol switching is serialized with a lock. - Cloud write-verify tasks are tracked and cancelled on unload. Docs: - Credit Pho3niX90/solis_modbus (fork origin), mkuthan/ solis-cloud-control and hultenvp/solis-sensor (SolisCloud references). - New protocols page; README protocol matrix and cloud limits. - Add HACS brand icon; gitignore local working notes. Tests: 264 passed (12 new). Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + README.md | 62 +++++-- custom_components/solisconnect/brand/icon.png | Bin 0 -> 20730 bytes .../solisconnect/cloud/cloud_controller.py | 153 +++++++++++++++++- .../solisconnect/cloud/cloud_retrieval.py | 14 ++ .../solisconnect/data_retrieval.py | 5 +- .../solisconnect/inverter_hub.py | 26 +-- .../solisconnect/modbus_controller.py | 61 +++---- .../solisconnect/sensor_data/cloud_mapping.py | 137 ++++++++++++++-- .../solisconnect/sensors/solis_sensor.py | 7 +- docs/source/conf.py | 5 +- docs/source/index.rst | 1 + docs/source/protocols.md | 48 ++++++ docs/source/sensors.md | 2 + tests/test_cloud_integration.py | 18 ++- tests/test_cloud_mapping.py | 80 +++++++++ tests/test_cloud_writes.py | 83 +++++++++- tests/test_data_retrieval.py | 3 - tests/test_integration_setup.py | 1 - tests/test_inverter_hub.py | 37 +++++ tests/test_modbus_controller.py | 104 +++++++++--- tests/test_sensor_watchdog.py | 57 +++++++ 22 files changed, 784 insertions(+), 123 deletions(-) create mode 100644 custom_components/solisconnect/brand/icon.png create mode 100644 docs/source/protocols.md create mode 100644 tests/test_sensor_watchdog.py diff --git a/.gitignore b/.gitignore index 188328d..749c9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ dmypy.json # Live smoke-test output (contains real inverter data) scripts/soliscloud_detail_dump.json + +# Working handoff notes (contain internal server details and real inverter serial; repo is public) +WORKING_NOTES.md diff --git a/README.md b/README.md index ef42813..3f8aa07 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,22 @@ ## Description -SolisConnect for Home Assistant is a streamlined solution to connect your Solis inverter with Home Assistant. This integration was inspired by [fboundy's ha_solis_modbus](https://github.com/fboundy/ha_solis_modbus/tree/main). However, it enhances the native Modbus integration in Home Assistant by consolidating multiple register queries into single calls, eliminating unnecessary overhead. +SolisConnect is a Home Assistant custom integration for Solis and compatible inverters. It can poll and control an inverter over local Modbus, the SolisCloud API, or a combined Modbus + SolisCloud setup that keeps one shared Home Assistant device and entity set. + +The integration stores protocol data in the same register cache, so entities do not duplicate when you add cloud support. Local Modbus remains the broadest and fastest path; SolisCloud is useful when local access is unavailable or as a backup protocol. + +## Credits and Acknowledgements + +SolisConnect started as a fork of [Pho3niX90/solis_modbus](https://github.com/Pho3niX90/solis_modbus), which provides the local Modbus foundation this integration is built on. Many thanks to Pho3niX90 and the solis_modbus contributors. + +The SolisCloud API support was framed with reference to these projects: + +- [mkuthan/solis-cloud-control](https://github.com/mkuthan/solis-cloud-control) — Solis Cloud Controls: control CID behaviour, TOU V2 timed-slot handling, and `old_value`/`yuanzhi` write semantics. +- [hultenvp/solis-sensor](https://github.com/hultenvp/solis-sensor) — SolisCloud API access patterns and sensor data handling. + ## Documentation https://solisconnect.readthedocs.io/ -## Solis cloud -You will lose access, unless you use a waveshare device. You will still have the option to disable the modbus, when updates are required. -https://github.com/fboundy/solisconnect/discussions/154 - ## Installation [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=fboundy&repository=solisconnect&category=integration) @@ -30,7 +38,9 @@ To install SolisConnect, follow these steps: Whilst the solis inverters do provide total sensors for today, yesterday, month and year. I highly suggest to create a utility meter in HA, as a time difference between HA and Solis might have the values reset before midnight, causing issues in charts. ## Manual Installation -1. Copy the "solisconnect" folder into your "custom_components" folder +1. Copy `custom_components/solisconnect` into your Home Assistant `custom_components` folder. +2. Restart Home Assistant. +3. Add the integration from **Settings** -> **Devices & services**. ## Setup 1. Navigate to Settings -> Devices & Services @@ -40,26 +50,41 @@ Whilst the solis inverters do provide total sensors for today, yesterday, month ### Configuration **Connection Type**: -- **TCP (WiFi Dongle)**: Use for Data Logging Sticks (DLS) or WiFi dongles. Requires IP and Port (Default 502). -- **Serial (RS485)**: Use for direct USB-RS485 connection. Requires Serial Port path. +- **TCP (WiFi Dongle)**: Use for local Modbus over a data logging stick, WiFi dongle, or Ethernet bridge. Requires host/IP and port. Default port is `502`. +- **Serial (RS485)**: Use for direct local Modbus over USB-RS485. Requires serial port path and serial settings. - **SolisCloud API**: Uses SolisCloud API key credentials. Polling is slower than local Modbus, but works when local Modbus is unavailable. - **Modbus + SolisCloud**: Creates one shared device/entity set with either automatic failover or a manual active-protocol selector. +Required fields depend on the protocol: +- Modbus requires inverter model, inverter serial number, slave address, and TCP or serial connection details. +- SolisCloud requires SolisCloud key ID, key secret, plant ID, and either a serial number or enough account metadata for the integration to resolve the inverter from the plant list. +- Dual protocol setup collects Modbus details first, then SolisCloud details, then asks whether failover or manual protocol selection should be used. + ### Protocol Support | Mode | Sensor polling | Control writes | Notes | | --- | --- | --- | --- | | TCP/Serial Modbus | Full local register set | Full mapped register writes | Fastest and preferred where local access is reliable. | -| SolisCloud API | Mapped cloud detail fields and verified control CIDs | Verified CIDs only: storage mode, backup SOC, over-discharge SOC, force-charge SOC, feed-in limit | Cloud data updates every few minutes and unmapped cloud-only sensors are marked unavailable. | -| Modbus + SolisCloud failover | One protocol polls at a time; automatic switch on health loss | Active protocol first, then healthy backup where possible | Primary can be Modbus or cloud. | -| Modbus + SolisCloud manual | One protocol polls at a time, selected by the Active Protocol entity | Active protocol first, then healthy backup where possible | Useful when maintenance or network conditions make one transport preferable. | +| SolisCloud API | Mapped cloud detail fields and verified control CIDs | Verified CIDs only: storage mode, backup SOC, over-discharge SOC, force-charge SOC, feed-in limit, TOU V2 timed slots | Cloud data updates every few minutes. Unmapped cloud-only sensors are marked unavailable instead of showing stale values. | +| Modbus + SolisCloud failover | One protocol polls at a time; automatic switch on health loss | Active protocol first, then healthy backup where possible | Primary can be Modbus or cloud. The standby protocol is idle until needed. | +| Modbus + SolisCloud manual | One protocol polls at a time, selected by the Active Protocol entity | Active protocol first, then healthy backup where possible | Useful for maintenance windows or unreliable network conditions. | Cloud setup derives the closest supported inverter model from SolisCloud metadata where possible. If SolisCloud identifies the model, that value is used to avoid choosing a sensor map that does not match the inverter. +Home Assistant only allows one `iot_class` value in the manifest. SolisConnect is marked `local_polling` because local Modbus is still the primary integration class, even though SolisCloud-only and dual-protocol entries can use a cloud API. + +### SolisCloud limits +- SolisCloud uses the same entity definitions as Modbus by mapping cloud fields and CIDs into the shared register cache. +- Cloud-only mode currently covers a limited set of inverter detail fields and verified control CIDs for storage mode (`636`), backup/reserved SOC (`157`), over-discharge SOC (`158`), force-charge SOC (`160`), feed-in limit (`696`), and TOU V2 timed slots (`5916-5987`). +- TOU V2 timed-slot CIDs map into the local `43707-43791` register model, including the `43707` slot-enable bitfield and cloud time strings such as `HH:MM-HH:MM`. Automatic gating from CID `6798` (`0xAA55`) is still pending, so unsupported inverters may reject those cloud controls. +- Cloud writes read the old CID value, send the control request, update Home Assistant optimistically, then verify after the cloud state catches up. If verification does not match, the integration restores device truth in the cache. +- The SolisCloud API is rate-limited by the integration. Avoid running another cloud integration against the same key if you are seeing slow or failed cloud updates. +- If older debug logs captured SolisCloud credentials before redaction was added, rotate the SolisCloud key secret and clear old logs. + **Inverter Serial**: (Required) - Enter your inverter's serial number. This is now **mandatory** for generating unique entity IDs and ensuring configuration stability. **Poll Interval**: -- Customize how frequently sensors update. Faster polling provides more real-time data but increases Modbus load. +- Customize how frequently sensors update. Faster local polling provides more real-time data but increases Modbus load. SolisCloud polling should stay slower because cloud updates and control verification lag behind local reads. ### Version 4.0+ Migration (Important) As of version 4.0+, the integration uses the **Inverter Serial Number** to generate unique IDs for all entities. @@ -72,7 +97,7 @@ As of version 4.0+, the integration uses the **Inverter Serial Number** to gener **Deprecated Settings**: - **Identification**: This field has been removed from the setup form. If you previously used it, the integration will still read it internally to migrate your old entities, but it is no longer user-configurable. -`Connection Type`: S2 is the default option, only select waveshare if you are using a waveshare device, and some sensors are higher than normal, see here https://solisconnect.readthedocs.io/en/latest/sensors.html#waveshare +`Connection Type`: S2 is the default Modbus dongle option. Only select Waveshare if you are using a Waveshare device and some sensors are higher than normal. See https://solisconnect.readthedocs.io/en/latest/sensors.html#waveshare # [JK BMS](https://github.com/Pho3niX90/jk-bms-card) Get the card here : https://github.com/Pho3niX90/jk-bms-card @@ -291,6 +316,17 @@ Solis and equivalent Axitec, Zonneplan inverters - Waveshare ## Troubleshooting +### Logs and diagnostics +Use the Home Assistant UI diagnostics download when reporting SolisConnect setup/runtime issues. The diagnostics output redacts configuration secrets and includes protocol state, active protocol, and cloud mapping counts. + +On Home Assistant OS/Supervised systems, live logs are available with: + +```bash +ha core logs +``` + +If you also run the separate `hultenvp/solis-sensor` cloud integration, log lines from `custom_components.solis` belong to that integration, not SolisConnect. Running both integrations with the same SolisCloud key can increase cloud `atRead` traffic. + ### Restoring Sensor History If a sensor's entity ID changes (e.g., during migration) and you lose its history, you can manually restore it using Home Assistant's statistics tool: diff --git a/custom_components/solisconnect/brand/icon.png b/custom_components/solisconnect/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9de8eace66c627aaa6fcd3847d498c8aca06c3f1 GIT binary patch literal 20730 zcmd3thd-77`^S%c?9H*W2{}etQG{$xoX8#_WMpKII0#3!kWI!(Mnq(jEn8*F9@%?- z-#)*8;#ZFzN)O!U-0%CkUf1*Wx*~M69+MI=5*yB67i z54g7Sn(`1xMGWzU1s?eKEi08Lnh=N&4+Ii~f< z-OjO1SxYsh$evkQTY2`LOKjTYaLluQ_Po}Z>i^3x$~gf7mI{k4yXi&_TMsi6$&v&= z4l1f}tb0^TDxaWe3!3{+a@yE>+_%e`1YtXSx?*?IbyoL1lC)J6{!+|bpo8(y*d2Ux zM;n2}=i8=+F8$Qr-rj{>yxi=sifS*~llt6K+W3mlQx|A#rzg!>S#hQd`eT#uj>p}+ zJnXNa><{bt(ZSAnj#p_Q=>TLg6ZK}lwGh-$ZzVo{loL>SQYT8(gtmsvq|2tC zS1|TNHkF|!Lr-S&(D@z}?$#oC=Guwd2u!&*@;GU62cZ|R8g$;MsB>+fNR9fKgR z7`r%Keb1~Pe0(w0r#Lhd)Pd)wFzuI=!l?WjTfNkmh#lK@SqT((GS>EgmjTH$Pjlqd zGt{e9|MnxN|6xm3Uy$ypz5e%QCJ47WEXkkbVd~arq0|%vbuZ)=YKsTOC2=fcpyX1e z)N=3q@|m7@I)k}?+wo9KrQcFE=i+*-~qHnWJTwP1t9L!p2E;4_d zhe=eEt-_?MGgnxDWx>lV>K7`tqNyKE*Lir+Pj1No83}UM9{;k1wu4YiU@-Pg%ipee zLfu*kRd#xBg*ITf`=V7_c!wb>%JK4VHVno%QaL8Ce>BaQ+ zenuGOZ4VV(d(G3$y^sFy?(uu|Y@L0enP~&(w9fdBer&5Lx>bnu+KG=`d~8rvh50!v z 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 literal 0 HcmV?d00001 diff --git a/custom_components/solisconnect/cloud/cloud_controller.py b/custom_components/solisconnect/cloud/cloud_controller.py index e1a13e6..fb22d43 100644 --- a/custom_components/solisconnect/cloud/cloud_controller.py +++ b/custom_components/solisconnect/cloud/cloud_controller.py @@ -9,9 +9,12 @@ from custom_components.solisconnect.controller_base import SolisControllerBase from custom_components.solisconnect.data.enums import PollSpeed from custom_components.solisconnect.data.solis_config import InverterConfig -from custom_components.solisconnect.helpers import cache_save, notify_register_update +from custom_components.solisconnect.helpers import cache_get, cache_save, notify_register_update from custom_components.solisconnect.sensor_data.cloud_mapping import ( REGISTER_TO_CID, + TOU_SWITCH_CIDS_BY_BIT, + TOU_SWITCH_REGISTER, + TOU_TIME_PAIR_TO_CID, CloudCidMapping, cid_msg_from_words, encode_cid_value, @@ -61,6 +64,9 @@ def __init__( self._sensor_groups = sensor_groups or [] self._derived_sensors = derived_sensors or [] self.last_cloud_success: datetime | None = None + # Delayed write-verify tasks; cancelled on close_connection so they cannot + # touch the cache after the entry is unloaded. + self._verify_tasks: set[asyncio.Task] = set() @property def host(self) -> str: @@ -75,10 +81,8 @@ def connected(self) -> bool: @property def poll_speed(self) -> dict[PollSpeed, int]: - # Entities size their staleness watchdog as timedelta(minutes=poll_speed + 10), - # so report the poll interval in minutes. - minutes = max(1, self.poll_interval // 60) - return {PollSpeed.FAST: minutes, PollSpeed.NORMAL: minutes, PollSpeed.SLOW: minutes} + # Same semantics as ModbusController: poll intervals in seconds. + return {PollSpeed.FAST: self.poll_interval, PollSpeed.NORMAL: self.poll_interval, PollSpeed.SLOW: self.poll_interval} @property def last_modbus_success(self) -> datetime | None: @@ -92,17 +96,26 @@ def _mapping_for_write(self, registers: list[int]) -> CloudCidMapping: return mapping async def async_write_holding_register(self, register, value): + if int(register) == TOU_SWITCH_REGISTER: + await self._execute_tou_switch_write(int(value)) + return mapping = self._mapping_for_write([int(register)]) await self._execute_cloud_write(mapping, [int(value)]) async def async_write_holding_registers(self, start_register, values): registers = [int(start_register) + i for i in range(len(values))] + time_mapping = TOU_TIME_PAIR_TO_CID.get(tuple(registers)) + if time_mapping is not None: + await self._execute_tou_time_write(time_mapping, registers, [int(v) for v in values]) + return mapping = self._mapping_for_write(registers) await self._execute_cloud_write(mapping, [int(v) for v in values]) async def _execute_cloud_write(self, mapping: CloudCidMapping, words: list[int]): """Write a control CID: read old value -> control -> optimistic cache -> delayed verify.""" msg = cid_msg_from_words(mapping, words) + if msg is None: + raise HomeAssistantError(f"Register(s) {mapping.registers} cannot be encoded for SolisCloud cid {mapping.cid}") try: old_value = await self.api.async_at_read(self.serial_number, mapping.cid) except SolisCloudApiError as error: @@ -121,7 +134,81 @@ async def _execute_cloud_write(self, mapping: CloudCidMapping, words: list[int]) cache_save(self.hass, self, register, word) notify_register_update(self.hass, self, register, word) - self.hass.async_create_task(self._verify_write(mapping, words)) + self._start_verify_task(self._verify_write(mapping, words)) + + async def _execute_tou_time_write(self, mapping: CloudCidMapping, write_registers: list[int], write_words: list[int]): + """Write one start/end HA time pair as the cloud API's full HH:MM-HH:MM slot string.""" + current_words = await self._current_words_for_mapping(mapping) + if current_words is None: + raise HomeAssistantError(f"SolisCloud write of cid {mapping.cid} failed: current time slot value is unavailable") + + index_by_register = {register: index for index, register in enumerate(mapping.registers)} + for register, word in zip(write_registers, write_words): + current_words[index_by_register[register]] = word + + await self._execute_cloud_write(mapping, current_words) + + async def _current_words_for_mapping(self, mapping: CloudCidMapping) -> list[int] | None: + cached = [cache_get(self.hass, self, register) for register in mapping.registers] + if all(value is not None for value in cached): + return [int(value) for value in cached] + + try: + msg = await self.api.async_at_read(self.serial_number, mapping.cid) + except SolisCloudApiError as error: + _LOGGER.debug("Read of cid %s for composed write failed: %s", mapping.cid, error) + return None + decoded = encode_cid_value(mapping, msg) + if decoded is None: + return None + return [decoded[register] for register in mapping.registers] + + async def _execute_tou_switch_write(self, new_value: int): + """Write register 43707 by sending changed TOU V2 switch CIDs with old bitfield state.""" + new_value &= 0x0FFF + old_value = cache_get(self.hass, self, TOU_SWITCH_REGISTER) + if old_value is not None: + old_value = int(old_value) + else: + try: + old_value = await self._read_tou_switch_bitfield() + except SolisCloudApiError as error: + raise HomeAssistantError(f"SolisCloud write of TOU switch register {TOU_SWITCH_REGISTER} failed: {error}") from error + + changes = [bit for bit in sorted(TOU_SWITCH_CIDS_BY_BIT) if ((old_value >> bit) & 1) != ((new_value >> bit) & 1)] + if not changes: + cache_save(self.hass, self, TOU_SWITCH_REGISTER, new_value) + notify_register_update(self.hass, self, TOU_SWITCH_REGISTER, new_value) + return + + running_old_value = old_value + try: + for bit in changes: + cid = TOU_SWITCH_CIDS_BY_BIT[bit] + desired = "1" if ((new_value >> bit) & 1) else "0" + await self.api.async_control(self.serial_number, cid, desired, old_value=str(running_old_value)) + if desired == "1": + running_old_value |= 1 << bit + else: + running_old_value &= ~(1 << bit) + except SolisCloudApiError as error: + raise HomeAssistantError(f"SolisCloud write of TOU switch register {TOU_SWITCH_REGISTER} failed: {error}") from error + + _LOGGER.info("SolisCloud write: TOU switch register %s = %s (was %s)", TOU_SWITCH_REGISTER, new_value, old_value) + cache_save(self.hass, self, TOU_SWITCH_REGISTER, new_value) + notify_register_update(self.hass, self, TOU_SWITCH_REGISTER, new_value) + self._start_verify_task(self._verify_tou_switch_write(new_value)) + + async def _read_tou_switch_bitfield(self) -> int: + batch = await self.api.async_at_read_batch(self.serial_number, list(TOU_SWITCH_CIDS_BY_BIT.values())) + value = 0 + for bit, cid in TOU_SWITCH_CIDS_BY_BIT.items(): + try: + if int(str(batch.get(cid, "0")).strip()): + value |= 1 << bit + except ValueError: + continue + return value async def _verify_write(self, mapping: CloudCidMapping, expected_words: list[int]): """Re-read the CID after the cloud catches up; never leave an optimistic lie in the cache.""" @@ -150,5 +237,57 @@ async def _verify_write(self, mapping: CloudCidMapping, expected_words: list[int cache_save(self.hass, self, register, word) notify_register_update(self.hass, self, register, word) + async def _verify_tou_switch_write(self, expected_value: int): + """Verify the composed TOU switch bitfield by reading the twelve switch CIDs.""" + actual_value = None + for _attempt in range(2): + await asyncio.sleep(_VERIFY_DELAY_SECONDS) + try: + actual_value = await self._read_tou_switch_bitfield() + except SolisCloudApiError as error: + _LOGGER.warning("Verify read of TOU switch CIDs failed: %s", error) + continue + if actual_value == expected_value: + _LOGGER.debug("SolisCloud write of TOU switch register %s verified", TOU_SWITCH_REGISTER) + return + + if actual_value is not None and actual_value != expected_value: + _LOGGER.warning( + "SolisCloud write of TOU switch register %s did not stick: wrote %s, device reports %s — restoring device value", + TOU_SWITCH_REGISTER, + expected_value, + actual_value, + ) + cache_save(self.hass, self, TOU_SWITCH_REGISTER, actual_value) + notify_register_update(self.hass, self, TOU_SWITCH_REGISTER, actual_value) + + async def async_read_holding_register(self, register, count=1): + """Read from the cloud-backed cache, with a live TOU switch read for cold-cache bit toggles.""" + register = int(register) + count = int(count) + if register == TOU_SWITCH_REGISTER and count == 1: + try: + value = await self._read_tou_switch_bitfield() + except SolisCloudApiError as error: + _LOGGER.warning("Read of TOU switch CIDs failed: %s", error) + return None + cache_save(self.hass, self, register, value) + notify_register_update(self.hass, self, register, value) + return [value] + values = [cache_get(self.hass, self, register + offset) for offset in range(count)] + if any(value is None for value in values): + return None + return [int(value) for value in values] + + def _start_verify_task(self, coro): + """Run a delayed verify as a tracked task so close_connection can cancel it.""" + task = self.hass.async_create_task(coro) + self._verify_tasks.add(task) + task.add_done_callback(self._verify_tasks.discard) + return task + def close_connection(self): - """Nothing to close: the aiohttp session is Home Assistant's shared session.""" + """Cancel outstanding verify tasks; the aiohttp session is Home Assistant's shared session.""" + for task in list(self._verify_tasks): + task.cancel() + self._verify_tasks.clear() diff --git a/custom_components/solisconnect/cloud/cloud_retrieval.py b/custom_components/solisconnect/cloud/cloud_retrieval.py index 9e4028c..afcb524 100644 --- a/custom_components/solisconnect/cloud/cloud_retrieval.py +++ b/custom_components/solisconnect/cloud/cloud_retrieval.py @@ -8,6 +8,7 @@ from custom_components.solisconnect.cloud.api_client import SolisCloudApiError from custom_components.solisconnect.cloud.cloud_controller import SolisCloudController from custom_components.solisconnect.helpers import ( + cache_get, cache_save, mark_platform_entities_unavailable_for_base_sensors, notify_register_update, @@ -101,6 +102,8 @@ async def _update_cycle(self, now=None): continue # the API returns extra related CIDs beyond those requested words = encode_cid_value(mapping, msg) if words: + if mapping.merge_register_bit is not None: + words = self._merge_bit_words(words, mapping.merge_register_bit) self._publish_words(words) cid_words += len(words) @@ -137,6 +140,17 @@ def _publish_words(self, words: dict[int, int]): cache_save(self.hass, self.controller, register, word) notify_register_update(self.hass, self.controller, register, word) + def _merge_bit_words(self, words: dict[int, int], bit_position: int) -> dict[int, int]: + """Merge one CID switch value into the shared TOU V2 bitfield register.""" + register, bit_value = next(iter(words.items())) + current = cache_get(self.hass, self.controller, register) + merged = int(current) if current is not None else 0 + if int(bit_value): + merged |= 1 << bit_position + else: + merged &= ~(1 << bit_position) + return {register: merged} + def _publish_status(self): """Pseudo-registers used by diagnostic derived sensors (same as DataRetrieval).""" notify_register_update(self.hass, self.controller, 90005, self.controller.enabled) diff --git a/custom_components/solisconnect/data_retrieval.py b/custom_components/solisconnect/data_retrieval.py index 1e1d297..a538f9b 100644 --- a/custom_components/solisconnect/data_retrieval.py +++ b/custom_components/solisconnect/data_retrieval.py @@ -243,8 +243,7 @@ async def poll_controller(self, event=None): """Poll the Modbus controller for data, retrying until success. This method sets up periodic polling of the Modbus controller at different - intervals based on the poll speed configuration. It also starts the write - queue processing. + intervals based on the poll speed configuration. Args: event (Event, optional): The Home Assistant started event. Defaults to None. @@ -279,8 +278,6 @@ async def poll_controller(self, event=None): ) ) - self.hass.create_task(self.controller.process_write_queue()) - async def modbus_update_all(self): """Updates all sensor groups regardless of their poll speed. diff --git a/custom_components/solisconnect/inverter_hub.py b/custom_components/solisconnect/inverter_hub.py index eb6d5da..bbdda13 100644 --- a/custom_components/solisconnect/inverter_hub.py +++ b/custom_components/solisconnect/inverter_hub.py @@ -1,3 +1,4 @@ +import asyncio import logging from datetime import UTC, datetime, timedelta @@ -58,6 +59,9 @@ def __init__(self, hass, entry_id: str, inverter_config: InverterConfig, serial_ self._unsub_health = None self._last_switch = datetime.min.replace(tzinfo=UTC) self._primary_healthy_since: datetime | None = None + # Serializes protocol switches: _health_tick, the protocol select entity, and its + # restore path can all call async_set_active concurrently. + self._switch_lock = asyncio.Lock() # --- lifecycle ----------------------------------------------------------------- @@ -93,20 +97,22 @@ def _start_retrieval(self, protocol: str): async def async_set_active(self, protocol: str, reason: str): """Switch the polling/writing protocol. No-op if it is already active.""" - if protocol == self._active_name: - return if self._controller_for(protocol) is None: raise HomeAssistantError(f"Protocol {protocol} is not configured for this entry") - _LOGGER.warning("(%s) Switching active protocol %s -> %s (%s)", self.cache_scope, self._active_name, protocol, reason) - if self._retrieval is not None: - await self._retrieval.async_stop() - self._retrieval = None + async with self._switch_lock: + if protocol == self._active_name: + return + + _LOGGER.warning("(%s) Switching active protocol %s -> %s (%s)", self.cache_scope, self._active_name, protocol, reason) + if self._retrieval is not None: + await self._retrieval.async_stop() + self._retrieval = None - self._active_name = protocol - self._last_switch = datetime.now(UTC) - self._primary_healthy_since = None - self._start_retrieval(protocol) + self._active_name = protocol + self._last_switch = datetime.now(UTC) + self._primary_healthy_since = None + self._start_retrieval(protocol) notify_register_update(self.hass, self, ACTIVE_PROTOCOL_REGISTER, protocol) # --- failover ------------------------------------------------------------------ diff --git a/custom_components/solisconnect/modbus_controller.py b/custom_components/solisconnect/modbus_controller.py index 1b4d63c..fb7c5c2 100644 --- a/custom_components/solisconnect/modbus_controller.py +++ b/custom_components/solisconnect/modbus_controller.py @@ -1,7 +1,7 @@ -import asyncio import logging from datetime import UTC, datetime +from homeassistant.exceptions import HomeAssistantError from pymodbus.client import AsyncModbusSerialClient, AsyncModbusTcpClient from custom_components.solisconnect.client_manager import ModbusClientManager @@ -133,39 +133,8 @@ def __init__( self._derived_sensors = derived_sensors # self.poll_lock = asyncio.Lock() # Replaced by shared lock from manager - # Modbus Write Queue - self.write_queue = asyncio.Queue() self._last_modbus_success = datetime.now(UTC) - async def process_write_queue(self): - """Process queued Modbus write requests sequentially. - - This method runs in an infinite loop, processing write requests from the queue. - It ensures that write operations are executed one at a time, with appropriate - delays between operations to avoid overwhelming the Modbus device. - - Returns: - None - """ - while True: - if not self.connected(): - await asyncio.sleep(5) - continue - - if self.write_queue.empty(): - await asyncio.sleep(0.2) - continue - - write_request = await self.write_queue.get() - register, value, multiple = write_request - - if multiple: - await self._execute_write_holding_registers(register, value) - else: - await self._execute_write_holding_register(register, value) - - self.write_queue.task_done() - async def _execute_write_holding_register(self, register, value): """Executes a single register write with interframe delay. @@ -263,28 +232,36 @@ async def _execute_write_holding_registers(self, start_register, values): return None async def async_write_holding_register(self, register, value): - """Queues a write request to the Modbus device (write single register). + """Write a single holding register. + + Serialized against reads and other writes by the shared per-link poll_lock and + inter-frame spacing inside the executor. Raises HomeAssistantError on failure so + callers (entities, services, the dual-protocol hub fallback) see the result. Args: register (int): The register address to write to. value (int): The value to write to the register. - - Returns: - None """ - await self.write_queue.put((register, value, False)) + result = await self._execute_write_holding_register(register, value) + if result is None: + raise HomeAssistantError(f"({self.host}.{self.device_id}) Modbus write of register {register} failed") async def async_write_holding_registers(self, start_register, values): - """Queues a write request to the Modbus device (write multiple registers). + """Write multiple consecutive holding registers. + + Serialized against reads and other writes by the shared per-link poll_lock and + inter-frame spacing inside the executor. Raises HomeAssistantError on failure so + callers (entities, services, the dual-protocol hub fallback) see the result. Args: start_register (int): The starting register address to write to. values (list): A list of values to write to consecutive registers. - - Returns: - None """ - await self.write_queue.put((start_register, values, True)) + result = await self._execute_write_holding_registers(start_register, values) + if result is None: + raise HomeAssistantError( + f"({self.host}.{self.device_id}) Modbus write of registers {start_register}-{int(start_register) + len(values) - 1} failed" + ) async def inter_frame_wait(self, is_write=False): """Spacing between Modbus frames on this link (shared across parallel inverters on the same host:port).""" diff --git a/custom_components/solisconnect/sensor_data/cloud_mapping.py b/custom_components/solisconnect/sensor_data/cloud_mapping.py index 431f558..ab4f526 100644 --- a/custom_components/solisconnect/sensor_data/cloud_mapping.py +++ b/custom_components/solisconnect/sensor_data/cloud_mapping.py @@ -24,6 +24,7 @@ """ import logging +import re from collections.abc import Callable from dataclasses import dataclass, field @@ -96,6 +97,9 @@ class CloudCidMapping: cid: int multiplier: float = 1.0 data_type: DataType = DataType.U16 + to_registers: Callable[[str], dict[int, int] | None] | None = field(default=None, compare=False) + to_cid_value: Callable[[list[int]], str | None] | None = field(default=None, compare=False) + merge_register_bit: int | None = None def _words_for_raw(raw: int, registers: tuple[int, ...], data_type: DataType) -> dict[int, int] | None: @@ -129,6 +133,8 @@ def encode_engineering_value(mapping: CloudFieldMapping, value: float, cloud_uni def encode_cid_value(mapping: CloudCidMapping, msg: str) -> dict[int, int] | None: """Convert an atRead 'msg' value into raw holding-register words.""" + if mapping.to_registers is not None: + return mapping.to_registers(msg) try: value = float(msg) except (TypeError, ValueError): @@ -220,24 +226,131 @@ def _encode_battery_power(value: float, detail: dict) -> dict[int, int]: ]} # fmt: on +# Lenient on shape (single-digit fields, spaces around the dash); ranges checked after parsing. +_TIME_SLOT_RE = re.compile(r"^(\d{1,2}):(\d{1,2})\s*-\s*(\d{1,2}):(\d{1,2})$") + + +def _decode_time_slot(registers: tuple[int, int, int, int]) -> Callable[[str], dict[int, int] | None]: + def decode(msg: str) -> dict[int, int] | None: + text = str(msg if msg is not None else "").strip() + if text in ("", "0"): + # Unset/disabled slot: publish 00:00-00:00 so entities update instead of going stale + return dict.fromkeys(registers, 0) + match = _TIME_SLOT_RE.match(text) + if not match: + return None + start_hour, start_minute, end_hour, end_minute = (int(part) for part in match.groups()) + if start_hour > 23 or end_hour > 23 or start_minute > 59 or end_minute > 59: + return None + return dict(zip(registers, (start_hour, start_minute, end_hour, end_minute))) + + return decode + + +def _encode_time_slot(words: list[int]) -> str | None: + if len(words) != 4: + return None + start_hour, start_minute, end_hour, end_minute = [int(word) for word in words] + if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23 and 0 <= start_minute <= 59 and 0 <= end_minute <= 59): + return None + return f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}" + + +def _decode_slot_switch(register: int) -> Callable[[str], dict[int, int] | None]: + def decode(msg: str) -> dict[int, int] | None: + try: + return {register: 1 if int(str(msg).strip()) else 0} + except (TypeError, ValueError): + return None + + return decode + + +def _tou_time_mapping(registers: tuple[int, int, int, int], cid: int) -> CloudCidMapping: + return CloudCidMapping( + registers=registers, + cid=cid, + to_registers=_decode_time_slot(registers), + to_cid_value=_encode_time_slot, + ) + + +def _tou_switch_mapping(cid: int, bit: int) -> CloudCidMapping: + return CloudCidMapping( + registers=(43707,), + cid=cid, + to_registers=_decode_slot_switch(43707), + to_cid_value=lambda words: str((int(words[0]) >> bit) & 1) if len(words) == 1 else None, + merge_register_bit=bit, + ) + + +def _tou_v2_mappings() -> list[CloudCidMapping]: + mappings: list[CloudCidMapping] = [] + + # Time-of-Use V2 slot enable switches: charge slots occupy bits 0-5, discharge slots bits 6-11. + for bit, cid in enumerate([5916, 5917, 5918, 5919, 5920, 5921, 5922, 5923, 5924, 5925, 5926, 5927]): + mappings.append(_tou_switch_mapping(cid, bit)) + + charge_time_cids = [5946, 5949, 5952, 5955, 5958, 5961] + discharge_time_cids = [5964, 5968, 5972, 5976, 5980, 5987] + charge_soc_cids = [5928, 5929, 5930, 5931, 5932, 5933] + discharge_soc_cids = [5965, 5969, 5973, 5977, 5981, 5984] + charge_voltage_cids = [5947, 5950, 5953, 5956, 5959, 5962] + discharge_voltage_cids = [5966, 5970, 5974, 5978, 5982, 5985] + charge_current_cids = [5948, 5951, 5954, 5957, 5960, 5963] + discharge_current_cids = [5967, 5971, 5975, 5979, 5983, 5986] + + for slot in range(6): + charge_base = 43708 + slot * 7 + discharge_base = 43750 + slot * 7 + mappings.extend( + [ + CloudCidMapping(registers=(charge_base,), cid=charge_soc_cids[slot]), + CloudCidMapping(registers=(charge_base + 1,), cid=charge_current_cids[slot], multiplier=0.1), + CloudCidMapping(registers=(charge_base + 2,), cid=charge_voltage_cids[slot], multiplier=0.1), + _tou_time_mapping((charge_base + 3, charge_base + 4, charge_base + 5, charge_base + 6), charge_time_cids[slot]), + CloudCidMapping(registers=(discharge_base,), cid=discharge_soc_cids[slot]), + CloudCidMapping(registers=(discharge_base + 1,), cid=discharge_current_cids[slot], multiplier=0.1), + CloudCidMapping(registers=(discharge_base + 2,), cid=discharge_voltage_cids[slot], multiplier=0.1), + _tou_time_mapping( + (discharge_base + 3, discharge_base + 4, discharge_base + 5, discharge_base + 6), + discharge_time_cids[slot], + ), + ] + ) + + return mappings + + # --- control CID <-> holding registers ------------------------------------------------------- -CLOUD_CID_MAP: dict[int, CloudCidMapping] = { - m.cid: m - for m in [ - CloudCidMapping(registers=(43110,), cid=636), # storage mode bitfield (== Modbus 43110) - CloudCidMapping(registers=(43024,), cid=157), # reserved / backup SOC % - CloudCidMapping(registers=(43011,), cid=158), # over-discharge SOC % - CloudCidMapping(registers=(43018,), cid=160), # force-charge SOC % - CloudCidMapping(registers=(43074,), cid=696, multiplier=100), # feed-in power limit (raw = W/100, cid msg = W) - ] -} +_CLOUD_CID_MAPPINGS = [ + CloudCidMapping(registers=(43110,), cid=636), # storage mode bitfield (== Modbus 43110) + CloudCidMapping(registers=(43024,), cid=157), # reserved / backup SOC % + CloudCidMapping(registers=(43011,), cid=158), # over-discharge SOC % + CloudCidMapping(registers=(43018,), cid=160), # force-charge SOC % + CloudCidMapping(registers=(43074,), cid=696, multiplier=100), # feed-in power limit (raw = W/100, cid msg = W) +] + _tou_v2_mappings() + +CLOUD_CID_MAP: dict[int, CloudCidMapping] = {m.cid: m for m in _CLOUD_CID_MAPPINGS} # Write routing: which CID a holding-register write lands on REGISTER_TO_CID: dict[int, CloudCidMapping] = {register: mapping for mapping in CLOUD_CID_MAP.values() for register in mapping.registers} +TOU_SWITCH_REGISTER = 43707 +TOU_SWITCH_CIDS_BY_BIT: dict[int, int] = { + mapping.merge_register_bit: mapping.cid for mapping in CLOUD_CID_MAP.values() if mapping.merge_register_bit is not None +} +TOU_TIME_PAIR_TO_CID: dict[tuple[int, int], CloudCidMapping] = {} +for mapping in CLOUD_CID_MAP.values(): + if mapping.to_cid_value is _encode_time_slot and len(mapping.registers) == 4: + TOU_TIME_PAIR_TO_CID[(mapping.registers[0], mapping.registers[1])] = mapping + TOU_TIME_PAIR_TO_CID[(mapping.registers[2], mapping.registers[3])] = mapping -def cid_msg_from_words(mapping: CloudCidMapping, words: list[int]) -> str: +def cid_msg_from_words(mapping: CloudCidMapping, words: list[int]) -> str | None: """Convert raw register word(s) into the control-API value string (inverse of encode_cid_value).""" + if mapping.to_cid_value is not None: + return mapping.to_cid_value(words) if len(words) == 1: raw = words[0] if mapping.data_type == DataType.S16 and raw > 0x7FFF: @@ -246,6 +359,8 @@ def cid_msg_from_words(mapping: CloudCidMapping, words: list[int]) -> str: combined = (words[0] << 16) | (words[1] & 0xFFFF) raw = combined - 0x100000000 if combined & 0x80000000 else combined value = raw if mapping.multiplier in (0, 1) else raw * mapping.multiplier + if isinstance(value, float) and not value.is_integer(): + return f"{value:.10g}" return str(round(value)) diff --git a/custom_components/solisconnect/sensors/solis_sensor.py b/custom_components/solisconnect/sensors/solis_sensor.py index 299d1a6..9f962df 100644 --- a/custom_components/solisconnect/sensors/solis_sensor.py +++ b/custom_components/solisconnect/sensors/solis_sensor.py @@ -40,9 +40,10 @@ def __init__(self, hass: HomeAssistant, sensor: SolisBaseSensor): self._received_values = {} self.poll_speed = sensor.poll_speed - # Watchdog parameters + # Watchdog parameters: one poll interval (seconds) plus a fixed grace period self._last_update = datetime.now(UTC).astimezone() - self._update_timeout = timedelta(minutes=self.base_sensor.controller.poll_speed.get(sensor.poll_speed, 0) + _WATCHDOG_TIMEOUT_MIN) + poll_interval_s = self.base_sensor.controller.poll_speed.get(sensor.poll_speed, 0) + self._update_timeout = timedelta(seconds=poll_interval_s) + timedelta(minutes=_WATCHDOG_TIMEOUT_MIN) def decimal_count(self, number: float) -> int | None: """Returns the number of decimal places in a given number.""" @@ -124,7 +125,7 @@ async def async_update(self): """Fallback-Check: If no update for more than _WATCHDOG_TIMEOUT_MIN minutes, set values to 0 or unavailable""" now = datetime.now(UTC).astimezone() if (now - self._last_update > self._update_timeout) and self.poll_speed != PollSpeed.ONCE: - _LOGGER.debug(f"⚠️ No Modbus update for sensor {self._attr_name} in over {_WATCHDOG_TIMEOUT_MIN} minutes. Setting to 0.") + _LOGGER.debug(f"⚠️ No update for sensor {self._attr_name} in over {self._update_timeout}. Marking unavailable.") # self._attr_native_value = 0 self._attr_available = False # Set attribute unavailable (if desired) self.schedule_update_ha_state() diff --git a/docs/source/conf.py b/docs/source/conf.py index b1045cc..2a3142e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -24,7 +24,10 @@ ] templates_path = ["_templates"] -source_suffix = ".rst" +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} exclude_patterns = [] diff --git a/docs/source/index.rst b/docs/source/index.rst index 5545fa8..e18cf25 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,4 +15,5 @@ Table of Contents :maxdepth: 2 :caption: Advanced + protocols.md migration.md diff --git a/docs/source/protocols.md b/docs/source/protocols.md new file mode 100644 index 0000000..c0c6936 --- /dev/null +++ b/docs/source/protocols.md @@ -0,0 +1,48 @@ +# Protocols + +SolisConnect supports local Modbus, SolisCloud, and dual-protocol entries. All modes use the same Home Assistant entity definitions and the same internal register cache, so adding cloud support does not create a second copy of every entity. + +## Modes + +| Mode | Reads | Writes | Best use | +| --- | --- | --- | --- | +| Modbus over TCP or serial | Full local register set for the selected inverter model | Full mapped Modbus writes | Preferred when local access is reliable. | +| SolisCloud API | Mapped `inverterDetail` fields plus mapped control CIDs | Verified control CIDs only | Use when local Modbus is unavailable or as a slower remote path. | +| Modbus + SolisCloud failover | One active protocol polls; the backup remains idle until failover | Active protocol first, then healthy backup where possible | Use when Modbus should normally be primary but cloud can cover outages, or the reverse. | +| Modbus + SolisCloud manual | One active protocol polls; a select entity chooses Modbus or cloud | Active protocol first, then healthy backup where possible | Use for maintenance or when you want explicit control of the active transport. | + +In dual failover mode the integration monitors the active protocol, switches after sustained health loss, and can return to the configured primary after it has been healthy for a recovery period. In manual mode the `Active Protocol` select entity controls which protocol polls. + +## SolisCloud capability + +SolisCloud support is intentionally mapped into the same register/entity model as Modbus. Cloud-only mode marks entities unavailable when their registers are not covered by the current cloud mappings. + +Current cloud write support is limited to verified CIDs and CID families: + +| CID or range | Register(s) | Purpose | +| --- | --- | --- | +| `636` | `43110` | Storage mode bitfield | +| `157` | `43024` | Backup/reserved SOC | +| `158` | `43011` | Over-discharge SOC | +| `160` | `43018` | Force-charge SOC | +| `696` | `43074` | Feed-in power limit | +| `5916-5927` | `43707` bits 0-11 | TOU V2 slot enable switches | +| `5928-5933`, `5965`, `5969`, `5973`, `5977`, `5981`, `5984` | `43708`, `43715`, ... / `43750`, `43757`, ... | TOU V2 slot SOC values | +| `5947-5963`, `5966-5986` | `43709-43710`, ... / `43751-43752`, ... | TOU V2 slot current/voltage values | +| `5946`, `5949`, `5952`, `5955`, `5958`, `5961`, `5964`, `5968`, `5972`, `5976`, `5980`, `5987` | `43711-43714`, ... / `43753-43756`, ... | TOU V2 `HH:MM-HH:MM` time slots | + +Cloud writes read the previous CID value, send the control command, update the cache optimistically, and verify after SolisCloud catches up. If the cloud value does not match, SolisConnect restores the value reported by the device. + +The TOU V2 timed-slot CID family (`5916-5987`) is mapped into SolisConnect's local `43707-43791` register model. The remaining caveat is feature detection: SolisCloud CID `6798` reports whether TOU V2 is enabled (`43605` / `0xAA55`), but SolisConnect does not yet use that gate to hide or disable the TOU V2 cloud mappings automatically. + +## Operational notes + +Home Assistant lists SolisConnect as `local_polling` in `manifest.json`. That is still the best single Home Assistant class for the integration because local Modbus is the full-featured path, even though cloud-only and dual entries can use the SolisCloud API. + +If you run another SolisCloud integration with the same key, such as `hultenvp/solis-sensor`, it may add significant SolisCloud `atRead` traffic. Consider disabling the other integration while testing SolisConnect cloud mode. + +If old debug logs captured SolisCloud credentials before redaction was added, rotate the SolisCloud key secret and remove old logs. + +## References + +The local Modbus foundation comes from the upstream project SolisConnect was forked from: [Pho3niX90/solis_modbus](https://github.com/Pho3niX90/solis_modbus). The SolisCloud support was framed with reference to [mkuthan/solis-cloud-control](https://github.com/mkuthan/solis-cloud-control) (Solis Cloud Controls: control CIDs, TOU V2 slots, `yuanzhi` write semantics) and [hultenvp/solis-sensor](https://github.com/hultenvp/solis-sensor) (SolisCloud API access patterns). diff --git a/docs/source/sensors.md b/docs/source/sensors.md index 885fe4f..d5c6dc0 100644 --- a/docs/source/sensors.md +++ b/docs/source/sensors.md @@ -5,6 +5,8 @@ myst: The following sensors are provided in the integration. +Local Modbus entries can populate the full register set for the selected inverter model. SolisCloud entries use the same entity definitions, but only registers covered by the current cloud field/CID mappings update in cloud-only mode; unsupported cloud-only entities are marked unavailable. TOU V2 timed-slot CIDs are mapped into the local `43707-43791` register model, but automatic TOU V2 feature gating from cloud CID `6798` is still pending. + # String Inverter Registers The string inverter uses the following register ranges: - 2xxx: Basic information and measurements diff --git a/tests/test_cloud_integration.py b/tests/test_cloud_integration.py index e411000..e9dbafc 100644 --- a/tests/test_cloud_integration.py +++ b/tests/test_cloud_integration.py @@ -44,7 +44,16 @@ "familyLoadPowerStr": "kW", } -CANNED_BATCH = {636: "33", 157: "52", 158: "15"} +CANNED_BATCH = { + 636: "33", + 157: "52", + 158: "15", + 5916: "1", + 5917: "0", + 5927: "1", + 5946: "09:30-10:45", + 5948: "12.5", +} @pytest.fixture(autouse=True) @@ -132,6 +141,12 @@ async def test_cloud_only_setup_populates_cache_with_raw_words(hass: HomeAssista assert values[f"{SERIAL}|43110"] == 33 # cid 636 assert values[f"{SERIAL}|43024"] == 52 # cid 157 assert values[f"{SERIAL}|43011"] == 15 # cid 158 + assert values[f"{SERIAL}|43707"] == 2049 # cid 5916 bit 0 + cid 5927 bit 11 + assert values[f"{SERIAL}|43711"] == 9 + assert values[f"{SERIAL}|43712"] == 30 + assert values[f"{SERIAL}|43713"] == 10 + assert values[f"{SERIAL}|43714"] == 45 + assert values[f"{SERIAL}|43709"] == 125 # cid 5948 = 12.5 A, register multiplier x0.1 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -204,7 +219,6 @@ async def test_migration_v3_to_v4_sets_modbus_only(hass: HomeAssistant): patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True), patch("custom_components.solisconnect.modbus_controller.ModbusController.connected", return_value=True), patch("custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", return_value=[1, 2, 3]), - patch("custom_components.solisconnect.modbus_controller.ModbusController.process_write_queue"), patch("custom_components.solisconnect.modbus_controller.ModbusController.async_read_holding_register", return_value=[1, 2, 3]), ): assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/test_cloud_mapping.py b/tests/test_cloud_mapping.py index 24c1789..ff6c0db 100644 --- a/tests/test_cloud_mapping.py +++ b/tests/test_cloud_mapping.py @@ -14,7 +14,10 @@ from custom_components.solisconnect.sensor_data.cloud_mapping import ( CLOUD_CID_MAP, CLOUD_INPUT_MAP, + TOU_SWITCH_CIDS_BY_BIT, + TOU_TIME_PAIR_TO_CID, CloudFieldMapping, + cid_msg_from_words, encode_cid_value, encode_engineering_value, registers_covered, @@ -172,9 +175,86 @@ def test_cid_values_encode_to_register_words(): assert encode_cid_value(CLOUD_CID_MAP[160], "not_a_number") is None +def test_tou_v2_time_cid_encodes_to_hour_minute_registers(): + assert encode_cid_value(CLOUD_CID_MAP[5946], "09:30-10:45") == { + 43711: 9, + 43712: 30, + 43713: 10, + 43714: 45, + } + assert encode_cid_value(CLOUD_CID_MAP[5946], "25:00-10:00") is None + + +def test_tou_v2_time_cid_accepts_lenient_cloud_formats(): + # Single-digit hours/minutes and whitespace around the dash must parse + assert encode_cid_value(CLOUD_CID_MAP[5946], "7:5-9:30") == { + 43711: 7, + 43712: 5, + 43713: 9, + 43714: 30, + } + assert encode_cid_value(CLOUD_CID_MAP[5946], " 09:30 - 10:45 ") == { + 43711: 9, + 43712: 30, + 43713: 10, + 43714: 45, + } + # Out-of-range values still rejected after lenient shape matching + assert encode_cid_value(CLOUD_CID_MAP[5946], "09:75-10:00") is None + assert encode_cid_value(CLOUD_CID_MAP[5946], "garbage") is None + + +def test_tou_v2_time_cid_empty_slot_decodes_to_zeros(): + # Unset/disabled slots must publish 00:00-00:00 instead of silently never updating + assert encode_cid_value(CLOUD_CID_MAP[5946], "") == { + 43711: 0, + 43712: 0, + 43713: 0, + 43714: 0, + } + assert encode_cid_value(CLOUD_CID_MAP[5946], "0") == { + 43711: 0, + 43712: 0, + 43713: 0, + 43714: 0, + } + assert encode_cid_value(CLOUD_CID_MAP[5946], None) == { + 43711: 0, + 43712: 0, + 43713: 0, + 43714: 0, + } + + +def test_tou_v2_time_registers_encode_to_cid_value(): + mapping = TOU_TIME_PAIR_TO_CID[(43711, 43712)] + assert mapping.cid == 5946 + assert cid_msg_from_words(mapping, [9, 30, 10, 45]) == "09:30-10:45" + assert cid_msg_from_words(mapping, [24, 0, 10, 0]) is None + + +def test_tou_v2_switch_cids_decode_to_bit_values(): + assert TOU_SWITCH_CIDS_BY_BIT[0] == 5916 + assert TOU_SWITCH_CIDS_BY_BIT[11] == 5927 + assert CLOUD_CID_MAP[5916].merge_register_bit == 0 + assert CLOUD_CID_MAP[5927].merge_register_bit == 11 + assert encode_cid_value(CLOUD_CID_MAP[5916], "1") == {43707: 1} + assert encode_cid_value(CLOUD_CID_MAP[5916], "0") == {43707: 0} + + +def test_tou_v2_current_voltage_cids_apply_register_multipliers(): + assert encode_cid_value(CLOUD_CID_MAP[5948], "12") == {43709: 120} + assert cid_msg_from_words(CLOUD_CID_MAP[5948], [120]) == "12" + assert encode_cid_value(CLOUD_CID_MAP[5947], "52.4") == {43710: 524} + assert cid_msg_from_words(CLOUD_CID_MAP[5947], [524]) == "52.4" + + def test_registers_covered_includes_inputs_and_cids(): covered = registers_covered() assert 33035 in covered # eToday assert 33135 in covered # battery direction (custom encoder) assert 43110 in covered # cid 636 + assert 43707 in covered # TOU V2 switch bitfield + assert 43711 in covered # TOU V2 time slot registers + assert 43791 in covered # TOU V2 final discharge end minute assert 33022 not in covered # RTC deliberately unmapped diff --git a/tests/test_cloud_writes.py b/tests/test_cloud_writes.py index 163fb98..d43957b 100644 --- a/tests/test_cloud_writes.py +++ b/tests/test_cloud_writes.py @@ -1,5 +1,6 @@ """Stage 5: cloud write path — CID control calls, optimistic cache, verify/restore, routing.""" +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -13,6 +14,11 @@ SERIAL = "CLOUDSN1" +def _close_background_task(coro): + coro.close() + return MagicMock() + + @pytest.fixture def controller(hass): hass.data.setdefault(DOMAIN, {}).setdefault(VALUES, {}) @@ -35,10 +41,11 @@ def test_cid_msg_from_words_applies_multiplier(): assert cid_msg_from_words(CLOUD_CID_MAP[636], [33]) == "33" assert cid_msg_from_words(CLOUD_CID_MAP[157], [52]) == "52" assert cid_msg_from_words(CLOUD_CID_MAP[696], [50]) == "5000" # raw W/100 -> msg W + assert cid_msg_from_words(CLOUD_CID_MAP[5948], [125]) == "12.5" async def test_write_sends_control_with_old_value(hass, controller): - with patch.object(controller.hass, "async_create_task"): # verify task tested separately + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): # verify task tested separately await controller.async_write_holding_register(43110, 33) controller.api.async_at_read.assert_awaited_once_with(SERIAL, 636) @@ -63,7 +70,7 @@ async def test_write_failure_raises_home_assistant_error(hass, controller): async def test_write_proceeds_without_old_value(hass, controller): controller.api.async_at_read = AsyncMock(side_effect=SolisCloudApiError("read failed")) - with patch.object(controller.hass, "async_create_task"): + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): await controller.async_write_holding_register(43024, 55) controller.api.async_control.assert_awaited_once_with(SERIAL, 157, "55", old_value=None) @@ -89,3 +96,75 @@ async def test_verify_mismatch_restores_device_truth(hass, controller): async def test_multi_register_write_without_mapping_raises(controller): with pytest.raises(HomeAssistantError, match="no control CID mapping"): await controller.async_write_holding_registers(43143, [10, 30]) + + +async def test_tou_time_pair_write_composes_full_slot_from_cache(hass, controller): + hass.data[DOMAIN][VALUES][f"{SERIAL}|43711"] = 9 + hass.data[DOMAIN][VALUES][f"{SERIAL}|43712"] = 0 + hass.data[DOMAIN][VALUES][f"{SERIAL}|43713"] = 10 + hass.data[DOMAIN][VALUES][f"{SERIAL}|43714"] = 30 + controller.api.async_at_read = AsyncMock(return_value="09:00-10:30") + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_registers(43711, [8, 15]) + + controller.api.async_control.assert_awaited_once_with(SERIAL, 5946, "08:15-10:30", old_value="09:00-10:30") + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43711"] == 8 + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43712"] == 15 + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43713"] == 10 + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43714"] == 30 + + +async def test_tou_time_pair_write_composes_full_slot_from_cid_read(hass, controller): + controller.api.async_at_read = AsyncMock(return_value="09:00-10:30") + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_registers(43713, [11, 45]) + + controller.api.async_control.assert_awaited_once_with(SERIAL, 5946, "09:00-11:45", old_value="09:00-10:30") + + +async def test_tou_switch_write_sends_changed_cid_with_old_bitfield(hass, controller): + hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] = 0b0011 + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_register(43707, 0b0101) + + assert controller.api.async_control.await_args_list[0].args == (SERIAL, 5917, "0") + assert controller.api.async_control.await_args_list[0].kwargs == {"old_value": "3"} + assert controller.api.async_control.await_args_list[1].args == (SERIAL, 5918, "1") + assert controller.api.async_control.await_args_list[1].kwargs == {"old_value": "1"} + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] == 0b0101 + + +async def test_tou_switch_write_reads_bitfield_when_cache_empty(hass, controller): + controller.api.async_at_read_batch = AsyncMock(return_value={5916: "1", 5917: "0", 5918: "0"}) + + with patch.object(controller.hass, "async_create_task", side_effect=_close_background_task): + await controller.async_write_holding_register(43707, 0b0011) + + controller.api.async_at_read_batch.assert_awaited_once() + controller.api.async_control.assert_awaited_once_with(SERIAL, 5917, "1", old_value="1") + + +async def test_close_connection_cancels_pending_verify_tasks(hass, controller): + """Verify tasks must not outlive the controller (no cache writes after unload).""" + await controller.async_write_holding_register(43110, 33) # schedules a real 15s-delayed verify task + + assert len(controller._verify_tasks) == 1 + task = next(iter(controller._verify_tasks)) + + controller.close_connection() + + assert controller._verify_tasks == set() + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_verify_tou_switch_mismatch_restores_device_truth(hass, controller): + controller.api.async_at_read_batch = AsyncMock(return_value={5916: "1", 5917: "0"}) + with patch("custom_components.solisconnect.cloud.cloud_controller.asyncio.sleep", new=AsyncMock()): + await controller._verify_tou_switch_write(0b0011) + + assert controller.api.async_at_read_batch.await_count == 2 + assert hass.data[DOMAIN][VALUES][f"{SERIAL}|43707"] == 0b0001 diff --git a/tests/test_data_retrieval.py b/tests/test_data_retrieval.py index da8fd10..b8a132b 100644 --- a/tests/test_data_retrieval.py +++ b/tests/test_data_retrieval.py @@ -102,9 +102,6 @@ async def test_poll_controller(self): # Verify time interval tracking was set up self.assertEqual(3, self.mock_track_time.call_count) - # Verify controller's process_write_queue was started - self.hass.create_task.assert_called_once_with(self.controller.process_write_queue()) - async def test_modbus_update_all(self): """Test modbus_update_all method.""" # Mock the update methods diff --git a/tests/test_integration_setup.py b/tests/test_integration_setup.py index 3ae9e6f..ddf0df5 100644 --- a/tests/test_integration_setup.py +++ b/tests/test_integration_setup.py @@ -39,7 +39,6 @@ async def test_setup_entry(hass: HomeAssistant): "custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register", return_value=[1, 2, 3], ), - patch("custom_components.solisconnect.modbus_controller.ModbusController.process_write_queue"), patch( "custom_components.solisconnect.modbus_controller.ModbusController.async_read_holding_register", return_value=[1, 2, 3], diff --git a/tests/test_inverter_hub.py b/tests/test_inverter_hub.py index 92dede2..3bdb369 100644 --- a/tests/test_inverter_hub.py +++ b/tests/test_inverter_hub.py @@ -1,5 +1,6 @@ """Stage 6: dual-protocol orchestration — manual switching, failover, write routing.""" +import asyncio from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch @@ -172,6 +173,42 @@ async def test_write_routes_to_active_with_fallback(hass): hub.modbus.async_write_holding_register.assert_awaited_once_with(43000, 26) +async def test_concurrent_switches_are_serialized(hass): + """Two overlapping async_set_active calls must not stop/start retrievals twice.""" + hub = _hub(hass, PROTO_BOTH_MANUAL) + modbus_patch, cloud_patch = _patch_retrievals() + with modbus_patch as modbus_retrieval, cloud_patch as cloud_retrieval: + + async def slow_stop(): + await asyncio.sleep(0.05) + + modbus_retrieval.return_value.async_stop = AsyncMock(side_effect=slow_stop) + cloud_retrieval.return_value.async_stop = AsyncMock() + await hub.async_start() + + await asyncio.gather( + hub.async_set_active(PROTOCOL_CLOUD, reason="select entity"), + hub.async_set_active(PROTOCOL_CLOUD, reason="health tick"), + ) + + # The second call became a no-op inside the lock: one stop, one new retrieval + modbus_retrieval.return_value.async_stop.assert_awaited_once() + assert cloud_retrieval.call_count == 1 + assert hub.active_protocol == PROTOCOL_CLOUD + + await hub.async_stop() + + +async def test_write_falls_back_from_modbus_to_cloud(hass): + """A failing Modbus write raises HomeAssistantError, which routes the write to healthy cloud.""" + hub = _hub(hass, PROTO_BOTH_FAILOVER) + hub._active_name = PROTOCOL_MODBUS + + hub.modbus.async_write_holding_register = AsyncMock(side_effect=HomeAssistantError("write failed")) + await hub.async_write_holding_register(43110, 33) + hub.cloud.async_write_holding_register.assert_awaited_once_with(43110, 33) + + async def test_write_fallback_requires_healthy_other(hass): hub = _hub(hass, PROTO_BOTH_MANUAL) hub._active_name = PROTOCOL_CLOUD diff --git a/tests/test_modbus_controller.py b/tests/test_modbus_controller.py index 0bd5a77..181a336 100644 --- a/tests/test_modbus_controller.py +++ b/tests/test_modbus_controller.py @@ -3,6 +3,8 @@ from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.exceptions import HomeAssistantError + from custom_components.solisconnect.const import ( CONN_TYPE_SERIAL, CONN_TYPE_TCP, @@ -146,24 +148,56 @@ async def test_async_read_holding_register_failure(self): self.assertIsNone(result) async def test_async_write_holding_register(self): - """Test queuing a write to a holding register.""" - # Mock the write queue - self.controller.write_queue = MagicMock() - self.controller.write_queue.put = AsyncMock() + """Test a successful direct write of a holding register updates cache and notifies.""" + self.controller.connect = AsyncMock(return_value=True) + mock_result = MagicMock() + mock_result.isError = MagicMock(return_value=False) + mock_result.registers = [42] + self.mock_client.write_register = AsyncMock(return_value=mock_result) + + with ( + patch("custom_components.solisconnect.modbus_controller.cache_save") as mock_cache, + patch("custom_components.solisconnect.modbus_controller.notify_register_update") as mock_notify, + ): + await self.controller.async_write_holding_register(100, 42) - await self.controller.async_write_holding_register(100, 42) + # TCP: slave is passed as parameter + self.mock_client.write_register.assert_called_once_with(address=100, value=42, device_id=1) + mock_cache.assert_called_once_with(self.hass, self.controller, 100, 42) + mock_notify.assert_called_once_with(self.hass, self.controller, 100, 42) - self.controller.write_queue.put.assert_called_once_with((100, 42, False)) + async def test_async_write_holding_register_failure_raises(self): + """A failed write raises HomeAssistantError so the dual-protocol hub fallback can fire.""" + self.controller.connect = AsyncMock(return_value=True) + self.mock_client.write_register = AsyncMock(side_effect=Exception("Test error")) + + with self.assertRaises(HomeAssistantError): + await self.controller.async_write_holding_register(100, 42) async def test_async_write_holding_registers(self): - """Test queuing a write to multiple holding registers.""" - # Mock the write queue - self.controller.write_queue = MagicMock() - self.controller.write_queue.put = AsyncMock() + """Test a successful direct write of multiple holding registers.""" + self.controller.connect = AsyncMock(return_value=True) + mock_result = MagicMock() + mock_result.isError = MagicMock(return_value=False) + self.mock_client.write_registers = AsyncMock(return_value=mock_result) + + with ( + patch("custom_components.solisconnect.modbus_controller.cache_save"), + patch("custom_components.solisconnect.modbus_controller.notify_register_update"), + ): + await self.controller.async_write_holding_registers(100, [42, 43]) + + self.mock_client.write_registers.assert_called_once_with(address=100, values=[42, 43], device_id=1) - await self.controller.async_write_holding_registers(100, [42, 43]) + async def test_async_write_holding_registers_failure_raises(self): + """A failed block write raises HomeAssistantError instead of being swallowed.""" + self.controller.connect = AsyncMock(return_value=True) + mock_result = MagicMock() + mock_result.isError = MagicMock(return_value=True) + self.mock_client.write_registers = AsyncMock(return_value=mock_result) - self.controller.write_queue.put.assert_called_once_with((100, [42, 43], True)) + with self.assertRaises(HomeAssistantError): + await self.controller.async_write_holding_registers(100, [42, 43]) def test_poll_speed(self): """Test the poll_speed property.""" @@ -346,24 +380,46 @@ async def test_async_read_holding_register_failure(self): self.assertIsNone(result) async def test_async_write_holding_register(self): - """Test queuing a write to a holding register.""" - # Mock the write queue - self.controller.write_queue = MagicMock() - self.controller.write_queue.put = AsyncMock() + """Test a successful direct write of a holding register.""" + self.controller.connect = AsyncMock(return_value=True) + mock_result = MagicMock() + mock_result.isError = MagicMock(return_value=False) + mock_result.registers = [42] + self.mock_client.write_register = AsyncMock(return_value=mock_result) - await self.controller.async_write_holding_register(100, 42) + with ( + patch("custom_components.solisconnect.modbus_controller.cache_save"), + patch("custom_components.solisconnect.modbus_controller.notify_register_update"), + ): + await self.controller.async_write_holding_register(100, 42) - self.controller.write_queue.put.assert_called_once_with((100, 42, False)) + # Serial: slave is set on client, not passed as parameter + self.mock_client.write_register.assert_called_once_with(address=100, value=42) + self.assertEqual(self.mock_client.slave, 1) + + async def test_async_write_holding_register_failure_raises(self): + """A failed write raises HomeAssistantError so the dual-protocol hub fallback can fire.""" + self.controller.connect = AsyncMock(return_value=True) + self.mock_client.write_register = AsyncMock(side_effect=Exception("Test error")) + + with self.assertRaises(HomeAssistantError): + await self.controller.async_write_holding_register(100, 42) async def test_async_write_holding_registers(self): - """Test queuing a write to multiple holding registers.""" - # Mock the write queue - self.controller.write_queue = MagicMock() - self.controller.write_queue.put = AsyncMock() + """Test a successful direct write of multiple holding registers.""" + self.controller.connect = AsyncMock(return_value=True) + mock_result = MagicMock() + mock_result.isError = MagicMock(return_value=False) + self.mock_client.write_registers = AsyncMock(return_value=mock_result) - await self.controller.async_write_holding_registers(100, [42, 43]) + with ( + patch("custom_components.solisconnect.modbus_controller.cache_save"), + patch("custom_components.solisconnect.modbus_controller.notify_register_update"), + ): + await self.controller.async_write_holding_registers(100, [42, 43]) - self.controller.write_queue.put.assert_called_once_with((100, [42, 43], True)) + self.mock_client.write_registers.assert_called_once_with(address=100, values=[42, 43]) + self.assertEqual(self.mock_client.slave, 1) def test_poll_speed(self): """Test the poll_speed property.""" diff --git a/tests/test_sensor_watchdog.py b/tests/test_sensor_watchdog.py new file mode 100644 index 0000000..5d33844 --- /dev/null +++ b/tests/test_sensor_watchdog.py @@ -0,0 +1,57 @@ +"""Watchdog staleness timeout: poll speeds are seconds, the grace period is minutes.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from custom_components.solisconnect.data.enums import PollSpeed +from custom_components.solisconnect.sensors.solis_sensor import SolisSensor + + +def _entity(poll_speed=PollSpeed.NORMAL, interval_s=30) -> SolisSensor: + base = MagicMock() + base.name = "Test Sensor" + base.unique_id = "uid1" + base.registrars = [33000] + base.device_class = None + base.unit_of_measurement = None + base.state_class = None + base.hidden = False + base.enabled = True + base.multiplier = 1 + base.poll_speed = poll_speed + base.controller.poll_speed = {poll_speed: interval_s} + return SolisSensor(MagicMock(), base) + + +def test_watchdog_timeout_treats_poll_speed_as_seconds(): + # Cloud-style 300s poll interval: 5 min + 10 min grace, not 310 minutes + entity = _entity(interval_s=300) + assert entity._update_timeout == timedelta(seconds=300) + timedelta(minutes=10) + + +def test_watchdog_timeout_fast_modbus_poll(): + entity = _entity(poll_speed=PollSpeed.FAST, interval_s=5) + assert entity._update_timeout == timedelta(seconds=5) + timedelta(minutes=10) + + +async def test_watchdog_marks_sensor_unavailable_after_timeout(): + entity = _entity(interval_s=5) + entity.schedule_update_ha_state = MagicMock() + entity._attr_available = True + entity._last_update = entity._last_update - (entity._update_timeout + timedelta(seconds=1)) + + await entity.async_update() + + assert entity._attr_available is False + entity.schedule_update_ha_state.assert_called_once() + + +async def test_watchdog_keeps_fresh_sensor_available(): + entity = _entity(interval_s=5) + entity.schedule_update_ha_state = MagicMock() + entity._attr_available = True + + await entity.async_update() + + assert entity._attr_available is True + entity.schedule_update_ha_state.assert_not_called() From 14dfe31323871bdb02bde7ed38ea94090dc9dcb3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Jul 2026 13:11:27 +0000 Subject: [PATCH 2/3] test: update known sensors baseline --- tests/known_sensors_host.json | 10 ++++++++++ tests/known_sensors_identification.json | 10 ++++++++++ tests/known_sensors_serial.json | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/tests/known_sensors_host.json b/tests/known_sensors_host.json index e59c056..1d54c96 100644 --- a/tests/known_sensors_host.json +++ b/tests/known_sensors_host.json @@ -123,9 +123,12 @@ "solisconnect_1.2.3.4_solisconnect_inverter_battery_voltage_bms", "solisconnect_1.2.3.4_solisconnect_inverter_c_phase_current", "solisconnect_1.2.3.4_solisconnect_inverter_c_phase_voltage", + "solisconnect_1.2.3.4_solisconnect_inverter_clock_day", "solisconnect_1.2.3.4_solisconnect_inverter_clock_hours", "solisconnect_1.2.3.4_solisconnect_inverter_clock_minutes", + "solisconnect_1.2.3.4_solisconnect_inverter_clock_month", "solisconnect_1.2.3.4_solisconnect_inverter_clock_seconds", + "solisconnect_1.2.3.4_solisconnect_inverter_clock_year", "solisconnect_1.2.3.4_solisconnect_inverter_current_status", "solisconnect_1.2.3.4_solisconnect_inverter_dc_bus_half_voltage", "solisconnect_1.2.3.4_solisconnect_inverter_dc_bus_voltage", @@ -180,22 +183,26 @@ "solisconnect_1.2.3.4_solisconnect_inverter_meter_ac_voltage_a", "solisconnect_1.2.3.4_solisconnect_inverter_meter_ac_voltage_b", "solisconnect_1.2.3.4_solisconnect_inverter_meter_ac_voltage_c", + "solisconnect_1.2.3.4_solisconnect_inverter_meter_active_power", "solisconnect_1.2.3.4_solisconnect_inverter_meter_active_power_a", "solisconnect_1.2.3.4_solisconnect_inverter_meter_active_power_b", "solisconnect_1.2.3.4_solisconnect_inverter_meter_active_power_c", "solisconnect_1.2.3.4_solisconnect_inverter_meter_apparent_power_a", "solisconnect_1.2.3.4_solisconnect_inverter_meter_apparent_power_b", "solisconnect_1.2.3.4_solisconnect_inverter_meter_apparent_power_c", + "solisconnect_1.2.3.4_solisconnect_inverter_meter_current", "solisconnect_1.2.3.4_solisconnect_inverter_meter_grid_frequency", "solisconnect_1.2.3.4_solisconnect_inverter_meter_power_factor", "solisconnect_1.2.3.4_solisconnect_inverter_meter_reactive_power_a", "solisconnect_1.2.3.4_solisconnect_inverter_meter_reactive_power_b", "solisconnect_1.2.3.4_solisconnect_inverter_meter_reactive_power_c", + "solisconnect_1.2.3.4_solisconnect_inverter_meter_total_active_energy", "solisconnect_1.2.3.4_solisconnect_inverter_meter_total_active_energy_from_grid", "solisconnect_1.2.3.4_solisconnect_inverter_meter_total_active_energy_to_grid", "solisconnect_1.2.3.4_solisconnect_inverter_meter_total_active_power", "solisconnect_1.2.3.4_solisconnect_inverter_meter_total_apparent_power", "solisconnect_1.2.3.4_solisconnect_inverter_meter_total_reactive_power", + "solisconnect_1.2.3.4_solisconnect_inverter_meter_voltage", "solisconnect_1.2.3.4_solisconnect_inverter_model_no", "solisconnect_1.2.3.4_solisconnect_inverter_mppt_scanning_interval", "solisconnect_1.2.3.4_solisconnect_inverter_operating_mode", @@ -229,10 +236,13 @@ "solisconnect_1.2.3.4_solisconnect_inverter_reactive_power", "solisconnect_1.2.3.4_solisconnect_inverter_remote_active_power_control_command", "solisconnect_1.2.3.4_solisconnect_inverter_serial_number", + "solisconnect_1.2.3.4_solisconnect_inverter_smartport_a_active_power", "solisconnect_1.2.3.4_solisconnect_inverter_smartport_a_phase_current", "solisconnect_1.2.3.4_solisconnect_inverter_smartport_a_phase_voltage", + "solisconnect_1.2.3.4_solisconnect_inverter_smartport_b_active_power", "solisconnect_1.2.3.4_solisconnect_inverter_smartport_b_phase_current", "solisconnect_1.2.3.4_solisconnect_inverter_smartport_b_phase_voltage", + "solisconnect_1.2.3.4_solisconnect_inverter_smartport_c_active_power", "solisconnect_1.2.3.4_solisconnect_inverter_smartport_c_phase_current", "solisconnect_1.2.3.4_solisconnect_inverter_smartport_c_phase_voltage", "solisconnect_1.2.3.4_solisconnect_inverter_special_settings", diff --git a/tests/known_sensors_identification.json b/tests/known_sensors_identification.json index 048695a..85a2bf2 100644 --- a/tests/known_sensors_identification.json +++ b/tests/known_sensors_identification.json @@ -123,9 +123,12 @@ "solisconnect_ID_SNAPSHOT_solisconnect_inverter_battery_voltage_bms", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_c_phase_current", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_c_phase_voltage", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_clock_day", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_clock_hours", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_clock_minutes", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_clock_month", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_clock_seconds", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_clock_year", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_current_status", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_dc_bus_half_voltage", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_dc_bus_voltage", @@ -180,22 +183,26 @@ "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_ac_voltage_a", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_ac_voltage_b", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_ac_voltage_c", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_active_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_active_power_a", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_active_power_b", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_active_power_c", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_apparent_power_a", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_apparent_power_b", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_apparent_power_c", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_current", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_grid_frequency", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_power_factor", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_reactive_power_a", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_reactive_power_b", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_reactive_power_c", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_total_active_energy", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_total_active_energy_from_grid", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_total_active_energy_to_grid", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_total_active_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_total_apparent_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_total_reactive_power", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_meter_voltage", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_model_no", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_mppt_scanning_interval", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_operating_mode", @@ -229,10 +236,13 @@ "solisconnect_ID_SNAPSHOT_solisconnect_inverter_reactive_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_remote_active_power_control_command", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_serial_number", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_a_active_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_a_phase_current", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_a_phase_voltage", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_b_active_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_b_phase_current", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_b_phase_voltage", + "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_c_active_power", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_c_phase_current", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_smartport_c_phase_voltage", "solisconnect_ID_SNAPSHOT_solisconnect_inverter_special_settings", diff --git a/tests/known_sensors_serial.json b/tests/known_sensors_serial.json index fdf50e1..b35afc6 100644 --- a/tests/known_sensors_serial.json +++ b/tests/known_sensors_serial.json @@ -123,9 +123,12 @@ "solisconnect_SN_SNAPSHOT_solisconnect_inverter_battery_voltage_bms", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_c_phase_current", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_c_phase_voltage", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_clock_day", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_clock_hours", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_clock_minutes", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_clock_month", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_clock_seconds", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_clock_year", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_current_status", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_dc_bus_half_voltage", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_dc_bus_voltage", @@ -180,22 +183,26 @@ "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_ac_voltage_a", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_ac_voltage_b", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_ac_voltage_c", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_active_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_active_power_a", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_active_power_b", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_active_power_c", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_apparent_power_a", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_apparent_power_b", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_apparent_power_c", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_current", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_grid_frequency", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_power_factor", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_reactive_power_a", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_reactive_power_b", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_reactive_power_c", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_total_active_energy", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_total_active_energy_from_grid", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_total_active_energy_to_grid", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_total_active_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_total_apparent_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_total_reactive_power", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_meter_voltage", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_model_no", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_mppt_scanning_interval", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_operating_mode", @@ -229,10 +236,13 @@ "solisconnect_SN_SNAPSHOT_solisconnect_inverter_reactive_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_remote_active_power_control_command", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_serial_number", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_a_active_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_a_phase_current", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_a_phase_voltage", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_b_active_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_b_phase_current", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_b_phase_voltage", + "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_c_active_power", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_c_phase_current", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_smartport_c_phase_voltage", "solisconnect_SN_SNAPSHOT_solisconnect_inverter_special_settings", From 7822f7dbde32f325f1a6f584f4471443150bb654 Mon Sep 17 00:00:00 2001 From: fboundy Date: Fri, 3 Jul 2026 14:13:13 +0100 Subject: [PATCH 3/3] ci: trigger checks after bot baseline commit Co-Authored-By: Claude Fable 5