From 766b44df1e12f7fdc9636acca53ddad915d71216 Mon Sep 17 00:00:00 2001 From: Alexander Pitkin Date: Mon, 2 Feb 2026 01:32:41 +0300 Subject: [PATCH] feat: Add sync time --- README.md | 8 +++- __init__.py | 67 ++++++++++++++++++++++++------- client.py | 83 +++++++++++++++++++++++++++++---------- manifest.json | 4 ++ services.yaml | 25 ++++++++++-- strings.json | 29 +++++++++----- translations/en.json | 67 +++++++++++++++++++++++++++++++ translations/zh-Hans.json | 9 +++++ 8 files changed, 243 insertions(+), 49 deletions(-) create mode 100644 translations/en.json diff --git a/README.md b/README.md index 9b34599..13ed953 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,15 @@ This integration registers a service called `pvvx_display.show`. * Can be used via *Developer Tools > Services* or within automations and scripts * You can specify the device address and display contents directly -> 💡 Supported parameters are listed in `services.yaml`, +> 💡 Supported parameters are listed in `services.yaml`, > or search for `pvvx_display.show` in the Developer Tools UI for schema details. +This integration also registers a service called `pvvx_display.sync_time`. + +* Can be used via *Developer Tools > Services* or within automations and scripts +* Synchronizes Home Assistant time to the PVVX device via BLE +* Supports specifying the device address directly + ### 2. Automation Action Once a device is configured, it also provides a **device action**: diff --git a/__init__.py b/__init__.py index b716cf6..1ae2109 100644 --- a/__init__.py +++ b/__init__.py @@ -1,18 +1,42 @@ -#coding: utf-8 +# coding: utf-8 from __future__ import annotations -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.config_entries import ConfigEntry from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import ServiceValidationError from .const import DOMAIN, CONF_ADDRESS PLATFORMS: list = [] # 这里不挂平台,不创建实体;只注册服务 + async def async_setup(hass: HomeAssistant, config: dict) -> bool: # 注册服务:pvvx_display.show 在 setup_entry 里完成(确保有 entry) return True + +def _get_address_from_target(hass: HomeAssistant, call: ServiceCall) -> str: + """Extract BLE address from device target or return provided address.""" + # Check if address is directly provided + if address := call.data.get("address"): + return address.upper() + + # Check for device target + if device_id := call.data.get("device"): + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + if device: + # Look for Bluetooth MAC in connections + for conn_type, conn_id in device.connections: + if conn_type == dr.CONNECTION_BLUETOOTH: + return conn_id.upper() + + raise ServiceValidationError( + "Either select a device or provide a MAC address" + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: address: str = entry.data[CONF_ADDRESS] @@ -23,36 +47,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connections={(dr.CONNECTION_BLUETOOTH, address)}, manufacturer="Xiaomi / PVVX", name=f"PVVX Display ({address})", - model="LYWSD03MMC (PVVX)" + model="LYWSD03MMC (PVVX)", ) # 延后注册服务,确保至少有一个 entry async def _unregister_services(): try: hass.services.async_remove(DOMAIN, "show") + hass.services.async_remove(DOMAIN, "sync_time") except Exception: pass - from .client import async_show_display + from .client import async_show_display, async_sync_time + + async def handle_sync_time_service(call: ServiceCall): + target_address = _get_address_from_target(hass, call) + await async_sync_time(hass, target_address) - async def handle_show_service(call): - await async_show_display(hass, address=call.data["address"], - big=call.data.get("big_number"), - small=call.data.get("small_number"), - unit=call.data.get("unit", "none"), - happy=call.data.get("happy", False), - sad=call.data.get("sad", False), - bracket=call.data.get("bracket", False), - percent=call.data.get("percent", False), - battery=call.data.get("battery", False), - validity=call.data.get("validity", 300)) + async def handle_show_service(call: ServiceCall): + target_address = _get_address_from_target(hass, call) + await async_show_display( + hass, + address=target_address, + big=call.data.get("big_number"), + small=call.data.get("small_number"), + unit=call.data.get("unit", "none"), + happy=call.data.get("happy", False), + sad=call.data.get("sad", False), + bracket=call.data.get("bracket", False), + percent=call.data.get("percent", False), + battery=call.data.get("battery", False), + validity=call.data.get("validity", 300), + ) # 只注册一次 if not hass.services.has_service(DOMAIN, "show"): hass.services.async_register(DOMAIN, "show", handle_show_service) + if not hass.services.has_service(DOMAIN, "sync_time"): + hass.services.async_register(DOMAIN, "sync_time", handle_sync_time_service) + entry.async_on_unload(_unregister_services) return True + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True diff --git a/client.py b/client.py index d0feb11..75fa99f 100644 --- a/client.py +++ b/client.py @@ -1,6 +1,7 @@ -#coding: utf-8 +# coding: utf-8 from __future__ import annotations +from contextlib import asynccontextmanager import struct from datetime import datetime, timezone @@ -9,10 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.components import bluetooth from homeassistant.exceptions import HomeAssistantError -from bleak_retry_connector import establish_connection, BleakClientWithServiceCache, BleakNotFoundError +from bleak_retry_connector import ( + establish_connection, + BleakClientWithServiceCache, + BleakNotFoundError, +) from bleak import BleakError from .const import PVVX_SERVICE_UUID, PVVX_CHAR_UUID +from datetime import datetime, timezone _LOGGER = logging.getLogger(__name__) @@ -28,20 +34,30 @@ "deg_e": 7, } + def _build_cfg(unit, happy, sad, bracket, percent, battery): cfg = 0 - if happy: cfg |= 1 << 0 - if sad: cfg |= 1 << 1 - if bracket: cfg |= 1 << 2 - if percent: cfg |= 1 << 3 - if battery: cfg |= 1 << 4 + if happy: + cfg |= 1 << 0 + if sad: + cfg |= 1 << 1 + if bracket: + cfg |= 1 << 2 + if percent: + cfg |= 1 << 3 + if battery: + cfg |= 1 << 4 u = UNIT_BITS.get(unit or "none", 0) & 0x7 cfg = (cfg & 0x1F) | (u << 5) return cfg -async def _connect(hass: HomeAssistant, address: str) -> BleakClientWithServiceCache: + +@asynccontextmanager +async def get_client(hass: HomeAssistant, address: str) -> BleakClientWithServiceCache: # 从 HA 获取可连接的 BLEDevice,再用 bleak-retry-connector 稳定建立连接 - ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True) + ble_device = bluetooth.async_ble_device_from_address( + hass, address, connectable=True + ) if not ble_device: if bluetooth.async_address_present(hass, address, connectable=False): raise HomeAssistantError( @@ -57,9 +73,8 @@ async def _connect(hass: HomeAssistant, address: str) -> BleakClientWithServiceC BleakClientWithServiceCache, ble_device, name=f"pvvx_display:{address}", - timeout=10 + timeout=10, ) - return client except BleakNotFoundError as e: # 常见于后端没有空闲连接槽/瞬时走丢 _LOGGER.debug("BleakNotFoundError while connecting to %s: %s", address, e) @@ -69,27 +84,55 @@ async def _connect(hass: HomeAssistant, address: str) -> BleakClientWithServiceC ) from e except BleakError as e: _LOGGER.debug("BleakError while connecting to %s: %s", address, e) - raise HomeAssistantError(f"Bluetooth error while connecting to {address}: {e}") from e + raise HomeAssistantError( + f"Bluetooth error while connecting to {address}: {e}" + ) from e except asyncio.TimeoutError as e: raise HomeAssistantError(f"Timed out connecting to {address} (10s)") from e + else: + try: + yield client + finally: + await client.disconnect() + async def async_show_display( hass: HomeAssistant, address: str, - big: float, small: int, unit: str, - happy: bool, sad: bool, bracket: bool, percent: bool, battery: bool, - validity: int + big: float, + small: int, + unit: str, + happy: bool, + sad: bool, + bracket: bool, + percent: bool, + battery: bool, + validity: int, ): - client = await _connect(hass, address) - try: + async with get_client(hass, address) as client: # 按 ESPHome 实现:首字节 0x22,随后 bignum(×10, uint16 LE)、smallnum(uint16 LE)、 # 有效期(uint16 LE)、cfg(uint8) bign = int(round((big or 0) * 10)) smal = int(small or 0) & 0xFFFF vali = int(validity or 300) & 0xFFFF - cfg = _build_cfg(unit, happy, sad, bracket, percent, battery) & 0xFF + cfg = _build_cfg(unit, happy, sad, bracket, percent, battery) & 0xFF payload = struct.pack("