Initial commit

This commit is contained in:
gerviba committed 2026-11-01 18:33:58 +01:00
commit e1e03ba73f
38 files changed
+2968

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
"""The HA Probe integration.
Receives pushes from a gerviba/systemmonitor probe instance over a webhook and
turns them into sensors. See hu.gerviba.probe.api.ApiService on the probe side
for the wire format: a POST of {"devices": [...], "entities": [...]} to
/api/webhook/<webhook_id>.
"""
from __future__ import annotations
import logging
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_WEBHOOK_ID, DOMAIN, PLATFORMS, HaProbeRuntimeData, signal_entities
_LOGGER = logging.getLogger(__name__)
type HaProbeConfigEntry = ConfigEntry[HaProbeRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: HaProbeConfigEntry) -> bool:
"""Set up a HA Probe config entry."""
entry.runtime_data = HaProbeRuntimeData()
webhook_id = entry.data[CONF_WEBHOOK_ID]
async def handle_webhook(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response:
"""Handle an incoming push from the probe."""
try:
payload = await request.json()
except ValueError:
_LOGGER.warning("Received invalid JSON on the HA Probe webhook")
return Response(status=400)
if not isinstance(payload, dict):
_LOGGER.warning("Received an unexpected HA Probe webhook payload")
return Response(status=400)
for device in payload.get("devices") or ():
ha_id = device.get("haId")
display_name = device.get("haDisplayName")
if ha_id and display_name:
entry.runtime_data.devices[ha_id] = display_name
entities = payload.get("entities") or ()
if entities:
async_dispatcher_send(hass, signal_entities(entry.entry_id), entities)
return Response(status=200)
webhook.async_register(
hass,
DOMAIN,
entry.title,
webhook_id,
handle_webhook,
local_only=True,
allowed_methods=[METH_POST],
)
@callback
def _unregister_webhook() -> None:
webhook.async_unregister(hass, webhook_id)
entry.async_on_unload(_unregister_webhook)
entry.runtime_data.webhook_url = webhook.async_generate_url(hass, webhook_id)
_LOGGER.info("HA Probe webhook URL for %s: %s", entry.title, entry.runtime_data.webhook_url)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HaProbeConfigEntry) -> bool:
"""Unload a HA Probe config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+58
View File
@@ -0,0 +1,58 @@
"""Config flow for the HA Probe integration."""
from __future__ import annotations
import re
from typing import Any
import probatio
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME
from .const import CONF_WEBHOOK_ID, DOMAIN
WEBHOOK_ID_PATTERN = re.compile(r"^[a-z0-9_-]+$")
class HaProbeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for HA Probe."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial setup step."""
errors: dict[str, str] = {}
if user_input is not None:
webhook_id = user_input[CONF_WEBHOOK_ID].strip()
if not WEBHOOK_ID_PATTERN.match(webhook_id):
errors[CONF_WEBHOOK_ID] = "invalid_webhook_id"
elif self._webhook_id_in_use(webhook_id):
errors[CONF_WEBHOOK_ID] = "webhook_id_in_use"
else:
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
data_schema = probatio.Schema(
{
probatio.Required(CONF_NAME, default="HA Probe"): str,
probatio.Required(
CONF_WEBHOOK_ID, default=webhook.async_generate_id()
): str,
}
)
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
def _webhook_id_in_use(self, webhook_id: str) -> bool:
return any(
entry.data.get(CONF_WEBHOOK_ID) == webhook_id
for entry in self._async_current_entries()
)
+30
View File
@@ -0,0 +1,30 @@
"""Constants for the HA Probe integration."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from homeassistant.const import Platform
if TYPE_CHECKING:
from .sensor import HaProbeSensor
DOMAIN = "ha_probe"
PLATFORMS: list[Platform] = [Platform.SENSOR]
CONF_WEBHOOK_ID = "webhook_id"
def signal_entities(entry_id: str) -> str:
"""Return the dispatcher signal used to deliver a webhook payload's entities."""
return f"{DOMAIN}_{entry_id}_entities"
@dataclass
class HaProbeRuntimeData:
"""Runtime state for a configured probe webhook, kept on ConfigEntry.runtime_data."""
webhook_url: str = ""
devices: dict[str, str] = field(default_factory=dict)
entities: dict[str, "HaProbeSensor"] = field(default_factory=dict)
+11
View File
@@ -0,0 +1,11 @@
{
"domain": "ha_probe",
"name": "HA Probe",
"version": "2.0.0",
"integration_type": "device",
"iot_class": "local_push",
"dependencies": ["webhook"],
"codeowners": [],
"requirements": [],
"config_flow": true
}
+293
View File
@@ -0,0 +1,293 @@
"""Sensor platform for the HA Probe integration.
Entities are created and updated dynamically from whatever the probe sends -
there is no static sensor list here. The probe only sends full metadata
(name/deviceClass/unit/device) once per entity, at startup; every later push
is just {"id": ..., "value"/"textValue": ...}, so an already-known entity is
updated in place and never re-created.
"""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HaProbeConfigEntry
from .const import DOMAIN, signal_entities
_LOGGER = logging.getLogger(__name__)
# hu.gerviba.probe.util.UnitOfMeasurement, by Kotlin constant name. kotlinx.serialization
# encodes enums by their declared constant name (e.g. "MEGABYTES"), not the overridden
# toString() display symbol (e.g. "MB") - verified against the probe's actual webhook
# payload, so this is a name -> unit-symbol table, not the symbols themselves.
UNIT_MAP: dict[str, str] = {
"VOLT_AMPERE": "VA",
"WATT": "W",
"KILO_WATT": "kW",
"BTU_PER_HOUR": "BTU/h",
"VOLT_AMPERE_REACTIVE": "var",
"GIGA_JOULE": "GJ",
"KILO_WATT_HOUR": "kWh",
"MEGA_JOULE": "MJ",
"MEGA_WATT_HOUR": "MWh",
"WATT_HOUR": "Wh",
"MILLIAMPERE": "mA",
"AMPERE": "A",
"MILLIVOLT": "mV",
"VOLT": "V",
"CELSIUS": "°C",
"FAHRENHEIT": "°F",
"KELVIN": "K",
"MICROSECONDS": "μs",
"MILLISECONDS": "ms",
"SECONDS": "s",
"MINUTES": "min",
"HOURS": "h",
"DAYS": "d",
"WEEKS": "w",
"MONTHS": "m",
"YEARS": "y",
"MILLIMETERS": "mm",
"CENTIMETERS": "cm",
"METERS": "m",
"KILOMETERS": "km",
"INCHES": "in",
"FEET": "ft",
"YARDS": "yd",
"MILES": "mi",
"HERTZ": "Hz",
"KILOHERTZ": "kHz",
"MEGAHERTZ": "MHz",
"GIGAHERTZ": "GHz",
"PA": "Pa",
"HPA": "hPa",
"KPA": "kPa",
"BAR": "bar",
"CBAR": "cbar",
"MBAR": "mbar",
"MMHG": "mmHg",
"INHG": "inHg",
"PSI": "psi",
"DECIBEL": "dB",
"WEIGHTED_DECIBEL_A": "dBA",
"CUBIC_FEET": "ft³",
"CENTUM_CUBIC_FEET": "CCF",
"CUBIC_METERS": "m³",
"LITERS": "L",
"MILLILITERS": "mL",
"GALLONS": "gal",
"FLUID_OUNCES": "fl. oz.",
"CUBIC_METERS_PER_HOUR": "m³/h",
"CUBIC_FEET_PER_MINUTE": "ft³/m",
"GRAMS": "g",
"KILOGRAMS": "kg",
"MILLIGRAMS": "mg",
"MICROGRAMS": "µg",
"OUNCES": "oz",
"POUNDS": "lb",
"STONES": "st",
"WATTS_PER_SQUARE_METER": "W/m²",
"BTUS_PER_HOUR_SQUARE_FOOT": "BTU/(h⋅ft²)",
"INCHES_PER_DAY": "in/d",
"INCHES_PER_HOUR": "in/h",
"MILLIMETERS_PER_DAY": "mm/d",
"MILLIMETERS_PER_HOUR": "mm/h",
"PRECIPITATION_INCHES": "in",
"PRECIPITATION_MILLIMETERS": "mm",
"PRECIPITATION_CENTIMETERS": "cm",
"FEET_PER_SECOND": "ft/s",
"METERS_PER_SECOND": "m/s",
"KILOMETERS_PER_HOUR": "km/h",
"KNOTS": "kn",
"MILES_PER_HOUR": "mph",
"BITS": "bit",
"KILOBITS": "kbit",
"MEGABITS": "Mbit",
"GIGABITS": "Gbit",
"BYTES": "B",
"KILOBYTES": "kB",
"MEGABYTES": "MB",
"GIGABYTES": "GB",
"TERABYTES": "TB",
"PETABYTES": "PB",
"EXABYTES": "EB",
"ZETTABYTES": "ZB",
"YOTTABYTES": "YB",
"KIBIBYTES": "KiB",
"MEBIBYTES": "MiB",
"GIBIBYTES": "GiB",
"TEBIBYTES": "TiB",
"PEBIBYTES": "PiB",
"EXBIBYTES": "EiB",
"ZEBIBYTES": "ZiB",
"YOBIBYTES": "YiB",
"BITS_PER_SECOND": "bit/s",
"KILOBITS_PER_SECOND": "kbit/s",
"MEGABITS_PER_SECOND": "Mbit/s",
"GIGABITS_PER_SECOND": "Gbit/s",
"BYTES_PER_SECOND": "B/s",
"KILOBYTES_PER_SECOND": "kB/s",
"MEGABYTES_PER_SECOND": "MB/s",
"GIGABYTES_PER_SECOND": "GB/s",
"KIBIBYTES_PER_SECOND": "KiB/s",
"MEBIBYTES_PER_SECOND": "MiB/s",
"GIBIBYTES_PER_SECOND": "GiB/s",
"DEGREE": "°",
"CURRENCY_EURO": "€",
"CURRENCY_DOLLAR": "$",
"CURRENCY_CENT": "¢",
"PERCENTAGE": PERCENTAGE,
"AREA_SQUARE_METERS": "m²",
"CONDUCTIVITY": "µS/cm",
"LIGHT_LUX": "lx",
"UV_INDEX": "UV index",
"REVOLUTIONS_PER_MINUTE": "rpm",
"SIGNAL_STRENGTH_DECIBELS": "dB",
"SIGNAL_STRENGTH_DECIBELS_MILLIWATT": "dBm",
"CONCENTRATION_MICROGRAMS_PER_CUBIC_METER": "µg/m³",
"CONCENTRATION_PARTS_PER_MILLION": "ppm",
"CONCENTRATION_PARTS_PER_BILLION": "ppb",
}
def _resolve_unit(unit_name: str | None) -> str | None:
"""Map a probe UnitOfMeasurement constant name to its HA unit symbol."""
if unit_name is None:
return None
unit = UNIT_MAP.get(unit_name)
if unit is None:
_LOGGER.warning("Unknown HA Probe unit '%s'", unit_name)
return unit
def _resolve_device_class(device_class_name: str | None) -> SensorDeviceClass | None:
"""Map a probe SensorDeviceClass constant name to HA's enum member."""
if device_class_name is None:
return None
device_class = SensorDeviceClass.__members__.get(device_class_name)
if device_class is None:
_LOGGER.warning("Unknown HA Probe device class '%s'", device_class_name)
return device_class
def _fallback_name(entity_id: str) -> str:
"""Make a readable name out of an id when no schema metadata is known yet."""
return entity_id.rsplit("__", 1)[-1].replace("_", " ").title()
async def async_setup_entry(
hass: HomeAssistant,
entry: HaProbeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up HA Probe sensors for a config entry."""
runtime_data = entry.runtime_data
@callback
def handle_entities(entities: list[dict[str, Any]]) -> None:
new_sensors: list[HaProbeSensor] = []
for entity in entities:
entity_id = entity.get("id")
if not entity_id:
continue
text_value = entity.get("textValue")
state = text_value if text_value is not None else entity.get("value")
existing = runtime_data.entities.get(entity_id)
if existing is not None:
existing.handle_update(entity, state)
continue
device_ha_id = entity.get("device")
device_name = (
runtime_data.devices.get(device_ha_id, device_ha_id)
if device_ha_id
else entry.title
)
sensor = HaProbeSensor(
entry_id=entry.entry_id,
entity_key=entity_id,
device_ha_id=device_ha_id or "probe",
device_name=device_name,
initial_state=state,
name=entity.get("name") or _fallback_name(entity_id),
device_class=_resolve_device_class(entity.get("deviceClass")),
unit=_resolve_unit(entity.get("unit")),
)
runtime_data.entities[entity_id] = sensor
new_sensors.append(sensor)
if new_sensors:
async_add_entities(new_sensors)
entry.async_on_unload(
async_dispatcher_connect(hass, signal_entities(entry.entry_id), handle_entities)
)
class HaProbeSensor(SensorEntity):
"""A sensor created dynamically from a probe webhook push."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
*,
entry_id: str,
entity_key: str,
device_ha_id: str,
device_name: str,
initial_state: Any,
name: str,
device_class: SensorDeviceClass | None,
unit: str | None,
) -> None:
"""Initialize the sensor from its first-seen webhook entity."""
self._attr_unique_id = f"{entry_id}_{entity_key}"
self._attr_name = name
self._attr_device_class = device_class
self._attr_native_unit_of_measurement = unit
self._attr_native_value = initial_state
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry_id}_{device_ha_id}")},
name=device_name,
manufacturer="gerviba/systemmonitor",
model="Probe",
)
@callback
def handle_update(self, entity: dict[str, Any], state: Any) -> None:
"""Apply a later webhook push for this already-known entity.
Usually this is just a bare value/textValue push, but if the probe
restarted it will resend full metadata too - pick that back up
instead of only ever trusting the first value we saw.
"""
self._attr_native_value = state
name = entity.get("name")
if name:
self._attr_name = name
device_class_name = entity.get("deviceClass")
if device_class_name:
self._attr_device_class = _resolve_device_class(device_class_name)
unit_name = entity.get("unit")
if unit_name:
self._attr_native_unit_of_measurement = _resolve_unit(unit_name)
if self.hass is not None:
self.async_write_ha_state()
@@ -0,0 +1,18 @@
{
"config": {
"step": {
"user": {
"title": "HA Probe",
"description": "Set up a webhook to receive pushes from a systemmonitor probe. Put this webhook ID into the probe's config.json as \"webhookToken\".",
"data": {
"name": "Integration Name",
"webhook_id": "Webhook ID"
}
}
},
"errors": {
"invalid_webhook_id": "Invalid webhook ID. Use lowercase letters, numbers, underscores, or hyphens.",
"webhook_id_in_use": "Webhook ID is already in use."
}
}
}