From 3601600fe7d7d7e09eb936d1317223dd3776c155 Mon Sep 17 00:00:00 2001 From: kkangg1 <45795696+kkangg1@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:06:09 +0800 Subject: [PATCH 1/6] Optimize BituoPMD for reliable Home Assistant polling --- .github/workflows/quality.yml | 27 + .github/workflows/validate.yml | 2 +- .gitignore | 8 + README.md | 163 +++-- custom_components/bituopmd/__init__.py | 152 +++-- .../__pycache__/__init__.cpython-312.pyc | Bin 4270 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 4293 -> 0 bytes .../__pycache__/button.cpython-312.pyc | Bin 5959 -> 0 bytes .../__pycache__/button.cpython-313.pyc | Bin 7721 -> 0 bytes .../__pycache__/config_flow.cpython-312.pyc | Bin 12003 -> 0 bytes .../__pycache__/config_flow.cpython-313.pyc | Bin 15254 -> 0 bytes .../__pycache__/const.cpython-312.pyc | Bin 250 -> 0 bytes .../__pycache__/const.cpython-313.pyc | Bin 250 -> 0 bytes .../__pycache__/frontend.cpython-312.pyc | Bin 5511 -> 0 bytes .../__pycache__/frontend.cpython-313.pyc | Bin 5634 -> 0 bytes .../__pycache__/sensor.cpython-312.pyc | Bin 20419 -> 0 bytes .../__pycache__/sensor.cpython-313.pyc | Bin 23952 -> 0 bytes .../__pycache__/switch.cpython-312.pyc | Bin 7014 -> 0 bytes .../__pycache__/switch.cpython-313.pyc | Bin 8358 -> 0 bytes .../bituopmd/__pycache__/text.cpython-312.pyc | Bin 6465 -> 0 bytes .../__pycache__/zigbee.cpython-312.pyc | Bin 1067 -> 0 bytes custom_components/bituopmd/api.py | 109 ++++ custom_components/bituopmd/button.py | 203 ++---- custom_components/bituopmd/config_flow.py | 405 ++++++------ custom_components/bituopmd/const.py | 13 +- custom_components/bituopmd/coordinator.py | 90 +++ custom_components/bituopmd/entity.py | 49 ++ custom_components/bituopmd/frontend.py | 178 ++++-- custom_components/bituopmd/helpers.py | 82 +++ custom_components/bituopmd/manifest.json | 10 +- custom_components/bituopmd/sensor.py | 599 ++++++------------ custom_components/bituopmd/services.yaml | 27 +- custom_components/bituopmd/settings.json | 1 - custom_components/bituopmd/strings.json | 65 ++ custom_components/bituopmd/switch.py | 187 ++---- .../bituopmd/translations/en.json | 65 ++ .../bituopmd/translations/zh-Hans.json | 65 ++ custom_components/bituopmd/www/bituo_panel.js | 8 +- hacs.json | 2 +- pyproject.toml | 9 + tests/test_helpers.py | 98 +++ tests/test_repository_privacy.py | 51 ++ 42 files changed, 1605 insertions(+), 1063 deletions(-) create mode 100644 .github/workflows/quality.yml mode change 100755 => 100644 custom_components/bituopmd/__init__.py delete mode 100755 custom_components/bituopmd/__pycache__/__init__.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/__init__.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/button.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/button.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/config_flow.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/config_flow.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/const.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/const.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/frontend.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/frontend.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/sensor.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/sensor.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/switch.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/switch.cpython-313.pyc delete mode 100755 custom_components/bituopmd/__pycache__/text.cpython-312.pyc delete mode 100755 custom_components/bituopmd/__pycache__/zigbee.cpython-312.pyc create mode 100644 custom_components/bituopmd/api.py mode change 100755 => 100644 custom_components/bituopmd/button.py mode change 100755 => 100644 custom_components/bituopmd/config_flow.py create mode 100644 custom_components/bituopmd/coordinator.py create mode 100644 custom_components/bituopmd/entity.py mode change 100755 => 100644 custom_components/bituopmd/frontend.py create mode 100644 custom_components/bituopmd/helpers.py mode change 100755 => 100644 custom_components/bituopmd/sensor.py delete mode 100755 custom_components/bituopmd/settings.json create mode 100644 custom_components/bituopmd/strings.json mode change 100755 => 100644 custom_components/bituopmd/switch.py create mode 100644 custom_components/bituopmd/translations/en.json create mode 100644 custom_components/bituopmd/translations/zh-Hans.json create mode 100644 pyproject.toml create mode 100644 tests/test_helpers.py create mode 100644 tests/test_repository_privacy.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..d0dcfb3 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,27 @@ +name: Quality + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install test tools + run: python -m pip install ruff + - name: Ruff + run: | + ruff check . + ruff format --check . + - name: Tests + run: | + python -m unittest discover -s tests + python -m compileall -q custom_components tests + - name: Frontend syntax + run: node --check custom_components/bituopmd/www/bituo_panel.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4b927da..a6ca12c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,7 +11,7 @@ jobs: validate-hacs: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - name: HACS validation uses: "hacs/action@main" with: diff --git a/.gitignore b/.gitignore index 63cfe3e..c15b242 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ # Ignore macOS .DS_Store files .DS_Store + +# Python and test artifacts +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.coverage +.venv/ diff --git a/README.md b/README.md index eced62a..934dad1 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,121 @@ -# Bituo Device for Home Assistant +# BituoPMD for Home Assistant -## Installation +An optimized fork of +[`script0803/BituoPMD`](https://github.com/script0803/BituoPMD) for Bituo +SPM/SDM power monitoring devices. -### Install with HACS +This fork keeps the original `bituopmd` integration domain, config-entry data, +device identifiers, and entity unique IDs. It can replace V1.0.4 without +re-adding devices or changing existing entity IDs. -[![Open BituoPMD inside your Home Assistant Community Store (HACS).](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Script0803&repository=bituopmd&category=integration) 👈Click this button to install.(Recommended) +## What is improved + +- One shared coordinator per device instead of separate sensor and switch + polling loops. +- Fully asynchronous HTTP through Home Assistant's shared client. +- A five-second timeout on every device request so an offline meter cannot + stall startup or reload. +- Clean unload behavior with no orphaned background tasks. +- Polling interval stored in the Home Assistant config entry instead of a JSON + file inside `custom_components`. +- Native options and reconfigure flows for polling interval and IP changes. +- Missing data returns `unavailable`, never a fabricated zero. +- Correct HA units and classes, including RSSI in dBm and current-unbalance as + a percentage rather than a power factor. +- Reliable three-phase totals derived when current firmware omits them: + current, average current, average line-to-neutral voltage, active/reactive/ + apparent power, forward energy, and reverse energy. +- Secured compatibility panel: administrator-only configuration actions, + configured devices only, explicit action allowlists, and bounded requests. +- HACS, hassfest, Ruff, syntax, and unit-test workflows. -1. Open HACS (Home Assistant Community Store). -2. Navigate to Integrations. -3. Click on the "![](/images/symbol-1.png)" button to add a new repository. -4. Click "Custom repositories". -5. Enter the URL of this GitHub repository. -6. Select the "Integration" category and click "Add". +## Supported devices -### Install manually +The upstream integration documents support for: -1. Install this platform by creating a `custom_components` folder in the same folder as your configuration.yaml, if it doesn't already exist. -2. Create another folder `bituopmd` in the `custom_components` folder. Copy all files from custom_components into the `bituopmd` folder. Do not copy files from master branch, download latest release (.zip). +- SPM01-xxEW +- SPM02-xxEW +- SDM01-EWx +- SDM02-EW -## Configuration +The optimized code is designed around the same local HTTP API and preserves +relay support for models that advertise it. -After connecting your ESP device to the network following the manual guide, We have three ways to add your ESP devices to HomeAssistant. +## Install with HACS -### Discoverd +1. Open HACS. +2. Open the menu and choose **Custom repositories**. +3. Add `https://github.com/kkangg1/BituoPMD` as category + **Integration**. +4. Download `BituoPMD`. +5. Restart Home Assistant. -When you add your ESP device to the same network as Home Assistant, the device will be automatically scanned and appear in the discovered list. +To add a new device, go to **Settings → Devices & services → Add +integration**, search for `BituoPMD`, and enter its fixed IP address. Home +Assistant native Zeroconf discovery is also supported. -![](/images/discoverd.png) +## Replace upstream V1.0.4 -After clicking 'Configure,' the following dialog box will pop up. Then, click 'Submit' to add the device. +Create a Home Assistant backup first. Then install this repository through +HACS over the existing `custom_components/bituopmd` directory and restart Home +Assistant. Existing entries and entity IDs are retained. -![](/images/discoverd-config.png) +After restart, verify: -### Zeroconf scan +- every BituoPMD config entry is `Loaded`; +- metering entities update; +- existing dashboards and automations resolve their old entity IDs; +- the system log contains no `bituopmd` errors. -1. Select 'Use Zeroconf to scan device', and click 'Submit'. The integration will start scanning for devices on the current network. +The previous V1.0.4 component should be kept as a rollback archive until the +new version has run successfully. -![](/images/zeroconf-1.png) +## Polling interval -2. The integration will list the devices it has scanned. Select the device you want to add and proceed with the addition. +Open the integration entry and choose **Configure**. The supported range is +5–3600 seconds. The default remains 5 seconds for compatibility. With several +meters, 10–30 seconds usually reduces LAN and recorder load without affecting +normal energy dashboards. -![](/images/zeroconf-2.png) +The legacy service remains available for panel compatibility: -### Manual configure +```yaml +action: bituopmd.set_frequency +data: + device_id: 192.0.2.10 + frequency: 15 +``` -1. Select 'Use IP to pair devices', and click 'Submit'. +`192.0.2.10` is a documentation-only TEST-NET address, not a real device. -![](/images/manual-1.png) +## Privacy and security -2. Enter the device's IP address into the input field. Click the submit button. +The integration communicates directly with configured devices on the local +network. The repository and tests contain no installation-specific IP +addresses, serial numbers, config-entry IDs, device-registry IDs, or real +meter readings. -![](/images/manual-2.png) +The sidebar panel can change device Wi-Fi, MQTT, Modbus, and firmware settings. +It is therefore visible only to Home Assistant administrators. The proxy +rejects unknown hosts and actions. -## Devices supported -- SPM01-xxEW [[More details]](https://shop.bituo-technik.com/products/esp32-wifi-energy-meter-spm01-1p-n-63a) -- SPM02-xxEW [[More details]](https://shop.bituo-technik.com/products/esp32-wifi-energy-meter-spm02-3p-n-63a-copy) -- SDM01-EWx [[More details]](https://shop.bituo-technik.com/products/sdm01-ew0-energy-meter-3p-n-up-to-200a-esp32-wi-fi-ble) -- SDM02-EW [[More details]](https://shop.bituo-technik.com/collections/all) +## Development -## Detail information +Run the local checks: -Click on the device name to enter the details page, where you can view all the parameters measured by the device. +```bash +python -m compileall -q custom_components tests +python -m unittest discover -s tests +node --check custom_components/bituopmd/www/bituo_panel.js +ruff check . +ruff format --check . +``` + +GitHub Actions additionally run HACS and hassfest validation. -![](/images/interface-1.png) +## Attribution and licensing -Clicking 'View' will redirect you to the device's embedded webpage. - -![](/images/interface-2.jpg) - -All entities can have their units adjusted according to Home Assistant's rules. - -![](/images/interface-3.jpg) - -## Device settings - -Once the integration is activated, you can see the entry to the settings page in Home Assistant's sidebar. - -![](/images/UI-1.jpg) - -On this page, you can adjust all the settings of the device, as well as access other functions like Zero Energy, Restart, Erase Factory, and OTA. - -![](/images/UI-2.jpg) - -Click the dropdown menu at the top to select the device you want to configure. - -![](/images/UI-3.png) - -## Device OTA -The device's parameter page will display the OTA status. When 'OTA available' is shown, please proceed to the embedded web page or the integration's settings page to perform the OTA. - -![](/images/OTA-2.jpg) - -![](/images/OTA-3.jpg) - -If 'Up to date' is displayed, it means the device is already on the latest version and no OTA is required. - -![](/images/OTA-1.png) +This repository is a fork of the work by +[`@Script0803`](https://github.com/script0803). The upstream repository does +not currently include a license file. No new license is asserted by this +fork; upstream authorship and applicable rights remain unchanged. diff --git a/custom_components/bituopmd/__init__.py b/custom_components/bituopmd/__init__.py old mode 100755 new mode 100644 index e6b5574..039cd14 --- a/custom_components/bituopmd/__init__.py +++ b/custom_components/bituopmd/__init__.py @@ -1,70 +1,118 @@ +"""Bituo power monitoring device integration.""" + +from __future__ import annotations + import logging -import requests +from typing import Any + +import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.const import Platform -from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, CONF_HOST_IP -from .frontend import setup_frontend +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import BituoApiClient +from .const import ( + CONF_HOST_IP, + CONF_SCAN_INTERVAL, + DATA_KEY_ENTRIES, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + MAX_SCAN_INTERVAL, + MIN_SCAN_INTERVAL, +) +from .coordinator import BituoDataUpdateCoordinator +from .frontend import async_setup_frontend _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BUTTON, Platform.SWITCH] +PLATFORMS = (Platform.SENSOR, Platform.BUTTON, Platform.SWITCH) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up BituoPMD integration from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - host_ip = entry.data[CONF_HOST_IP] - _LOGGER.info("Setting up BituoPMD integration for %s", host_ip) - - # Initialize the data dictionary for this entry - hass.data[DOMAIN][entry.entry_id] = {} - - # Fetch device data to get model id and verify connection - try: - response = await hass.async_add_executor_job( - requests.get, f"http://{host_ip}/data" +SERVICE_SET_FREQUENCY = "set_frequency" +SERVICE_SET_FREQUENCY_SCHEMA = vol.Schema( + { + vol.Required("device_id"): cv.string, + vol.Required("frequency"): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL), + ), + } +) + + +async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: + """Set up shared integration resources.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data.setdefault(DATA_KEY_ENTRIES, {}) + + async def async_handle_set_frequency(call: ServiceCall) -> None: + """Update one device's coordinator interval and persisted options.""" + host = call.data["device_id"] + frequency = call.data["frequency"] + entries: dict[str, dict[str, Any]] = domain_data[DATA_KEY_ENTRIES] + + for entry_id, runtime in entries.items(): + coordinator: BituoDataUpdateCoordinator = runtime["coordinator"] + if coordinator.host != host: + continue + + coordinator.set_scan_interval(frequency) + entry = hass.config_entries.async_get_entry(entry_id) + if entry is not None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SCAN_INTERVAL: frequency}, + ) + await coordinator.async_request_refresh() + return + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"host": host}, ) - response.raise_for_status() # Check for HTTP errors - data = response.json() - model_id = data.get("ProductModel") or data.get("productModel", "Unknown Model") - except requests.exceptions.RequestException as e: - _LOGGER.error("Error fetching device data from %s: %s", host_ip, e) - raise ConfigEntryNotReady from e - - # Update the entry title to be "modelid - ip" - hass.config_entries.async_update_entry( - entry, title=f"{model_id} - {host_ip}" + + hass.services.async_register( + DOMAIN, + SERVICE_SET_FREQUENCY, + async_handle_set_frequency, + schema=SERVICE_SET_FREQUENCY_SCHEMA, ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up BituoPMD from a config entry.""" + host = entry.data[CONF_HOST_IP] + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + client = BituoApiClient(async_get_clientsession(hass), host) + coordinator = BituoDataUpdateCoordinator(hass, entry, client, scan_interval) + + await coordinator.async_config_entry_first_refresh() + await coordinator.async_detect_capabilities() - # Forward the setup to the sensor platforms - try: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - except ConfigEntryNotReady as e: - _LOGGER.error("Error setting up platforms for BituoPMD: %s", e) - raise ConfigEntryNotReady from e + info = coordinator.device_info_data + expected_title = f"{info['model']} - {coordinator.host}" + if entry.title != expected_title: + hass.config_entries.async_update_entry(entry, title=expected_title) - # Set up the frontend - await setup_frontend(hass) + hass.data[DOMAIN][DATA_KEY_ENTRIES][entry.entry_id] = { + "client": client, + "coordinator": coordinator, + } + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Keep the vendor settings panel for compatibility, but register it once. + await async_setup_frontend(hass) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a BituoPMD config entry.""" - _LOGGER.info("Unloading BituoPMD integration for %s", entry.data[CONF_HOST_IP]) +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry cleanly.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - _LOGGER.info("Successfully unloaded BituoPMD integration for %s", entry.data[CONF_HOST_IP]) - if DOMAIN in hass.data: - hass.data[DOMAIN].pop(entry.entry_id, None) - + hass.data[DOMAIN][DATA_KEY_ENTRIES].pop(entry.entry_id, None) return unload_ok - -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle removal of an entry.""" - _LOGGER.info("Removing BituoPMD integration for %s", entry.data[CONF_HOST_IP]) - if DOMAIN in hass.data: - hass.data[DOMAIN].pop(entry.entry_id, None) diff --git a/custom_components/bituopmd/__pycache__/__init__.cpython-312.pyc b/custom_components/bituopmd/__pycache__/__init__.cpython-312.pyc deleted file mode 100755 index cffb54376dece9dc4cc95970e086831b917fc503..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4270 zcmcH+TTC3+_0G=B?(CB{7%*fB277VLwh5{q!gjFDtHBEati-EUqxH@#>}GdnJu_=y zyVS(45|c*4mZCt^MxkHDa-;NosUqxoAEORe3Xo;$m<19VfX zAHCAdJ@=e@9{0|9&2ODf8-nraua-hr0s0GG=);@`cFY7q^N2$n5k>yoO!x^{o1&%| z=_d({BcoJ|_R|_nMa?mb-=e{El!;mWR)Ec%C2EV={q~r{@6gsv)ERU6T?8^A9z_~; z#m3B+-UHou_{(@po|3PzSf7Vb?dL@y6dvmq6ln^U&OR~5cgb>CRsw< z1Gr+>UZ1E8^8s$^YkV+|Ni|;>yx7(6Qyu3AeLZa7;K(T3KLk83nO72VHYACH!V8>A zjR#~|rFpDRHA_I0grI5HA@S$B3D3?o5~0@+0iR4MkW(nKdv6|pjgTtBs5s5w?S$Pa zq8PtI=?ZEAm^LZ8%bzC+V!noN@wwUkw8<9Y^p+iV&o;L6D zrFxxW%p8+4rOfx>yWs&g9n3-CG3Fa4j+!?A&^$p*ph@yFnjpZE?}H_UJ)q&m=W4Kn z{DQX{yx$-{`6)R;c$~>^jqr*)5qF;pD+zJv;stjY!govxC}B}>LomnO0e4W#9BwU? z+mo%pp@fC8Z{`vucZ-~~j4Mj~l-HZ2y<9*EK*ku7L@p6jE{Yr z2yTs-Z0(jLkP_mR;5b&r@z=sZ-i_ttjB1fjVI8FVm>Y5rt%Q{*pFEo5f<&a-7tiN3 z*$tVka7N5J>Zz0%_y#J4g-SU5RSP>X*xTDZtWseiB&v*dU^dLD)d6`*2(kf=WBExw zm{3HCjfhuNM&hp~cv+EEa*S8hQYjFYc@|W$kX4n0tWptK6jU2O8RX;mMrE~hILFeh zqaa1%vr0G>sjl2rvsiPOm(}u|;zS&bx7oX{v3LaV{{olZzFLZ!Gq&RkdeHL?3;EOtd*` zqre(hdB)Y0b~R;OooQF+@}-sjb(b&0_|_QT69=-_E>y1D8Z%Vm8r8UMFUh_F#ytgs zqcUS}OxqhXc5mA5{X59>Y%wKs&ROSL^_lyY)v^udwJlfE;?*_g$iK2Qke_9%k#&%G zx@B+NyZ!wA*Eg8n{hMuwe&LyX9lpnnE^p6C;=#*@yD9W#EzxbFzO3uA!s6G>F9Ko3 z+}vYDD;}c9Lanq>7;blAd?(R!oLV_e1N_iL!2X9+Lk~qhY-;R2OFleH1N?|0f%?co z0M{d{wWou8RNm0zB_DZd40ljK-O~rqV9!l>c4ojw_V)pie8vQNVL)9bB85s`KqDgq z<4SDgLa0hO()W!Bj*NW+>In`NZRq?^DNI_u%~y(PsK`>t=r)R4Nd9jY8a)8mTI6Kc zkaVm##o)weE>_3E5*+6H2DhuTNts3vR2)I82vqWgOF~o(aFF*4HDPZ7XvuBBdAYD- zPu0-!zc2aikwh@a%W^0YjZV1}npB?qZ#*8GggY6j62Gw3T3(iLH=yU>oEWx{*d^Rt z)+rKh12%Pn8D zecrb6z4WQ!4Qgb&x-L`gNmqL^)vuMdP+(^cq)jrqJ^yjOx)DHA>@W=!3+Uq*Um#e<0`@C*tAbs-n4QhA`Dq!V& z?VZ}RrEZgP6zX4wX-G2-3;ZX-N5T^KN9GkMD@^@U88-$ux;$M+2~VABiHc)ma5||5 zuVdr!qj|~oC^V>nt=e5_ zZ4;ya^JzVYYK2y40IcLCm0?*S5aU@^yKU7x((M}=99GTeE{%>3`e6M=|LFNXEreC- ziYP|4hC#w%s9oq?v;!d00`?ix35`l3%Ab+W01j#el0Sr3mLv$`E9CkLl{`irkI_qy z(UHgK#GldO$LP?ut^U^GjIBOxt6%6_x3$bzww+aT>@2%5{)zaJxHS3M^}E;C9A{^& zS*n#NT|AaWFfH4)X_eZ+tLzYABdQipXAw-x_1d&rxr0~P0~FDLDKKFQOskbo@FblE G)BX*InYKy* diff --git a/custom_components/bituopmd/__pycache__/__init__.cpython-313.pyc b/custom_components/bituopmd/__pycache__/__init__.cpython-313.pyc deleted file mode 100755 index ba07eb559e54887eae9a6f9ebf87c592b163c17c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4293 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 v!(-+-nQaVGPO5zF%m#vCzSxY{~<9ZXG zHAMt9c0>eG)c}GBSA8f5qB;+4c&OAkXcia>7fNqzCm^bbV z`r<{wB1#ydkb%1V1{VI6;>*;!3YLHkD?6?@3q7T>>o^HM#E@IixF#1`W5>)fI}y6dC3zX-_61(LT6I9qCQ# zJCu+-@+Cf1fH7^?G#^S^V~{56#P#Qf~SdxsF@WL~zNXGU%N$cgFv zyzj8XG~h{v-q$)-eQ9T-pMN`p4-`!SCV!f8C zv;1GwlVU{C!~LRcMibl^fgYHlUc2qtop?Es98QSak`RQ3J$HGDG5EOI3VYMfIWN{i9S2X(NfS z1OW!IDJdA7QY5Nsx}+)*RnhuYe0PRj>e=4i-MPcy6?|^hft^dtCz7lulJJQPKB_Bm z&ER9nVMR4;p}|2VAsa4p16E!MI1R2pq=AjOm{DwPASBDUI-I7W8TS4pY#=&lxWWtB z#-NJfTD+FsdMhwfB&x(@SS_r>32EqDVxirBw9$IF#_*7)C*$y_@xf#Q*3ude;JG~B zXwB0wIAScZHlr!}(4ch~>S~y+7=GGn$W{L&-yJ1icuP(Wj1Qczp7t(3+C5kN*u`Cw z>t>3Zk3KukaC{)^t(^5XO?#VWyc=hQjTvF%yqn{nq1oycv(+1>t2fM4H_w(ekGbZZ z#JA*Vch+7!YcK!UUY_-@ob|7s_OG7xubK9*nenf^L3qazI=*eJ^VCyWU-_A}7n;vD zpKF=+t;`lxWPPP)Y_I%a_Kx?SZ=N_j*?o2Q`&}PBn|WTE?s+M5Af8F2 zrVkvRsY-w19-VjLgkO0HUw&$Mg8PK4pI=2BE#J?%%5M;c56lU^lb&%;rtInW>2;xP z&RaY2N=8`u!#odzx98oYd}H4B8Co!Iy4rnR=*WtVS4XDHw!ZE{>u2b{wSG9ZV`4{u z{ma0$AUj4PRA!C~ zK~81?H+h)@X&WPlXwbfs1b7uQT&kfx22P4$8xJKyeTo&m808pOVnj{GMZI6ig>%Cl z*o4^;vFhdy5Wl8?OiY?cpj&MLk;d6Hc>_WzP4zt4Sd=Rlve*@x2X4rzOn^q<@C}(541uUC>cLQ~H(*oiVM&A6h6W)T1qp_tx+JMp(5t#|$g{J(wO88O zyQ_1@p4OfKXKP&IVBYJHq%fX@Bx%SUAgqq6kKyPV5UFJk&MXc2)gA{!O$33V zQ(iO1op8?yo^kuMP;sX8{P2}MAMVcx6`u$@Z-Na8=Xeh41P{do`G0|8fjodNxgUTI z;DO)O^c>p(m?<3Zf{9FFrUEc6^q&J>_jZ980PM4iVSkhY&A_9(tK~s?Cjm$*1xUio zYc_zql2JzY<*#Kf&731|GkNelMrC%CIl?S}nh8F9s;bKZjQjc9ZF|424>mP6rtFRV zA#};!RE-5mkl*4%iD)>aqqhQB^oy;}ZEX@S3Fd1x$`&QuLrQoEh@3Q#JYWc_^2(5+ z>6*dzDZ2Icxd9Cz$I*ExtPEmqHNhajs%zn4@CFSssiqh#fP#iE0Nlg$4x?DIR>`>} zZZNg_$DHLJ^QT}sO$TA-+eDT6w)|>S#$7jWhtBVB38bj<2B9$9TyagNcGLUp z)uGJN&Y9w_jJxZHc@{_ht!;pXkI~k(^mub?8Tm79E#;<238+&g*gI89Q7?0}KEX~c z^|r2Nr&jZ*Kf%K2{eAd2c=rE?505fw=6+OI=JGy!h-$?Y(%OBkfKKJ<+XF1@|J6K# z?8wg}JI^y_WJ&i#7P~02Txs^6$YPHGmAix1xl>Q(BFmy;CdU#(30VZ<6CVz#ikKr? zCQDP}5J|~IgNimwRcaBvNmH7_87KJzh+JSf z9i2ZFSi3Vd%?}8yv5qe){TC|ER!lU^R6hQ3<%^TM-h2Mt=dUI{O3ti(F7TIhOh3<5o}+t#xmtmjd0 zVPP~NAPgd0N|ND}BzX5jG1T3X^vX~umK(84lAH_!QXQ)|IIRkcGIgrp4I)^u4h84*LGMY|iqi)&ldF<;-j1p*lgCR(eF+kuln zjhv?WAca5&o+9OU26ZxA;oW9SvjbQR+|^{DSoz@nF}OSja4r?V8G#XC8G{&CEbLe> zRM|Slj@i;ogz2Yg9`0aGKxF|~`Lyi{e>qp-!wiKw(_~-;*dDP3?5VN^4rMaAToqtY z={6Npthic)Pa#+!>{7!0iRjA#-f%@_tWlucP&M^uunofv>~JUog~yNz7>EU!;nWJn zT5rIuJ^_8E8yPkztgue8OID#`*i{88%CKVaQ1`?X@W+s@tEP7vK1&B62T+!xvWgF7 zakeGa(Be`^ka87MK(KPga3zf<)fL3RoT+YQ|rzwJJWY&UuMaQY~_k< zae219_Lj?5=S1_Gc`vCH$GA5JSJW1tjE+ZVy$#dehBv^|l^6AXOZGZ$mCr~Vh6KY0>^R|XDd8}1vEv8dW+FHa-xon^Z?CKU6 zP`9FZ2F0@|%#8XHYCR~nfiSn8OQ4v?%w*P!1Bj$%0j0K;zYYZg<+2+ z`}zPr&4V!nz-9%%WvZB-jHUg_xB|6HRMSHVy;anotDPSdbTi9r!*wg9&T%sFj5fDISDgBIiKO?@+NzLbE{pV!O zw+u&_uLy{*orG@qD_QY{vubR`tg~v`S#@U1jI;iz{clB;6Sdci9y#i{!97fu-&$Wo dJ87o&saphSmY1PVIV`A94&jsdLapU4w4w)^*k)&!Crj zp==ZFL%u;j^$%9jDuG-eqwVHvtmUobLd|Z*c&4=%EUiRzh|XsjS|_@Kgsy8Kpm7I@ z=(%9sZ7H^^*R@{I`Yt#@&lU9zlT65eA0NUYMsui%X*n(@)hIl;4`)?1o$5=e33UdZ zok!(Y5;1uoHIWWkw5lWNG>s=xQ8i7CCR*Lms2YuA;!#yDtJK_vsy~`Y%5fN{=14j< zk(h)|bS9iuN9AaI=01K;h}En|M}~U`!kYWYNVs1*J|do$22S4Rv69hjiX2O);!3Re zQTW!x0lzokxANzpZ6g9~QG^-An#a-@YbtQKl%<(O$S4*cOJnASlTrI)Rq!Y>5sMeL ze3xZ+al8T8>?xT073NJ#n0J#D7vV--PxjnhRHgU`KZlswm)%1yXY`S6rE2Q(^b~S-N~h_V>RZ7h+cEP8ghIytg&5RW3##}AFDMV za06h!!08@OSR?xMy>bX-q|F5@G3GT}jaHeZwOp*KJd*iM0-3aowV3VC{Y}IwR_pUy z#hPMXTg>Y)-(9qXv9?m5J%6MJc7xQ#rkoMBl|-E4y7JlWk zkxprjiA!eDR%|ruIXrMWG9sMrJ90ccFr+z$k3@=XLY&4=>*G5oE=jKx$8b+aQ`w1V zOwCf6YOd*6)~uS{WPp^QriBSvjZF!09rA<(V4eUUB5YK8;CFu~ER$BP$SEaFrPvw} zPX7IMOdlPZ-TP#vkV>mCNH!JMhmsNhU@##%p~_Ua6iuj!)FiEkp($<~4WPhnI~}Ul zI9xS~W}~f(Dl@5=6phCv`BgcV1pt;VrpGlqm0!-vimGUqNmWe>qvG1x zq!S~@j`fXdyi94DYCMgmCS{6HrP=kjkrHvuJ|xfd>Gj@TRiz2oN!fU|>GLMi8m}Z} zIipn}+>go=R92>pP@&ZtA>u?j7R8S-v>}(qO+^(2=EYCes>|LVV@pC-G}}~KQKdu% zKSIf*Q;JO2<9oS`4UljHG)C4KiK5YJHp9Ra1>as+OWL+B2oVrnx?)*HO;5w-Ov7+6 zvC=i3P_yaGbiB*x+nJfss*G1rWHp_w#e}sl~eOOLfoRs{OFxgNECkpLZ?pc)k#9e!Js($HH@q!LFsC zc&q!veIM+*J@k2CahF(V+FEGb@b4k!UsRG-L(&W zUDsX+wiTK-78=|2rTCfa_scxA`~7N=R0V#~ne&BK2;c4z26p%L+WrH~Tbui8$S;{bKlgF(b5Q)W#|zqDIokSd?60=XKUHBL$p!nq&?)I+hC~s z&w-e29F;TRL}1O}gXxrzo)Dsf;fbBG61Rbt2qqTzt^5`Q5L+ffl29^Igwa_J3p(%v zBZ(G>uf+&w>M$2MkvF59!Hu97j;yD#64x2iNj54YOgFoOiLlVxiZku9M7Rhm+H`JZ z2VUke`j*QGZ$=|%&xsDb9}C3S3bWbFmgoG*5^ED(P*a+H(jD?VS~rjaz=*{BxPxG);k?f^h`%H+oz-SC7FgpPR%Jr0fHqQ zNAnrwY$^dDk3emsGKANdtT}K>bp~`4H^%^UFNWQyYDPA#%IIDWr*_E%-Qqk6c&U(8 z;}khLL7%~)Dg+}1O~?RTx(7eYE&)0xR7uj-8;fYJVbSKy>Vwc&`8yB*sllyt+)sUl z*3d#Ax1}ey;kh~PuDz+y*t)Ry-9zsjy0z=JWwC94uJu6fz;G@QhQ{7|EnDveg@w(z zz?NLY)@0s7WFn*(UDYQQy+JAd&F*K0t7|hiV z6@<+Qlwe5F&j##u*~ zVY=y5IKn7lPz-~($I9E`7r}&`n>-&4H%rBWbpFja&YP5WlK!+&;G5ZXouw6u9;qU=yn69d{Ehdth2dpTBdXcRRYi3(qx)CPA zoMeYP3BhBC!loD#j8bfb9WB9+{xZ4{S+hkuv^V-pkdS4zyI=QA0YD-d7tptnZjQ^s zbehV7l1-@5aoAO4n%}Ogp$o`wy^Z$D_Amu@0Ec~2$sWwT6PccKghV0YMo2rht>CI_ zJC$O(2<#^V76eX_vP3ME!ksyG$%Zx&0telSR!|oFN>>6Ai)k5;=K>i9l&n0GSh6^1 zb;__qksU?vc`QXrmWNjn?c}lJW*ykT|6mR{-u=be@$RN2tl6^0sf;3$-?Y;)$iVP2 z$%INTVA&c4lT5`m3mBtjNu*TGc_y078pj8E9((bc%{+$jnJ9&`iqEhtv{6IBVWw5B zsf!(SbI=2iZohO0r)>K}##-$7JTzSS2M`c=T-8?(TsiQQL-UD6*XBY^a4uZ%*T3cY zxo1AM=x?3l3&Ey2-VnJFWWDhA@$1JIq{XJ)IeVbs6LNOp+f@%~ zXa#OsHrS14muhzdFRj}1Xa(+i$UpOKhpX97s~ZpR;6Hta0eRC(Pe27loTbN597loJ zO$R}Q;dlih7;ywbuv3!Yz?)5C*)2&gXQRntjZcy$5>!!>2{;X%uX3Fv88teI(2sQITF_Ka*aUNc1@&St9W|y)K~|(rc<8K-}d^ zr+Fm2xsa%FPe2dAj5>hm@r)kA#~+~RMd3$*J3Q+`HeBo!4!HqmvJmbJTV3;l0c?+6 zu*Q6Ki#1$E_Yw4)kX#{Euh78hC?iU9o|E$GuUSF zcB^DbI`;tAE7Dd2!PY;yGi6-$GPG9kUa81cm%Pn+Z}XD3JMZmY^zK=*?^(3(`A$L4 ze!aPaSztDC?^rvYLP!6fe*E9j4;q6lp)!h>Kvbjx!joHHB&|gr0Ca{>ib6r5f(W^2 z7R#@oxP;;sRTskl-jv=&)I2QZ&D6hHGRL;l$-<*_g|EoIugI?7u^a=} zjUc{p66V=|Bb&c=RxLRjzi>9rA76BCylne-e_){{?{B~CdB|;IHm#myx|o*v{i_7B V)p4eSX~{K&ZtVYtKuLd%{|1LQup9sY diff --git a/custom_components/bituopmd/__pycache__/config_flow.cpython-312.pyc b/custom_components/bituopmd/__pycache__/config_flow.cpython-312.pyc deleted file mode 100755 index fc620d582c2efb7d59fc867e99be69bfc514080d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12003 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 VVXs__7q^JTE%(75fhN!e{eReeuaW=& diff --git a/custom_components/bituopmd/__pycache__/const.cpython-312.pyc b/custom_components/bituopmd/__pycache__/const.cpython-312.pyc deleted file mode 100755 index c5b8fce797467d2ed4f8889797e0f78ab802dcd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 250 zcmX@j%ge<81l8AerbPnj#~=<2FhUuh1%Qm{3@Hpz3@MCJj44b}OqI-H5i~#U=T< z@yYqQ1^IcYKpXX8rh-%ym*^E#{^GF7%}*)KNwq5ic?snBVj&>$ftit!@g{@T4Nl<( Owj2BsjXXshKw$vZmq`r( diff --git a/custom_components/bituopmd/__pycache__/const.cpython-313.pyc b/custom_components/bituopmd/__pycache__/const.cpython-313.pyc deleted file mode 100755 index 9f5519e9ad1f9b58d5480818f9bd47e92e2bda23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 250 zcmey&%ge<81ZL^a(j$TNV-N=h7@>^M0zk%8hG2#whG51b#$cu*rgUaamMRtJ{Ji3l z#JrMXg|z%4g_4X^1*goC(tL%?ypq)PqQsKS{5-u|97!Otg4~o_>>2sRCGnXBewxg; z*yH0<@{{A^Z?U=f`#O61-Qscf_j8N)@DC1&_Y7Fc@EK(HEp7ee{JgZxbp7Pg;*$K_ z_~iWDg8aNxppE)4Q$Z?z|6?Vc#}cv2B&ZX O+YNq+MxG)LpfCV#Ye;Ya diff --git a/custom_components/bituopmd/__pycache__/frontend.cpython-312.pyc b/custom_components/bituopmd/__pycache__/frontend.cpython-312.pyc deleted file mode 100755 index 26c3944a9540a7c7c2f419fdf8cbf8ea0a2d612c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5511 zcmb7IO>7&-6`tiTcll3=)URbblq_4KZIM>&*p~b&iMFNKrqoIe62dJ(b5{}>iez?I zRzy>PV;hYG1W=3?mWsHjQS{){38F&}J@g>S1{$C}kd*?lTL(4JOL9{q0db8U`et^Q zloh5)2H4p*Z{N(koq6w@_vSBly9GfxboQg+HXB0!!VSF`HDco~5Oat}Je5Ep{5B-0 zF+<2eVVX|RV@!x4G?Ornu^|>{BhMzdF;mDiW)7JtWIzInSLp+n-h%ZZrM5yg1G<2C z?l$60g5e>paqWa_2Ciib*TGwFqmXlw^4h+{f;yD2xDG5X0$6Qb+*5R^^WfayXiZ2}xNJpl2%iCDz7kP}o5K<*vS?zMxF_=yb%=TCRs11I0C}8SX8(K znqciRtpj4}T8tMw5zniG{XvhACXc7mW4vGe zCS|IC+o^LqHSSQr;O0wq4P`cDK<;TD@$#i z%CcG9!=<-NZ=SwK>$*`$E}CGxwj`rdJl49*7$1N+XN@1AUo)kV-jkWFqv6HdR(Q}= z{~rovQM_TB05m~Dv!g-uF+D+fxuC*~MkFa?`APE4WNISm=^OCU3X_bC35p?>hKJ8+ zh6Dy9i<6z640{ly;O$fmj2KO)XRqXSD0C#9Rvgh(ayT{;#_};iQtYbnYfTZ`h{Es@ zIik?u5F*|;#VE(*grKm{AM8>wk-5S#Ug5%*`Y&8K_hZG#3s=WS6egA&PAN=WN+rR@ z8A(VCE1W32H7-c9h__bZG|NMUaE_o;UyDJMPS8C2q zUno@7&t1H85kA`7v%#6*{c{Voi^e}YmK^zp{#Dz+6JTGSxjY|y@X9BrKRlh=-}%RV zi?xg9+>Td2vt3*_OQ$XqdNo3Z7p_dVcNO@|6j_w3}%1L0; z$4LdfCdCvE<6RGj6-zigmg2_~n6`z(Z;eM1+7~Vy=2KB%)8k@7v_pTzj@LdW3Q{;S zE{~EW`Veiv7bG^~3d23A%s@!kZf~Q%GcP~2G~VbbSQ~Ey3RQJCE^jbaW0UCBWy>R9 zD}4SgFFIB8t`ZqBYEVMM;;Y4{PSg6UxdCi?(Dy= zS`^sOl>d4pDg$s&Wn3?yArTykGN7Vfidgu15es=LYXI*yWNGZ(Q;e+3qBvb7iv&++ z8IUvRJ_~qK#6&>VA{G`qAfCYstjovol5Z>7BBmTcx8MZ14)fkM%+WtL8WEtNu^eJP z)W*04$1jnmHF*|L1{=Y)0_erYC|!%Ffyc)-(y{3lw71Smp+R&Ey>}L#{qUIgpms!L zU@h;GnT{bNlBfwd{@;KTg+i0)ZTbx~K@kLFgU`89Sx$HQ{m&_XrjG1!EIH!2aBfK7 zWxUBMHkiOr!Nn!P%ZYWMwuskD#H%V|Cnn~rFtYHrEMoWsshya;3o6B2v;u%BVg%wo z%xCbJq8rGq;G$YTK@7Kw7(_E{h!`@OvG_fxRLH2Poj1RK{eI*9^Y@SE90#ZACno2t zea4=vJG9_lFy?BGu9}WLaqoX{?2{88o>;h+KX7`*eP+$wlXv$l_N=&jzeWag`#P>Z z4HW8k{igm`^&d2>xtpg01$XUS$DNM(_=D!$F8_-A(AP$gBJy8h`DX=p^RsmtQ-n#X zf8#Izl|rE4%`RK_erm}8}M$&xB93yY8?COwj$$mi4>l~p2*X7~}C$1T92A^zm9$ z8pDZVk^~XQ;F4ldW1wgxkstxkHuO=LGz2N)Aw0kq%n{pxA-;?&yb}a(#KS;+6KOhu zo2lB4G#n8D<2VagVKsiUbD$Lo2&!NXs-1{Zr8~b_oo^ ztbJk6LiNI%i_MEQxtiWpQ(qZ83*_B_#lcVIoI9}M9x8!a?)59*8`TJQtvgW#z#5C} z*;|Gy`;LguB-%hNiZ68tP; zE@+{ZauG=>hXZV6N)eKDcndC)O97taO+3w;c`ior6yTjU1E9C{#VCZgf&OLY-#*t# z&4eXq$`-I9phu2qj2-mmWYjv%1May+ODP&GD+?rAp|&A z^R+N!C@{V}s#H!0SBse)$#LLpfP0Xu5izW0oi@E0gM6@<(S#%XSS+cqYL2L|aS4wZ z9LntN8t5zDo=9*isFytgPu5dc)oX{;8UjznehC@$X0`XP&bPUUU3Kc>B zr;O>{zIXc8n5I0_R4^Scm~6Aw8S9#?gND#&4v1x3p-vaR5i?pa_-iGyJxe~?6iE1 z%x23vsjAT7W7Ev6dB*(9*WT~V?QF}z31jjXb!o^m4flep%z=Wg9pUt^G6$b9)^{(x zb17GO{4=IweHRS*T*4>e&93&IW@_m$)zic*9qIBz!&{+niPXplScI|*TL@fhsCRR| z;_gd6ydxrx;FP`JDno28tQxbr8_h4ujtYsiAWFVSEQQC@uJlM6p%}qj;3OwIiP1zc zL$rSh^b$lB;>iYLq*YNH@6k3me{MRd=HI8qAHi3=!O}UX)@h2O{(&5yqZ6N_^Pi*M zf1;MBmWJt;HA_R@(lFng$dYn* z4M9w12$^Z{lf$$rZkw4hZKgvW+VIeaKJ>u`rtRaZrm?bxDKniu(5Fz7VbaM%&)wBZ z#t|8&tJ!5H>0!s ze#&ZN-x{kHWYnNlR+k)M!$h94zD~vYC{nr&U%z1ugG@_Q&win3527NED_e z2WBK1J0%9Qk`ucm7CwNJT-crALT4*}NuB6tI{!2Z%Ws5iYexlErM~TvnSd6sOV=t4}Qjf!k+u`>Ncrlw*a?E}%bv zcllFLehU&o?F1EHAz+gdMNA{_h@d>3aKu;6Q5A{Xwqp;0dYrWz_C5<}2*W%~f{Al> zU%ifd>$FXcgN<5`LAxj3 zZd3JIB04vi!w;69aQe}!Ons))8bV%J%@s3t)Mv6O3ADApNJI$~@3edBHAqy=stX;U zC#aAsW-yaURV{fE`M2|hi+K?&lQx)qa!N5A*&@8@Qq`WoWHfxKPb{&!Knm)BWoM?@ z^od*5kl&z_#iHR)nbs^5`K+QEKFdFt{t26J95>*ha!3waGx3KuV7f;vWv z_0_g_h2ay3ji|qd;xW2{DuK4y%QKhfddh*#*O*F6@S5vUYsXS+ce%BDskNuv+VlGh zU$h>*cC6CYdE@-`^EdL>^Pir*eR#3$^=l_8!Hydv*GHg}7iMEKv75){oqzT(_{*JV z?(?G$fO&f6^jz%L{?Ctoc2wWd_lIrs?elJZ?a43rktc4{_S#b(xtnJB8D4MQcFT32 z+fxY$vzKNr&9yHEHtF1^Z&cz$R|oxrP1I)qbsmG0WwP6{YiN0(84%IbBErK~Lix4v=;6kxj^AMU|7&+N8Bst+EUN3K%CRHL!(q z(;9Tkcq_;+!;A11QV=&a3j$OsG3_6rFPT>!c{W`ctavwFiB^JZuAKgw@j5r-uq~Sc zqV`mc8ZGYu*2`ffAf{X5uqWe&HK)R`MA5@V^fJ54pC?r4xmZ+ z-wV{obj-;kz)fc(jNM-yJPfD%bMh>plFy#GUIXH>dnj8A)d4X9{A?^6hW5%?MAU^i z6nh=s9q_t0pWzZ3NBbuO0>|EO0 z;f+uj?wUOSz?tTNcM?8B=ESW)u7oAaDNL*pEUYm#BX&u^hB%Kp1BD4TP3?2;k1pTb zbo2PlgSvm$HTnS;nDx#0^fh~K3%8wmThD#&zyo2&tplGQ`s~o{3+0_h7lqfBgu${f zI6t@~4BZ!o{t~Tptp8=l&pLk5xg>O5i&lj88@<{V zd&k2Px|st09f2ZrD?6~ub*GaW*udXe#}m5S1^mWN5{1LN7Gl6AuYO)=giL~#sJ=H5 zO2!ZgPZ|Utl7sAPe5G?%r;igSUG;4 zd%gail}GVL-|bB>_3wU3_x~5T{%_!o!W)Ly_kY6xA2WO^gzgh2#OG6&EPr>`+6GI` zuxYi_Tw{sTMTlRNG=3F68}Ean)KPrzX&+!9`D z=`FYP{&CxUL?4s&q@qu}Q%+)CQWsmaC0^5c%?6Z7z59^9{_vu3MCXrGUJPA(Z*RHX zdi(A9uK6~-ZRkEX+yKC$Wg$8*=|Xf#7%K~7`kQB80>lykVnq5$05x?zVUcf|dcvV# zf~umL*$*PRF!rtb9xTSyU{s`jN(~-nZuBD{7uu=8qs+pZ{w>hBbBKx}_Re9N(60rf zEPYp?q7L?Mu#M0=c;Mgdp`r|X_W(=iqZE|)kXbq?kh#a0G|NWUyYBH+G{E2U@r3Sh zfutEH#X?@Z4K%^qNkNV^93lnD3aw2h4vxvrnP(6tyNh>|f}9jqz_f)>BsMY&axkf+ zAYs<7N1lBo%-UEDv;M<4oqNCbdz^c%p=4BQrPq>Z?E`q1{{-AB0;4!ERzHnGlzKn{ zSm}^x8zUpN;~&tHdH%SEprJwN(kNJ|BP1Z9vmOZ5f&uuL@mc_q0ECrX5+}JOTJlKT z9tw2w_Hy6VK;j$gu=@YV7J=mIg@AUvhQjsE@g|$L(bqf~Z03r(;b?*y=2IWNB0+4F za{B6$83r)(b9jqpDC95gl@tw7&5UjBl*hapAi?ecxeFLQ9TnleK*?YT=nMI939_db zr%e|xUbJ#3>y{*ZUM=l^iEQh;!;Z&{W^0bx#ft*@p?wii@|x6c^Df5VY)wn;Q|WBq zBwXU`&Sf)`S{~xM-RBB9%}fFzaRe;4@;Vt37?@w1G@36e=V}?CnG?am05>M*l32E~ zQ{HZ7A#bcDJaRHUmCYNhl}j4zd6mpLR@&M>I$XPEQQ<_eu8owmxvsXRAs#Yth(e9d zRkusk#EGb;ViK8KxK83i>sTP+2~nbY8+8HVLo1as9o>vk*MSI}I3z7ri4TirY-#x6 zyqhkQ zBd6E(3^|>yugsze@lCV6GrhA%W{!MveD0vWWuM-$f63cZ_VzsRZ?5o7l?~mM4Ld7q zyDA;8RMu{+1Uu*6(uMAdF!-d|9q>FuZkXCEg6P!)*O*!NjQb~Vd^Du5+pWVv!$lr3 z+=s*O4=*vDWv26!*nMVaHfjQ7J+@1N3}5B`nmeG&onp0vQc->3uvukIY& zME#Z;T+jTj9|5@#rUo}M3z7Z^H12MspbRw|u3RCL0edml6Z?V75$h5#T(b=|Og0RY z@Xpx!LK)K{Wy?jdxmR7!%T6k}qJq_MGFu??RqqbXl)`X=F~K2EqD}LD#54fI4dMSO zP)@u{E2GrwFd__|f^>^fdhz>nO#~-4hf1vFT zQQsp^^OC3Yub$4ip+(QuE3T(Z3$+<;5}qQcpLVaI_UdihpCYK2_jOQx<_YmXMRVO} diff --git a/custom_components/bituopmd/__pycache__/sensor.cpython-312.pyc b/custom_components/bituopmd/__pycache__/sensor.cpython-312.pyc deleted file mode 100755 index 9895acbb78a131c2f96052c825666e3724a8fa10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20419 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 diff --git a/custom_components/bituopmd/__pycache__/switch.cpython-313.pyc b/custom_components/bituopmd/__pycache__/switch.cpython-313.pyc deleted file mode 100755 index e346350639149903b973fedbb5b5b821c89e411a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8358 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 diff --git a/custom_components/bituopmd/__pycache__/text.cpython-312.pyc b/custom_components/bituopmd/__pycache__/text.cpython-312.pyc deleted file mode 100755 index 5edf47223578cc4ab61a91a43056ae0b2e26be0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6465 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 Vy~wq?+!8~$XZ!YV1j str: + """Validate and normalize the IPv4 or IPv6 address used by a device.""" + value = host.strip() + try: + return str(ip_address(value)) + except ValueError as err: + raise BituoApiResponseError("A valid IP address is required") from err + + +class BituoApiClient: + """Small async client for the device's local HTTP API.""" + + def __init__(self, session: ClientSession, host: str) -> None: + """Initialize the client.""" + self._session = session + self.host = normalize_host(host) + bracketed_host = f"[{self.host}]" if ":" in self.host else self.host + self._base_url = f"http://{bracketed_host}" + + async def async_get_data(self) -> dict[str, Any]: + """Return the primary metering payload.""" + payload = await self._async_request_json("GET", "data") + if not isinstance(payload, dict) or not payload: + raise BituoApiResponseError("Device returned an empty metering payload") + return payload + + async def async_get_auxiliary_data(self) -> dict[str, Any]: + """Return the optional Home Assistant capability payload.""" + payload = await self._async_request_json("GET", "hadata") + if not isinstance(payload, dict): + raise BituoApiResponseError("Device returned an invalid capability payload") + return payload + + async def async_get_switch_state(self) -> bool: + """Return the relay state exposed by switch-capable models.""" + payload = await self._async_request("GET", "status") + text = payload.strip().lower() + if text in {"true", "on", "1"}: + return True + if text in {"false", "off", "0"}: + return False + raise BituoApiResponseError("Device returned an invalid switch state") + + async def async_get_action(self, action: str) -> str: + """Run an explicitly validated GET action.""" + return await self._async_request("GET", action) + + async def async_post_action(self, action: str, data: dict[str, Any]) -> str: + """Run an explicitly validated POST action.""" + return await self._async_request("POST", action, json=data) + + async def _async_request_json(self, method: str, path: str, **kwargs: Any) -> Any: + """Request and decode a JSON response.""" + raw = await self._async_request(method, path, **kwargs) + try: + import json + + return json.loads(raw) + except (TypeError, ValueError) as err: + raise BituoApiResponseError( + f"Device returned invalid JSON from /{path}" + ) from err + + async def _async_request(self, method: str, path: str, **kwargs: Any) -> str: + """Perform one bounded local API request.""" + try: + async with asyncio.timeout(REQUEST_TIMEOUT): + response = await self._session.request( + method, f"{self._base_url}/{path}", **kwargs + ) + response.raise_for_status() + return await response.text() + except TimeoutError as err: + raise BituoApiConnectionError( + f"Timed out communicating with {self.host}" + ) from err + except ClientResponseError as err: + raise BituoApiResponseError( + f"Device {self.host} returned HTTP {err.status}" + ) from err + except ClientError as err: + raise BituoApiConnectionError( + f"Unable to communicate with {self.host}" + ) from err diff --git a/custom_components/bituopmd/button.py b/custom_components/bituopmd/button.py old mode 100755 new mode 100644 index 4326d70..4aab61a --- a/custom_components/bituopmd/button.py +++ b/custom_components/bituopmd/button.py @@ -1,150 +1,81 @@ -import logging -import requests -from datetime import timedelta -import asyncio +"""Button platform for Bituo power monitoring devices.""" + +from __future__ import annotations + from homeassistant.components.button import ButtonEntity -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, CONF_HOST_IP +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -_LOGGER = logging.getLogger(__name__) +from .api import BituoApiError +from .const import CONF_HOST_IP, CONF_IDENTITY_HOST, DATA_KEY_ENTRIES, DOMAIN +from .coordinator import BituoDataUpdateCoordinator +from .entity import BituoEntity -SCAN_INTERVAL = timedelta(seconds=5) -async def async_setup_entry(hass, entry, async_add_entities): - """Set up button platform.""" - host_ip = entry.data[CONF_HOST_IP] - try: - response = await hass.async_add_executor_job( - requests.get, f"http://{host_ip}/data" +async def async_setup_entry( + hass: HomeAssistant, + entry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up buttons from the shared coordinator.""" + coordinator: BituoDataUpdateCoordinator = hass.data[DOMAIN][DATA_KEY_ENTRIES][ + entry.entry_id + ]["coordinator"] + identity_host = entry.data.get(CONF_IDENTITY_HOST, entry.data[CONF_HOST_IP]) + async_add_entities( + ( + DataRefreshButton(coordinator, identity_host), + DeviceLocatingButton(coordinator, identity_host), ) - data = response.json() - device_info = { - "model": data.get("productModel") or data.get("ProductModel", "Unknown Model"), - "fw_version": data.get("FWVersion") or data.get("fwVersion", "Unknown"), - "manufacturer": "BITUO TECHNIK", - "mcu_version": data.get("MCUVersion", "Unknown"), - } - except Exception as e: - _LOGGER.error("Failed to fetch device info for %s: %s", host_ip, e) - device_info = { - "model": "Unknown Model", - "fw_version": "Unknown", - "manufacturer": "Unknown", - "mcu_version": "Unknown", - } + ) - for _ in range(50): - try: - sensor_coordinator = hass.data[DOMAIN][entry.entry_id]['sensor_coordinator'] - break - except (KeyError, AttributeError): - await asyncio.sleep(0.1) - else: - _LOGGER.error("sensor_coordinator not found for entry %s after waiting", entry.entry_id) - return - buttons = [ - DataRefreshButton(sensor_coordinator, host_ip, device_info["model"], device_info["fw_version"], device_info["manufacturer"], device_info["mcu_version"]), - DeviceLocatingButton(host_ip, device_info["model"], device_info["fw_version"], device_info["manufacturer"], device_info["mcu_version"]) - ] - - async_add_entities(buttons, True) +class DataRefreshButton(BituoEntity, ButtonEntity): + """Request an immediate coordinator refresh.""" -class DeviceLocatingButton(ButtonEntity): - """Representation of a Button.""" + _attr_name = "Data Refresh" + _attr_icon = "mdi:refresh" + _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, host_ip, model, fw_version, manufacturer, mcu_version): - """Initialize the button.""" - self._attr_name = "Device Locating" - self._attr_unique_id = f"{host_ip}_device_locating" - self.entity_id = f"button.{host_ip.replace('.', '_')}_device_locating" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, host_ip)}, - name=f"{model} - {host_ip}", - manufacturer=manufacturer, - model=model, - sw_version=f"S{fw_version}_M{self.format_version(mcu_version)}", - configuration_url=f"http://{host_ip}" # embed URL - ) - self._host_ip = host_ip - self._attr_icon = "mdi:map-marker" + def __init__( + self, + coordinator: BituoDataUpdateCoordinator, + identity_host: str, + ) -> None: + """Initialize the refresh button with its upstream identity.""" + super().__init__(coordinator, identity_host) + self._attr_unique_id = f"{identity_host}_data_refresh" + self.entity_id = f"button.{identity_host.replace('.', '_')}_data_refresh" - async def async_press(self): - """Handle the button press.""" - await self.hass.async_add_executor_job( - requests.get, f"http://{self._host_ip}/location" - ) - - @staticmethod - def format_field_entity_id(field): - """Format field name to be more suitable for unique_id.""" - formatted_name = ''.join(['_' + char.lower() if char.isupper() else char for char in field]) - return formatted_name.strip('_') - - @staticmethod - def format_version(version): - if version.lower() == "unknown": - return version - parts = version.split('.') - formatted_parts = [] - for part in parts: - if part.strip(): # 检查部分是否为空 - try: - formatted_parts.append(str(int(part))) - except ValueError: - formatted_parts.append('unknown') - else: - formatted_parts.append('unknown') # 如果部分为空,设置为 'unknown' - - formatted_version = '.'.join(formatted_parts) - return formatted_version - -class DataRefreshButton(CoordinatorEntity, ButtonEntity): - """Representation of a Data Refresh Button.""" + async def async_press(self) -> None: + """Refresh device data.""" + await self.coordinator.async_request_refresh() - def __init__(self, coordinator, host_ip, model, fw_version, manufacturer, mcu_version): - """Initialize the button.""" - super().__init__(coordinator) - self._attr_name = "Data Refresh" - self._attr_unique_id = f"{host_ip}_data_refresh" - self.entity_id = f"button.{host_ip.replace('.', '_')}_data_refresh" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, host_ip)}, - name=f"{model} - {host_ip}", - manufacturer=manufacturer, - model=model, - sw_version=f"S{fw_version}_M{self.format_version(mcu_version)}", - configuration_url=f"http://{host_ip}" # embed URL - ) - self._host_ip = host_ip - self._attr_icon = "mdi:refresh" - async def async_press(self): - """Handle the button press to refresh data.""" - await self.coordinator.async_request_refresh() +class DeviceLocatingButton(BituoEntity, ButtonEntity): + """Ask the physical device to identify itself.""" - @staticmethod - def format_version(version): - if version.lower() == "unknown": - return version - parts = version.split('.') - formatted_parts = [] - for part in parts: - if part.strip(): # 检查部分是否为空 - try: - formatted_parts.append(str(int(part))) - except ValueError: - formatted_parts.append('unknown') - else: - formatted_parts.append('unknown') # 如果部分为空,设置为 'unknown' - - formatted_version = '.'.join(formatted_parts) - return formatted_version + _attr_name = "Device Locating" + _attr_icon = "mdi:map-marker" + _attr_entity_category = EntityCategory.CONFIG -# by Script0803 \ No newline at end of file + def __init__( + self, + coordinator: BituoDataUpdateCoordinator, + identity_host: str, + ) -> None: + """Initialize the locating button with its upstream identity.""" + super().__init__(coordinator, identity_host) + self._attr_unique_id = f"{identity_host}_device_locating" + self.entity_id = f"button.{identity_host.replace('.', '_')}_device_locating" + + async def async_press(self) -> None: + """Run the device locating command.""" + try: + await self.coordinator.client.async_get_action("location") + except BituoApiError as err: + raise HomeAssistantError( + f"Unable to locate BituoPMD device: {err}" + ) from err diff --git a/custom_components/bituopmd/config_flow.py b/custom_components/bituopmd/config_flow.py old mode 100755 new mode 100644 index 42d2ef2..1aac89b --- a/custom_components/bituopmd/config_flow.py +++ b/custom_components/bituopmd/config_flow.py @@ -1,244 +1,229 @@ -import asyncio -import logging -import socket -import requests -from homeassistant import config_entries -from homeassistant.core import callback -from homeassistant.components.zeroconf import async_get_instance -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +"""Config flow for Bituo power monitoring devices.""" + +from __future__ import annotations + +from typing import Any + import voluptuous as vol -from zeroconf import ServiceBrowser, ServiceStateChange -from .const import DOMAIN, CONF_HOST_IP +from homeassistant import config_entries +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import BituoApiClient, BituoApiError, normalize_host +from .const import ( + CONF_HOST_IP, + CONF_IDENTITY_HOST, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + MAX_SCAN_INTERVAL, + MIN_SCAN_INTERVAL, +) + + +async def _async_validate_device( + hass: HomeAssistant, host: str +) -> tuple[str, dict[str, Any]]: + """Connect to a device and return its normalized host and payload.""" + normalized_host = normalize_host(host) + client = BituoApiClient(async_get_clientsession(hass), normalized_host) + data = await client.async_get_data() + return normalized_host, data -_LOGGER = logging.getLogger(__name__) class BituoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle BituoPMD configuration.""" + VERSION = 1 - def __init__(self): - self.host = None - self.name = None - self.devices = [] - self._manual_scan_active = False + def __init__(self) -> None: + """Initialize discovery state.""" + self._discovered_host: str | None = None + self._discovered_name: str | None = None - async def async_step_user(self, user_input=None): - errors = {} - + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Return the options flow.""" + return BituoOptionsFlow() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Set up a device by IP address.""" + errors: dict[str, str] = {} if user_input is not None: - if user_input["method"] == "zeroconf": - return await self.async_step_zeroconf_discovery() - elif user_input["method"] == "manual": - return await self.async_step_manual() - - data_schema = vol.Schema({ - vol.Required("method", default="zeroconf"): vol.In({"zeroconf": "Use zeroconf to scan devices", "manual": "Use IP to pair devices"}) - }) + try: + host, data = await _async_validate_device( + self.hass, user_input[CONF_HOST_IP] + ) + except BituoApiError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + if self._host_is_configured(host): + return self.async_abort(reason="already_configured") + model = data.get("ProductModel") or data.get("productModel", "BituoPMD") + return self.async_create_entry( + title=f"{model} - {host}", + data={CONF_HOST_IP: host}, + ) - return self.async_show_form( - step_id="user", - data_schema=data_schema, - errors=errors + schema = vol.Schema( + { + vol.Required( + CONF_HOST_IP, + default=(user_input.get(CONF_HOST_IP, "") if user_input else ""), + ): str + } ) - - async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo): - """Handle zeroconf discovery.""" - self.host = discovery_info.host - self.name = discovery_info.name.split(".")[0] - - if "energysensor" not in self.name.lower(): + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: + """Handle Home Assistant's native zeroconf discovery.""" + name = discovery_info.name.split(".")[0] + if "energysensor" not in name.lower(): return self.async_abort(reason="not_bituotechnik_device") - - existing_entries = self._async_current_entries() - if any(entry.data.get(CONF_HOST_IP) == self.host for entry in existing_entries): - _LOGGER.info(f"Device {self.name} at {self.host} already configured, ignoring discovery.") - return self.async_abort(reason="already_configured") - await self.async_set_unique_id(self.host) - self._abort_if_unique_id_configured() + try: + host = normalize_host(discovery_info.host) + except BituoApiError: + return self.async_abort(reason="cannot_connect") - self.context.update({ - "title_placeholders": { - "name": self.name, - "host": self.host, - } - }) + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + if self._host_is_configured(host): + return self.async_abort(reason="already_configured") + self._discovered_host = host + self._discovered_name = name + self.context["title_placeholders"] = { + "name": name, + "host": host, + } return await self.async_step_zeroconf_confirm() - async def async_step_zeroconf_confirm(self, user_input=None): - """Handle zeroconf discovery confirmation.""" + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm a discovered device.""" + if self._discovered_host is None: + return self.async_abort(reason="cannot_connect") + + errors: dict[str, str] = {} if user_input is not None: - # 检查设备是否已经存在于当前配置的设备列表中 - existing_entries = self._async_current_entries() - if any(entry.data.get(CONF_HOST_IP) == self.host for entry in existing_entries): - _LOGGER.info(f"Device {self.name} at {self.host} is already configured, aborting configuration.") - return self.async_abort(reason="already_configured") - - # 如果设备没有配置,继续创建配置条目 - entry = self.async_create_entry( - title=self.name, - data={CONF_HOST_IP: self.host} - ) - - # 在设备配对成功后,移除已配对的设备 - await self.hass.async_add_executor_job(self.async_remove_discovered_device, self.host) - - return entry - - data_schema = vol.Schema({ - vol.Required("confirmation", default=f"Do you want to set up this device?"): vol.In([f"Do you want to set up this device?"]) - }) + try: + host, data = await _async_validate_device( + self.hass, self._discovered_host + ) + except BituoApiError: + errors["base"] = "cannot_connect" + else: + model = data.get("ProductModel") or data.get( + "productModel", self._discovered_name or "BituoPMD" + ) + return self.async_create_entry( + title=f"{model} - {host}", + data={CONF_HOST_IP: host}, + ) return self.async_show_form( step_id="zeroconf_confirm", - data_schema=data_schema, + errors=errors, description_placeholders={ - "name": self.name, + "name": self._discovered_name or "BituoPMD", + "host": self._discovered_host, }, ) - - async def async_step_zeroconf_discovery(self, user_input=None): - """Handle manual zeroconf scanning triggered by the user.""" - errors = {} - devices = await self._discover_devices() - if devices: - existing_entries = self._async_current_entries() - new_devices = [device for device in devices if not any(entry.data.get(CONF_HOST_IP) == device["ip"] for entry in existing_entries)] - - if new_devices: - # Show discovered devices for the user to configure - return self.async_show_form( - step_id="zeroconf_discovery_selected", - data_schema=vol.Schema({ - vol.Required("device", default=new_devices[0]["ip"]): vol.In({device["ip"]: device["name"] for device in new_devices}) - }), - errors=errors, - ) - else: - errors["base"] = "no_new_devices_found" - return self.async_show_form( - step_id="zeroconf_discovery", - errors=errors - ) - else: - errors["base"] = "no_devices_found" - return self.async_show_form( - step_id="zeroconf_discovery", - errors=errors - ) - - async def async_step_zeroconf_discovery_selected(self, user_input=None): - """Handle the user's device selection after manual zeroconf scanning.""" - if user_input is not None: - selected_device_ip = user_input["device"] - selected_device_name = next(device["name"] for device in self.devices if device["ip"] == selected_device_ip) - - # 检查设备是否已经配置 - existing_entries = self._async_current_entries() - if any(entry.data.get(CONF_HOST_IP) == selected_device_ip for entry in existing_entries): - _LOGGER.info(f"Device {selected_device_name} at {selected_device_ip} already configured, ignoring.") - return self.async_abort(reason="already_configured") # 终止重复配置 - - # 创建配置条目 - entry = self.async_create_entry(title=f"{selected_device_name} - {selected_device_ip}", data={CONF_HOST_IP: selected_device_ip}) - - # 成功配对后移除设备 - await self.async_remove_discovered_device(selected_device_ip) - - return entry - - return self.async_abort(reason="no_device_selected") - - async def async_remove_discovered_device(self, host_ip): - """Remove the discovered device from the discovered list.""" - for flow in self.hass.config_entries.flow.async_progress(): - if flow["context"]["source"] == 'zeroconf': - if flow["context"]["unique_id"] == host_ip: - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - break - else: - _LOGGER.info(f"No matching flow found for IP: {host_ip}.") - - async def async_step_manual(self, user_input=None): - errors = {} + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow an IP change without deleting entities or history.""" + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} if user_input is not None: - # if exist - existing_entries = self._async_current_entries() - if any(entry.data.get(CONF_HOST_IP) == user_input[CONF_HOST_IP] for entry in existing_entries): - errors["base"] = "device_already_configured" + try: + host, data = await _async_validate_device( + self.hass, user_input[CONF_HOST_IP] + ) + except BituoApiError: + errors["base"] = "cannot_connect" else: - # if correct - ip_address = user_input.get(CONF_HOST_IP) - try: - response = await self.hass.async_add_executor_job( - requests.get, f"http://{ip_address}/data" + if self._host_is_configured(host, exclude_entry_id=entry.entry_id): + errors["base"] = "already_configured" + else: + model = data.get("ProductModel") or data.get( + "productModel", "BituoPMD" ) - response.raise_for_status() - data = response.json() - - # check json - if not data: - raise requests.exceptions.RequestException("No data returned") - except requests.exceptions.RequestException: - errors["base"] = "device_not_found" - return self.async_show_form( - step_id="manual", - data_schema=self._get_manual_schema(user_input), - errors=errors, + return self.async_update_reload_and_abort( + entry, + data_updates={ + CONF_HOST_IP: host, + CONF_IDENTITY_HOST: entry.data.get( + CONF_IDENTITY_HOST, + entry.data[CONF_HOST_IP], + ), + }, + title=f"{model} - {host}", ) - entry = self.async_create_entry(title=f"Manual IP Configuration - {user_input[CONF_HOST_IP]}", data=user_input) - - # remove device discovered - self.devices = [device for device in self.devices if device["ip"] != ip_address] - return entry - - data_schema = vol.Schema({ - vol.Required(CONF_HOST_IP): str, - }) - return self.async_show_form( - step_id="manual", - data_schema=data_schema, + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST_IP, + default=entry.data[CONF_HOST_IP], + ): str + } + ), errors=errors, - description_placeholders={ - CONF_HOST_IP: "Device IP", - }, ) - - def _get_manual_schema(self, user_input=None): - """Helper to generate the manual input schema.""" - return vol.Schema({ - vol.Required(CONF_HOST_IP, default=user_input.get(CONF_HOST_IP) if user_input else ""): str, - }) - - async def _discover_devices(self): - self.devices = [] - zeroconf = await async_get_instance(self.hass) # Use the shared Zeroconf instance - loop = asyncio.get_running_loop() - browser = ServiceBrowser(zeroconf, "_http._tcp.local.", handlers=[self._on_service_state_change]) - - def _stop_browser(): - browser.cancel() - try: - await asyncio.sleep(5) # Wait for devices to be discovered - finally: - loop.call_soon(_stop_browser) + def _host_is_configured( + self, host: str, exclude_entry_id: str | None = None + ) -> bool: + """Return whether another entry already uses the address.""" + return any( + entry.entry_id != exclude_entry_id and entry.data.get(CONF_HOST_IP) == host + for entry in self._async_current_entries() + ) - return self.devices - @callback - def _on_service_state_change(self, zeroconf, service_type, name, state_change): - if state_change == ServiceStateChange.Added: - info = zeroconf.get_service_info(service_type, name) - if info: - address = socket.inet_ntoa(info.addresses[0]) - if "energysensor" in name.lower(): # Ensure the device name contains 'bituotechnik' - # Check if the device is already in the list or already configured - existing_entries = self._async_current_entries() - - # 过滤掉已存在的设备 - if not any(entry.data.get(CONF_HOST_IP) == address for entry in existing_entries): - if not any(device["ip"] == address for device in self.devices): - self.devices.append({"ip": address, "name": name.split(".")[0]}) \ No newline at end of file +class BituoOptionsFlow(config_entries.OptionsFlowWithReload): + """Configure local polling without writing into the integration folder.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the polling interval.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): vol.All( + vol.Coerce(int), + vol.Range( + min=MIN_SCAN_INTERVAL, + max=MAX_SCAN_INTERVAL, + ), + ) + } + ), + ) diff --git a/custom_components/bituopmd/const.py b/custom_components/bituopmd/const.py index caffedc..a48dda1 100755 --- a/custom_components/bituopmd/const.py +++ b/custom_components/bituopmd/const.py @@ -1,4 +1,15 @@ -"""Constants for the Bituo integration.""" +"""Constants for the BituoPMD integration.""" + DOMAIN = "bituopmd" + CONF_HOST_IP = "host_ip" +CONF_IDENTITY_HOST = "identity_host" +CONF_SCAN_INTERVAL = "scan_interval" + +DEFAULT_SCAN_INTERVAL = 5 +MIN_SCAN_INTERVAL = 5 +MAX_SCAN_INTERVAL = 3600 +REQUEST_TIMEOUT = 5 +DATA_KEY_ENTRIES = "entries" +DATA_KEY_FRONTEND = "frontend_registered" diff --git a/custom_components/bituopmd/coordinator.py b/custom_components/bituopmd/coordinator.py new file mode 100644 index 0000000..5b4121a --- /dev/null +++ b/custom_components/bituopmd/coordinator.py @@ -0,0 +1,90 @@ +"""Shared data coordinator for Bituo power monitoring devices.""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import BituoApiClient, BituoApiError +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .helpers import normalize_meter_data + +_LOGGER = logging.getLogger(__name__) + + +class BituoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinate one bounded poll for every entity on a device.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: BituoApiClient, + scan_interval: int = DEFAULT_SCAN_INTERVAL, + ) -> None: + """Initialize the coordinator.""" + self.client = client + self.supports_switch = False + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{client.host}", + update_interval=timedelta(seconds=scan_interval), + ) + + @property + def host(self) -> str: + """Return the configured device address.""" + return self.client.host + + @property + def device_info_data(self) -> dict[str, str]: + """Return normalized metadata from the latest payload.""" + data = self.data or {} + return { + "model": str( + data.get("ProductModel") or data.get("productModel") or "Unknown Model" + ), + "fw_version": str( + data.get("FWVersion") or data.get("fwVersion") or "Unknown" + ), + "manufacturer": str( + data.get("Manufactor") or data.get("Manufacturer") or "BITUO TECHNIK" + ), + "mcu_version": str(data.get("MCUVersion") or "Unknown"), + "serial_number": str(data.get("SerialNumber") or ""), + } + + async def async_detect_capabilities(self) -> None: + """Probe optional capabilities once without making setup depend on them.""" + if "switchstatus" in (self.data or {}): + self.supports_switch = True + return + try: + auxiliary = await self.client.async_get_auxiliary_data() + except BituoApiError as err: + _LOGGER.debug("Capability probe failed for %s: %s", self.host, err) + return + self.supports_switch = "switchstatus" in auxiliary + + def set_scan_interval(self, seconds: int) -> None: + """Apply a new polling interval without reloading the integration.""" + self.update_interval = timedelta(seconds=seconds) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch and normalize one device snapshot.""" + try: + data = normalize_meter_data(await self.client.async_get_data()) + if self.supports_switch: + data["switchstatus"] = await self.client.async_get_switch_state() + return data + except BituoApiError as err: + raise UpdateFailed( + f"Error communicating with Bituo device {self.host}: {err}" + ) from err diff --git a/custom_components/bituopmd/entity.py b/custom_components/bituopmd/entity.py new file mode 100644 index 0000000..fef05ce --- /dev/null +++ b/custom_components/bituopmd/entity.py @@ -0,0 +1,49 @@ +"""Shared entities for the BituoPMD integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BituoDataUpdateCoordinator + + +def format_version(value: str) -> str: + """Normalize the vendor's zero-padded MCU version.""" + if not value or value.lower() == "unknown": + return "Unknown" + parts: list[str] = [] + for part in value.split("."): + try: + parts.append(str(int(part))) + except ValueError: + parts.append(part or "unknown") + return ".".join(parts) + + +class BituoEntity(CoordinatorEntity[BituoDataUpdateCoordinator]): + """Base class that preserves the upstream device identity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BituoDataUpdateCoordinator, + identity_host: str, + ) -> None: + """Initialize a Bituo entity.""" + super().__init__(coordinator) + info = coordinator.device_info_data + serial_number = info["serial_number"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identity_host)}, + name=f"{info['model']} - {coordinator.host}", + manufacturer=info["manufacturer"], + model=info["model"], + serial_number=serial_number or None, + sw_version=( + f"S{info['fw_version']}_M{format_version(info['mcu_version'])}" + ), + configuration_url=f"http://{coordinator.host}", + ) diff --git a/custom_components/bituopmd/frontend.py b/custom_components/bituopmd/frontend.py old mode 100755 new mode 100644 index 3d91680..a84b87e --- a/custom_components/bituopmd/frontend.py +++ b/custom_components/bituopmd/frontend.py @@ -1,81 +1,141 @@ +"""Compatibility sidebar panel and secured device proxy.""" + +from __future__ import annotations + import logging -from homeassistant.components.http import HomeAssistantView +from pathlib import Path +from typing import Any + +from aiohttp import web +from homeassistant.components.http import HomeAssistantView, StaticPathConfig from homeassistant.components.panel_custom import async_register_panel -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, CONF_HOST_IP -from homeassistant.components.http import StaticPathConfig +from homeassistant.core import HomeAssistant + +from .api import BituoApiClient, BituoApiError +from .const import ( + CONF_HOST_IP, + DATA_KEY_ENTRIES, + DATA_KEY_FRONTEND, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) +READ_ONLY_GET_ACTIONS = frozenset({"data", "hadata", "status"}) +ADMIN_GET_ACTIONS = frozenset({"zeroenergy", "ota"}) +ADMIN_POST_ACTIONS = frozenset({"save-config"}) + + +def _client_for_host(hass: HomeAssistant, host: str) -> BituoApiClient: + """Resolve a configured host instead of accepting an arbitrary URL.""" + for runtime in hass.data[DOMAIN][DATA_KEY_ENTRIES].values(): + client: BituoApiClient = runtime["client"] + if client.host == host: + return client + raise web.HTTPNotFound(text="Unknown BituoPMD device") + + +def _require_admin(request: web.Request) -> None: + """Restrict commands that change physical device configuration.""" + user = request.get("hass_user") + if user is None or not user.is_admin: + raise web.HTTPUnauthorized( + text="Administrator access is required for this action" + ) + + class DeviceListView(HomeAssistantView): - """Provide a JSON endpoint for device list.""" + """Provide configured devices to the compatibility panel.""" url = "/api/bituopmd/devices" name = "api:bituopmd:devices" requires_auth = True - async def get(self, request): - hass = request.app["hass"] - devices = [] - - for entry in hass.config_entries.async_entries(DOMAIN): - host_ip = entry.data.get(CONF_HOST_IP, "Unknown IP") - title = entry.title - devices.append({"name": title, "ip": host_ip, "id": entry.entry_id}) - _LOGGER.debug(f"Device entry: {entry.entry_id}, IP: {host_ip}") - - _LOGGER.info("Device list: %s", devices) + async def get(self, request: web.Request) -> web.Response: + """Return configured devices.""" + hass: HomeAssistant = request.app["hass"] + devices = [ + { + "name": entry.title, + "ip": entry.data[CONF_HOST_IP], + "id": entry.entry_id, + } + for entry in hass.config_entries.async_entries(DOMAIN) + ] return self.json(devices) + class DeviceProxyView(HomeAssistantView): - """Proxy HTTP requests to the device.""" + """Proxy only known actions to explicitly configured devices.""" url = "/api/bituopmd/proxy/{device_ip}/{action}" name = "api:bituopmd:proxy" requires_auth = True - async def get(self, request, device_ip, action): - url = f"http://{device_ip}/{action}" - _LOGGER.debug(f"Proxying GET request to {url}") - async with async_get_clientsession(request.app["hass"]).get(url) as resp: - data = await resp.text() - return self.json({"response": data}) - - async def post(self, request, device_ip, action): - data = await request.json() - if action == "set_frequency": - # 调用处理频率更新的服务 - await request.app["hass"].services.async_call(DOMAIN, "set_frequency", {"frequency": data["frequency"]}) - return self.json({"status": "frequency updated"}) - url = f"http://{device_ip}/{action}" - _LOGGER.debug(f"Proxying POST request to {url} with data {data}") - async with async_get_clientsession(request.app["hass"]).post(url, json=data) as resp: - data = await resp.text() - return self.json({"response": data}) - -async def setup_frontend(hass): - """Set up the frontend for the BituoPMD integration.""" - await hass.http.async_register_static_paths([ - StaticPathConfig("/custom_components/bituopmd/www/bituo_panel.js", - "/config/custom_components/bituopmd/www/bituo_panel.js", - False - ) - ]) - - # Register the panel only once - if not hass.data.get(f"{DOMAIN}_panel_registered"): - await async_register_panel( - hass, - frontend_url_path="bituopmd", - webcomponent_name="bituo-panel", - sidebar_title="BituoPMD", - sidebar_icon="mdi:home-lightning-bolt", - require_admin=False, - config={"title": "BituoPMD"}, - js_url="/custom_components/bituopmd/www/bituo_panel.js" - ) - hass.data[f"{DOMAIN}_panel_registered"] = True + async def get( + self, request: web.Request, device_ip: str, action: str + ) -> web.Response: + """Run a whitelisted GET action.""" + if action not in READ_ONLY_GET_ACTIONS | ADMIN_GET_ACTIONS: + raise web.HTTPBadRequest(text="Unsupported BituoPMD action") + if action in ADMIN_GET_ACTIONS: + _require_admin(request) + + client = _client_for_host(request.app["hass"], device_ip) + try: + response = await client.async_get_action(action) + except BituoApiError as err: + raise web.HTTPBadGateway(text=str(err)) from err + return self.json({"response": response}) + + async def post( + self, request: web.Request, device_ip: str, action: str + ) -> web.Response: + """Run a whitelisted administrator-only POST action.""" + if action not in ADMIN_POST_ACTIONS: + raise web.HTTPBadRequest(text="Unsupported BituoPMD action") + _require_admin(request) + client = _client_for_host(request.app["hass"], device_ip) + try: + payload: Any = await request.json() + except ValueError as err: + raise web.HTTPBadRequest(text="Expected a JSON body") from err + if not isinstance(payload, dict): + raise web.HTTPBadRequest(text="Expected a JSON object") + + try: + response = await client.async_post_action(action, payload) + except BituoApiError as err: + raise web.HTTPBadGateway(text=str(err)) from err + return self.json({"response": response}) + + +async def async_setup_frontend(hass: HomeAssistant) -> None: + """Register the compatibility panel and API exactly once.""" + if hass.data[DOMAIN].get(DATA_KEY_FRONTEND): + return + static_path = Path(__file__).parent / "www" / "bituo_panel.js" + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + "/custom_components/bituopmd/www/bituo_panel.js", + str(static_path), + False, + ) + ] + ) hass.http.register_view(DeviceListView) hass.http.register_view(DeviceProxyView) - _LOGGER.info("API endpoints registered for BituoPMD") + await async_register_panel( + hass, + frontend_url_path="bituopmd", + webcomponent_name="bituo-panel", + sidebar_title="BituoPMD", + sidebar_icon="mdi:home-lightning-bolt", + require_admin=True, + config={"title": "BituoPMD"}, + js_url="/custom_components/bituopmd/www/bituo_panel.js", + ) + hass.data[DOMAIN][DATA_KEY_FRONTEND] = True + _LOGGER.debug("Registered secured BituoPMD compatibility panel") diff --git a/custom_components/bituopmd/helpers.py b/custom_components/bituopmd/helpers.py new file mode 100644 index 0000000..802e9f7 --- /dev/null +++ b/custom_components/bituopmd/helpers.py @@ -0,0 +1,82 @@ +"""Pure helpers shared by the BituoPMD integration and its tests.""" + +from __future__ import annotations + +import re +from statistics import fmean +from typing import Any + +PHASES = ("X", "Y", "Z") + + +def number(value: Any) -> float | int | None: + """Convert a device value to a number when possible.""" + if isinstance(value, bool): + return int(value) + if isinstance(value, (float, int)): + return value + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +def _derived_sum(data: dict[str, Any], prefix: str) -> float | None: + """Return the sum of three phase values when all are available.""" + values = [number(data.get(f"{prefix}{phase}")) for phase in PHASES] + if any(value is None for value in values): + return None + return sum(value for value in values if value is not None) + + +def _derived_average(data: dict[str, Any], prefix: str) -> float | None: + """Return the average of three phase values when all are available.""" + values = [number(data.get(f"{prefix}{phase}")) for phase in PHASES] + if any(value is None for value in values): + return None + return fmean(value for value in values if value is not None) + + +def normalize_meter_data(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize units and restore useful totals removed by newer firmware.""" + data = dict(payload) + + # Firmware reports active/reactive/apparent power in kW/kvar/kVA. + # Home Assistant power device classes use W/var/VA. + for key, value in tuple(data.items()): + key_lower = key.lower() + if "power" not in key_lower or "factor" in key_lower: + continue + numeric = number(value) + if numeric is not None: + data[key] = numeric * 1000 + + derived_values = { + "TotalCurrent": _derived_sum(data, "Current"), + "AverageLineCurrent": _derived_average(data, "Current"), + "AverageVoltageLN": _derived_average(data, "Voltage"), + "TotalActivePower": _derived_sum(data, "ActivePower"), + "TotalReactivePower": _derived_sum(data, "ReactivePower"), + "TotalApparentPower": _derived_sum(data, "ApparentPower"), + "TotalForwardEnergy": _derived_sum(data, "ForwardEnergy"), + "TotalReverseEnergy": _derived_sum(data, "ReverseEnergy"), + } + for key, value in derived_values.items(): + if value is not None and key not in data: + data[key] = value + + return data + + +def format_field_entity_id(field: str) -> str: + """Format a device field exactly like upstream V1.0.4.""" + value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", field).lower() + return re.sub(r"_+", "_", value).strip("_") + + +def format_field_name(field: str) -> str: + """Turn a camel-case device field into a readable entity name.""" + value = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", field) + return value.replace("_", " ").strip().title() diff --git a/custom_components/bituopmd/manifest.json b/custom_components/bituopmd/manifest.json index 14ac3ac..ee5936c 100755 --- a/custom_components/bituopmd/manifest.json +++ b/custom_components/bituopmd/manifest.json @@ -1,14 +1,14 @@ { "domain": "bituopmd", "name": "BituoPMD", - "codeowners": ["@Script0803"], + "codeowners": ["@kkangg1"], "config_flow": true, - "dependencies": ["http"], - "documentation": "https://github.com/script0803/BituoPMD", + "dependencies": ["http", "panel_custom"], + "documentation": "https://github.com/kkangg1/BituoPMD", "integration_type": "device", "iot_class": "local_polling", - "issue_tracker": "https://github.com/script0803/BituoPMD/issues", + "issue_tracker": "https://github.com/kkangg1/BituoPMD/issues", "requirements": [], - "version": "1.0.4", + "version": "1.1.0", "zeroconf": ["_http._tcp.local."] } diff --git a/custom_components/bituopmd/sensor.py b/custom_components/bituopmd/sensor.py old mode 100755 new mode 100644 index 7541ae7..7d344bf --- a/custom_components/bituopmd/sensor.py +++ b/custom_components/bituopmd/sensor.py @@ -1,425 +1,246 @@ -import logging -import requests +"""Sensor platform for Bituo power monitoring devices.""" + +from __future__ import annotations + import json -import asyncio -from datetime import timedelta +from pathlib import Path +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import ( - UnitOfEnergy, - UnitOfFrequency, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, - UnitOfApparentPower, + UnitOfEnergy, + UnitOfFrequency, UnitOfPower, - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS, + UnitOfReactivePower, ) -try: - from homeassistant.const import UnitOfReactivePower - POWER_VOLT_AMPERE_REACTIVE = UnitOfReactivePower.VOLT_AMPERE_REACTIVE -except (ImportError, AttributeError): - from homeassistant.const import POWER_VOLT_AMPERE_REACTIVE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from packaging.version import InvalidVersion, Version + +from .const import CONF_HOST_IP, CONF_IDENTITY_HOST, DATA_KEY_ENTRIES, DOMAIN +from .coordinator import BituoDataUpdateCoordinator +from .entity import BituoEntity +from .helpers import format_field_entity_id, format_field_name + +EXCLUDED_FIELDS = { + "Post", + "Time", + "Config485", + "MqttStatus", + "MQTTStatus", + "ProductModel", + "productModel", + "IP", + "SerialNumber", + "DeviceType", + "FWVersion", + "fwVersion", + "MCUVersion", + "Manufactor", + "Manufacturer", + "switchstatus", +} -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, +UNIT_MAPPING = ( + ("unbalancelinecurrents", PERCENTAGE), + ("powerfactor", None), + ("voltage", UnitOfElectricPotential.VOLT), + ("current", UnitOfElectricCurrent.AMPERE), + ("energy", UnitOfEnergy.KILO_WATT_HOUR), + ("apparentpower", UnitOfApparentPower.VOLT_AMPERE), + ("reactivepower", UnitOfReactivePower.VOLT_AMPERE_REACTIVE), + ("activepower", UnitOfPower.WATT), + ("frequency", UnitOfFrequency.HERTZ), + ("rssi", SIGNAL_STRENGTH_DECIBELS_MILLIWATT), ) -from packaging import version -from .const import DOMAIN, CONF_HOST_IP -_LOGGER = logging.getLogger(__name__) -SETTINGS_FILE = "custom_components/bituopmd/settings.json" -OTA_VERSIONS_FILE = "custom_components/bituopmd/ota_versions.json" - -def load_settings(): - try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except FileNotFoundError: - _LOGGER.warning("Settings file not found, using default values.") - return {"devices": {}} - except json.JSONDecodeError as err: - _LOGGER.error(f"Error decoding settings file: {err}") - return {"devices": {}} - -def save_settings(settings): - try: - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - except IOError as err: - _LOGGER.error(f"Error saving settings: {err}") - -def load_ota_versions(): +def _coerce_native_value(value: Any) -> Any: + """Return numeric strings as numbers while preserving real text states.""" + if not isinstance(value, str): + return value try: - with open(OTA_VERSIONS_FILE, 'r') as f: - return json.load(f) - except FileNotFoundError: - _LOGGER.warning("OTA versions file not found.") - return {} - except json.JSONDecodeError as err: - _LOGGER.error(f"Error decoding OTA versions file: {err}") - return {} - -settings = load_settings() -ota_versions = load_ota_versions() - - + number = float(value) + except ValueError: + return value + return int(number) if number.is_integer() else number -UNIT_MAPPING = { - "unbalancelinecurrents": PERCENTAGE, - "powerfactor": None, - "voltage": UnitOfElectricPotential.VOLT, - "current": UnitOfElectricCurrent.AMPERE, - "energy": UnitOfEnergy.KILO_WATT_HOUR, - "apparent": UnitOfApparentPower.VOLT_AMPERE, - "reactive": POWER_VOLT_AMPERE_REACTIVE, - "power": UnitOfPower.WATT, - "frequency": UnitOfFrequency.HERTZ, - "rssi": SIGNAL_STRENGTH_DECIBELS, -} -STATE_CLASSES = { - "voltage": SensorStateClass.MEASUREMENT, - "current": SensorStateClass.MEASUREMENT, - "power": SensorStateClass.MEASUREMENT, - "energy": SensorStateClass.TOTAL_INCREASING, - "frequency": SensorStateClass.MEASUREMENT, - "rssi": SensorStateClass.MEASUREMENT, -} +def _unit_for_field(field: str) -> str | None: + normalized = field.lower() + for keyword, unit in UNIT_MAPPING: + if keyword in normalized: + return unit + return None -EXCLUDE_FIELDS = {"Post", "Time", "Config485", "MqttStatus", "ProductModel", "IP", "SerialNumber", "DeviceType", "FWVersion", "MCUVersion", "Manufactor"} -async def async_setup_entry(hass, entry, async_add_entities): - """Set up sensor platform.""" - host_ip = entry.data[CONF_HOST_IP] - current_scan_interval = settings["devices"].get(host_ip, {}).get("scan_interval", 5) - coordinator = BituoDataUpdateCoordinator(hass, host_ip, current_scan_interval) - # Store the coordinator so it can be accessed by other platforms like button - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - 'sensor_coordinator': coordinator - } - await coordinator.async_config_entry_first_refresh() - - # Fetch device model and firmware version - try: - device_info = await coordinator.fetch_device_info() - except UpdateFailed: - _LOGGER.error("Failed to fetch device info for %s", host_ip) - device_info = {} - - # Create sensor entities for each data field - sensors = [ - BituoSensor(coordinator, host_ip, field, device_info.get("model", "Unknown Model"), device_info.get("fw_version", "Unknown"), device_info.get("manufacturer", "Unknown"), device_info.get("mcu_version", "Unknown")) - for field in coordinator.data.keys() - if field not in EXCLUDE_FIELDS +def _device_class_for_field(field: str) -> SensorDeviceClass | None: + normalized = field.lower() + if "unbalancelinecurrents" in normalized: + return None + if "powerfactor" in normalized: + return SensorDeviceClass.POWER_FACTOR + if "reactivepower" in normalized: + return SensorDeviceClass.REACTIVE_POWER + if "apparentpower" in normalized: + return SensorDeviceClass.APPARENT_POWER + if "activepower" in normalized: + return SensorDeviceClass.POWER + if "energy" in normalized: + return SensorDeviceClass.ENERGY + if "current" in normalized: + return SensorDeviceClass.CURRENT + if "voltage" in normalized: + return SensorDeviceClass.VOLTAGE + if "frequency" in normalized: + return SensorDeviceClass.FREQUENCY + if "rssi" in normalized: + return SensorDeviceClass.SIGNAL_STRENGTH + return None + + +def _state_class_for_field(field: str) -> SensorStateClass | None: + normalized = field.lower() + if "energy" in normalized: + return SensorStateClass.TOTAL_INCREASING + if any( + keyword in normalized + for keyword in ( + "voltage", + "current", + "power", + "frequency", + "rssi", + "unbalance", + ) + ): + return SensorStateClass.MEASUREMENT + return None + + +async def async_setup_entry( + hass: HomeAssistant, + entry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up all fields from the shared device snapshot.""" + coordinator: BituoDataUpdateCoordinator = hass.data[DOMAIN][DATA_KEY_ENTRIES][ + entry.entry_id + ]["coordinator"] + identity_host = entry.data.get(CONF_IDENTITY_HOST, entry.data[CONF_HOST_IP]) + + entities: list[SensorEntity] = [ + BituoSensor(coordinator, identity_host, field) + for field in coordinator.data + if field not in EXCLUDED_FIELDS ] - ota_sensor = BituoOTASensor(coordinator, host_ip, device_info.get("model", "Unknown Model"), device_info.get("fw_version", "Unknown"), device_info.get("manufacturer", "Unknown"), device_info.get("mcu_version", "Unknown")) - sensors.append(ota_sensor) - - # Assign the OTA sensor to the coordinator - coordinator.ota_entity = ota_sensor - - async_add_entities(sensors, True) - - async def handle_set_frequency(call): - """Handle the service call to set the data fetch frequency.""" - frequency = call.data.get("frequency", 5) - device_id = call.data.get("device_id") # 获取设备 ID - - # 更新特定设备的频率配置 - settings["devices"].setdefault(device_id, {})["scan_interval"] = frequency - save_settings(settings) - - # 立即刷新设备数据 - await coordinator.async_refresh() - - # 更新协调器的扫描间隔 - coordinator.update_interval = timedelta(seconds=frequency) - - hass.services.async_register(DOMAIN, "set_frequency", handle_set_frequency) + entities.append(BituoOTASensor(coordinator, identity_host)) + async_add_entities(entities) -class BituoDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the device.""" - def __init__(self, hass, host_ip, scan_interval): - """Initialize.""" - self.host_ip = host_ip - self.ota_versions = load_ota_versions() - self.ota_entity = None # init ota_entity - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=scan_interval)) +class BituoSensor(BituoEntity, SensorEntity): + """Representation of one field in the Bituo payload.""" - self.hass.loop.create_task(self._periodically_update_scan_interval()) - - # Schedule OTA update check every 30 minutes - self._ota_update_task = hass.loop.create_task(self._schedule_ota_update_checks()) - - def get_scan_interval(self): - """Get the scan interval from settings.""" - return settings["devices"].get(self.host_ip, {}).get("scan_interval", 5) - - async def _periodically_update_scan_interval(self): - """Periodically update the scan interval from settings.json.""" - while True: - # 读取settings中的最新扫描间隔 - new_interval = self.get_scan_interval() - - # 检查是否需要更新coordinator的扫描间隔 - if new_interval != self.update_interval.total_seconds(): - _LOGGER.info(f"Updating scan interval for {self.host_ip} to {new_interval} seconds") - self.update_interval = timedelta(seconds=new_interval) - - # 等待一段时间后再检查 - await asyncio.sleep(60) # 每分钟检查一次 - - - async def _async_update_data(self): - """Fetch data from the device.""" - try: - response = await self.hass.async_add_executor_job( - requests.get, f"http://{self.host_ip}/data" - ) - response.raise_for_status() - data = response.json() - # _LOGGER.debug(f"{self.host_ip} - {data}") - - # Multiply power values by 1000 - for key in data: - if 'power' in key.lower() and 'factor' not in key.lower(): - try: - data[key] = float(data[key]) * 1000 - except ValueError: - _LOGGER.error(f"Non-numeric value found for key {key}: {data[key]}") - data[key] = None - - return data - except Exception as err: - raise UpdateFailed(f"Error communicating with API: {err}") - - async def fetch_device_info(self): - """Fetch device model and firmware version information.""" - try: - response = await self.hass.async_add_executor_job( - requests.get, f"http://{self.host_ip}/data" - ) - response.raise_for_status() - data = response.json() - return { - "model": data.get("ProductModel", "Unknown Model"), - "fw_version": data.get("FWVersion", "Unknown"), - "manufacturer": "BITUO TECHNIK", - "mcu_version": data.get("MCUVersion", "Unknown"), - } - except Exception as err: - raise UpdateFailed(f"Error fetching device info: {err}") - - async def check_for_ota_updates(self): - """Check for OTA updates.""" - try: - if self.ota_entity is None: - return - - device_info = await self.fetch_device_info() - current_version = device_info.get("fw_version", "Unknown") - - if current_version != "Unknown" and int(current_version.split('.')[0]) >= 4: - latest_version = self.ota_versions.get("common", "Unknown") - else: - model = device_info.get("model", "Unknown Model") - latest_version = self.ota_versions.get(model, "Unknown") - - if latest_version != "Unknown" and current_version != "Unknown" and version.parse(current_version) < version.parse(latest_version): - self.ota_entity._attr_state = "OTA Available" - else: - self.ota_entity._attr_state = "Up to Date" - self.ota_entity.async_write_ha_state() - except Exception as err: - _LOGGER.error(f"Error checking OTA updates: {err}") - - async def _schedule_ota_update_checks(self): - """Schedule periodic OTA update checks.""" - while True: - await self.check_for_ota_updates() - await asyncio.sleep(1800) # Wait for 30 minutes - -class BituoSensor(CoordinatorEntity, SensorEntity): - """Representation of a Sensor.""" - - def __init__(self, coordinator, host_ip, field, model, fw_version, manufacturer, mcu_version): - """Initialize the sensor.""" - super().__init__(coordinator) + def __init__( + self, + coordinator: BituoDataUpdateCoordinator, + identity_host: str, + field: str, + ) -> None: + """Initialize a metering sensor without changing its unique ID.""" + super().__init__(coordinator, identity_host) self._field = field - self._attr_name = self.format_field_name(field) - self._attr_unique_id = f"{host_ip}_{field}" - self.entity_id = f"sensor.{host_ip.replace('.', '_')}_{self.format_field_entity_id(field)}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, host_ip)}, - name=f"{model} - {host_ip}", - manufacturer=manufacturer, - model=model, - sw_version=f"S{fw_version}_M{self.format_version(mcu_version)}", - configuration_url=f"http://{host_ip}" # embed URL + self._attr_name = format_field_name(field) + self._attr_unique_id = f"{identity_host}_{field}" + self.entity_id = ( + f"sensor.{identity_host.replace('.', '_')}_{format_field_entity_id(field)}" ) - self._host_ip = host_ip - self._native_unit_of_measurement = self.get_initial_unit_of_measurement() - self._attr_state_class = self.get_state_class() - self._attr_device_class = self.get_device_class() - - # Set default precision for power data - if "power" in self._field.lower() and "active" in self._field.lower(): - self._attr_suggested_display_precision = 0 - if "power" in self._field.lower() and "apparent" in self._field.lower(): + self._attr_native_unit_of_measurement = _unit_for_field(field) + self._attr_device_class = _device_class_for_field(field) + self._attr_state_class = _state_class_for_field(field) + + normalized = field.lower() + if any( + keyword in normalized + for keyword in ("activepower", "apparentpower", "reactivepower") + ): self._attr_suggested_display_precision = 0 - if "unbalancelinecurrents" in self._field.lower(): + elif "energy" in normalized: + self._attr_suggested_display_precision = 2 + elif "voltage" in normalized: + self._attr_suggested_display_precision = 1 + elif "current" in normalized: + self._attr_suggested_display_precision = 3 + elif "unbalancelinecurrents" in normalized: self._attr_suggested_display_precision = 0 - def get_initial_unit_of_measurement(self): - """Determine the initial unit of measurement based on the field.""" - for unit_keyword, unit in UNIT_MAPPING.items(): - if unit_keyword in self._field.lower(): - return unit - return None - - def get_state_class(self): - """Determine the state class based on the field.""" - for state_class_keyword, state_class in STATE_CLASSES.items(): - if state_class_keyword in self._field.lower(): - return state_class - return SensorStateClass.MEASUREMENT # Default state class - - def get_device_class(self): - """Determine the device class based on the field.""" - if "unbalancelinecurrents" in self._field.lower(): - return SensorDeviceClass.POWER_FACTOR - if "power" in self._field.lower(): - if "factor" in self._field.lower(): - return SensorDeviceClass.POWER_FACTOR - elif "reactive" in self._field.lower(): - return SensorDeviceClass.REACTIVE_POWER - elif "apparent" in self._field.lower(): - return SensorDeviceClass.APPARENT_POWER - elif "active" in self._field.lower(): - return SensorDeviceClass.POWER - elif "energy" in self._field.lower(): - return SensorDeviceClass.ENERGY - elif "current" in self._field.lower(): - return SensorDeviceClass.CURRENT - elif "voltage" in self._field.lower(): - return SensorDeviceClass.VOLTAGE - elif "frequency" in self._field.lower(): - return SensorDeviceClass.FREQUENCY - elif "rssi" in self._field.lower(): - return SensorDeviceClass.SIGNAL_STRENGTH - return None - - @staticmethod - def format_field_name(field): - """Format field name to be more readable.""" - formatted_name = ''.join([' ' + char if char.isupper() else char for char in field]).title().strip() - formatted_name = formatted_name.replace("X", " X").replace("Y", " Y").replace("Z", " Z") - return formatted_name - - @staticmethod - def format_field_entity_id(field): - """Format field name to be more suitable for unique_id.""" - import re - - formatted_name = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', field).lower() - - formatted_name = re.sub(r'_+', '_', formatted_name) - - return formatted_name.strip('_') - - @staticmethod - def format_version(version): - if version.lower() == "unknown": - return version - parts = version.split('.') - formatted_parts = [] - for part in parts: - if part.strip(): # 检查部分是否为空 - try: - formatted_parts.append(str(int(part))) - except ValueError: - formatted_parts.append('unknown') - else: - formatted_parts.append('unknown') # 如果部分为空,设置为 'unknown' - - formatted_version = '.'.join(formatted_parts) - return formatted_version + if normalized in {"rssi", "tp"}: + self._attr_entity_category = EntityCategory.DIAGNOSTIC @property - def native_value(self): - """Return the native state of the sensor.""" + def native_value(self) -> Any: + """Return None for a missing field instead of fabricating zero.""" value = self.coordinator.data.get(self._field) - if value is None: - return 0 # 或其它合适的默认值 - return value + return None if value is None else _coerce_native_value(value) - @property - def native_unit_of_measurement(self): - """Return the native unit of measurement.""" - return self._native_unit_of_measurement - @property - def device_class(self): - """Return the class of this device.""" - return self._attr_device_class +class BituoOTASensor(BituoEntity, SensorEntity): + """Compatibility sensor comparing firmware to bundled vendor metadata.""" -class BituoOTASensor(CoordinatorEntity, SensorEntity): - """Representation of an OTA status sensor.""" + _attr_icon = "mdi:update" + _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator, host_ip, model, fw_version, manufacturer, mcu_version): - """Initialize the OTA status sensor.""" - super().__init__(coordinator) + def __init__( + self, + coordinator: BituoDataUpdateCoordinator, + identity_host: str, + ) -> None: + """Initialize the OTA status sensor with the upstream unique ID.""" + super().__init__(coordinator, identity_host) self._attr_name = "OTA Status" - self._attr_unique_id = f"{host_ip}_ota_status" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, host_ip)}, - name=f"{model} - {host_ip}", - manufacturer=manufacturer, - model=model, - sw_version=f"S{fw_version}_M{self.format_version(mcu_version)}", - configuration_url=f"http://{host_ip}" # embed URL - ) - self._host_ip = host_ip - self._attr_state = "unknown" - coordinator.ota_entity = self # 存储自身的引用到协调器中 - self._attr_icon = "mdi:update" - self.entity_id = f"sensor.{self._attr_unique_id.replace('.', '_')}" - - @staticmethod - def format_version(version): - if version.lower() == "unknown": - return version - parts = version.split('.') - formatted_parts = [] - for part in parts: - if part.strip(): # 检查部分是否为空 - try: - formatted_parts.append(str(int(part))) - except ValueError: - formatted_parts.append('unknown') - else: - formatted_parts.append('unknown') # 如果部分为空,设置为 'unknown' - - formatted_version = '.'.join(formatted_parts) - return formatted_version + self._attr_unique_id = f"{identity_host}_ota_status" + self.entity_id = f"sensor.{identity_host.replace('.', '_')}_ota_status" @property - def native_value(self): - """Return the native state of the sensor.""" - return self._attr_state + def native_value(self) -> str: + """Return the firmware comparison result without another HTTP poll.""" + info = self.coordinator.device_info_data + current = info["fw_version"] + versions = _load_ota_versions() + try: + current_version = Version(current) + if current_version.major >= 4: + latest = versions.get("common") + else: + latest = versions.get(info["model"]) + if not latest: + return "Unknown" + return ( + "OTA Available" if current_version < Version(latest) else "Up to Date" + ) + except InvalidVersion: + return "Unknown" - async def async_added_to_hass(self): - """When entity is added to hass.""" - await super().async_added_to_hass() - # Set up the initial state - await self.coordinator.check_for_ota_updates() - async def async_update(self): - """Update the sensor.""" - await self.coordinator.check_for_ota_updates() +def _load_ota_versions() -> dict[str, str]: + """Load static OTA metadata from the integration package.""" + try: + with ( + Path(__file__) + .with_name("ota_versions.json") + .open(encoding="utf-8") as file_handle + ): + payload = json.load(file_handle) + except (OSError, ValueError): + return {} + return payload if isinstance(payload, dict) else {} diff --git a/custom_components/bituopmd/services.yaml b/custom_components/bituopmd/services.yaml index 75f076d..c59d638 100755 --- a/custom_components/bituopmd/services.yaml +++ b/custom_components/bituopmd/services.yaml @@ -1,10 +1,23 @@ set_frequency: - name: "Set Frequency" - description: "Set the frequency of the device." + name: Set polling interval + description: Change how often Home Assistant polls one Bituo device. fields: - frequency: - name: "Frequency" - description: "The frequency to set." device_id: - name: "Device ID" - description: "The ID of the device to set the frequency for." + name: Device IP + description: The configured IP address of the Bituo device. + required: true + example: 192.0.2.10 + selector: + text: + frequency: + name: Polling interval + description: Polling interval in seconds. + required: true + default: 5 + example: 15 + selector: + number: + min: 5 + max: 3600 + step: 1 + mode: box diff --git a/custom_components/bituopmd/settings.json b/custom_components/bituopmd/settings.json deleted file mode 100755 index 9ce6e73..0000000 --- a/custom_components/bituopmd/settings.json +++ /dev/null @@ -1 +0,0 @@ -{"devices": {"192.168.50.86": {"scan_interval": 5}}} \ No newline at end of file diff --git a/custom_components/bituopmd/strings.json b/custom_components/bituopmd/strings.json new file mode 100644 index 0000000..dc8c1a2 --- /dev/null +++ b/custom_components/bituopmd/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "This device is already configured.", + "cannot_connect": "Unable to connect to the device.", + "not_bituotechnik_device": "The discovered service is not a Bituo power monitoring device." + }, + "error": { + "already_configured": "This device is already configured.", + "cannot_connect": "Unable to connect. Check the IP address and device availability." + }, + "step": { + "user": { + "title": "Add a Bituo power monitoring device", + "description": "Enter the device's fixed IP address.", + "data": { + "host_ip": "IP address" + } + }, + "zeroconf_confirm": { + "title": "Confirm discovered Bituo device", + "description": "Set up {name} at {host}?" + }, + "reconfigure": { + "title": "Update device address", + "description": "Changing the address preserves the existing config entry.", + "data": { + "host_ip": "IP address" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "BituoPMD options", + "description": "Choose how often Home Assistant polls this device.", + "data": { + "scan_interval": "Polling interval (seconds)" + } + } + } + }, + "services": { + "set_frequency": { + "name": "Set polling interval", + "description": "Change the polling interval for one configured Bituo device.", + "fields": { + "device_id": { + "name": "Device IP", + "description": "The configured IP address of the Bituo device." + }, + "frequency": { + "name": "Polling interval", + "description": "Polling interval in seconds (5-3600)." + } + } + } + }, + "exceptions": { + "device_not_found": { + "message": "No configured BituoPMD device was found at {host}." + } + } +} diff --git a/custom_components/bituopmd/switch.py b/custom_components/bituopmd/switch.py old mode 100755 new mode 100644 index d279721..20da663 --- a/custom_components/bituopmd/switch.py +++ b/custom_components/bituopmd/switch.py @@ -1,142 +1,69 @@ -import logging -import requests -from datetime import timedelta -from homeassistant.components.switch import SwitchEntity -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, CONF_HOST_IP - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=2) +"""Switch platform for relay-capable Bituo devices.""" -async def async_setup_entry(hass, entry, async_add_entities): - """Set up switch platform.""" - host_ip = entry.data[CONF_HOST_IP] - coordinator = BituoDataUpdateCoordinator(hass, host_ip) - try: - await coordinator.async_config_entry_first_refresh() - except UpdateFailed as e: - _LOGGER.error("Error initializing switch platform: %s", e) - raise ConfigEntryNotReady from e +from __future__ import annotations - # Fetch device model and firmware version - try: - device_info = await coordinator.fetch_device_info() - except UpdateFailed: - _LOGGER.error("Failed to fetch device info for %s", host_ip) - device_info = {"model": "Unknown Model", "fw_version": "Unknown", "manufacturer": "Unknown", "mcu_version": "Unknown"} +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .api import BituoApiError +from .const import CONF_HOST_IP, CONF_IDENTITY_HOST, DATA_KEY_ENTRIES, DOMAIN +from .coordinator import BituoDataUpdateCoordinator +from .entity import BituoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up a relay only when the device advertises one.""" + coordinator: BituoDataUpdateCoordinator = hass.data[DOMAIN][DATA_KEY_ENTRIES][ + entry.entry_id + ]["coordinator"] + if coordinator.supports_switch: + identity_host = entry.data.get( + CONF_IDENTITY_HOST, + entry.data[CONF_HOST_IP], + ) + async_add_entities((BituoSwitch(coordinator, identity_host),)) - switches = [] - if coordinator.data and "switchstatus" in coordinator.data: - switches.append(BituoSwitch(coordinator, host_ip, device_info["model"], device_info["fw_version"], device_info["manufacturer"], device_info["mcu_version"])) - async_add_entities(switches, True) +class BituoSwitch(BituoEntity, SwitchEntity): + """Representation of a Bituo device relay.""" -class BituoDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the device.""" + _attr_name = "Switch" - def __init__(self, hass, host_ip): - """Initialize.""" - self.host_ip = host_ip - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + def __init__( + self, + coordinator: BituoDataUpdateCoordinator, + identity_host: str, + ) -> None: + """Initialize the relay without changing its upstream unique ID.""" + super().__init__(coordinator, identity_host) + self._attr_unique_id = f"{identity_host}_switch" + self.entity_id = f"switch.{identity_host.replace('.', '_')}_switch" - async def _async_update_data(self): - """Fetch data from the device.""" - try: - # Fetch data from /hadata - response = await self.hass.async_add_executor_job( - requests.get, f"http://{self.host_ip}/hadata" - ) - data = response.json() + @property + def is_on(self) -> bool: + """Return the current relay state.""" + return bool(self.coordinator.data.get("switchstatus", False)) - # Only fetch switch status if 'switchstatus' is in the data - if "switchstatus" in data: - status_response = await self.hass.async_add_executor_job( - requests.get, f"http://{self.host_ip}/status" - ) - status = status_response.text.strip().lower() == "true" - data["switchstatus"] = status + async def async_turn_on(self, **kwargs) -> None: + """Turn on the relay.""" + await self._async_set_state("switchon") - return data - except Exception as err: - raise UpdateFailed(f"Error communicating with API: {err}") + async def async_turn_off(self, **kwargs) -> None: + """Turn off the relay.""" + await self._async_set_state("switchoff") - async def fetch_device_info(self): - """Fetch device model and firmware version information.""" + async def _async_set_state(self, action: str) -> None: + """Run a relay action and refresh state.""" try: - response = await self.hass.async_add_executor_job( - requests.get, f"http://{self.host_ip}/data" - ) - data = response.json() - return { - "model": data.get("productModel") or data.get("ProductModel", "Unknown Model"), - "fw_version": data.get("FWVersion") or data.get("fwVersion", "Unknown"), - "manufacturer": "BITUO TECHNIK", - "mcu_version": data.get("MCUVersion", "Unknown"), - } - except Exception as err: - raise UpdateFailed(f"Error fetching device info: {err}") - -class BituoSwitch(CoordinatorEntity, SwitchEntity): - """Representation of a Switch.""" - - def __init__(self, coordinator, host_ip, model, fw_version, manufacturer, mcu_version): - """Initialize the switch.""" - super().__init__(coordinator) - self._attr_name = f"{model} - {host_ip}" - self._attr_unique_id = f"{host_ip}_switch" - self.entity_id = f"switch.{host_ip.replace('.', '_')}_switch" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, host_ip)}, - name=f"{model} - {host_ip}", - manufacturer=manufacturer, - model=model, - sw_version=f"S{fw_version}_M{self.format_version(mcu_version)}", - configuration_url=f"http://{host_ip}" # embed URL - ) - self._host_ip = host_ip - - @staticmethod - def format_version(version): - if version.lower() == "unknown": - return version - parts = version.split('.') - formatted_parts = [] - for part in parts: - if part.strip(): # 检查部分是否为空 - try: - formatted_parts.append(str(int(part))) - except ValueError: - formatted_parts.append('unknown') - else: - formatted_parts.append('unknown') # 如果部分为空,设置为 'unknown' - - formatted_version = '.'.join(formatted_parts) - return formatted_version - - @property - def is_on(self): - """Return true if the switch is on.""" - return self.coordinator.data.get("switchstatus", False) - - async def async_turn_on(self, **kwargs): - """Turn the switch on.""" - await self.hass.async_add_executor_job( - requests.get, f"http://{self._host_ip}/switchon" - ) + await self.coordinator.client.async_get_action(action) + except BituoApiError as err: + raise HomeAssistantError( + f"Unable to control BituoPMD relay: {err}" + ) from err await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs): - """Turn the switch off.""" - await self.hass.async_add_executor_job( - requests.get, f"http://{self._host_ip}/switchoff" - ) - await self.coordinator.async_request_refresh() - -# by Script0803 \ No newline at end of file diff --git a/custom_components/bituopmd/translations/en.json b/custom_components/bituopmd/translations/en.json new file mode 100644 index 0000000..dc8c1a2 --- /dev/null +++ b/custom_components/bituopmd/translations/en.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "This device is already configured.", + "cannot_connect": "Unable to connect to the device.", + "not_bituotechnik_device": "The discovered service is not a Bituo power monitoring device." + }, + "error": { + "already_configured": "This device is already configured.", + "cannot_connect": "Unable to connect. Check the IP address and device availability." + }, + "step": { + "user": { + "title": "Add a Bituo power monitoring device", + "description": "Enter the device's fixed IP address.", + "data": { + "host_ip": "IP address" + } + }, + "zeroconf_confirm": { + "title": "Confirm discovered Bituo device", + "description": "Set up {name} at {host}?" + }, + "reconfigure": { + "title": "Update device address", + "description": "Changing the address preserves the existing config entry.", + "data": { + "host_ip": "IP address" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "BituoPMD options", + "description": "Choose how often Home Assistant polls this device.", + "data": { + "scan_interval": "Polling interval (seconds)" + } + } + } + }, + "services": { + "set_frequency": { + "name": "Set polling interval", + "description": "Change the polling interval for one configured Bituo device.", + "fields": { + "device_id": { + "name": "Device IP", + "description": "The configured IP address of the Bituo device." + }, + "frequency": { + "name": "Polling interval", + "description": "Polling interval in seconds (5-3600)." + } + } + } + }, + "exceptions": { + "device_not_found": { + "message": "No configured BituoPMD device was found at {host}." + } + } +} diff --git a/custom_components/bituopmd/translations/zh-Hans.json b/custom_components/bituopmd/translations/zh-Hans.json new file mode 100644 index 0000000..87e157c --- /dev/null +++ b/custom_components/bituopmd/translations/zh-Hans.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "该设备已配置。", + "cannot_connect": "无法连接到设备。", + "not_bituotechnik_device": "发现的服务不是 Bituo 电力监测设备。" + }, + "error": { + "already_configured": "该设备已配置。", + "cannot_connect": "无法连接,请检查 IP 地址和设备在线状态。" + }, + "step": { + "user": { + "title": "添加 Bituo 电力监测设备", + "description": "请输入设备的固定 IP 地址。", + "data": { + "host_ip": "IP 地址" + } + }, + "zeroconf_confirm": { + "title": "确认发现的 Bituo 设备", + "description": "是否添加位于 {host} 的 {name}?" + }, + "reconfigure": { + "title": "更新设备地址", + "description": "修改地址不会删除现有配置条目。", + "data": { + "host_ip": "IP 地址" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "BituoPMD 选项", + "description": "设置 Home Assistant 轮询该设备的频率。", + "data": { + "scan_interval": "轮询间隔(秒)" + } + } + } + }, + "services": { + "set_frequency": { + "name": "设置轮询间隔", + "description": "修改一个已配置 Bituo 设备的轮询间隔。", + "fields": { + "device_id": { + "name": "设备 IP", + "description": "Bituo 设备当前配置的 IP 地址。" + }, + "frequency": { + "name": "轮询间隔", + "description": "轮询间隔,范围 5-3600 秒。" + } + } + } + }, + "exceptions": { + "device_not_found": { + "message": "未找到 IP 地址为 {host} 的已配置 BituoPMD 设备。" + } + } +} diff --git a/custom_components/bituopmd/www/bituo_panel.js b/custom_components/bituopmd/www/bituo_panel.js index fea12ad..e1bb319 100755 --- a/custom_components/bituopmd/www/bituo_panel.js +++ b/custom_components/bituopmd/www/bituo_panel.js @@ -278,6 +278,10 @@ class BituoPanel extends HTMLElement { } } + disconnectedCallback() { + this.stopRefreshingDevices(); + } + addEventListeners() { const actions = [ { id: '#zero-energy', action: 'zeroenergy' }, @@ -920,4 +924,6 @@ class BituoPanel extends HTMLElement { } } -customElements.define('Bituo-panel', BituoPanel); \ No newline at end of file +if (!customElements.get('bituo-panel')) { + customElements.define('bituo-panel', BituoPanel); +} diff --git a/hacs.json b/hacs.json index 53b2cf8..bba0951 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "BituoPMD", - "homeassistant": "0.104.0" + "homeassistant": "2024.4.0" } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..48a1ca4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.ruff.format] +quote-style = "double" diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..2d13756 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,98 @@ +"""Tests for Bituo device payload normalization.""" + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +HELPERS_PATH = ( + Path(__file__).parents[1] / "custom_components" / "bituopmd" / "helpers.py" +) +SPEC = importlib.util.spec_from_file_location("bituopmd_helpers", HELPERS_PATH) +assert SPEC is not None and SPEC.loader is not None +helpers = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(helpers) + + +class HelpersTest(unittest.TestCase): + """Exercise normalization with synthetic, non-user device data.""" + + def test_normalize_spm02_payload_shape(self) -> None: + """Power is converted to HA units and missing totals are restored.""" + payload = { + "ProductModel": "SPM02", + "VoltageX": "230.0", + "VoltageY": "231.0", + "VoltageZ": "229.0", + "CurrentX": "1.0", + "CurrentY": "2.0", + "CurrentZ": "3.0", + "ActivePowerX": "0.100", + "ActivePowerY": "0.200", + "ActivePowerZ": "0.300", + "ReactivePowerX": "-0.010", + "ReactivePowerY": "0.020", + "ReactivePowerZ": "0.030", + "ApparentPowerX": "0.110", + "ApparentPowerY": "0.220", + "ApparentPowerZ": "0.330", + "PowerFactorX": "0.90", + "PowerFactorY": "0.80", + "PowerFactorZ": "0.70", + "ForwardEnergyX": "10.0", + "ForwardEnergyY": "20.0", + "ForwardEnergyZ": "30.0", + "ReverseEnergyX": "0.1", + "ReverseEnergyY": "0.2", + "ReverseEnergyZ": "0.3", + } + + data = helpers.normalize_meter_data(payload) + + self.assertEqual(data["ActivePowerX"], 100.0) + self.assertEqual(data["TotalActivePower"], 600.0) + self.assertEqual(data["TotalReactivePower"], 40.0) + self.assertEqual(data["TotalApparentPower"], 660.0) + self.assertEqual(data["PowerFactorX"], "0.90") + self.assertEqual(data["TotalCurrent"], 6.0) + self.assertEqual(data["AverageLineCurrent"], 2.0) + self.assertEqual(data["AverageVoltageLN"], 230.0) + self.assertEqual(data["TotalForwardEnergy"], 60.0) + self.assertAlmostEqual(data["TotalReverseEnergy"], 0.6) + + def test_existing_vendor_totals_are_not_overwritten(self) -> None: + """A firmware-provided total remains authoritative.""" + data = helpers.normalize_meter_data( + { + "CurrentX": 1, + "CurrentY": 2, + "CurrentZ": 3, + "TotalCurrent": 99, + } + ) + self.assertEqual(data["TotalCurrent"], 99) + + def test_partial_phases_do_not_create_misleading_total(self) -> None: + """A missing phase must not be silently treated as zero.""" + data = helpers.normalize_meter_data({"ActivePowerX": "1", "ActivePowerY": "2"}) + self.assertNotIn("TotalActivePower", data) + + def test_entity_id_format_is_backward_compatible(self) -> None: + """Existing entity IDs continue to resolve after replacement.""" + self.assertEqual( + helpers.format_field_entity_id("AverageVoltageLN"), + "average_voltage_ln", + ) + self.assertEqual( + helpers.format_field_entity_id("UnbalanceLineCurrents"), + "unbalance_line_currents", + ) + + def test_field_name_formatting_has_no_double_spaces(self) -> None: + """Phase labels remain readable.""" + self.assertEqual(helpers.format_field_name("ActivePowerX"), "Active Power X") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_privacy.py b/tests/test_repository_privacy.py new file mode 100644 index 0000000..b4e037e --- /dev/null +++ b/tests/test_repository_privacy.py @@ -0,0 +1,51 @@ +"""Guard against committing installation-specific private IP addresses.""" + +from __future__ import annotations + +import re +import unittest +from ipaddress import IPv4Address, IPv4Network +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).parents[1] +TEXT_SUFFIXES = {".js", ".json", ".md", ".py", ".yaml", ".yml"} +IPV4_PATTERN = re.compile(r"(? None: + """Tracked source and documentation use only TEST-NET examples.""" + findings: list[str] = [] + for path in REPOSITORY_ROOT.rglob("*"): + relative_path = path.relative_to(REPOSITORY_ROOT) + if ( + not path.is_file() + or path.suffix not in TEXT_SUFFIXES + or any(part in {".git", ".venv"} for part in relative_path.parts) + ): + continue + text = path.read_text(encoding="utf-8") + for match in IPV4_PATTERN.findall(text): + address = IPv4Address(match) + if any(address in network for network in DOCUMENTATION_NETWORKS): + continue + if any(address in network for network in PRIVATE_NETWORKS): + findings.append(f"{relative_path}: {match}") + + self.assertEqual(findings, [], "\n".join(findings)) + + +if __name__ == "__main__": + unittest.main() From 39db7a907e0e6f8bc239f1a15623f2ca37673fe5 Mon Sep 17 00:00:00 2001 From: kkangg1 <45795696+kkangg1@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:14:55 +0800 Subject: [PATCH 2/6] Fix zeroconf import for Home Assistant 2026.7 --- custom_components/bituopmd/config_flow.py | 2 +- custom_components/bituopmd/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/bituopmd/config_flow.py b/custom_components/bituopmd/config_flow.py index 1aac89b..65d90bf 100644 --- a/custom_components/bituopmd/config_flow.py +++ b/custom_components/bituopmd/config_flow.py @@ -6,10 +6,10 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .api import BituoApiClient, BituoApiError, normalize_host from .const import ( diff --git a/custom_components/bituopmd/manifest.json b/custom_components/bituopmd/manifest.json index ee5936c..5e80b2a 100755 --- a/custom_components/bituopmd/manifest.json +++ b/custom_components/bituopmd/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/kkangg1/BituoPMD/issues", "requirements": [], - "version": "1.1.0", + "version": "1.1.1", "zeroconf": ["_http._tcp.local."] } From 78f1717e050d3377ced2e941f34de583e047b979 Mon Sep 17 00:00:00 2001 From: kkangg1 <45795696+kkangg1@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:18:49 +0800 Subject: [PATCH 3/6] Move OTA metadata loading off the event loop --- custom_components/bituopmd/manifest.json | 2 +- custom_components/bituopmd/sensor.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/custom_components/bituopmd/manifest.json b/custom_components/bituopmd/manifest.json index 5e80b2a..78a83db 100755 --- a/custom_components/bituopmd/manifest.json +++ b/custom_components/bituopmd/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/kkangg1/BituoPMD/issues", "requirements": [], - "version": "1.1.1", + "version": "1.1.2", "zeroconf": ["_http._tcp.local."] } diff --git a/custom_components/bituopmd/sensor.py b/custom_components/bituopmd/sensor.py index 7d344bf..57d210a 100644 --- a/custom_components/bituopmd/sensor.py +++ b/custom_components/bituopmd/sensor.py @@ -138,13 +138,14 @@ async def async_setup_entry( entry.entry_id ]["coordinator"] identity_host = entry.data.get(CONF_IDENTITY_HOST, entry.data[CONF_HOST_IP]) + ota_versions = await hass.async_add_executor_job(_load_ota_versions) entities: list[SensorEntity] = [ BituoSensor(coordinator, identity_host, field) for field in coordinator.data if field not in EXCLUDED_FIELDS ] - entities.append(BituoOTASensor(coordinator, identity_host)) + entities.append(BituoOTASensor(coordinator, identity_host, ota_versions)) async_add_entities(entities) @@ -204,9 +205,11 @@ def __init__( self, coordinator: BituoDataUpdateCoordinator, identity_host: str, + ota_versions: dict[str, str], ) -> None: """Initialize the OTA status sensor with the upstream unique ID.""" super().__init__(coordinator, identity_host) + self._ota_versions = ota_versions self._attr_name = "OTA Status" self._attr_unique_id = f"{identity_host}_ota_status" self.entity_id = f"sensor.{identity_host.replace('.', '_')}_ota_status" @@ -216,13 +219,12 @@ def native_value(self) -> str: """Return the firmware comparison result without another HTTP poll.""" info = self.coordinator.device_info_data current = info["fw_version"] - versions = _load_ota_versions() try: current_version = Version(current) if current_version.major >= 4: - latest = versions.get("common") + latest = self._ota_versions.get("common") else: - latest = versions.get(info["model"]) + latest = self._ota_versions.get(info["model"]) if not latest: return "Unknown" return ( From c85730875a63c2a26350d479925ccb42ae5803b1 Mon Sep 17 00:00:00 2001 From: kkangg1 <45795696+kkangg1@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:42:17 +0800 Subject: [PATCH 4/6] v1.1.3: tolerate transient poll failures, raise HTTP timeout to 10s - REQUEST_TIMEOUT 5s -> 10s: slow meters (or event-loop stalls on the HA host) regularly exceeded 5s and flipped every entity unavailable. - FAILURE_THRESHOLD = 2: a single failed poll now keeps the last data instead of marking all entities unavailable; entities only go unavailable after two consecutive failures. --- custom_components/bituopmd/const.py | 5 ++++- custom_components/bituopmd/coordinator.py | 16 ++++++++++++++-- custom_components/bituopmd/manifest.json | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/custom_components/bituopmd/const.py b/custom_components/bituopmd/const.py index a48dda1..fe90b87 100755 --- a/custom_components/bituopmd/const.py +++ b/custom_components/bituopmd/const.py @@ -9,7 +9,10 @@ DEFAULT_SCAN_INTERVAL = 5 MIN_SCAN_INTERVAL = 5 MAX_SCAN_INTERVAL = 3600 -REQUEST_TIMEOUT = 5 +REQUEST_TIMEOUT = 10 +# Consecutive poll failures tolerated before entities are marked unavailable. +# Single transient failures (e.g. HA event-loop stalls) keep the last data. +FAILURE_THRESHOLD = 2 DATA_KEY_ENTRIES = "entries" DATA_KEY_FRONTEND = "frontend_registered" diff --git a/custom_components/bituopmd/coordinator.py b/custom_components/bituopmd/coordinator.py index 5b4121a..380c541 100644 --- a/custom_components/bituopmd/coordinator.py +++ b/custom_components/bituopmd/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import BituoApiClient, BituoApiError -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, FAILURE_THRESHOLD from .helpers import normalize_meter_data _LOGGER = logging.getLogger(__name__) @@ -30,6 +30,7 @@ def __init__( """Initialize the coordinator.""" self.client = client self.supports_switch = False + self._consecutive_failures = 0 super().__init__( hass, _LOGGER, @@ -83,8 +84,19 @@ async def _async_update_data(self) -> dict[str, Any]: data = normalize_meter_data(await self.client.async_get_data()) if self.supports_switch: data["switchstatus"] = await self.client.async_get_switch_state() - return data except BituoApiError as err: + self._consecutive_failures += 1 + if self.data is not None and self._consecutive_failures < FAILURE_THRESHOLD: + _LOGGER.debug( + "Ignoring transient failure %s/%s for %s, keeping last data: %s", + self._consecutive_failures, + FAILURE_THRESHOLD, + self.host, + err, + ) + return self.data raise UpdateFailed( f"Error communicating with Bituo device {self.host}: {err}" ) from err + self._consecutive_failures = 0 + return data diff --git a/custom_components/bituopmd/manifest.json b/custom_components/bituopmd/manifest.json index 78a83db..f99c40e 100755 --- a/custom_components/bituopmd/manifest.json +++ b/custom_components/bituopmd/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/kkangg1/BituoPMD/issues", "requirements": [], - "version": "1.1.2", + "version": "1.1.3", "zeroconf": ["_http._tcp.local."] } From 308a42129beb213d88fe0504de36cd2c546d7b2f Mon Sep 17 00:00:00 2001 From: kkangg1 <45795696+kkangg1@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:44:43 +0800 Subject: [PATCH 5/6] discovery: also match BITUO TECHNIK-* mDNS hostnames Newer firmware advertises the device as "BITUO TECHNIK--" instead of "EnergySensor--"; the previous filter only matched "energysensor" and silently ignored those devices. --- custom_components/bituopmd/config_flow.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/custom_components/bituopmd/config_flow.py b/custom_components/bituopmd/config_flow.py index 65d90bf..32589a9 100644 --- a/custom_components/bituopmd/config_flow.py +++ b/custom_components/bituopmd/config_flow.py @@ -33,6 +33,16 @@ async def _async_validate_device( return normalized_host, data +def _is_bituo_device(name: str) -> bool: + """Match Bituo devices advertised via mDNS. + + Devices ship with either "EnergySensor--" or + "BITUO TECHNIK--" hostnames depending on firmware. + """ + lowered = name.lower() + return "energysensor" in lowered or "bituo" in lowered + + class BituoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle BituoPMD configuration.""" @@ -89,7 +99,7 @@ async def async_step_zeroconf( ) -> FlowResult: """Handle Home Assistant's native zeroconf discovery.""" name = discovery_info.name.split(".")[0] - if "energysensor" not in name.lower(): + if not _is_bituo_device(name): return self.async_abort(reason="not_bituotechnik_device") try: From e1ba9d4ee9752ef494b4c7055b6c5a467eca3f4d Mon Sep 17 00:00:00 2001 From: kkangg1 <45795696+kkangg1@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:44:43 +0800 Subject: [PATCH 6/6] v1.1.4: synthesize TotalEnergy aggregates dropped by firmware >= 5.1.10 Firmware 5.1.10 stopped reporting the TotalEnergy{phase} and TotalEnergy fields, so the corresponding entities vanished. Derive them from forward/reverse energy (native or synthesized); firmware-provided values always win. --- custom_components/bituopmd/helpers.py | 17 +++++++++++++++++ custom_components/bituopmd/manifest.json | 2 +- tests/test_helpers.py | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/custom_components/bituopmd/helpers.py b/custom_components/bituopmd/helpers.py index 802e9f7..bf8c84a 100644 --- a/custom_components/bituopmd/helpers.py +++ b/custom_components/bituopmd/helpers.py @@ -67,6 +67,23 @@ def normalize_meter_data(payload: dict[str, Any]) -> dict[str, Any]: if value is not None and key not in data: data[key] = value + # Per-phase and overall total energy, from native or derived + # forward/reverse figures. Firmware-provided values always win. + for phase in PHASES: + key = f"TotalEnergy{phase}" + if key in data: + continue + forward = number(data.get(f"ForwardEnergy{phase}")) + reverse = number(data.get(f"ReverseEnergy{phase}")) + if forward is not None and reverse is not None: + data[key] = forward + reverse + + if "TotalEnergy" not in data: + forward = number(data.get("TotalForwardEnergy")) + reverse = number(data.get("TotalReverseEnergy")) + if forward is not None and reverse is not None: + data["TotalEnergy"] = forward + reverse + return data diff --git a/custom_components/bituopmd/manifest.json b/custom_components/bituopmd/manifest.json index f99c40e..36349cb 100755 --- a/custom_components/bituopmd/manifest.json +++ b/custom_components/bituopmd/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/kkangg1/BituoPMD/issues", "requirements": [], - "version": "1.1.3", + "version": "1.1.4", "zeroconf": ["_http._tcp.local."] } diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 2d13756..21a3325 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -60,6 +60,25 @@ def test_normalize_spm02_payload_shape(self) -> None: self.assertEqual(data["AverageVoltageLN"], 230.0) self.assertEqual(data["TotalForwardEnergy"], 60.0) self.assertAlmostEqual(data["TotalReverseEnergy"], 0.6) + self.assertAlmostEqual(data["TotalEnergyX"], 10.1) + self.assertAlmostEqual(data["TotalEnergyY"], 20.2) + self.assertAlmostEqual(data["TotalEnergyZ"], 30.3) + self.assertAlmostEqual(data["TotalEnergy"], 60.6) + + def test_total_energy_prefers_native_vendor_values(self) -> None: + """A firmware-provided TotalEnergy remains authoritative.""" + data = helpers.normalize_meter_data( + { + "ForwardEnergyX": 10.0, + "ReverseEnergyX": 0.5, + "TotalEnergyX": 99.0, + "TotalForwardEnergy": 100.0, + "TotalReverseEnergy": 5.0, + "TotalEnergy": 95.0, + } + ) + self.assertEqual(data["TotalEnergyX"], 99.0) + self.assertEqual(data["TotalEnergy"], 95.0) def test_existing_vendor_totals_are_not_overwritten(self) -> None: """A firmware-provided total remains authoritative."""