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 cffb543..0000000 Binary files a/custom_components/bituopmd/__pycache__/__init__.cpython-312.pyc and /dev/null differ 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 ba07eb5..0000000 Binary files a/custom_components/bituopmd/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/button.cpython-312.pyc b/custom_components/bituopmd/__pycache__/button.cpython-312.pyc deleted file mode 100755 index 1e3479f..0000000 Binary files a/custom_components/bituopmd/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/button.cpython-313.pyc b/custom_components/bituopmd/__pycache__/button.cpython-313.pyc deleted file mode 100755 index fba5ac1..0000000 Binary files a/custom_components/bituopmd/__pycache__/button.cpython-313.pyc and /dev/null differ 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 fc620d5..0000000 Binary files a/custom_components/bituopmd/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/config_flow.cpython-313.pyc b/custom_components/bituopmd/__pycache__/config_flow.cpython-313.pyc deleted file mode 100755 index 8f4cade..0000000 Binary files a/custom_components/bituopmd/__pycache__/config_flow.cpython-313.pyc and /dev/null differ 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 c5b8fce..0000000 Binary files a/custom_components/bituopmd/__pycache__/const.cpython-312.pyc and /dev/null differ 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 9f5519e..0000000 Binary files a/custom_components/bituopmd/__pycache__/const.cpython-313.pyc and /dev/null differ 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 26c3944..0000000 Binary files a/custom_components/bituopmd/__pycache__/frontend.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/frontend.cpython-313.pyc b/custom_components/bituopmd/__pycache__/frontend.cpython-313.pyc deleted file mode 100755 index 7067efc..0000000 Binary files a/custom_components/bituopmd/__pycache__/frontend.cpython-313.pyc and /dev/null differ 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 9895acb..0000000 Binary files a/custom_components/bituopmd/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/sensor.cpython-313.pyc b/custom_components/bituopmd/__pycache__/sensor.cpython-313.pyc deleted file mode 100755 index dfabe90..0000000 Binary files a/custom_components/bituopmd/__pycache__/sensor.cpython-313.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/switch.cpython-312.pyc b/custom_components/bituopmd/__pycache__/switch.cpython-312.pyc deleted file mode 100755 index a65dbdf..0000000 Binary files a/custom_components/bituopmd/__pycache__/switch.cpython-312.pyc and /dev/null differ 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 e346350..0000000 Binary files a/custom_components/bituopmd/__pycache__/switch.cpython-313.pyc and /dev/null differ 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 5edf472..0000000 Binary files a/custom_components/bituopmd/__pycache__/text.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bituopmd/__pycache__/zigbee.cpython-312.pyc b/custom_components/bituopmd/__pycache__/zigbee.cpython-312.pyc deleted file mode 100755 index 8b00304..0000000 Binary files a/custom_components/bituopmd/__pycache__/zigbee.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bituopmd/api.py b/custom_components/bituopmd/api.py new file mode 100644 index 0000000..43c1228 --- /dev/null +++ b/custom_components/bituopmd/api.py @@ -0,0 +1,109 @@ +"""Async HTTP client for Bituo power monitoring devices.""" + +from __future__ import annotations + +import asyncio +from ipaddress import ip_address +from typing import Any + +from aiohttp import ClientError, ClientResponseError, ClientSession + +from .const import REQUEST_TIMEOUT + + +class BituoApiError(Exception): + """Base class for Bituo API errors.""" + + +class BituoApiConnectionError(BituoApiError): + """Raised when a device cannot be reached.""" + + +class BituoApiResponseError(BituoApiError): + """Raised when a device returns an invalid response.""" + + +def normalize_host(host: str) -> 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..32589a9 --- a/custom_components/bituopmd/config_flow.py +++ b/custom_components/bituopmd/config_flow.py @@ -1,244 +1,239 @@ -import asyncio -import logging -import socket -import requests +"""Config flow for Bituo power monitoring devices.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback -from homeassistant.components.zeroconf import async_get_instance +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 -import voluptuous as vol -from zeroconf import ServiceBrowser, ServiceStateChange -from .const import DOMAIN, CONF_HOST_IP -_LOGGER = logging.getLogger(__name__) +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 + + +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.""" + 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 not _is_bituo_device(name): 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..fe90b87 100755 --- a/custom_components/bituopmd/const.py +++ b/custom_components/bituopmd/const.py @@ -1,4 +1,18 @@ -"""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 = 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 new file mode 100644 index 0000000..380c541 --- /dev/null +++ b/custom_components/bituopmd/coordinator.py @@ -0,0 +1,102 @@ +"""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, FAILURE_THRESHOLD +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 + self._consecutive_failures = 0 + 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() + 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/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..bf8c84a --- /dev/null +++ b/custom_components/bituopmd/helpers.py @@ -0,0 +1,99 @@ +"""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 + + # 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 + + +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..36349cb 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.4", "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..57d210a --- a/custom_components/bituopmd/sensor.py +++ b/custom_components/bituopmd/sensor.py @@ -1,425 +1,248 @@ -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]) + 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 ] - 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, ota_versions)) + 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, + 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"{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"] + try: + current_version = Version(current) + if current_version.major >= 4: + latest = self._ota_versions.get("common") + else: + latest = self._ota_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..21a3325 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,117 @@ +"""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) + 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.""" + 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()