Compare commits
4 Commits
7abfedc1ca
...
1ad10b4b1e
| Author | SHA1 | Date |
|---|---|---|
|
|
1ad10b4b1e | |
|
|
b9815713a0 | |
|
|
211965dd52 | |
|
|
9288ae4a64 |
|
|
@ -48,6 +48,7 @@ from .const import (
|
||||||
ECOWITT_ENABLED,
|
ECOWITT_ENABLED,
|
||||||
ECOWITT_URL_PREFIX,
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
|
LEGACY_ENABLED,
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
SENSORS_TO_LOAD,
|
SENSORS_TO_LOAD,
|
||||||
WINDY_ENABLED,
|
WINDY_ENABLED,
|
||||||
|
|
@ -421,6 +422,7 @@ def register_path(
|
||||||
|
|
||||||
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
||||||
_ecowitt_enabled: bool = checked_or(config.options.get(ECOWITT_ENABLED), bool, False)
|
_ecowitt_enabled: bool = checked_or(config.options.get(ECOWITT_ENABLED), bool, False)
|
||||||
|
_legacy: bool = checked_or(config.options.get(LEGACY_ENABLED), bool, True)
|
||||||
|
|
||||||
# Load registred routes
|
# Load registred routes
|
||||||
routes: Routes | None = hass_data.get("routes", None)
|
routes: Routes | None = hass_data.get("routes", None)
|
||||||
|
|
@ -450,9 +452,10 @@ def register_path(
|
||||||
raise ConfigEntryNotReady from Ex
|
raise ConfigEntryNotReady from Ex
|
||||||
|
|
||||||
# Finally create internal route dispatcher with provided urls, while we have webhooks registered.
|
# Finally create internal route dispatcher with provided urls, while we have webhooks registered.
|
||||||
routes.add_route(DEFAULT_URL, _default_route, coordinator.received_data, enabled=not _wslink)
|
routes.add_route(DEFAULT_URL, _default_route, coordinator.received_data, enabled=_legacy and not _wslink)
|
||||||
routes.add_route(WSLINK_URL, _wslink_post_route, coordinator.received_data, enabled=_wslink)
|
routes.add_route(WSLINK_URL, _wslink_post_route, coordinator.received_data, enabled=_legacy and _wslink)
|
||||||
routes.add_route(WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_wslink)
|
routes.add_route(WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_legacy and _wslink)
|
||||||
|
|
||||||
# Make health route `sticky` so it will not change upon updating options.
|
# Make health route `sticky` so it will not change upon updating options.
|
||||||
routes.add_route(
|
routes.add_route(
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
|
|
@ -530,6 +533,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
||||||
|
|
||||||
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
||||||
|
_legacy = checked_or(entry.options.get(LEGACY_ENABLED), bool, True)
|
||||||
_ecowitt_enabled = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
|
_ecowitt_enabled = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
|
||||||
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
||||||
|
|
||||||
|
|
@ -537,7 +541,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
||||||
if routes:
|
if routes:
|
||||||
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
|
_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.switch_route(coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL, enabled=_legacy)
|
||||||
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.recieved_ecowitt_data, _ecowitt_enabled)
|
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.recieved_ecowitt_data, _ecowitt_enabled)
|
||||||
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
||||||
coordinator_health.update_routing(routes)
|
coordinator_health.update_routing(routes)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from .const import (
|
||||||
ECOWITT_ENABLED,
|
ECOWITT_ENABLED,
|
||||||
ECOWITT_WEBHOOK_ID,
|
ECOWITT_WEBHOOK_ID,
|
||||||
INVALID_CREDENTIALS,
|
INVALID_CREDENTIALS,
|
||||||
|
LEGACY_ENABLED,
|
||||||
POCASI_CZ_API_ID,
|
POCASI_CZ_API_ID,
|
||||||
POCASI_CZ_API_KEY,
|
POCASI_CZ_API_KEY,
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
|
|
@ -69,15 +70,17 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
self.user_data = {
|
self.user_data = {
|
||||||
API_ID: self.config_entry.options.get(API_ID, ""),
|
API_ID: self.config_entry.options.get(API_ID, ""),
|
||||||
API_KEY: self.config_entry.options.get(API_KEY, ""),
|
API_KEY: self.config_entry.options.get(API_KEY, ""),
|
||||||
|
LEGACY_ENABLED: self.config_entry.options.get(LEGACY_ENABLED, True),
|
||||||
WSLINK: self.config_entry.options.get(WSLINK, False),
|
WSLINK: self.config_entry.options.get(WSLINK, False),
|
||||||
DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
|
DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.user_data_schema = {
|
self.user_data_schema = {
|
||||||
vol.Required(API_ID, default=self.user_data.get(API_ID, "")): str,
|
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
|
||||||
vol.Required(API_KEY, default=self.user_data.get(API_KEY, "")): str,
|
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): str,
|
||||||
vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool,
|
vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool,
|
||||||
vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool,
|
vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool,
|
||||||
|
vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sensors = {
|
self.sensors = {
|
||||||
|
|
@ -145,7 +148,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_basic(self, user_input: Any = None):
|
async def async_step_basic(self, user_input: Any = None):
|
||||||
"""Manage basic options - credentials."""
|
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
|
||||||
|
|
||||||
|
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
|
||||||
|
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credantials empty.
|
||||||
|
|
||||||
|
"""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
await self._get_entry_data()
|
await self._get_entry_data()
|
||||||
|
|
@ -157,15 +165,16 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_input[API_ID] in INVALID_CREDENTIALS:
|
if user_input.get(LEGACY_ENABLED):
|
||||||
|
if user_input[API_ID] in INVALID_CREDENTIALS or user_input.get(API_ID, "") == "":
|
||||||
errors[API_ID] = "valid_credentials_api"
|
errors[API_ID] = "valid_credentials_api"
|
||||||
elif user_input[API_KEY] in INVALID_CREDENTIALS:
|
elif user_input[API_KEY] in INVALID_CREDENTIALS or user_input.get(API_KEY, "") == "":
|
||||||
errors[API_KEY] = "valid_credentials_key"
|
errors[API_KEY] = "valid_credentials_key"
|
||||||
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:
|
|
||||||
user_input = self.retain_data(user_input)
|
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
user_input = self.retain_data(user_input)
|
||||||
return self.async_create_entry(title=DOMAIN, data=user_input)
|
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||||
|
|
||||||
self.user_data = user_input
|
self.user_data = user_input
|
||||||
|
|
@ -317,7 +326,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
||||||
|
|
||||||
data_schema = {
|
pws_schema = {
|
||||||
vol.Required(API_ID): str,
|
vol.Required(API_ID): str,
|
||||||
vol.Required(API_KEY): str,
|
vol.Required(API_KEY): str,
|
||||||
vol.Optional(WSLINK): bool,
|
vol.Optional(WSLINK): bool,
|
||||||
|
|
@ -328,17 +337,23 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
async def async_step_user(self, user_input: Any = None):
|
async def async_step_user(self, user_input: Any = None):
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
if user_input is None:
|
|
||||||
await self.async_set_unique_id(DOMAIN)
|
await self.async_set_unique_id(DOMAIN)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_menu(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(self.data_schema),
|
menu_options=["pws", "ecowitt"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_pws(self, user_input: Any = None) -> ConfigFlowResult:
|
||||||
|
"""PWS/WSLink credentials setup."""
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(step_id="pws", data_schema=vol.Schema(self.pws_schema), errors=errors)
|
||||||
|
|
||||||
if user_input[API_ID] in INVALID_CREDENTIALS:
|
if user_input[API_ID] in INVALID_CREDENTIALS:
|
||||||
errors[API_ID] = "valid_credentials_api"
|
errors[API_ID] = "valid_credentials_api"
|
||||||
elif user_input[API_KEY] in INVALID_CREDENTIALS:
|
elif user_input[API_KEY] in INVALID_CREDENTIALS:
|
||||||
|
|
@ -346,14 +361,51 @@ 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(title=DOMAIN, data=user_input, options=user_input)
|
options: dict[str, Any] = {
|
||||||
|
**user_input,
|
||||||
|
LEGACY_ENABLED: True,
|
||||||
|
ECOWITT_ENABLED: False,
|
||||||
|
}
|
||||||
|
return self.async_create_entry(title=DOMAIN, data=options, options=options)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="pws",
|
||||||
data_schema=vol.Schema(self.data_schema),
|
data_schema=vol.Schema(self.pws_schema),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
|
||||||
|
"""Ecowitt stations setup."""
|
||||||
|
|
||||||
|
if user_input is None:
|
||||||
|
webhook = secrets.token_hex(8)
|
||||||
|
url: URL = URL(get_url(self.hass))
|
||||||
|
host = url.host or "UNKNOWN"
|
||||||
|
|
||||||
|
ecowitt_schema = {
|
||||||
|
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
|
||||||
|
vol.Optional(ECOWITT_ENABLED, default=True): bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="ecowitt",
|
||||||
|
data_schema=vol.Schema(ecowitt_schema),
|
||||||
|
description_placeholders={
|
||||||
|
"url": host,
|
||||||
|
"port": str(url.port),
|
||||||
|
"webhook_id": webhook,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
options: dict[str, Any] = {
|
||||||
|
**user_input,
|
||||||
|
LEGACY_ENABLED: False,
|
||||||
|
WSLINK: False,
|
||||||
|
API_ID: "",
|
||||||
|
API_KEY: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.async_create_entry(title=DOMAIN, data=options, options=options)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
|
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
|
||||||
|
|
||||||
|
|
||||||
WSLINK: Final = "wslink"
|
WSLINK: Final = "wslink"
|
||||||
|
LEGACY_ENABLED: Final = "legacy_enabled"
|
||||||
|
|
||||||
WINDY_MAX_RETRIES: Final = 3
|
WINDY_MAX_RETRIES: Final = 3
|
||||||
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
|
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
|
||||||
|
|
@ -165,6 +166,7 @@ __all__ = [
|
||||||
"SENSOR_TO_MIGRATE",
|
"SENSOR_TO_MIGRATE",
|
||||||
"DEV_DBG",
|
"DEV_DBG",
|
||||||
"WSLINK",
|
"WSLINK",
|
||||||
|
"LEGACY_ENABLED",
|
||||||
"ECOWITT",
|
"ECOWITT",
|
||||||
"ECOWITT_WEBHOOK_ID",
|
"ECOWITT_WEBHOOK_ID",
|
||||||
"ECOWITT_ENABLED",
|
"ECOWITT_ENABLED",
|
||||||
|
|
|
||||||
|
|
@ -126,17 +126,19 @@ class Routes:
|
||||||
handler = info.handler if info.enabled else info.fallback
|
handler = info.handler if info.enabled else info.fallback
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|
||||||
def switch_route(self, handler: Handler, url_path: str) -> None:
|
def switch_route(self, handler: Handler, url_path: str | None, *, enabled: bool = True) -> None:
|
||||||
"""Enable routes based on URL, disable all others. Leave sticky routes enabled.
|
"""Enable routes based on URL, disable all others. Leave sticky routes enabled.
|
||||||
|
|
||||||
This is called when options change (e.g. WSLink toggle). The aiohttp router stays
|
When `enabled` is False (or url_path is None), all non-sticky (legacy) routes are disabled.
|
||||||
untouched; we only flip which internal handler is active.
|
- used when only Ecowitt is active.
|
||||||
|
Sticky routes (health, ecowitt) are left untouched.
|
||||||
|
The aiohttp router stays untouched; we only flip which internal handler is active.
|
||||||
"""
|
"""
|
||||||
for route in self.routes.values():
|
for route in self.routes.values():
|
||||||
if route.sticky:
|
if route.sticky:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if route.url_path == url_path:
|
if enabled and route.url_path == url_path:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"New coordinator to route: (%s):%s",
|
"New coordinator to route: (%s):%s",
|
||||||
route.route.method,
|
route.route.method,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from dev.custom_components.sws12500.const import VOCLevel
|
||||||
from homeassistant.components.sensor import SensorEntityDescription
|
from homeassistant.components.sensor import SensorEntityDescription
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -11,8 +12,8 @@ from homeassistant.components.sensor import SensorEntityDescription
|
||||||
class WeatherSensorEntityDescription(SensorEntityDescription):
|
class WeatherSensorEntityDescription(SensorEntityDescription):
|
||||||
"""Describe Weather Sensor entities."""
|
"""Describe Weather Sensor entities."""
|
||||||
|
|
||||||
value_fn: Callable[[Any], int | float | str | None] | None = None
|
value_fn: Callable[[Any], int | float | str | VOCLevel | 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 | VOCLevel | None] | None = None
|
||||||
|
|
||||||
deprecated: bool = False
|
deprecated: bool = False
|
||||||
replacement_entity_domain: str | None = None
|
replacement_entity_domain: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ from .const import (
|
||||||
VOCLevel,
|
VOCLevel,
|
||||||
)
|
)
|
||||||
from .sensors_common import WeatherSensorEntityDescription
|
from .sensors_common import WeatherSensorEntityDescription
|
||||||
from .utils import battery_level, to_float, to_int, wind_dir_to_text
|
from .utils import battery_5step_to_pct, battery_level, to_float, to_int, voc_level_to_text, wind_dir_to_text
|
||||||
|
|
||||||
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
||||||
WeatherSensorEntityDescription(
|
WeatherSensorEntityDescription(
|
||||||
|
|
@ -530,7 +530,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
icon="mdi:molecule",
|
icon="mdi:molecule",
|
||||||
value_fn=lambda data: cast("int", data),
|
value_fn=to_int,
|
||||||
),
|
),
|
||||||
WeatherSensorEntityDescription(
|
WeatherSensorEntityDescription(
|
||||||
key=VOC,
|
key=VOC,
|
||||||
|
|
@ -538,7 +538,42 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
||||||
device_class=SensorDeviceClass.ENUM,
|
device_class=SensorDeviceClass.ENUM,
|
||||||
options=list(VOCLevel),
|
options=list(VOCLevel),
|
||||||
icon="mdi:air-filter",
|
icon="mdi:air-filter",
|
||||||
value_fn=lambda data: cast("str", voc_level_to_text(data)),
|
value_fn=voc_level_to_text,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
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=to_int,
|
||||||
|
),
|
||||||
|
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=to_int,
|
||||||
|
),
|
||||||
|
WeatherSensorEntityDescription(
|
||||||
|
key=VOC,
|
||||||
|
translation_key=VOC,
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
options=list(VOCLevel),
|
||||||
|
icon="mdi:air-filter",
|
||||||
|
value_from_data_fn=lambda data: voc_level_to_text(data.get(VOC, None)),
|
||||||
),
|
),
|
||||||
WeatherSensorEntityDescription(
|
WeatherSensorEntityDescription(
|
||||||
key=T9_BATTERY,
|
key=T9_BATTERY,
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,39 @@
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
|
"title": "Choose your station type",
|
||||||
"title": "Configure access for Weather Station",
|
"description": "Choose the type of your station. If you don't have Eccowit station, choose PWS/WSLink",
|
||||||
|
"menu_options": {
|
||||||
|
"pws": "PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground compatible)",
|
||||||
|
"ecowitt": "Ecowitt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pws": {
|
||||||
|
"title": "PWS/WSLink credentials.",
|
||||||
|
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant.",
|
||||||
"data": {
|
"data": {
|
||||||
"API_ID": "API ID / Station ID",
|
"API_ID": "API ID / Station ID",
|
||||||
"API_KEY": "API KEY / Password",
|
"API_KEY": "API KEY / Password",
|
||||||
"WSLINK": "WSLink API",
|
"wslink": "WSLink Protocol",
|
||||||
"dev_debug_checkbox": "Developer log"
|
"dev_debug_checkbox": "Developer log"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
|
|
||||||
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
||||||
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
||||||
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
|
"wslink": "Enable WSLink Protocol if the station is set to send data via WSLink. If you are unsure, use https://test-station.schizza.cz/",
|
||||||
|
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ecowitt": {
|
||||||
|
"title": "Ecowitt configuration.",
|
||||||
|
"description": "No API ID/KEY needed. Set your Ecowitt station to send data to the enndpoint below.",
|
||||||
|
"data": {
|
||||||
|
"ecowitt_webhook_id": "Unique webhook ID",
|
||||||
|
"ecowitt_enabled": "Enable Ecowitt station data"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
|
||||||
|
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -41,19 +61,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic": {
|
"basic": {
|
||||||
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
|
"description": "Configure the PWS/WSLink endpoint. Turn off 'Enable PWS/WSLink' for an Ecowitt-only setup - API ID/KEY are not required."""title": "Configure PWS/WSLink","data": {
|
||||||
"title": "Configure credentials",
|
|
||||||
"data": {
|
|
||||||
"API_ID": "API ID / Station ID",
|
"API_ID": "API ID / Station ID",
|
||||||
"API_KEY": "API KEY / Password",
|
"API_KEY": "API KEY / Password",
|
||||||
"WSLINK": "WSLink API",
|
"wslink": "WSLink protocol",
|
||||||
|
"legacy_enbaled": "Enable PWS/WSLink endpoint (disable for Ecowitt-only setup)",
|
||||||
"dev_debug_checkbox": "Developer log"
|
"dev_debug_checkbox": "Developer log"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
|
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
|
||||||
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
||||||
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
||||||
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
|
"wslink": "Enable WSLink API if the station is set to send data via WSLink. (If you are unsure, use https://test-station.schizza.cz/)",
|
||||||
|
"legacy_enbaled": "Turn off if your station uses Ecowitt only."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"windy": {
|
"windy": {
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,39 @@
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem",
|
"title": "Vyberte typ stanice",
|
||||||
"title": "Nastavení přihlášení",
|
"description": "Zadejte typ stanice, kterou používáte. Pokude nepoužíváte Ecowitt, vyberte PWS/WSLink",
|
||||||
|
"menu_options": {
|
||||||
|
"pws": "PWS/WSLink (Sencor, Garni, Bresser, jiné - Weather Underground kompatibilní)",
|
||||||
|
"ecowitt": "Ecowitt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pws": {
|
||||||
|
"title": "Přihlašovací údaje PWS/WSLink.",
|
||||||
|
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem.",
|
||||||
"data": {
|
"data": {
|
||||||
"API_ID": "API ID / ID Stanice",
|
"API_ID": "API ID / ID Stanice",
|
||||||
"API_KEY": "API KEY / Heslo",
|
"API_KEY": "API KEY / Heslo",
|
||||||
"wslink": "WSLink API",
|
"wslink": "WSLink Protorkol",
|
||||||
"dev_debug_checkbox": "Developer log"
|
"dev_debug_checkbox": "Developer log"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
|
|
||||||
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
|
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
|
||||||
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
|
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
|
||||||
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink."
|
"wslink": "Zapněte tuto volbu, pokud stanice používá WSLink protokol. Pokud si nejstě jistí, použijte https://test-station.schizza.cz/",
|
||||||
|
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ecowitt": {
|
||||||
|
"title": "Nastavení pro Ecowitt stanice",
|
||||||
|
"description": "Zadejte unikátní webhook ID pro příjem dat ze stanic Ecowitt. Pokud nepoužíváte stanice Ecowitt, tento krok přeskočte.",
|
||||||
|
"data": {
|
||||||
|
"ecowitt_webhook_id": "Unikátní webhook ID pro Ecowitt stanice",
|
||||||
|
"ecowitt_enabled": "Povolit data ze stanic Ecowitt"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
|
||||||
|
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,19 +69,21 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic": {
|
"basic": {
|
||||||
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem",
|
"description": "Nastavení endpointu PWS/WSLink. Pro stanici jen s Ecowwittem vypněte 'Povolit endpoint PWS/WSLink'. API ID/KEY nejsou potřeba",
|
||||||
"title": "Nastavení přihlášení",
|
"title": "Nastavení PWS/WSLink",
|
||||||
"data": {
|
"data": {
|
||||||
"API_ID": "API ID / ID Stanice",
|
"API_ID": "API ID / ID Stanice",
|
||||||
"API_KEY": "API KEY / Heslo",
|
"API_KEY": "API KEY / Heslo",
|
||||||
"wslink": "WSLink API",
|
"wslink": "WSLink protokol",
|
||||||
"dev_debug_checkbox": "Developer log"
|
"dev_debug_checkbox": "Developer log",
|
||||||
|
"legacy_enabled": "Povolit endpoint PWS/WSLink"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
|
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
|
||||||
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
|
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
|
||||||
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
|
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
|
||||||
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink."
|
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink.",
|
||||||
|
"legacy_enabled": "Vyplněte, pokud vaše stanice používá pouze Ecowitt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"windy": {
|
"windy": {
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,39 @@
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
|
"title": "Choose your station type",
|
||||||
"title": "Configure access for Weather Station",
|
"description": "Choose the type of your station. If you don't have Eccowit station, choose PWS/WSLink",
|
||||||
|
"menu_options": {
|
||||||
|
"pws": "PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground compatible)",
|
||||||
|
"ecowitt": "Ecowitt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pws": {
|
||||||
|
"title": "PWS/WSLink credentials.",
|
||||||
|
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant.",
|
||||||
"data": {
|
"data": {
|
||||||
"API_ID": "API ID / Station ID",
|
"API_ID": "API ID / Station ID",
|
||||||
"API_KEY": "API KEY / Password",
|
"API_KEY": "API KEY / Password",
|
||||||
"WSLINK": "WSLink API",
|
"wslink": "WSLink Protocol",
|
||||||
"dev_debug_checkbox": "Developer log"
|
"dev_debug_checkbox": "Developer log"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
|
|
||||||
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
||||||
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
||||||
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
|
"wslink": "Enable WSLink Protocol if the station is set to send data via WSLink. If you are unsure, use https://test-station.schizza.cz/",
|
||||||
|
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ecowitt": {
|
||||||
|
"title": "Ecowitt configuration.",
|
||||||
|
"description": "No API ID/KEY needed. Set your Ecowitt station to send data to the enndpoint below.",
|
||||||
|
"data": {
|
||||||
|
"ecowitt_webhook_id": "Unique webhook ID",
|
||||||
|
"ecowitt_enabled": "Enable Ecowitt station data"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
|
||||||
|
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from homeassistant.helpers.translation import async_get_translations
|
||||||
from .const import (
|
from .const import (
|
||||||
AZIMUT,
|
AZIMUT,
|
||||||
CONNECTION_GATED_SENSORS,
|
CONNECTION_GATED_SENSORS,
|
||||||
DATABASE_PATH,
|
# DATABASE_PATH,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
OUTSIDE_HUMIDITY,
|
OUTSIDE_HUMIDITY,
|
||||||
OUTSIDE_TEMP,
|
OUTSIDE_TEMP,
|
||||||
|
|
@ -122,23 +122,22 @@ def remap_items(entities: dict[str, str]) -> dict[str, str]:
|
||||||
stable keys from `const.py` (e.g. "outside_temp", "outside_humidity"). This function produces
|
stable keys from `const.py` (e.g. "outside_temp", "outside_humidity"). This function produces
|
||||||
a normalized dict that the rest of the integration can work with.
|
a normalized dict that the rest of the integration can work with.
|
||||||
"""
|
"""
|
||||||
return {
|
return {REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS}
|
||||||
REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
|
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
|
||||||
"""Remap WSLink payload field names into internal sensor keys.
|
"""Remap items in query for WSLink API."""
|
||||||
|
items: dict[str, str] = {}
|
||||||
|
for item, value in entities.items():
|
||||||
|
if item in REMAP_WSLINK_ITEMS:
|
||||||
|
items[REMAP_WSLINK_ITEMS[item]] = value
|
||||||
|
|
||||||
WSLink uses a different naming scheme than the legacy endpoint (e.g. "t1tem", "t1ws").
|
for conn_key, gated in CONNECTION_GATED_SENSORS.items():
|
||||||
Just like `remap_items`, this function normalizes the payload to the integration's stable
|
if str(entities.get(conn_key, "0")) != "1":
|
||||||
internal keys.
|
for key in gated:
|
||||||
"""
|
items.pop(key, None)
|
||||||
return {
|
|
||||||
REMAP_WSLINK_ITEMS[key]: value
|
return items
|
||||||
for key, value in entities.items()
|
|
||||||
if key in REMAP_WSLINK_ITEMS
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
||||||
|
|
@ -150,9 +149,7 @@ def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
||||||
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
||||||
|
|
||||||
|
|
||||||
def check_disabled(
|
def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str] | None:
|
||||||
items: dict[str, str], config_entry: ConfigEntry
|
|
||||||
) -> list[str] | None:
|
|
||||||
"""Detect payload fields that are not enabled yet (auto-discovery).
|
"""Detect payload fields that are not enabled yet (auto-discovery).
|
||||||
|
|
||||||
The integration supports "auto-discovery" of sensors: when the station starts sending a new
|
The integration supports "auto-discovery" of sensors: when the station starts sending a new
|
||||||
|
|
@ -290,9 +287,7 @@ def to_float(val: Any) -> float | None:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
def heat_index(
|
def heat_index(data: dict[str, int | float | str], convert: bool = False) -> float | None:
|
||||||
data: dict[str, int | float | str], convert: bool = False
|
|
||||||
) -> float | None:
|
|
||||||
"""Calculate heat index from temperature.
|
"""Calculate heat index from temperature.
|
||||||
|
|
||||||
data: dict with temperature and humidity
|
data: dict with temperature and humidity
|
||||||
|
|
@ -341,9 +336,7 @@ def heat_index(
|
||||||
return simple
|
return simple
|
||||||
|
|
||||||
|
|
||||||
def chill_index(
|
def chill_index(data: dict[str, str | float | int], convert: bool = False) -> float | None:
|
||||||
data: dict[str, str | float | int], convert: bool = False
|
|
||||||
) -> float | None:
|
|
||||||
"""Calculate wind chill index from temperature and wind speed.
|
"""Calculate wind chill index from temperature and wind speed.
|
||||||
|
|
||||||
data: dict with temperature and wind speed
|
data: dict with temperature and wind speed
|
||||||
|
|
@ -377,3 +370,126 @@ def chill_index(
|
||||||
if temp < 50 and wind > 3
|
if temp < 50 and wind > 3
|
||||||
else temp
|
else temp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def voc_level_to_text(value: str | None) -> 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():
|
||||||
|
# """Get units in long term statitstics."""
|
||||||
|
# sensor_units = []
|
||||||
|
# if not Path(DATABASE_PATH).exists():
|
||||||
|
# _LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
# db = conn.cursor()
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# db.execute(
|
||||||
|
# """
|
||||||
|
# SELECT statistic_id, unit_of_measurement from statistics_meta
|
||||||
|
# WHERE statistic_id LIKE 'sensor.weather_station_sws%'
|
||||||
|
# """
|
||||||
|
# )
|
||||||
|
# rows = db.fetchall()
|
||||||
|
# sensor_units = {
|
||||||
|
# statistic_id: f"{statistic_id} ({unit})" for statistic_id, unit in rows
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# except sqlite3.Error as e:
|
||||||
|
# _LOGGER.error("Error during data migration: %s", e)
|
||||||
|
# finally:
|
||||||
|
# conn.close()
|
||||||
|
#
|
||||||
|
# return sensor_units
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> int | bool:
|
||||||
|
# """Migrate data from mm/d to mm."""
|
||||||
|
#
|
||||||
|
# _LOGGER.debug("Sensor %s is required for data migration", sensor_id)
|
||||||
|
# updated_rows = 0
|
||||||
|
#
|
||||||
|
# if not Path(DATABASE_PATH).exists():
|
||||||
|
# _LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
# db = conn.cursor()
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# _LOGGER.info(sensor_id)
|
||||||
|
# db.execute(
|
||||||
|
# """
|
||||||
|
# UPDATE statistics_meta
|
||||||
|
# SET unit_of_measurement = 'mm'
|
||||||
|
# WHERE statistic_id = ?
|
||||||
|
# AND unit_of_measurement = 'mm/d';
|
||||||
|
# """,
|
||||||
|
# (sensor_id,),
|
||||||
|
# )
|
||||||
|
# updated_rows = db.rowcount
|
||||||
|
# conn.commit()
|
||||||
|
# _LOGGER.info(
|
||||||
|
# "Data migration completed successfully. Updated rows: %s for %s",
|
||||||
|
# updated_rows,
|
||||||
|
# sensor_id,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# except sqlite3.Error as e:
|
||||||
|
# _LOGGER.error("Error during data migration: %s", e)
|
||||||
|
# finally:
|
||||||
|
# conn.close()
|
||||||
|
# return updated_rows
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# def migrate_data_old(sensor_id: str | None = None):
|
||||||
|
# """Migrate data from mm/d to mm."""
|
||||||
|
# updated_rows = 0
|
||||||
|
#
|
||||||
|
# if not Path(DATABASE_PATH).exists():
|
||||||
|
# _LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
# db = conn.cursor()
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# _LOGGER.info(sensor_id)
|
||||||
|
# db.execute(
|
||||||
|
# """
|
||||||
|
# UPDATE statistics_meta
|
||||||
|
# SET unit_of_measurement = 'mm'
|
||||||
|
# WHERE statistic_id = ?
|
||||||
|
# AND unit_of_measurement = 'mm/d';
|
||||||
|
# """,
|
||||||
|
# (sensor_id,),
|
||||||
|
# )
|
||||||
|
# updated_rows = db.rowcount
|
||||||
|
# conn.commit()
|
||||||
|
# _LOGGER.info(
|
||||||
|
# "Data migration completed successfully. Updated rows: %s for %s",
|
||||||
|
# updated_rows,
|
||||||
|
# sensor_id,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# except sqlite3.Error as e:
|
||||||
|
# _LOGGER.error("Error during data migration: %s", e)
|
||||||
|
# finally:
|
||||||
|
# conn.close()
|
||||||
|
# return updated_rows
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue