Compare commits

...

5 Commits

Author SHA1 Message Date
SchiZzA 202447405f
Typos corrected. 2026-04-11 17:37:53 +02:00
schizza fed00b437a
Add test-station-server as a submodule.
Include the test station server tool in the tools directory to facilitate local development and testing of weather station protocols.
2026-04-11 17:24:49 +02:00
schizza 15e9df00ca
Implement Ecowitt protocol support and dynamic binary sensor discovery.
- Integrate aioecowitt to parse incoming weather station payloads and map sensors to the SWS pipeline.
- Add a new webhook endpoint at /weatherhub/{webhook_id} with support for dynamic route resolution.
- Expand battery binary sensor definitions and implement logic for dynamic entity creation.
- Bump version to 2.0.0-pre1.
2026-04-11 16:37:17 +02:00
SchiZzA 50fe9e35fe
Add Ecowitt compatibility and deprecate legacy battery sensors. 2026-04-10 18:35:33 +02:00
SchiZzA 7f72497e9e
Update version to 2.0.pre0 2026-03-23 18:35:29 +01:00
19 changed files with 852 additions and 70 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "tools/test-station-server"]
path = tools/test-station-server
url = https://github.com/schizza/test-station-server.git

View File

@ -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 Im 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 Im 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 hasnt been set yet, but its currently expected to happen within the next ~23 months. At the moment, Im 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 wont 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
@ -101,7 +134,7 @@ As soon as the integration is added into Home Assistant it will listen for incom
## Upgrading from PWS to WSLink
If you upgrade your station, that was previously sending data in PWS protocol, to station with WSLink protocol, you have to remove the integration a reinstall it. WSLink protocol is using metric scale instead of imperial used in PWS protocol.
So, deleteing integration and reinstalling will make sure, that sensors will be avare of change of the measurement scale.
So, deleteing integration and reinstalling will make sure, that sensors will be avare of change of the measurement scale.
- as sensors unique IDs are the same, you will not loose any of historical data
@ -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`.
@ -149,4 +183,5 @@ 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._
_Most of the stations does not care about self-signed certificates on the server side._

View File

@ -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())

View File

@ -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",
)

View File

@ -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,
),
)

View File

@ -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)

View File

@ -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,

View File

@ -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"

View File

@ -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()

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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": []
}

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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,

8
tools/.cargo/config.toml Normal file
View File

@ -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