Compare commits
5 Commits
e737fb16d3
...
202447405f
| Author | SHA1 | Date |
|---|---|---|
|
|
202447405f | |
|
|
fed00b437a | |
|
|
15e9df00ca | |
|
|
50fe9e35fe | |
|
|
7f72497e9e |
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "tools/test-station-server"]
|
||||
path = tools/test-station-server
|
||||
url = https://github.com/schizza/test-station-server.git
|
||||
39
README.md
39
README.md
|
|
@ -9,7 +9,7 @@ This integration will listen for data from your station and passes them to respe
|
|||
|
||||
---
|
||||
|
||||
### In the next major release, I plan to rename the integration, as its current name no longer reflects its original purpose. The integration was initially developed primarily for the SWS12500 station, but it already supports other weather stations as well (e.g., Bresser, Garni, and others). Support for Ecowitt stations will also be added in the future, so the current name has become misleading. This information will be provided via an update, and I’m also planning to offer a full data migration from the existing integration to the new one, so will not lose any of historical data.
|
||||
### In the next major release, I plan to rename the integration, as its current name no longer reflects its original purpose. The integration was initially developed primarily for the SWS12500 station, but it already supports other weather stations as well (e.g., Bresser, Garni, and others). Support for Ecowitt stations will also be added in the future, so the current name has become misleading. This information will be provided via an update, and I’m also planning to offer a full data migration from the existing integration to the new one, so will not lose any of historical data
|
||||
|
||||
- The transition date hasn’t been set yet, but it’s currently expected to happen within the next ~2–3 months. At the moment, I’m working on a full refactor and general code cleanup. Looking further ahead, the goal is to have the integration fully incorporated into Home Assistant as a native component—meaning it won’t need to be installed via HACS, but will become part of the official Home Assistant distribution.
|
||||
|
||||
|
|
@ -19,8 +19,41 @@ This integration will listen for data from your station and passes them to respe
|
|||
|
||||
## Warning - WSLink APP (applies also for SWS 12500 with firmware >3.0)
|
||||
|
||||
Please, read IMPORTANT down below.
|
||||
|
||||
For stations that are using WSLink app to setup station and WSLink API for resending data (also SWS 12500 manufactured in 2024 and later). You will need to install [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) to your Home Assistant if you are not running your Home Assistant instance in SSL mode or you do not have SSL proxy for your Home Assistant.
|
||||
|
||||
---
|
||||
!IMPORTANT!
|
||||
This recommendation above does not applies for all stations. As it is know by now, some stations, even configured by `WSLink App` does not use `WSLink protocol` to send data to custom servers.
|
||||
So, it could be very confusing how to configure your station. And for that case, I made simple debugging server.
|
||||
|
||||
## Not Sure How Your Station Sends Data?
|
||||
|
||||
If you are struggling with configuration and don't know which protocol your station uses (`WSLink` or `PWS/WU`), or whether it sends data over SSL or plain HTTP — use our public test server:
|
||||
|
||||
**<https://test-station.schizza.cz>**
|
||||
|
||||
1. Open the link above and create a new session.
|
||||
2. Point your weather station to the generated subdomain address.
|
||||
3. Within a few seconds the server will show you:
|
||||
- the **protocol** your station is using (`WSLink` or `PWS/WU`)
|
||||
- whether the request arrived over **SSL** or **non-SSL**
|
||||
- the full query string, headers, and payload your station sent
|
||||
|
||||
This information tells you exactly how to configure the integration in Home Assistant.
|
||||
|
||||
### Quick Recommendations
|
||||
|
||||
| Station sends via | Protocol | Recommended Setup |
|
||||
|---|---|--- |
|
||||
| **plain HTTP** | PWS/WU | Point the station directly at Home Assistant — no proxy needed. |
|
||||
| **plain HTTP** | WSLink | Point the station directly at Home Assistant — no proxy needed. Enable `WSLink` in settings. |
|
||||
| **HTTPS (SSL)** | PWS/WU | Install the [WSLink Proxy Add-on](https://github.com/schizza/wslink-addon) — it terminates TLS and forwards plain HTTP to Home Assistant. And keep `WSLink API` unchecked. (Disabled) |
|
||||
| **HTTPS (SSL)** | WSLink | Install the [WSLink Proxy Add-on](https://github.com/schizza/wslink-addon) — it terminates TLS and forwards plain HTTP to Home Assistant and enable `WSLink API` |
|
||||
|
||||
— If the test server shows your station is sending over SSL, you need the [WSLink Proxy Add-on](https://github.com/schizza/wslink-addon). If it sends plain HTTP, you can connect directly.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Weather station that supports sending data to custom server in their API [(list of supported stations.)](#list-of-supported-stations)
|
||||
|
|
@ -42,7 +75,7 @@ For stations that are using WSLink app to setup station and WSLink API for resen
|
|||
|
||||
### For stations that send data through WSLink API
|
||||
|
||||
Make sure you have your Home Assistant cofigured in SSL mode or use [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) to bypass SSL configuration of whole Home Assistant.
|
||||
Make sure you have your Home Assistant configured in SSL mode or use [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) to bypass SSL configuration of whole Home Assistant.
|
||||
|
||||
### HACS installation
|
||||
|
||||
|
|
@ -122,6 +155,7 @@ So, deleteing integration and reinstalling will make sure, that sensors will be
|
|||
- You are done.
|
||||
|
||||
## Resending data to Pocasi Meteo
|
||||
|
||||
- If you are willing to use [Pocasi Meteo Application](https://pocasimeteo.cz) you can enable resending your data to their servers
|
||||
- You must have account at Pocasi Meteo, where you will recieve `ID` and `KEY`, which are needed to connect to server
|
||||
- In `Settings` -> `Devices & services` find SWS12500 and click `Configure`.
|
||||
|
|
@ -150,3 +184,4 @@ you will set URL in station to: 192.0.0.2:4443
|
|||
- Your station will be sending data to this SSL proxy and addon will handle the rest.
|
||||
|
||||
_Most of the stations does not care about self-signed certificates on the server side._
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ from .const import (
|
|||
API_ID,
|
||||
API_KEY,
|
||||
DEFAULT_URL,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
POCASI_CZ_ENABLED,
|
||||
SENSORS_TO_LOAD,
|
||||
|
|
@ -52,6 +55,7 @@ from .const import (
|
|||
WSLINK_URL,
|
||||
)
|
||||
from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS
|
||||
from .ecowitt import EcowittBridge # noqa: PLC0415
|
||||
from .health_coordinator import HealthCoordinator
|
||||
from .pocasti_cz import PocasiPush
|
||||
from .routes import Routes
|
||||
|
|
@ -68,7 +72,7 @@ from .utils import (
|
|||
from .windy_func import WindyPush
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
class IncorrectDataError(InvalidStateError):
|
||||
|
|
@ -102,6 +106,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
self.config: ConfigEntry = config
|
||||
self.windy: WindyPush = WindyPush(hass, config)
|
||||
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
||||
|
||||
# Ecowitt bridge - aioecowitt parser without HTTP server
|
||||
|
||||
self.ecowitt_bridge: EcowittBridge = EcowittBridge(hass, config)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN)
|
||||
|
||||
def _health_coordinator(self) -> HealthCoordinator | None:
|
||||
|
|
@ -114,6 +123,93 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
coordinator = entry.get(ENTRY_HEALTH_COORD)
|
||||
return coordinator if isinstance(coordinator, HealthCoordinator) else None
|
||||
|
||||
async def recieved_ecowitt_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
"""Handle incoming Ecowitt webhook payload.
|
||||
|
||||
We are using aioecowitt for parsing payload. Sensors with internal
|
||||
mapping will use SWS pipline. Sensors withou mapping will create
|
||||
native Ecowitt entity trough bridge callback.
|
||||
"""
|
||||
|
||||
from .const import ECOWITT_ENABLED, ECOWITT_WEBHOOK_ID # noqa: PLC0415
|
||||
|
||||
health = self._health_coordinator()
|
||||
|
||||
# Do we have Ecowitt enabled?
|
||||
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False):
|
||||
if health:
|
||||
health.update_ingress_result(
|
||||
webdata,
|
||||
accepted=False,
|
||||
authorized=None,
|
||||
reason="ecowitt_disabled",
|
||||
)
|
||||
return aiohttp.web.Response(text="Ecowitt disabled", status=403)
|
||||
|
||||
# Check webhook ID from URL
|
||||
expected_webhook = self.config.options.get(ECOWITT_WEBHOOK_ID, "")
|
||||
actual_webhook = webdata.match_info.get("webhook_id", "")
|
||||
|
||||
if not expected_webhook or actual_webhook != expected_webhook:
|
||||
_LOGGER.error("Ecowitt: invalid webhook ID")
|
||||
if health:
|
||||
health.update_ingress_result(
|
||||
webdata,
|
||||
accepted=False,
|
||||
authorized=False,
|
||||
reason="ecowitt_invalid_webhook_id",
|
||||
)
|
||||
raise HTTPUnauthorized
|
||||
|
||||
# Parse POST body
|
||||
post_data = await webdata.post()
|
||||
data: dict[str, Any] = dict(post_data)
|
||||
|
||||
# Bridge: aioecowitt parsing + internal remap
|
||||
mapped_data = await self.ecowitt_bridge.process_payload(data)
|
||||
|
||||
# Mapped sensors to SWS pipline (auto-discovery + fan-out)
|
||||
if mapped_data:
|
||||
if sensors := check_disabled(mapped_data, self.config):
|
||||
newly_discovered = list(sensors)
|
||||
if _loaded_senosrs := loaded_sensors(self.config):
|
||||
sensors.extend(_loaded_senosrs)
|
||||
await update_options(self.hass, self.config, SENSORS_TO_LOAD, sensors)
|
||||
|
||||
from .binary_sensor import add_new_binary_sensors # noqa: PLC0415
|
||||
from .sensor import add_new_sensors # noqa: PLC0415
|
||||
|
||||
add_new_binary_sensors(self.hass, self.config, newly_discovered)
|
||||
add_new_sensors(self.hass, self.config, newly_discovered)
|
||||
self.async_set_updated_data(mapped_data)
|
||||
|
||||
if health:
|
||||
health.update_ingress_result(
|
||||
webdata,
|
||||
accepted=True,
|
||||
authorized=True,
|
||||
reason="accepted",
|
||||
)
|
||||
|
||||
# Forwarding (mapped data in WU units)
|
||||
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
|
||||
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
|
||||
if _windy_enabled:
|
||||
await self.windy.push_data_to_windy(data, False)
|
||||
|
||||
# Will push just WU payload to POCASI
|
||||
# TODO: create ecowitt protocol to send full payload to Pocasi CZ
|
||||
if _pocasi_enabled:
|
||||
await self.pocasi.push_data_to_server(data, "WU")
|
||||
|
||||
if health:
|
||||
health.update_forwarding(self.windy, self.pocasi)
|
||||
|
||||
if (_ := checked(self.config.options.get(DEV_DBG), True)) is not None:
|
||||
_LOGGER.info("Dev log (ecowitt): %s", anonymize(data))
|
||||
|
||||
return aiohttp.web.Response(body="OK", status=200)
|
||||
|
||||
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
"""Handle incoming webhook payload from the station.
|
||||
|
||||
|
|
@ -268,9 +364,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
# NOTE: Some linters prefer top-level imports. In this case the local import is
|
||||
# intentional and prevents "partially initialized module" errors.
|
||||
|
||||
from .binary_sensor import add_new_binary_sensors # noqa: PLC0415 (local import is intentional)
|
||||
from .sensor import add_new_sensors # noqa: PLC0415 (local import is intentional)
|
||||
|
||||
add_new_sensors(self.hass, self.config, newly_discovered)
|
||||
add_new_binary_sensors(self.hass, self.config, newly_discovered)
|
||||
|
||||
# Fan-out update: notify all subscribed entities.
|
||||
self.async_set_updated_data(remaped_items)
|
||||
|
|
@ -322,6 +420,7 @@ def register_path(
|
|||
raise ConfigEntryNotReady
|
||||
|
||||
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
||||
_ecowitt_enabled: bool = checked_or(config.options.get(ECOWITT_ENABLED), bool, False)
|
||||
|
||||
# Load registred routes
|
||||
routes: Routes | None = hass_data.get("routes", None)
|
||||
|
|
@ -337,6 +436,12 @@ def register_path(
|
|||
_wslink_get_route = hass.http.app.router.add_get(WSLINK_URL, routes.dispatch, name="_wslink_get_route")
|
||||
_health_route = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch, name="_health_route")
|
||||
|
||||
# Ecowitt URL contains {webhook_id} as a parameter.
|
||||
# Station is configured to send data to: http://ha:8123/weatherhub/<webhook_id>
|
||||
|
||||
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
||||
_ecowitt_route = hass.http.app.router.add_post(_ecowitt_path, routes.dispatch, name="_ecowitt_route")
|
||||
|
||||
# Save initialised routes
|
||||
hass_data["routes"] = routes
|
||||
|
||||
|
|
@ -356,6 +461,13 @@ def register_path(
|
|||
enabled=True,
|
||||
sticky=True,
|
||||
)
|
||||
routes.add_route(
|
||||
_ecowitt_path,
|
||||
_ecowitt_route,
|
||||
coordinator.recieved_ecowitt_data,
|
||||
enabled=_ecowitt_enabled,
|
||||
sticky=True,
|
||||
)
|
||||
else:
|
||||
routes.set_ingress_observer(coordinator_h.record_dispatch)
|
||||
_LOGGER.info("We have already registered routes: %s", routes.show_enabled())
|
||||
|
|
@ -418,12 +530,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
||||
|
||||
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
||||
_ecowitt_enabled = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
|
||||
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
||||
|
||||
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
||||
_LOGGER.debug("WS Link is %s", "enabled" if _wslink else "disabled")
|
||||
|
||||
if routes:
|
||||
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
|
||||
routes.switch_route(coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL)
|
||||
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.recieved_ecowitt_data, _ecowitt_enabled)
|
||||
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
||||
coordinator_health.update_routing(routes)
|
||||
_LOGGER.debug("%s", routes.show_enabled())
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
"""Battery binary sensor entities."""
|
||||
"""Battery binary sensor entities for SWS 12500.
|
||||
|
||||
Expose low-batter warnings as binary sensors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from py_typecheck import checked_or
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
CoordinatorEntity, BinarySensorEntity
|
||||
|
|
@ -31,7 +39,7 @@ class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride
|
|||
"""Initialize the battery binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{description.key}_battery"
|
||||
self._attr_unique_id = f"{description.key}_binary"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
|
@ -51,3 +59,15 @@ class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride
|
|||
return None
|
||||
|
||||
return value == 0
|
||||
|
||||
@cached_property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info."""
|
||||
return DeviceInfo(
|
||||
connections=set(),
|
||||
name="Weather Station SWS 12500",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
|
||||
manufacturer="Schizza",
|
||||
model="Weather Station SWS 12500",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
"""Battery sensors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
|
||||
|
||||
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
||||
BinarySensorEntityDescription(
|
||||
|
|
@ -21,4 +18,34 @@ BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
|||
translation_key="ch2_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="ch3_battery",
|
||||
translation_key="ch3_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="ch4_battery",
|
||||
translation_key="ch4_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="ch5_battery",
|
||||
translation_key="ch5_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="ch6_battery",
|
||||
translation_key="ch6_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="ch7_battery",
|
||||
translation_key="ch7_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="ch8_battery",
|
||||
translation_key="ch8_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
"""Binary sensor platform for SWS12500.
|
||||
|
||||
Exposes low-battery warnings as binary sensors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from py_typecheck import checked
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .battery_sensors import BatteryBinarySensor
|
||||
from .battery_sensors_def import BATTERY_BINARY_SENSORS
|
||||
from .const import DOMAIN, SENSORS_TO_LOAD
|
||||
from .data import ENTRY_ADD_BINARY_ENTITIES, ENTRY_ADDED_BINARY_KEYS, ENTRY_BINARY_DESCRIPTION, ENTRY_COORDINATOR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Set up battery binary sensors."""
|
||||
|
||||
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
if (entry_data := checked(hass_data.get(entry.entry_id), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
coordinator = entry_data.get(ENTRY_COORDINATOR)
|
||||
if coordinator is None:
|
||||
return
|
||||
|
||||
# Save callback and descriptions for later auto-discovery.
|
||||
# webhook needs this to dynamicaly add entities withou reload
|
||||
|
||||
description = {desc.key: desc for desc in BATTERY_BINARY_SENSORS}
|
||||
entry_data[ENTRY_ADD_BINARY_ENTITIES] = async_add_entities
|
||||
entry_data[ENTRY_BINARY_DESCRIPTION] = description
|
||||
|
||||
added_keys: set[str] = set()
|
||||
entry_data[ENTRY_ADDED_BINARY_KEYS] = added_keys
|
||||
|
||||
# Create binary sensors for battery key that station send.
|
||||
# SENSORS_TO_LOAD contains all discovered keys.
|
||||
|
||||
loaded = set(entry.options.get(SENSORS_TO_LOAD, []))
|
||||
|
||||
entities: list[BatteryBinarySensor] = []
|
||||
|
||||
for desc in BATTERY_BINARY_SENSORS:
|
||||
if desc.key in loaded:
|
||||
entities.append(BatteryBinarySensor(coordinator, desc))
|
||||
added_keys.add(desc.key)
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def add_new_binary_sensors(hass: HomeAssistant, entry: ConfigEntry, keys: list[str]) -> None:
|
||||
"""Dynamic add newly discovered enetities.
|
||||
|
||||
Called from webhook handler in __init__.py.
|
||||
"""
|
||||
|
||||
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
if (entry_data := checked(hass_data.get(entry.entry_id), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
add_entities = entry_data.get(ENTRY_ADD_BINARY_ENTITIES)
|
||||
description = entry_data.get(ENTRY_BINARY_DESCRIPTION)
|
||||
coordinator = entry_data.get(ENTRY_COORDINATOR)
|
||||
added_keys: set[str] | None = entry_data.get(ENTRY_ADDED_BINARY_KEYS)
|
||||
|
||||
if add_entities is None or description is None or coordinator is None:
|
||||
return
|
||||
|
||||
if added_keys is None:
|
||||
added_keys = set()
|
||||
entry_data[ENTRY_ADDED_BINARY_KEYS] = added_keys
|
||||
|
||||
description_map = cast("dict[str, Any]", description)
|
||||
added = added_keys
|
||||
|
||||
new_entities: list[BinarySensorEntity] = []
|
||||
for key in keys:
|
||||
if key in added:
|
||||
continue
|
||||
desc = description_map.get(key)
|
||||
if desc is None:
|
||||
continue
|
||||
new_entities.append(BatteryBinarySensor(coordinator, desc))
|
||||
added.add(key)
|
||||
|
||||
if new_entities:
|
||||
add_fn = cast("AddEntitiesCallback", add_entities)
|
||||
add_fn(new_entities)
|
||||
|
|
@ -225,11 +225,46 @@ __all__ = [
|
|||
"AZIMUT",
|
||||
"UnitOfBat",
|
||||
"BATTERY_LEVEL",
|
||||
"ECOWITT_URL",
|
||||
"ECOWITT_META_KEYS",
|
||||
"REMAP_ECOWITT_COMPAT",
|
||||
]
|
||||
|
||||
ECOWITT: Final = "ecowitt"
|
||||
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
|
||||
ECOWITT_ENABLED: Final = "ecowitt_enabled"
|
||||
ECOWITT_URL: Final = "/weather/ecowitt"
|
||||
ECOWITT_URL_PREFIX: Final = "/weatherhub"
|
||||
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
|
||||
|
||||
REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
||||
"tempf": OUTSIDE_TEMP,
|
||||
"humidity": OUTSIDE_HUMIDITY,
|
||||
"dewpointf": DEW_POINT,
|
||||
"windspeedmph": WIND_SPEED,
|
||||
"windgustmph": WIND_GUST,
|
||||
"winddir": WIND_DIR,
|
||||
"dailyrainin": DAILY_RAIN,
|
||||
"solarradiation": SOLAR_RADIATION,
|
||||
"tempinf": INDOOR_TEMP,
|
||||
"humidityin": INDOOR_HUMIDITY,
|
||||
"uv": UV,
|
||||
"baromrelin": BARO_PRESSURE,
|
||||
"temp1f": CH2_TEMP,
|
||||
"humidity1": CH2_HUMIDITY,
|
||||
"temp2f": CH3_TEMP,
|
||||
"humidity2": CH3_HUMIDITY,
|
||||
"temp3f": CH4_TEMP,
|
||||
"humidity3": CH4_HUMIDITY,
|
||||
"temp4f": CH5_TEMP,
|
||||
"humidity4": CH5_HUMIDITY,
|
||||
"temp5f": CH6_TEMP,
|
||||
"humidity5": CH6_HUMIDITY,
|
||||
"temp6f": CH7_TEMP,
|
||||
"humidity6": CH7_HUMIDITY,
|
||||
"temp7f": CH8_TEMP,
|
||||
"humidity7": CH8_HUMIDITY,
|
||||
}
|
||||
|
||||
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
|
||||
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
|
||||
|
|
@ -403,9 +438,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
|
|||
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
|
||||
# &t11co= CO concentration integer ppm
|
||||
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
|
||||
# &t11cn= CO sensor connection (Connected=1, No connect=0) integer
|
||||
# &t11cn= CO sensor connection (Connected=1, No connect=0) integero
|
||||
#
|
||||
|
||||
|
||||
DISABLED_BY_DEFAULT: Final = [
|
||||
CH2_TEMP,
|
||||
CH2_HUMIDITY,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ from typing import Final
|
|||
ENTRY_COORDINATOR: Final[str] = "coordinator"
|
||||
ENTRY_ADD_ENTITIES: Final[str] = "async_add_entities"
|
||||
ENTRY_DESCRIPTIONS: Final[str] = "sensor_descriptions"
|
||||
|
||||
# Binary sensor dynamic support
|
||||
ENTRY_ADD_BINARY_ENTITIES: Final[str] = "async_add_binary_entities"
|
||||
ENTRY_BINARY_DESCRIPTION: Final[str] = "binary_sensor_description"
|
||||
ENTRY_ADDED_BINARY_KEYS: Final[str] = "added_binary_keys"
|
||||
|
||||
ENTRY_LAST_OPTIONS: Final[str] = "last_options"
|
||||
ENTRY_HEALTH_COORD: Final[str] = "coord_h"
|
||||
ENTRY_HEALTH_DATA: Final[str] = "health_data"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
"""Ecowitt bridge.
|
||||
|
||||
Uses the ecowitt library for payload parsing and sensor discovery,
|
||||
but does NOT start a separate HTTP server. Instead, the HA webhook handler
|
||||
feeds raw POST data into EcoWittListener.process_data().
|
||||
|
||||
Sensors that have an internal mapping (REMAP_ECOWITT_COMPACT) are unified
|
||||
with the existing SWS sensor pipline. Unmapped sensors are exposed as
|
||||
native Ecowitt entites for forward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Reverse mapping: internal key to ecowitt field name
|
||||
# we need to know which key is internaly covered.
|
||||
_MAPPED_ECOWITT_KEYS: set[str] = set(REMAP_ECOWITT_COMPAT.keys())
|
||||
|
||||
# aioecowitt sensor type to HA device class + unit
|
||||
# We cover most common types, addidional will be covered later.
|
||||
STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None, SensorStateClass | None]] = {
|
||||
EcoWittSensorTypes.TEMPERATURE_C: (
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
"°C",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.TEMPERATURE_F: (
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
"°F",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.HUMIDITY: (
|
||||
SensorDeviceClass.HUMIDITY,
|
||||
"%",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.PRESSURE_HPA: (
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
"hPa",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.PRESSURE_INHG: (
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
"inHg",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.SPEED_KPH: (
|
||||
SensorDeviceClass.WIND_SPEED,
|
||||
"km/h",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.SPEED_MPH: (
|
||||
SensorDeviceClass.WIND_SPEED,
|
||||
"mph",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.DEGREE: (
|
||||
None,
|
||||
"°",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.WATT_METERS_SQUARED: (
|
||||
SensorDeviceClass.IRRADIANCE,
|
||||
"W/m²",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.UV_INDEX: (
|
||||
None,
|
||||
"UV index",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.PM25: (
|
||||
SensorDeviceClass.PM25,
|
||||
"µg/m³",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.PM10: (
|
||||
SensorDeviceClass.PM10,
|
||||
"µg/m³",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.CO2_PPM: (
|
||||
SensorDeviceClass.CO2,
|
||||
"ppm",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_RATE_MM: (
|
||||
SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
"mm/h",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_COUNT_MM: (
|
||||
SensorDeviceClass.PRECIPITATION,
|
||||
"mm",
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
EcoWittSensorTypes.LIGHTNING_COUNT: (
|
||||
None,
|
||||
"strikes",
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: (
|
||||
SensorDeviceClass.DISTANCE,
|
||||
"km",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.SOIL_MOISTURE: (
|
||||
SensorDeviceClass.MOISTURE,
|
||||
"%",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.BATTERY_VOLTAGE: (
|
||||
SensorDeviceClass.VOLTAGE,
|
||||
"V",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.BATTERY_PERCENTAGE: (
|
||||
SensorDeviceClass.BATTERY,
|
||||
"%",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class EcowittBridge:
|
||||
"""Bridge between HA webhook and aioecowitt parsing.
|
||||
|
||||
We do not run EcoWittListener.start() - this would start separate HTTP server.
|
||||
Instead we are calling listener.process_data() manualy from our webhook handler
|
||||
and we are just using parsing/discovery logic.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||
"""Initialize bridge."""
|
||||
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
|
||||
# Listener without start - just parser
|
||||
self._listener = EcoWittListener()
|
||||
|
||||
# Callback for new sensors
|
||||
self._listener.new_sensor_cb.append(self._on_new_sensor)
|
||||
|
||||
# We need to know which native ecowitt senosrs have an entity
|
||||
self._know_native_keys: set[str] = set()
|
||||
|
||||
# Callback for new entities
|
||||
self._add_entities_cb: AddEntitiesCallback | None = None
|
||||
|
||||
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
|
||||
"""Store the platform callback for dynamic entity creation."""
|
||||
|
||||
self._add_entities_cb = callback
|
||||
|
||||
async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Process raw Ecowitt POST payload.
|
||||
|
||||
Returns:
|
||||
Dict of internal sensor keys -> values (fro mapped senors).
|
||||
Unmapped sensors are handeled via _on_new_sensor callback.
|
||||
|
||||
"""
|
||||
|
||||
# Let aioecowitt parse payload and derive values,
|
||||
# then call new_sensors_cb for new sensors
|
||||
self._listener.process_data(data)
|
||||
|
||||
# Get values for internaly mapped sensors
|
||||
mapped_result: dict[str, str] = {}
|
||||
for ecowitt_key, internal_key in REMAP_ECOWITT_COMPAT.items():
|
||||
if ecowitt_key in data:
|
||||
mapped_result[internal_key] = data[ecowitt_key]
|
||||
|
||||
return mapped_result
|
||||
|
||||
def _on_new_sensor(self, sensor: EcoWittSensor) -> None:
|
||||
"""Call me by aioecowitt when a new sensor is discovered.
|
||||
|
||||
If the senosor does not have internal mapping,
|
||||
create native Ecowitt entity.
|
||||
"""
|
||||
|
||||
# Sensors with internal mapping handle with current pipeline.
|
||||
if sensor.key in _MAPPED_ECOWITT_KEYS:
|
||||
_LOGGER.debug(
|
||||
"Ecowitt sensor %s has internal mapping, skipping native entity",
|
||||
sensor.key,
|
||||
)
|
||||
return
|
||||
|
||||
# Is entity created?
|
||||
if sensor.key in self._know_native_keys:
|
||||
return
|
||||
|
||||
if self._add_entities_cb is None:
|
||||
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
|
||||
return
|
||||
|
||||
self._know_native_keys.add(sensor.key)
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
self._add_entities_cb([entity])
|
||||
|
||||
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
|
||||
|
||||
@property
|
||||
def unmapped_sensor(self) -> dict[str, EcoWittSensor]:
|
||||
"""Return al sensors that don't have an internal mapping."""
|
||||
|
||||
return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS}
|
||||
|
||||
@property
|
||||
def all_sensors(self) -> dict[str, EcoWittSensor]:
|
||||
"""Return all discovered sensors."""
|
||||
return self._listener.sensors
|
||||
|
||||
|
||||
class EcoWittNativeSensor(SensorEntity):
|
||||
"""Sensor entity for Ecowitt sensors without internal mapping.
|
||||
|
||||
These entities are "pass-trough" - theri values are directly from EcoWittSensor
|
||||
and maps `stype` to HA device class. They do not have coordinator, because
|
||||
EcoWittSensor have his own update_cb callback mechanism.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, sensor: EcoWittSensor) -> None:
|
||||
"""Initialize native EcoWittSensor."""
|
||||
|
||||
self._ecowitt_sensor = sensor
|
||||
self._attr_unique_id = f"ecowitt_{sensor.key}"
|
||||
self._attr_translation_key = None # we do not have translation_keys for native sensors
|
||||
|
||||
# set HomeAssistant metadata from aioecowitt sensor type
|
||||
ha_meta = STYPE_TO_HA.get(sensor.stype)
|
||||
if ha_meta:
|
||||
device_class, unit, state_class = ha_meta
|
||||
self._attr_device_class = device_class
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_state_class = state_class
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Sensor name from aioecowitt."""
|
||||
return self._ecowitt_sensor.name
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Current value from Ecowitt sensor."""
|
||||
value = self._ecowitt_sensor.value
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Link to the Ecowitt station device."""
|
||||
station = self._ecowitt_sensor.station
|
||||
return DeviceInfo(
|
||||
connections=set(),
|
||||
name=f"Ecowitt {station.model}" if station else "Ecowitt station",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, f"ecowitt_{station.key}" if station else "ecowitt")},
|
||||
manufacturer="Ecowitt impl. from Schizza for SWS12500",
|
||||
model=station.model if station else None,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register update callback when entity is added to HA."""
|
||||
self._ecowitt_sensor.update_cb.append(self._handle_update)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""REmove update callback when entity is removed."""
|
||||
if self._handle_update in self._ecowitt_sensor.update_cb:
|
||||
self._ecowitt_sensor.update_cb.remove(self._handle_update)
|
||||
|
||||
@callback
|
||||
def _handle_update(self) -> None:
|
||||
"""Handle sensro values update from aioecowitt."""
|
||||
self.async_write_ha_state()
|
||||
|
|
@ -36,6 +36,7 @@ from homeassistant.util import dt as dt_util
|
|||
from .const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
POCASI_CZ_ENABLED,
|
||||
WINDY_ENABLED,
|
||||
|
|
@ -64,6 +65,8 @@ def _protocol_from_path(path: str) -> str:
|
|||
return "wu"
|
||||
if path == HEALTH_URL:
|
||||
return "health"
|
||||
if path.startswith(ECOWITT_URL_PREFIX + "/"):
|
||||
return "ecowitt"
|
||||
return "unknown"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ from typing import Any, cast
|
|||
|
||||
from py_typecheck import checked, checked_or
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
"""Legacy definitions."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from py_typecheck import checked_or
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
|
||||
from .const import DOMAIN, SENSORS_TO_LOAD
|
||||
|
||||
LEGACY_REMOVE_VERSION: Final = "2.1.0"
|
||||
LEGACY_BATTERY_KEYS: Final[set[str]] = {
|
||||
"outside_battery",
|
||||
"indoor_battery",
|
||||
"ch2_battery",
|
||||
"ch3_battery",
|
||||
"ch4_battery",
|
||||
"ch5_battery",
|
||||
"ch6_battery",
|
||||
"ch7_battery",
|
||||
"ch8_battery",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_battery_issue_id(entry: ConfigEntry) -> str:
|
||||
"""Issued id."""
|
||||
|
||||
return f"legacy_battery_sensor_deprecation_{entry.entry_id}"
|
||||
|
||||
|
||||
def _has_legacy_battery_loaded(entry: ConfigEntry) -> bool:
|
||||
loaded = set(checked_or(entry.options.get(SENSORS_TO_LOAD), list[str], []))
|
||||
return bool(loaded & LEGACY_BATTERY_KEYS)
|
||||
|
||||
|
||||
def update_legacy_battery_issue(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update legacy battery issue."""
|
||||
|
||||
issue_id = _legacy_battery_issue_id(entry=entry)
|
||||
|
||||
if _has_legacy_battery_loaded(entry=entry):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id=issue_id,
|
||||
is_persistent=True,
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="legacy_battery_sensor_deprecated",
|
||||
translation_placeholders={"remove_version": LEGACY_REMOVE_VERSION},
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
{
|
||||
"domain": "sws12500",
|
||||
"name": "Sencor SWS 12500 Weather Station",
|
||||
"codeowners": ["@schizza"],
|
||||
"codeowners": [
|
||||
"@schizza"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"dependencies": [
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/schizza/SWS-12500-custom-component",
|
||||
"homekit": {},
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
||||
"requirements": ["typecheck-runtime==0.2.0"],
|
||||
"requirements": [
|
||||
"typecheck-runtime==0.2.0",
|
||||
"aioecowitt==2025.9.2"
|
||||
],
|
||||
"ssdp": [],
|
||||
"version": "1.6.9",
|
||||
"version": "2.0.0-pre1",
|
||||
"zeroconf": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,18 +62,56 @@ class Routes:
|
|||
self.routes: dict[str, RouteInfo] = {}
|
||||
self._ingress_observer: IngressObserver | None = None
|
||||
|
||||
def _resolve_route(self, request: Request) -> RouteInfo | None:
|
||||
"""Find the matching RouteInfo for a request.
|
||||
|
||||
Two step lookup:
|
||||
1) Find exact match using method:path (for fix routes)
|
||||
2) Fallback to aiohttp resource canonical URL
|
||||
works for routes with path parameter - as {webhook_id}
|
||||
"""
|
||||
|
||||
key = f"{request.method}:{request.path}"
|
||||
if key in self.routes:
|
||||
return self.routes[key]
|
||||
|
||||
resource = request.match_info.route.resource
|
||||
if resource is not None:
|
||||
canonical_key = f"{request.method}:{resource.canonical}"
|
||||
if canonical_key in self.routes:
|
||||
return self.routes[canonical_key]
|
||||
|
||||
return None
|
||||
|
||||
def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None:
|
||||
"""Enable or disable the Ecowitt sticky route.
|
||||
|
||||
switch_route() does not involves sticky routes, so we need another
|
||||
method for Ecowitt state at reload.
|
||||
"""
|
||||
|
||||
for route in self.routes.values():
|
||||
if route.url_path == url_path and route.sticky:
|
||||
route.enabled = enabled
|
||||
route.handler = handler if enabled else unregistered
|
||||
_LOGGER.info(
|
||||
"Ecowitt route %s %s",
|
||||
route.url_path,
|
||||
"enabled" if enabled else "disabled",
|
||||
)
|
||||
return
|
||||
|
||||
def set_ingress_observer(self, observer: IngressObserver | None) -> None:
|
||||
"""Set a callback notified for every incoming dispatcher request."""
|
||||
self._ingress_observer = observer
|
||||
|
||||
async def dispatch(self, request: Request) -> Response:
|
||||
"""Dispatch incoming request to either the enabled handler or a fallback."""
|
||||
key = f"{request.method}:{request.path}"
|
||||
info = self.routes.get(key)
|
||||
|
||||
info = self._resolve_route(request)
|
||||
|
||||
if not info:
|
||||
_LOGGER.debug(
|
||||
"Route (%s):%s is not registered!", request.method, request.path
|
||||
)
|
||||
_LOGGER.debug("Route (%s):%s is not registered!", request.method, request.path)
|
||||
if self._ingress_observer is not None:
|
||||
self._ingress_observer(request, False, "route_not_registered")
|
||||
return await unregistered(request)
|
||||
|
|
@ -125,9 +163,7 @@ class Routes:
|
|||
`dispatch` uses after aiohttp has routed the request by path.
|
||||
"""
|
||||
key = f"{route.method}:{url_path}"
|
||||
self.routes[key] = RouteInfo(
|
||||
url_path, route=route, handler=handler, enabled=enabled, sticky=sticky
|
||||
)
|
||||
self.routes[key] = RouteInfo(url_path, route=route, handler=handler, enabled=enabled, sticky=sticky)
|
||||
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
|
||||
|
||||
def show_enabled(self) -> str:
|
||||
|
|
@ -144,9 +180,7 @@ class Routes:
|
|||
|
||||
def path_enabled(self, url_path: str) -> bool:
|
||||
"""Return whether any route registered for `url_path` is enabled."""
|
||||
return any(
|
||||
route.enabled for route in self.routes.values() if route.url_path == url_path
|
||||
)
|
||||
return any(route.enabled for route in self.routes.values() if route.url_path == url_path)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""Return a compact routing snapshot for diagnostics."""
|
||||
|
|
|
|||
|
|
@ -98,9 +98,7 @@ async def async_setup_entry(
|
|||
|
||||
# we have to check if entry_data are present
|
||||
# It is created by integration setup, so it should be presnet
|
||||
if (
|
||||
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
|
||||
) is None:
|
||||
if (entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])) is None:
|
||||
# This should not happen in normal operation.
|
||||
return
|
||||
|
||||
|
|
@ -125,25 +123,24 @@ async def async_setup_entry(
|
|||
# look up its description here and instantiate the matching entity.
|
||||
entry_data[ENTRY_DESCRIPTIONS] = {desc.key: desc for desc in sensor_types}
|
||||
|
||||
sensors_to_load = checked_or(
|
||||
config_entry.options.get(SENSORS_TO_LOAD), list[str], []
|
||||
)
|
||||
sensors_to_load = checked_or(config_entry.options.get(SENSORS_TO_LOAD), list[str], [])
|
||||
if not sensors_to_load:
|
||||
return
|
||||
|
||||
requested = _auto_enable_derived_sensors(set(sensors_to_load))
|
||||
|
||||
entities: list[WeatherSensor] = [
|
||||
WeatherSensor(description, coordinator)
|
||||
for description in sensor_types
|
||||
if description.key in requested
|
||||
WeatherSensor(description, coordinator) for description in sensor_types if description.key in requested
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
# Connect Ecowitt bridge to sensor platform,
|
||||
# so it can dynamically add native Ecowitt entities
|
||||
if hasattr(coordinator, "ecowitt_bridge"):
|
||||
coordinator.ecowitt_bridge.set_add_entities(async_add_entities)
|
||||
|
||||
def add_new_sensors(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, keys: list[str]
|
||||
) -> None:
|
||||
|
||||
def add_new_sensors(hass: HomeAssistant, config_entry: ConfigEntry, keys: list[str]) -> None:
|
||||
"""Dynamically add newly discovered sensors without reloading the entry.
|
||||
|
||||
Called by the webhook handler when the station starts sending new fields.
|
||||
|
|
@ -157,9 +154,7 @@ def add_new_sensors(
|
|||
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
if (
|
||||
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
|
||||
) is None:
|
||||
if (entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
add_entities = entry_data.get(ENTRY_ADD_ENTITIES)
|
||||
|
|
@ -208,9 +203,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
|
||||
config_entry = getattr(self.coordinator, "config", None)
|
||||
self._dev_log = checked_or(
|
||||
config_entry.options.get("dev_debug_checkbox")
|
||||
if config_entry is not None
|
||||
else False,
|
||||
config_entry.options.get("dev_debug_checkbox") if config_entry is not None else False,
|
||||
bool,
|
||||
False,
|
||||
)
|
||||
|
|
@ -236,9 +229,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
try:
|
||||
value = description.value_from_data_fn(data)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"native_value compute failed via value_from_data_fn for key=%s", key
|
||||
)
|
||||
_LOGGER.exception("native_value compute failed via value_from_data_fn for key=%s", key)
|
||||
return None
|
||||
|
||||
return value
|
||||
|
|
@ -257,9 +248,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
try:
|
||||
value = description.value_fn(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"native_value compute failed via value_fn for key=%s raw=%s", key, raw
|
||||
)
|
||||
_LOGGER.exception("native_value compute failed via value_fn for key=%s raw=%s", key, raw)
|
||||
return None
|
||||
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class WeatherSensorEntityDescription(SensorEntityDescription):
|
|||
"""Describe Weather Sensor entities."""
|
||||
|
||||
value_fn: Callable[[Any], int | float | str | None] | None = None
|
||||
value_from_data_fn: Callable[[dict[str, Any]], int | float | str | None] | None = (
|
||||
None
|
||||
)
|
||||
value_from_data_fn: Callable[[dict[str, Any]], int | float | str | None] | None = None
|
||||
|
||||
deprecated: bool = False
|
||||
replacement_entity_domain: str | None = None
|
||||
replacement_entity_key: str | None = None
|
||||
|
|
|
|||
|
|
@ -380,42 +380,75 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
translation_key=CH3_BATTERY,
|
||||
icon="mdi:battery-unknown",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_fn=to_int,
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH3_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH3_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CH4_BATTERY,
|
||||
translation_key=CH4_BATTERY,
|
||||
icon="mdi:battery-unknown",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_fn=to_int,
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH4_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH4_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CH5_BATTERY,
|
||||
translation_key=CH5_BATTERY,
|
||||
icon="mdi:battery-unknown",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_fn=to_int,
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH5_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH5_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CH6_BATTERY,
|
||||
translation_key=CH6_BATTERY,
|
||||
icon="mdi:battery-unknown",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=lambda data: data,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH6_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH6_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CH7_BATTERY,
|
||||
translation_key=CH7_BATTERY,
|
||||
icon="mdi:battery-unknown",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=to_int,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH7_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH7_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CH8_BATTERY,
|
||||
translation_key=CH8_BATTERY,
|
||||
icon="mdi:battery-unknown",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=to_int,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH8_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH8_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=HEAT_INDEX,
|
||||
|
|
@ -445,9 +478,11 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_fn=None,
|
||||
value_from_data_fn=lambda data: (
|
||||
battery_level(data.get(OUTSIDE_BATTERY, None)).value
|
||||
),
|
||||
value_from_data_fn=lambda data: battery_level(data.get(OUTSIDE_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=OUTSIDE_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CH2_BATTERY,
|
||||
|
|
@ -455,15 +490,23 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_fn=None,
|
||||
value_from_data_fn=lambda data: (
|
||||
battery_level(data.get(CH2_BATTERY, None)).value
|
||||
),
|
||||
value_from_data_fn=lambda data: battery_level(data.get(CH2_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=CH2_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=INDOOR_BATTERY,
|
||||
translation_key=INDOOR_BATTERY,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfBat],
|
||||
value_fn=to_int,
|
||||
value_from_data_fn=lambda data: battery_level(data.get(INDOOR_BATTERY, None)).value,
|
||||
deprecated=True,
|
||||
replacement_entity_domain="binary_sensor",
|
||||
replacement_entity_key=INDOOR_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=WBGT_TEMP,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "x86_64-linux-gnu-gcc"
|
||||
|
||||
[env]
|
||||
CC_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-gcc"
|
||||
CXX_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-g++"
|
||||
AR_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-ar"
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 81539fc79d8c3af267241ad6fd63b1924583354f
|
||||
Loading…
Reference in New Issue