diff --git a/README.md b/README.md index 662f9eb..a11f792 100644 --- a/README.md +++ b/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: + +**** + +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._ \ No newline at end of file +_Most of the stations does not care about self-signed certificates on the server side._ + diff --git a/custom_components/sws12500/battery_sensors.py b/custom_components/sws12500/battery_sensors.py index b17cb96..ac8e9f0 100644 --- a/custom_components/sws12500/battery_sensors.py +++ b/custom_components/sws12500/battery_sensors.py @@ -31,7 +31,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 +51,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", + ) diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 97fb18a..ebf8706 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -225,11 +225,45 @@ __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_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 +437,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, diff --git a/custom_components/sws12500/data.py b/custom_components/sws12500/data.py index e7673ac..3985654 100644 --- a/custom_components/sws12500/data.py +++ b/custom_components/sws12500/data.py @@ -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" diff --git a/custom_components/sws12500/health_sensor.py b/custom_components/sws12500/health_sensor.py index 52f6209..6e2000f 100644 --- a/custom_components/sws12500/health_sensor.py +++ b/custom_components/sws12500/health_sensor.py @@ -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 diff --git a/custom_components/sws12500/legacy.py b/custom_components/sws12500/legacy.py new file mode 100644 index 0000000..4b3ff8c --- /dev/null +++ b/custom_components/sws12500/legacy.py @@ -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) diff --git a/custom_components/sws12500/manifest.json b/custom_components/sws12500/manifest.json index 7acc82a..c858579 100644 --- a/custom_components/sws12500/manifest.json +++ b/custom_components/sws12500/manifest.json @@ -13,7 +13,8 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues", "requirements": [ - "typecheck-runtime==0.2.0" + "typecheck-runtime==0.2.0", + "aioecowitt==2025.9.2" ], "ssdp": [], "version": "2.0.0-pre0", diff --git a/custom_components/sws12500/sensors_common.py b/custom_components/sws12500/sensors_common.py index d48ab35..66a4994 100644 --- a/custom_components/sws12500/sensors_common.py +++ b/custom_components/sws12500/sensors_common.py @@ -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 diff --git a/custom_components/sws12500/sensors_wslink.py b/custom_components/sws12500/sensors_wslink.py index 73aeafe..8500e35 100644 --- a/custom_components/sws12500/sensors_wslink.py +++ b/custom_components/sws12500/sensors_wslink.py @@ -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,