diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1d6db7e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms + +github: schizza +ko_fi: schizza +buy_me_a_coffee: schizza diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4edd987 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Logs** +Provide `Developer log` if applicable + +**Provide information about your station:** + - Weather station type: + - firmware version: + +- [ ] Using PWS protocol +- [ ] Using WSLink API +- [ ] Using WSLink proxy Add-on + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..269790e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Is your feature request a new addition** +Describe what you want to achieve. How new feature should work. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..5c4f806 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,34 @@ +--- +name: Issue +about: A minor issue that does not significantly affect functionality. +title: "[ISSUE]" +labels: issue +assignees: '' + +--- + +**Describe the issue** +A clear and concise description of what the issue is. + +**To Reproduce** +Steps to reproduce the behavior if any: + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Logs** +Provide `Developer log` if applicable + +**Provide information about your station:** + - Weather station type: + - firmware version: + +- [ ] Using PWS protocol +- [ ] Using WSLink API +- [ ] Using WSLink proxy Add-on + +**Additional context** +Add any other context about the problem here. diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index 508d01e..9e6b2b2 100644 --- a/custom_components/sws12500/config_flow.py +++ b/custom_components/sws12500/config_flow.py @@ -1,13 +1,15 @@ """Config flow for Sencor SWS 12500 Weather Station integration.""" +import logging from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.const import UnitOfPrecipitationDepth, UnitOfVolumetricFlux from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from .utils import long_term_units_in_statistics_meta, migrate_data +import homeassistant.helpers.entity_registry as er from .const import ( API_ID, @@ -15,13 +17,18 @@ from .const import ( DEV_DBG, DOMAIN, INVALID_CREDENTIALS, - SENSORS_TO_LOAD, + MIG_FROM, + MIG_TO, SENSOR_TO_MIGRATE, + SENSORS_TO_LOAD, WINDY_API_KEY, WINDY_ENABLED, WINDY_LOGGER_ENABLED, WSLINK, ) +from .utils import long_term_units_in_statistics_meta, migrate_data + +_LOGGER = logging.getLogger(__name__) class CannotConnect(HomeAssistantError): @@ -41,16 +48,23 @@ class ConfigOptionsFlowHandler(OptionsFlow): self.windy_data: dict[str, Any] = {} self.windy_data_schema = {} - self.user_data: dict[str, str] = {} + self.user_data: dict[str, Any] = {} self.user_data_schema = {} self.sensors: dict[str, Any] = {} self.migrate_schema = {} + self.migrate_sensor_select = {} + self.migrate_unit_selection = {} + self.count = 0 + self.selected_sensor = "" + + self.unit_values = [unit.value for unit in UnitOfVolumetricFlux] + self.unit_values.extend([unit.value for unit in UnitOfPrecipitationDepth]) @property def config_entry(self): return self.hass.config_entries.async_get_entry(self.handler) - def _get_entry_data(self): + async def _get_entry_data(self): """Get entry data.""" self.user_data: dict[str, Any] = { @@ -61,10 +75,10 @@ class ConfigOptionsFlowHandler(OptionsFlow): } self.user_data_schema = { - vol.Required(API_ID, default=self.user_data[API_ID] or ""): str, - vol.Required(API_KEY, default=self.user_data[API_KEY] or ""): str, - vol.Optional(WSLINK, default=self.user_data[WSLINK]): bool or False, - vol.Optional(DEV_DBG, default=self.user_data[DEV_DBG]): bool or False, + vol.Required(API_ID, default=self.user_data.get(API_ID, "")): str, + vol.Required(API_KEY, default=self.user_data.get(API_KEY, "")): str, + vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool, + vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool, } self.sensors: dict[str, Any] = { @@ -83,7 +97,7 @@ class ConfigOptionsFlowHandler(OptionsFlow): self.windy_data_schema = { vol.Optional( - WINDY_API_KEY, default=self.windy_data[WINDY_API_KEY] or "" + WINDY_API_KEY, default=self.windy_data.get(WINDY_API_KEY, "") ): str, vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool or False, @@ -93,12 +107,34 @@ class ConfigOptionsFlowHandler(OptionsFlow): ): bool or False, } - self.migrate_schema = { + self.migrate_sensor_select = { vol.Required(SENSOR_TO_MIGRATE): vol.In( - long_term_units_in_statistics_meta() or {} + await self.load_sensors_to_migrate() or {} ), + } + + self.migrate_unit_selection = { + vol.Required(MIG_FROM): vol.In(self.unit_values), + vol.Required(MIG_TO): vol.In(self.unit_values), vol.Optional("trigger_action", default=False): bool, } + # "mm/d", "mm/h", "mm", "in/d", "in/h", "in" + + async def load_sensors_to_migrate(self) -> dict[str, Any]: + """Load sensors to migrate.""" + + sensor_statistics = await long_term_units_in_statistics_meta(self.hass) + + entity_registry = er.async_get(self.hass) + sensors = entity_registry.entities.get_entries_for_config_entry_id( + self.config_entry.entry_id + ) + + return { + sensor.entity_id: f"{sensor.name or sensor.original_name} (current settings: {sensor.unit_of_measurement}, longterm stats unit: {sensor_statistics.get(sensor.entity_id)})" + for sensor in sensors + if sensor.unique_id in {"rain", "daily_rain"} + } async def async_step_init(self, user_input=None): """Manage the options - show menu first.""" @@ -110,7 +146,7 @@ class ConfigOptionsFlowHandler(OptionsFlow): """Manage basic options - credentials.""" errors = {} - self._get_entry_data() + await self._get_entry_data() if user_input is None: return self.async_show_form( @@ -147,7 +183,7 @@ class ConfigOptionsFlowHandler(OptionsFlow): """Manage windy options.""" errors = {} - self._get_entry_data() + await self._get_entry_data() if user_input is None: return self.async_show_form( @@ -160,7 +196,7 @@ class ConfigOptionsFlowHandler(OptionsFlow): errors[WINDY_API_KEY] = "windy_key_required" return self.async_show_form( step_id="windy", - data_schema=self.windy_data_schema, + data_schema=vol.Schema(self.windy_data_schema), errors=errors, ) @@ -175,15 +211,17 @@ class ConfigOptionsFlowHandler(OptionsFlow): async def async_step_migration(self, user_input=None): """Migrate sensors.""" - # hj errors = {} - self._get_entry_data() + data_schema = vol.Schema(self.migrate_sensor_select) + data_schema.schema.update() + + await self._get_entry_data() if user_input is None: return self.async_show_form( step_id="migration", - data_schema=vol.Schema(self.migrate_schema), + data_schema=vol.Schema(self.migrate_sensor_select), errors=errors, description_placeholders={ "migration_status": "-", @@ -191,18 +229,116 @@ class ConfigOptionsFlowHandler(OptionsFlow): }, ) - if user_input.get("trigger_action"): - # Akce se vykoná po zaškrtnutí - count = await self.hass.async_add_executor_job( - migrate_data, user_input.get(SENSOR_TO_MIGRATE) - ) + self.selected_sensor = user_input.get(SENSOR_TO_MIGRATE) + + return await self.async_step_migration_units() + + async def async_step_migration_units(self, user_input=None): + """Migrate unit step.""" + + registry = er.async_get(self.hass) + sensor_entry = registry.async_get(self.selected_sensor) + sensor_stats = await long_term_units_in_statistics_meta(self.hass) + + default_unit = sensor_entry.unit_of_measurement if sensor_entry else None + + if default_unit not in self.unit_values: + default_unit = self.unit_values[0] + + data_schema = vol.Schema({ + vol.Required(MIG_FROM, default=default_unit): vol.In(self.unit_values), + vol.Required(MIG_TO): vol.In(self.unit_values), + vol.Optional("trigger_action", default=False): bool, + }) + + if user_input is None: return self.async_show_form( - step_id="migration", - data_schema=vol.Schema(self.migrate_schema), + step_id="migration_units", + data_schema=data_schema, + errors={}, + description_placeholders={ + "migration_sensor": sensor_entry.original_name, + "migration_stats": sensor_stats.get(self.selected_sensor), + }, + ) + + if user_input.get("trigger_action"): + self.count = await migrate_data( + self.hass, + self.selected_sensor, + user_input.get(MIG_FROM), + user_input.get(MIG_TO), + ) + + registry.async_update_entity(self.selected_sensor, + unit_of_measurement=user_input.get(MIG_TO), + ) + + state = self.hass.states.get(self.selected_sensor) + if state: + _LOGGER.info("State attributes before update: %s", state.attributes) + attributes = dict(state.attributes) + attributes["unit_of_measurement"] = user_input.get(MIG_TO) + self.hass.states.async_set(self.selected_sensor, state.state, attributes) + _LOGGER.info("State attributes after update: %s", attributes) + + options = {**self.config_entry.options, "reload_sensor": self.selected_sensor} + self.hass.config_entries.async_update_entry(self.config_entry, options=options) + + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + + await self.hass.async_block_till_done() + + _LOGGER.info("Migration complete for sensor %s: %s row updated, new measurement unit: %s, ", + self.selected_sensor, + self.count, + user_input.get(MIG_TO), + ) + + await self._get_entry_data() + sensor_entry = er.async_get(self.hass).async_get(self.selected_sensor) + sensor_stat = await self.load_sensors_to_migrate() + + return self.async_show_form( + step_id="migration_complete", + data_schema=vol.Schema({}), + errors={}, + description_placeholders={ + "migration_sensor": sensor_entry.unit_of_measurement, + "migration_stats": sensor_stat.get(self.selected_sensor), + "migration_count": self.count, + }, + ) + + # retain windy data + user_input.update(self.windy_data) + + # retain user_data + user_input.update(self.user_data) + + # retain senors + user_input.update(self.sensors) + + return self.async_create_entry(title=DOMAIN, data=user_input) + + async def async_step_migration_complete(self, user_input=None): + """Migration complete.""" + + errors = {} + + await self._get_entry_data() + sensor_entry = er.async_get(self.hass).async_get(self.selected_sensor) + sensor_stat = await self.load_sensors_to_migrate() + + if user_input is None: + return self.async_show_form( + step_id="migration_complete", + data_schema=vol.Schema({}), errors=errors, description_placeholders={ - "migration_status": user_input.get(SENSOR_TO_MIGRATE), - "migration_count": count, + "migration_sensor": sensor_entry.unit_of_measurement, + "migration_stats": sensor_stat.get(self.selected_sensor), + "migration_count": self.count, }, ) diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index a3c3a42..1819c7f 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -68,6 +68,10 @@ WIND_GUST: Final = "wind_gust" WIND_DIR: Final = "wind_dir" WIND_AZIMUT: Final = "wind_azimut" RAIN: Final = "rain" +HOURLY_RAIN: Final = "hourly_rain" +WEEKLY_RAIN: Final = "weekly_rain" +MONTHLY_RAIN: Final = "monthly_rain" +YEARLY_RAIN: Final = "yearly_rain" DAILY_RAIN: Final = "daily_rain" SOLAR_RADIATION: Final = "solar_radiation" INDOOR_TEMP: Final = "indoor_temp" @@ -129,15 +133,15 @@ REMAP_WSLINK_ITEMS: dict = { "t234c2cn": CH3_CONNECTION, "t1chill": CHILL_INDEX, "t1heat": HEAT_INDEX, + "t1rainhr": HOURLY_RAIN, + "t1rainwy": WEEKLY_RAIN, + "t1rainmth": MONTHLY_RAIN, + "t1rainyr": YEARLY_RAIN, } # TODO: Add more sensors # # 'inbat' indoor battery level (1 normal, 0 low) -# 't1rainhr' hourly rain rate in mm -# 't1rainwy' weekly rain rate in mm -# 't1rainmth': monthly rain rate in mm -# 't1rainyr': yearly rain rate in mm # 't1bat': outdoor battery level (1 normal, 0 low) # 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink diff --git a/custom_components/sws12500/manifest.json b/custom_components/sws12500/manifest.json index c1aa0c4..56461c7 100644 --- a/custom_components/sws12500/manifest.json +++ b/custom_components/sws12500/manifest.json @@ -10,6 +10,6 @@ "issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues", "requirements": [], "ssdp": [], - "version": "1.5.3", + "version": "1.6.2", "zeroconf": [] } diff --git a/custom_components/sws12500/sensors_wslink.py b/custom_components/sws12500/sensors_wslink.py index a3a7e97..91e6c50 100644 --- a/custom_components/sws12500/sensors_wslink.py +++ b/custom_components/sws12500/sensors_wslink.py @@ -39,6 +39,10 @@ from .const import ( WIND_GUST, WIND_SPEED, UnitOfDir, + MONTHLY_RAIN, + YEARLY_RAIN, + HOURLY_RAIN, + WEEKLY_RAIN, ) from .sensors_common import WeatherSensorEntityDescription from .utils import wind_dir_to_text @@ -139,10 +143,10 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( ), WeatherSensorEntityDescription( key=RAIN, - native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL, - suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + suggested_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, suggested_display_precision=2, icon="mdi:weather-pouring", translation_key=RAIN, @@ -159,6 +163,50 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( translation_key=DAILY_RAIN, value_fn=lambda data: cast("float", data), ), + WeatherSensorEntityDescription( + key=HOURLY_RAIN, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + suggested_display_precision=2, + icon="mdi:weather-pouring", + translation_key=HOURLY_RAIN, + value_fn=lambda data: cast("float", data), + ), + WeatherSensorEntityDescription( + key=WEEKLY_RAIN, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + suggested_display_precision=2, + icon="mdi:weather-pouring", + translation_key=WEEKLY_RAIN, + value_fn=lambda data: cast("float", data), + ), + WeatherSensorEntityDescription( + key=MONTHLY_RAIN, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + suggested_display_precision=2, + icon="mdi:weather-pouring", + translation_key=MONTHLY_RAIN, + value_fn=lambda data: cast("float", data), + ), + WeatherSensorEntityDescription( + key=YEARLY_RAIN, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + suggested_display_precision=2, + icon="mdi:weather-pouring", + translation_key=YEARLY_RAIN, + value_fn=lambda data: cast("float", data), + ), WeatherSensorEntityDescription( key=SOLAR_RADIATION, native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 24bf2f5..e024ec3 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -112,6 +112,10 @@ "ch4_humidity": { "name": "Vlhkost sensoru 4" }, "heat_index": { "name": "Tepelný index" }, "chill_index": { "name": "Pocitová teplota" }, + "hourly_rain": { "name": "Hodinový úhrn srážek" }, + "weekly_rain": { "name": "Týdenní úhrn srážek" }, + "monthly_rain": { "name": "Měsíční úhrn srážek" }, + "yearly_rain": { "name": "Roční úhrn srážek" }, "wind_azimut": { "name": "Azimut", "state": { diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 0115968..e0ce1a6 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -35,7 +35,6 @@ }, "step": { - "init": { "title": "Configure SWS12500 Integration", "description": "Choose what do you want to configure. If basic access or resending data for Windy site", @@ -83,8 +82,8 @@ "trigger_action": "Trigger migration" }, "data_description": { - "sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.", - "trigger_action": "Trigger the sensor statistics migration after checking." + "sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.", + "trigger_action": "Trigger the sensor statistics migration after checking." } } } @@ -113,6 +112,10 @@ "ch4_humidity": { "name": "Channel 4 humidity" }, "heat_index": { "name": "Apparent temperature" }, "chill_index": { "name": "Wind chill" }, + "hourly_rain": { "name": "Hourly precipitation" }, + "weekly_rain": { "name": "Weekly precipitation" }, + "monthly_rain": { "name": "Monthly precipitation" }, + "yearly_rain": { "name": "Yearly precipitation" }, "wind_azimut": { "name": "Bearing", "state": { diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 3673417..30a667b 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -284,7 +284,46 @@ def long_term_units_in_statistics_meta(): return sensor_units -def migrate_data(sensor_id: str | None = None): +async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> 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