Add HCHO / VOC air-quality sensors (T9 module)

Support the WSLink t9 air-quality module:
  - t9hcho (formaldehyde, ppb),
  - t9voclv (VOC level 1-5 -> ENUM state)
  - t9bat (0-5 battery -> percentage).

  The t9hcho/t9voclv/t9bat values are dropped when
  t9cn reports the module as disconnected,
  so no empty entities are created.

  - const: HCHO/VOC/T9_BATTERY keys, VOCLevel enum +
  VOC_LEVEL_MAP, BATTERY_NON_BINARY, CONNECTION_GATED_SENSORS,
  REMAP_WSLINK_ITEMS entries
  - utils: voc_level_to_text(),
  battery_5step_to_pct(), connection gating
  - sensors_wslink: HCHO / VOC / T9_BATTERY entity
  descriptions
  - translations (en, cs): hcho, voc (+ states),
  t9_battery
  - tests: tests/conftest.py +
  tests/test_t9_air_quality.py
stable
SchiZzA 2026-05-11 23:55:36 +02:00
parent 8547e7a7f5
commit 93fd85a487
No known key found for this signature in database
9 changed files with 419 additions and 93 deletions

View File

@ -4,12 +4,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -79,9 +74,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
self.sensors = { self.sensors = {
SENSORS_TO_LOAD: ( SENSORS_TO_LOAD: (
entry_data.get(SENSORS_TO_LOAD) entry_data.get(SENSORS_TO_LOAD) if isinstance(entry_data.get(SENSORS_TO_LOAD), list) else []
if isinstance(entry_data.get(SENSORS_TO_LOAD), list)
else []
) )
} }
@ -93,14 +86,9 @@ class ConfigOptionsFlowHandler(OptionsFlow):
} }
self.windy_data_schema = { self.windy_data_schema = {
vol.Optional( vol.Optional(WINDY_STATION_ID, default=self.windy_data.get(WINDY_STATION_ID, "")): str,
WINDY_STATION_ID, default=self.windy_data.get(WINDY_STATION_ID, "") vol.Optional(WINDY_STATION_PW, default=self.windy_data.get(WINDY_STATION_PW, "")): str,
): str, vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool or False,
vol.Optional(
WINDY_STATION_PW, default=self.windy_data.get(WINDY_STATION_PW, "")
): str,
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool
or False,
vol.Optional( vol.Optional(
WINDY_LOGGER_ENABLED, WINDY_LOGGER_ENABLED,
default=self.windy_data[WINDY_LOGGER_ENABLED], default=self.windy_data[WINDY_LOGGER_ENABLED],
@ -116,19 +104,13 @@ class ConfigOptionsFlowHandler(OptionsFlow):
} }
self.pocasi_cz_schema = { self.pocasi_cz_schema = {
vol.Required( vol.Required(POCASI_CZ_API_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID)): str,
POCASI_CZ_API_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID) vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): str,
): str,
vol.Required(
POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)
): str,
vol.Required( vol.Required(
POCASI_CZ_SEND_INTERVAL, POCASI_CZ_SEND_INTERVAL,
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL), default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
): int, ): int,
vol.Optional( vol.Optional(POCASI_CZ_ENABLED, default=self.pocasi_cz.get(POCASI_CZ_ENABLED)): bool,
POCASI_CZ_ENABLED, default=self.pocasi_cz.get(POCASI_CZ_ENABLED)
): bool,
vol.Optional( vol.Optional(
POCASI_CZ_LOGGER_ENABLED, POCASI_CZ_LOGGER_ENABLED,
default=self.pocasi_cz.get(POCASI_CZ_LOGGER_ENABLED), default=self.pocasi_cz.get(POCASI_CZ_LOGGER_ENABLED),
@ -137,9 +119,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
"""Manage the options - show menu first.""" """Manage the options - show menu first."""
return self.async_show_menu( return self.async_show_menu(step_id="init", menu_options=["basic", "windy", "pocasi"])
step_id="init", menu_options=["basic", "windy", "pocasi"]
)
async def async_step_basic(self, user_input=None): async def async_step_basic(self, user_input=None):
"""Manage basic options - credentials.""" """Manage basic options - credentials."""
@ -293,9 +273,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
elif user_input[API_KEY] == user_input[API_ID]: elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match" errors["base"] = "valid_credentials_match"
else: else:
return self.async_create_entry( return self.async_create_entry(title=DOMAIN, data=user_input, options=user_input)
title=DOMAIN, data=user_input, options=user_input
)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",

View File

@ -28,26 +28,22 @@ POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL" POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox" POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox"
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox" POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = ( POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
"Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
)
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo" POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
POCASI_CZ_UNEXPECTED: Final = ( POCASI_CZ_UNEXPECTED: Final = "Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!"
"Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!"
)
WINDY_STATION_ID = "WINDY_STATION_ID" WINDY_STATION_ID = "WINDY_STATION_ID"
WINDY_STATION_PW = "WINDY_STATION_PWD" WINDY_STATION_PW = "WINDY_STATION_PWD"
WINDY_ENABLED: Final = "windy_enabled_checkbox" WINDY_ENABLED: Final = "windy_enabled_checkbox"
WINDY_LOGGER_ENABLED: Final = "windy_logger_checkbox" WINDY_LOGGER_ENABLED: Final = "windy_logger_checkbox"
WINDY_NOT_INSERTED: Final = "Data was succefuly sent to Windy, but not inserted by Windy API. Does anyone else sent data to Windy?" WINDY_NOT_INSERTED: Final = (
WINDY_INVALID_KEY: Final = "Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again." "Data was succefuly sent to Windy, but not inserted by Windy API. Does anyone else sent data to Windy?"
WINDY_SUCCESS: Final = (
"Windy successfully sent data and data was successfully inserted by Windy API"
) )
WINDY_UNEXPECTED: Final = ( WINDY_INVALID_KEY: Final = (
"Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!" "Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again."
) )
WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully inserted by Windy API"
WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
INVALID_CREDENTIALS: Final = [ INVALID_CREDENTIALS: Final = [
"API", "API",
@ -118,6 +114,10 @@ CH4_CONNECTION: Final = "ch4_connection"
HEAT_INDEX: Final = "heat_index" HEAT_INDEX: Final = "heat_index"
CHILL_INDEX: Final = "chill_index" CHILL_INDEX: Final = "chill_index"
WBGT_TEMP: Final = "wbgt_temp" WBGT_TEMP: Final = "wbgt_temp"
HCHO: Final = "hcho"
VOC: Final = "voc"
T9_BATTERY: Final = "t9_battery" # T9 sensors are HCHO and VOC
T9_CONN: Final = "t9_conn"
REMAP_ITEMS: dict[str, str] = { REMAP_ITEMS: dict[str, str] = {
@ -173,15 +173,11 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"inbat": INDOOR_BATTERY, "inbat": INDOOR_BATTERY,
"t234c1bat": CH2_BATTERY, "t234c1bat": CH2_BATTERY,
"t1wbgt": WBGT_TEMP, "t1wbgt": WBGT_TEMP,
"t9hcho": HCHO,
"t9voclv": VOC,
"t9bat": T9_BATTERY, # T9 battery is 0-5, where 5 is full
} }
# TODO: Add more sensors
#
# 'inbat' indoor battery level (1 normal, 0 low)
# 't1bat': outdoor battery level (1 normal, 0 low)
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink
DISABLED_BY_DEFAULT: Final = [ DISABLED_BY_DEFAULT: Final = [
CH2_TEMP, CH2_TEMP,
CH2_HUMIDITY, CH2_HUMIDITY,
@ -200,6 +196,31 @@ BATTERY_LIST = [
CH2_BATTERY, CH2_BATTERY,
] ]
BATTERY_NON_BINARY: list[str] = [T9_BATTERY]
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
"t9cn": [HCHO, VOC, T9_BATTERY],
}
class VOCLevel(StrEnum):
"""WSLink VOC Level 1-5 (1-worst)."""
UNHEALTHY = "unhealthy"
POOR = "poor"
MODERATE = "moderate"
GOOD = "good"
EXCELLENT = "excellent"
VOC_LEVEL_MAP: dict[int, VOCLevel] = {
1: VOCLevel.UNHEALTHY,
2: VOCLevel.POOR,
3: VOCLevel.MODERATE,
4: VOCLevel.GOOD,
5: VOCLevel.EXCELLENT,
}
class UnitOfDir(StrEnum): class UnitOfDir(StrEnum):
"""Wind direrction azimut.""" """Wind direrction azimut."""

View File

@ -4,6 +4,7 @@ from typing import cast
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
DEGREE, DEGREE,
PERCENTAGE, PERCENTAGE,
UV_INDEX, UV_INDEX,
@ -25,6 +26,7 @@ from .const import (
CHILL_INDEX, CHILL_INDEX,
DAILY_RAIN, DAILY_RAIN,
DEW_POINT, DEW_POINT,
HCHO,
HEAT_INDEX, HEAT_INDEX,
HOURLY_RAIN, HOURLY_RAIN,
INDOOR_BATTERY, INDOOR_BATTERY,
@ -36,7 +38,9 @@ from .const import (
OUTSIDE_TEMP, OUTSIDE_TEMP,
RAIN, RAIN,
SOLAR_RADIATION, SOLAR_RADIATION,
T9_BATTERY,
UV, UV,
VOC,
WBGT_TEMP, WBGT_TEMP,
WEEKLY_RAIN, WEEKLY_RAIN,
WIND_AZIMUT, WIND_AZIMUT,
@ -45,9 +49,10 @@ from .const import (
WIND_SPEED, WIND_SPEED,
YEARLY_RAIN, YEARLY_RAIN,
UnitOfDir, UnitOfDir,
VOCLevel,
) )
from .sensors_common import WeatherSensorEntityDescription from .sensors_common import WeatherSensorEntityDescription
from .utils import wind_dir_to_text from .utils import battery_5step_to_pct, voc_level_to_text, wind_dir_to_text
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
@ -311,21 +316,21 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
translation_key=OUTSIDE_BATTERY, translation_key=OUTSIDE_BATTERY,
icon="mdi:battery-unknown", icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: (data), value_fn=lambda data: data,
), ),
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
key=CH2_BATTERY, key=CH2_BATTERY,
translation_key=CH2_BATTERY, translation_key=CH2_BATTERY,
icon="mdi:battery-unknown", icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: (data), value_fn=lambda data: data,
), ),
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
key=INDOOR_BATTERY, key=INDOOR_BATTERY,
translation_key=INDOOR_BATTERY, translation_key=INDOOR_BATTERY,
icon="mdi:battery-unknown", icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: (data), value_fn=lambda data: data,
), ),
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
key=WBGT_TEMP, key=WBGT_TEMP,
@ -337,4 +342,30 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2, suggested_display_precision=2,
value_fn=lambda data: cast("int", data), value_fn=lambda data: cast("int", data),
), ),
WeatherSensorEntityDescription(
key=HCHO,
translation_key=HCHO,
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:molecule",
value_fn=lambda data: cast("int", data),
),
WeatherSensorEntityDescription(
key=VOC,
translation_key=VOC,
device_class=SensorDeviceClass.ENUM,
options=list(VOCLevel),
icon="mdi:air-filter",
value_fn=lambda data: cast("str", voc_level_to_text(data)),
),
WeatherSensorEntityDescription(
key=T9_BATTERY,
translation_key=T9_BATTERY,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=battery_5step_to_pct,
),
) )

View File

@ -189,6 +189,22 @@
"wbgt_temp": { "wbgt_temp": {
"name": "WBGT index" "name": "WBGT index"
}, },
"hcho": {
"name": "Formaldehyd (HCHO)"
},
"voc": {
"name": "Úroveň VOC",
"state": {
"unhealthy": "Nezdravá",
"poor": "Špatná",
"moderate": "Průměrná",
"good": "Dobrá",
"excellent": "Velmi dobrá"
}
},
"t9_battery": {
"name": "Baterie senzoru HCHO/VOC"
},
"wind_azimut": { "wind_azimut": {
"name": "Azimut", "name": "Azimut",
"state": { "state": {

View File

@ -184,6 +184,22 @@
"wbgt_index": { "wbgt_index": {
"name": "WBGT index" "name": "WBGT index"
}, },
"hcho": {
"name": "Formaldehyde (HCHO)"
},
"voc": {
"name": "VOC level",
"state": {
"unhealthy": "Unhealthy",
"poor": "Poor",
"moderate": "Moderate",
"good": "Good",
"excellent": "Excellent"
}
},
"t9_battery": {
"name": "HCHO/VOC sensor battery"
},
"wind_azimut": { "wind_azimut": {
"name": "Bearing", "name": "Bearing",
"state": { "state": {

View File

@ -15,6 +15,7 @@ from homeassistant.helpers.translation import async_get_translations
from .const import ( from .const import (
AZIMUT, AZIMUT,
CONNECTION_GATED_SENSORS,
DATABASE_PATH, DATABASE_PATH,
DEV_DBG, DEV_DBG,
OUTSIDE_HUMIDITY, OUTSIDE_HUMIDITY,
@ -22,9 +23,11 @@ from .const import (
REMAP_ITEMS, REMAP_ITEMS,
REMAP_WSLINK_ITEMS, REMAP_WSLINK_ITEMS,
SENSORS_TO_LOAD, SENSORS_TO_LOAD,
VOC_LEVEL_MAP,
WIND_SPEED, WIND_SPEED,
UnitOfBat, UnitOfBat,
UnitOfDir, UnitOfDir,
VOCLevel,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -44,9 +47,7 @@ async def translations(
language = hass.config.language language = hass.config.language
_translations = await async_get_translations( _translations = await async_get_translations(hass, language, category, [translation_domain])
hass, language, category, [translation_domain]
)
if localize_key in _translations: if localize_key in _translations:
return _translations[localize_key] return _translations[localize_key]
return "" return ""
@ -66,15 +67,11 @@ async def translated_notification(
localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}" localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}"
localize_title = ( localize_title = f"component.{translation_domain}.{category}.{translation_key}.title"
f"component.{translation_domain}.{category}.{translation_key}.title"
)
language = hass.config.language language = hass.config.language
_translations = await async_get_translations( _translations = await async_get_translations(hass, language, category, [translation_domain])
hass, language, category, [translation_domain]
)
if localize_key in _translations: if localize_key in _translations:
if not translation_placeholders: if not translation_placeholders:
persistent_notification.async_create( persistent_notification.async_create(
@ -85,14 +82,10 @@ async def translated_notification(
) )
else: else:
message = _translations[localize_key].format(**translation_placeholders) message = _translations[localize_key].format(**translation_placeholders)
persistent_notification.async_create( persistent_notification.async_create(hass, message, _translations[localize_title], notification_id)
hass, message, _translations[localize_title], notification_id
)
async def update_options( async def update_options(hass: HomeAssistant, entry: ConfigEntry, update_key, update_value) -> bool:
hass: HomeAssistant, entry: ConfigEntry, update_key, update_value
) -> bool:
"""Update config.options entry.""" """Update config.options entry."""
conf = {**entry.options} conf = {**entry.options}
conf[update_key] = update_value conf[update_key] = update_value
@ -128,6 +121,11 @@ def remap_wslink_items(entities):
if item in REMAP_WSLINK_ITEMS: if item in REMAP_WSLINK_ITEMS:
items[REMAP_WSLINK_ITEMS[item]] = entities[item] items[REMAP_WSLINK_ITEMS[item]] = entities[item]
for conn_key, gated in CONNECTION_GATED_SENSORS.items():
if str(entities.get(conn_key, "0")) != "1":
for key in gated:
items.pop(key, None)
return items return items
@ -137,9 +135,7 @@ def loaded_sensors(config_entry: ConfigEntry) -> list | None:
return config_entry.options.get(SENSORS_TO_LOAD) or [] return config_entry.options.get(SENSORS_TO_LOAD) or []
def check_disabled( def check_disabled(hass: HomeAssistant, items, config_entry: ConfigEntry) -> list | None:
hass: HomeAssistant, items, config_entry: ConfigEntry
) -> list | None:
"""Check if we have data for unloaded sensors. """Check if we have data for unloaded sensors.
If so, then add sensor to load queue. If so, then add sensor to load queue.
@ -284,11 +280,7 @@ def chill_index(data: Any, convert: bool = False) -> float | None:
return ( return (
round( round(
( ((35.7 + (0.6215 * temp)) - (35.75 * (wind**0.16)) + (0.4275 * (temp * (wind**0.16)))),
(35.7 + (0.6215 * temp))
- (35.75 * (wind**0.16))
+ (0.4275 * (temp * (wind**0.16)))
),
2, 2,
) )
if temp < 50 and wind > 3 if temp < 50 and wind > 3
@ -296,6 +288,22 @@ def chill_index(data: Any, convert: bool = False) -> float | None:
) )
def voc_level_to_text(value: str) -> VOCLevel | None:
"""Map 1-5 VOC level to text state."""
if value in (None, ""):
return None
return VOC_LEVEL_MAP.get(int(value))
def battery_5step_to_pct(value: str) -> int | None:
"""Convert 0-5 battery steps to percentage."""
if value in (None, ""):
return None
return round(int(value) / 5 * 100)
def long_term_units_in_statistics_meta(): def long_term_units_in_statistics_meta():
"""Get units in long term statitstics.""" """Get units in long term statitstics."""
sensor_units = [] sensor_units = []
@ -314,9 +322,7 @@ def long_term_units_in_statistics_meta():
""" """
) )
rows = db.fetchall() rows = db.fetchall()
sensor_units = { sensor_units = {statistic_id: f"{statistic_id} ({unit})" for statistic_id, unit in rows}
statistic_id: f"{statistic_id} ({unit})" for statistic_id, unit in rows
}
except sqlite3.Error as e: except sqlite3.Error as e:
_LOGGER.error("Error during data migration: %s", e) _LOGGER.error("Error during data migration: %s", e)

View File

@ -98,8 +98,6 @@ class WindyPush:
from station. But we need to do some clean up. from station. But we need to do some clean up.
""" """
text_for_test = None
if self.log: if self.log:
_LOGGER.info( _LOGGER.info(
"Windy last update = %s, next update at: %s", "Windy last update = %s, next update at: %s",
@ -169,9 +167,7 @@ class WindyPush:
_LOGGER.info("Dataset for windy: %s", purged_data) _LOGGER.info("Dataset for windy: %s", purged_data)
session = async_get_clientsession(self.hass, verify_ssl=False) session = async_get_clientsession(self.hass, verify_ssl=False)
try: try:
async with session.get( async with session.get(request_url, params=purged_data, headers=headers) as resp:
request_url, params=purged_data, headers=headers
) as resp:
status = await resp.text() status = await resp.text()
try: try:
self.verify_windy_response(status) self.verify_windy_response(status)
@ -179,26 +175,21 @@ class WindyPush:
# log despite of settings # log despite of settings
_LOGGER.error(WINDY_NOT_INSERTED) _LOGGER.error(WINDY_NOT_INSERTED)
text_for_test = WINDY_NOT_INSERTED
except WindyApiKeyError: except WindyApiKeyError:
# log despite of settings # log despite of settings
_LOGGER.critical(WINDY_INVALID_KEY) _LOGGER.critical(WINDY_INVALID_KEY)
text_for_test = WINDY_INVALID_KEY
await update_options(self.hass, self.config, WINDY_ENABLED, False) await update_options(self.hass, self.config, WINDY_ENABLED, False)
except WindySuccess: except WindySuccess:
if self.log: if self.log:
_LOGGER.info(WINDY_SUCCESS) _LOGGER.info(WINDY_SUCCESS)
text_for_test = WINDY_SUCCESS
except ClientError as ex: except ClientError as ex:
_LOGGER.critical("Invalid response from Windy: %s", str(ex)) _LOGGER.critical("Invalid response from Windy: %s", str(ex))
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.invalid_response_count > 3: if self.invalid_response_count > 3:
_LOGGER.critical(WINDY_UNEXPECTED) _LOGGER.critical(WINDY_UNEXPECTED)
text_for_test = WINDY_UNEXPECTED
await update_options(self.hass, self.config, WINDY_ENABLED, False) await update_options(self.hass, self.config, WINDY_ENABLED, False)
self.last_update = datetime.now() self.last_update = datetime.now()
@ -207,6 +198,4 @@ class WindyPush:
if self.log: if self.log:
_LOGGER.info("Next update: %s", str(self.next_update)) _LOGGER.info("Next update: %s", str(self.next_update))
if RESPONSE_FOR_TEST and text_for_test:
return text_for_test
return None return None

38
tests/conftest.py Normal file
View File

@ -0,0 +1,38 @@
"""Pytest configuration for tests under `dev/tests`.
Goals:
- Make `custom_components.*` importable.
- Keep this file lightweight and avoid global HA test-harness side effects.
Repository layout:
- Root custom components: `SWS-12500/custom_components/...` (symlinked to `dev/custom_components/...`)
- Integration sources: `SWS-12500/dev/custom_components/...`
Note:
Some tests use lightweight `hass` stubs (e.g. SimpleNamespace) that are not compatible with
Home Assistant's full test fixtures. Do NOT enable HA-only fixtures globally here.
Instead, request such fixtures (e.g. `enable_custom_integrations`) explicitly in the specific
tests that need HA's integration loader / flow managers.
"""
from __future__ import annotations
from pathlib import Path
import sys
def pytest_configure() -> None:
"""Adjust sys.path so imports and HA loader discovery work in tests."""
repo_root = Path(__file__).resolve().parents[2] # .../SWS-12500
dev_root = repo_root / "dev"
# Ensure the repo root is importable so HA can find `custom_components/<domain>/manifest.json`.
repo_root_str = str(repo_root)
if repo_root_str not in sys.path:
sys.path.insert(0, repo_root_str)
# Also ensure `dev/` is importable for direct imports from dev tooling/tests.
dev_root_str = str(dev_root)
if dev_root_str not in sys.path:
sys.path.insert(0, dev_root_str)

View File

@ -0,0 +1,231 @@
"""Tests for the T9 air-quality (HCHO / VOC) sensor support.
Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` /
``t9cn`` parameters:
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``)
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
- the connection gating in ``utils.remap_wslink_items``
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions
- the ``hcho`` / ``voc`` / ``t9_battery`` entries in the translation files
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from custom_components.sws12500.const import (
BATTERY_LIST,
BATTERY_NON_BINARY,
CONNECTION_GATED_SENSORS,
HCHO,
OUTSIDE_TEMP,
REMAP_WSLINK_ITEMS,
T9_BATTERY,
VOC,
VOC_LEVEL_MAP,
VOCLevel,
)
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
from custom_components.sws12500.utils import battery_5step_to_pct, remap_wslink_items, voc_level_to_text
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import CONCENTRATION_PARTS_PER_BILLION, PERCENTAGE
# Realistic WSLink payload taken from an issue report: the station sends every
# parameter, even for channels with no sensor connected (``*cn == "0"``).
ISSUE_PAYLOAD = {
"rbar": "1013.3",
"intem": "25.0",
"inhum": "44",
"t1cn": "1",
"t1tem": "11.3",
"t1hum": "92",
"t234c1cn": "1",
"t234c2cn": "1",
"t234c3cn": "0",
"t8cn": "0",
"t9cn": "1",
"t9hcho": "57",
"t9voclv": "5",
"t9bat": "5",
"t10cn": "0",
"t11cn": "0",
"apiver": "1.00",
}
# --- constants -------------------------------------------------------------
def test_t9_keys_are_remapped() -> None:
assert REMAP_WSLINK_ITEMS["t9hcho"] == HCHO
assert REMAP_WSLINK_ITEMS["t9voclv"] == VOC
assert REMAP_WSLINK_ITEMS["t9bat"] == T9_BATTERY
# t9cn is intentionally NOT remapped - it is only used as a gating flag.
assert "t9cn" not in REMAP_WSLINK_ITEMS
def test_connection_gated_sensors_definition() -> None:
assert CONNECTION_GATED_SENSORS == {"t9cn": [HCHO, VOC, T9_BATTERY]}
def test_t9_battery_is_non_binary_only() -> None:
assert BATTERY_NON_BINARY == [T9_BATTERY]
# the 0-5 / percentage battery must not be treated as a binary low/normal one
assert T9_BATTERY not in BATTERY_LIST
def test_voc_level_map_is_complete_and_ordered() -> None:
# 1 == highest VOC reading (worst air) ... 5 == lowest VOC reading (best air)
assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5}
assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel)
assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELENT
assert [member.value for member in VOCLevel] == [
"unhealthy",
"poor",
"moderate",
"good",
"excellent",
]
# --- voc_level_to_text -----------------------------------------------------
@pytest.mark.parametrize("empty", [None, ""])
def test_voc_level_to_text_handles_empty(empty) -> None:
assert voc_level_to_text(empty) is None
@pytest.mark.parametrize(
("raw", "expected"),
[
("1", VOCLevel.UNHEALTHY),
("2", VOCLevel.POOR),
("3", VOCLevel.MODERATE),
("4", VOCLevel.GOOD),
("5", VOCLevel.EXCELENT),
(3, VOCLevel.MODERATE),
],
)
def test_voc_level_to_text_maps_known_levels(raw, expected) -> None:
assert voc_level_to_text(raw) == expected
@pytest.mark.parametrize("raw", ["0", "6", 0, 6])
def test_voc_level_to_text_out_of_range_is_none(raw) -> None:
assert voc_level_to_text(raw) is None
# --- battery_5step_to_pct --------------------------------------------------
@pytest.mark.parametrize("empty", [None, ""])
def test_battery_5step_to_pct_handles_empty(empty) -> None:
assert battery_5step_to_pct(empty) is None
@pytest.mark.parametrize(
("raw", "expected"),
[("0", 0), ("1", 20), ("2", 40), ("3", 60), ("4", 80), ("5", 100), (5, 100)],
)
def test_battery_5step_to_pct_scales_to_percentage(raw, expected) -> None:
assert battery_5step_to_pct(raw) == expected
# --- remap_wslink_items connection gating ----------------------------------
def test_remap_keeps_t9_group_when_connected() -> None:
out = remap_wslink_items({"t9cn": "1", "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
assert out[HCHO] == "57"
assert out[VOC] == "5"
assert out[T9_BATTERY] == "5"
assert out[OUTSIDE_TEMP] == "11.3"
@pytest.mark.parametrize("conn", [{"t9cn": "0"}, {}], ids=["disconnected", "absent"])
def test_remap_drops_t9_group_when_disconnected_or_absent(conn) -> None:
out = remap_wslink_items({**conn, "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
assert HCHO not in out
assert VOC not in out
assert T9_BATTERY not in out
# unrelated sensors are untouched by the gating
assert out[OUTSIDE_TEMP] == "11.3"
def test_remap_issue_payload_exposes_t9_when_connected() -> None:
out = remap_wslink_items(ISSUE_PAYLOAD)
# t9cn == "1" -> the T9 sensors are exposed
assert out[HCHO] == "57"
assert out[VOC] == "5"
assert out[T9_BATTERY] == "5"
# connection flags never leak into the sensor data
assert "t9cn" not in out
assert "t9_conn" not in out
# --- sensor entity descriptions -------------------------------------------
@pytest.fixture
def wslink_descriptions():
return {description.key: description for description in SENSOR_TYPES_WSLINK}
def test_hcho_entity_description(wslink_descriptions) -> None:
description = wslink_descriptions[HCHO]
assert description.translation_key == HCHO
assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION
assert description.state_class is SensorStateClass.MEASUREMENT
# value_fn is a pass-through (typing.cast is a no-op at runtime; HA coerces the str)
assert description.value_fn("57") == "57"
def test_voc_entity_description(wslink_descriptions) -> None:
description = wslink_descriptions[VOC]
assert description.translation_key == VOC
assert description.device_class is SensorDeviceClass.ENUM
assert description.options == list(VOCLevel)
# ENUM sensors must not declare a state_class
assert description.state_class is None
assert description.value_fn("1") == VOCLevel.UNHEALTHY
assert description.value_fn("5") == "excellent"
assert description.value_fn(None) is None
def test_t9_battery_entity_description(wslink_descriptions) -> None:
description = wslink_descriptions[T9_BATTERY]
assert description.translation_key == T9_BATTERY
assert description.device_class is SensorDeviceClass.BATTERY
assert description.native_unit_of_measurement == PERCENTAGE
assert description.state_class is SensorStateClass.MEASUREMENT
assert description.suggested_display_precision == 0
# no explicit icon -> HA renders the battery icon from the device class + %
assert description.icon is None
assert description.value_fn("5") == 100
assert description.value_fn("0") == 0
assert description.value_fn(None) is None
# --- translation files -----------------------------------------------------
_TRANSLATIONS_DIR = Path(__file__).resolve().parents[1] / "custom_components" / "sws12500" / "translations"
@pytest.mark.parametrize("filename", ["en.json", "cs.json"])
def test_translation_files_have_t9_entries(filename) -> None:
sensors = json.loads((_TRANSLATIONS_DIR / filename).read_text(encoding="utf-8"))["entity"]["sensor"]
assert sensors["hcho"]["name"]
assert sensors["t9_battery"]["name"]
voc = sensors["voc"]
assert voc["name"]
assert set(voc["state"]) == {member.value for member in VOCLevel}