Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
67 changes: 52 additions & 15 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -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]

Expand All @@ -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
83 changes: 63 additions & 20 deletions client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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__)

Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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("<BHHHB", 0x22, bign & 0xFFFF, smal, vali, cfg)
await client.write_gatt_char(PVVX_CHAR_UUID, payload, response=False)
finally:
await client.disconnect()


async def async_sync_time(hass: HomeAssistant, address: str):
"""Sync Home Assistant time to PVVX display"""
async with get_client(hass, address) as client:
# Get current time from Home Assistant
now = datetime.now(timezone.utc).astimezone().replace(tzinfo=timezone.utc)

# ESPHome PVVX time sync format: Unix timestamp (4 bytes, little-endian)
# Send as command 0x23 with timestamp
timestamp = int(now.timestamp())

# Pack as little-endian 32-bit unsigned integer
payload = struct.pack("<BI", 0x23, timestamp)

await client.write_gatt_char(PVVX_CHAR_UUID, payload, response=False)
_LOGGER.info("Time synced to PVVX device %s: %s", address, now.isoformat())
4 changes: 4 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
{
"service_uuid": "00001f10-0000-1000-8000-00805f9b34fb",
"connectable": true
},
{
"service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb",
"connectable": true
}
]
}
25 changes: 22 additions & 3 deletions services.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
show:
fields:
device:
required: false
selector:
device:
integration: pvvx_display
address:
required: true
required: false
example: "A4:C1:38:12:34:56"
selector:
text:
Expand All @@ -10,14 +15,16 @@ show:
example: 23.5
selector:
number:
min: -99.5
max: 1999.5
step: 0.1
small_number:
required: true
example: 55
selector:
number:
min: 0
max: 999
min: -9
max: 99
unit:
required: false
default: none
Expand Down Expand Up @@ -51,3 +58,15 @@ show:
number:
min: 1
max: 65535
sync_time:
fields:
device:
required: false
selector:
device:
integration: pvvx_display
address:
required: false
example: "A4:C1:38:12:34:56"
selector:
text:
29 changes: 19 additions & 10 deletions strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,30 @@
"invalid_address": "Invalid address."
}
},
"services": {
"services": {
"show": {
"name": "Show display",
"description": "Write custom content (Big/Small/Icons) to the PVVX Mi Thermometer via BLE (GATT). Coexists with BTHome; no sensors are created.",
"description": "Write custom content (Big/Small/Icons) to PVVX Mi Thermometer via BLE (GATT). Coexists with BTHome; no sensors are created.",
"fields": {
"address": { "name": "Device address", "description": "BLE MAC address of the target device." },
"big_number": { "name": "Big number", "description": "Main number on the LCD (x10 internally, e.g., 23.5 → 235)." },
"device": { "name": "Device", "description": "Target BLE device." },
"address": { "name": "Device address", "description": "BLE MAC address of target device." },
"big_number": { "name": "Big number", "description": "Main number on LCD (x10 internally, e.g., 23.5 → 235)." },
"small_number": { "name": "Small number", "description": "Auxiliary number (e.g., humidity)." },
"unit": { "name": "Unit/flags", "description": "Unit/flags encoded into config bits (deg_c/deg_f/percent/...)." },
"happy": { "name": "Happy icon", "description": "Show the happy face icon." },
"sad": { "name": "Sad icon", "description": "Show the sad face icon." },
"bracket": { "name": "Bracket", "description": "Show brackets around the small number." },
"percent": { "name": "Percent", "description": "Render percent sign with the small number." },
"battery": { "name": "Battery icon", "description": "Show the battery icon." },
"validity": { "name": "Validity (seconds)", "description": "How long the custom content should be displayed." }
"happy": { "name": "Happy icon", "description": "Show happy face icon." },
"sad": { "name": "Sad icon", "description": "Show sad face icon." },
"bracket": { "name": "Bracket", "description": "Show brackets around small number." },
"percent": { "name": "Percent", "description": "Render percent sign with small number." },
"battery": { "name": "Battery icon", "description": "Show battery icon." },
"validity": { "name": "Validity (seconds)", "description": "How long to display custom content." }
}
},
"sync_time": {
"name": "Sync time",
"description": "Sync Home Assistant time to PVVX display via BLE.",
"fields": {
"address": { "name": "Device address", "description": "BLE MAC address of target device." },
"device": { "name": "Device", "description": "Target BLE device." }
}
}
},
Expand Down
Loading