Skip to content

Commit

Permalink
Dynamic entities, multiple weather stations, code refactoring (#17)
Browse files Browse the repository at this point in the history
* first part of this working

* config flow fixes

* handle multiple config entries properly

* cleanup usage of list for keeping track of stations, rather use config entry for each

* enable all sensors by default

* ensure calc'd sensors are taken care of

* refactoring, store list of known entities for next HA start, restore state of entities on start

* restore for time sensor only, refactor, fix calc

* update storage key format, make event handle a property of ambient

* refactor stationtype

* remove unneeded code

* changelog, version, readme
  • Loading branch information
tlskinneriv authored Feb 26, 2023
1 parent 8f02433 commit 0c0c2b4
Show file tree
Hide file tree
Showing 13 changed files with 286 additions and 280 deletions.
31 changes: 15 additions & 16 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Home Assistant Core",
"type": "shell",
"command": "cd /srv/homeassistant; source bin/activate; hass -c ./config",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}

"version": "2.0.0",
"tasks": [
{
"label": "Run Home Assistant Core",
"type": "shell",
"command": "cd /srv/homeassistant; source bin/activate; hass -c ./config",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
}
]
}
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog],
and this project adheres to [Semantic Versioning].

## [1.0.0] - 2023-02-26

### Added

- Sensor auto-population: when the integration is set up for the first time, it will no longer
populate every possible sensor. Rather, it will wait for the first data update from the add-on (or
alternative method) to populate sensors at that time. If sensors are added to the WS in the
future, they will be added automatically
- Timestamp sensor (Last Rain, Last Lightning) persistence across reboots of HA
- Config Flow validation to prevent bad MACs from being entered
- Multiple weather station configuration: if another weather station needs to be added, just need to
add the integration again for the new MAC; the service is now aware of other config entries
- Error text in config flow

### Changed

- All sensors are now enabled by default. This does not effect current installations, but is good
for new installation to show all available sensors and allow the use to choose what to hide
- Known sensors persist over a restart of HA or reload of the integration
- Calculated fields now show unknown instead of unavailable when their dependent sensors are not
available
- Firmware version shown on the device page: right now this only seems to show up on new instances
of the integration
- Rename of "Lightning Strike Timestamp" to "Last Lightning Strike"

### Housekeeping

- Lots of code refactoring

## [0.4.0] - 2023-02-25

### Added
Expand Down
12 changes: 2 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration)

# Ambient Weather Station - Local

## Overview
Expand All @@ -16,13 +18,3 @@ Configuration is performed via the Home Assistant user interface. You will need
- MAC: the MAC address for the device

Once configured, setup the accompanying add-on [AWNET](https://github.com/tlskinneriv/hassio-addons/tree/master/awnet) (see the [docs](https://github.com/tlskinneriv/hassio-addons/blob/master/awnet/DOCS.md) for direct instructions).

## Known Issues

Currently, there is not a method implemented to determine what sensors a weather station supports.
Therefore, all possible supported sensors are populated for the integration (this list was
originally pulled from the [cloud-based
integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/ambient_station)).
The sensors will populate as "Unavailable" until a value is received from the add-on. Sensors not
populated by the device will remain in the "Unavailable" state. Recommendation is to disable the
unsupported sensors until such a method is implemented. Many uncommon sensors are disabled by default.
187 changes: 124 additions & 63 deletions custom_components/awnet_local/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import re
from typing import Any

from homeassistant.core import HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
Expand All @@ -14,15 +15,21 @@
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store

from .const import (
ATTR_LAST_DATA,
ATTR_PASSKEY,
ATTR_MAC,
ATTR_KNOWN_SENSORS,
ATTR_SENSOR_UPDATE_IN_PROGRESS,
ATTR_STATIONTYPE,
CONF_MAC,
CONF_NAME,
DOMAIN,
MAC_REGEX,
)

from .const_binary_sensor import (
Expand All @@ -37,8 +44,8 @@
_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]

MAC_REGEX = r"^(?:[a-f0-9]{2}:){5}[a-f0-9]{2}$"
STORAGE_KEY = DOMAIN + "_data"
STORAGE_VERSION = 1


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand Down Expand Up @@ -71,22 +78,45 @@ async def async_handle_update(call):
else:
_LOGGER.error("MAC address not found in data. Raw data: %s", data)
return
if mac not in hass.data[DOMAIN][entry.entry_id].stations:
_LOGGER.warning("Data received for %s that is not our MAC", mac)
return
_LOGGER.debug(
"Last data: %s", hass.data[DOMAIN][entry.entry_id].stations.get(mac, None)

real_entry = entry
if mac != entry.unique_id:
_LOGGER.debug(
"Data received for %s that is not this entry's MAC. Try to find the other entry.",
mac,
)
config_entries = hass.config_entries.async_entries(DOMAIN)
config_entry_for_mac = [x for x in config_entries if x.unique_id == mac]
if len(config_entry_for_mac) == 0:
_LOGGER.warning(
"Data received for %s does not belong to any config entries", mac
)
return
_LOGGER.debug("Found real entry for %s", mac)
real_entry = config_entry_for_mac[0]

_LOGGER.info(
"Last data: %s",
hass.data[DOMAIN][real_entry.entry_id].station,
)
hass.data[DOMAIN][entry.entry_id].on_data(mac, call.data)
await hass.data[DOMAIN][real_entry.entry_id].async_on_data(mac, call.data)

hass.services.async_register(DOMAIN, "update", async_handle_update)

await ambient.async_load()

if not ambient._entry_setup_complete:
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
ambient._entry_setup_complete = True

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Ambient PWS config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await hass.data[DOMAIN][entry.entry_id].async_unload()
return unload_ok


Expand All @@ -98,98 +128,129 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self._entry = entry
self._entry_setup_complete = False
self._hass = hass
self.stations: dict[str, dict] = {}

self.add_station(entry.data[CONF_MAC], entry.data[CONF_NAME])

def add_station(self, mac: str, name: str) -> None:
"""Add a station to the list of stations in the integration data; currently the integration
only supports 1 as configured from the UI."""
if mac not in self.stations:
self.stations.setdefault(mac, {})
self.stations[mac][ATTR_NAME] = name
self.stations[mac].setdefault(ATTR_LAST_DATA, {})
for attr_type in SUPPORTED_SENSOR_TYPES + SUPPORTED_BINARY_SENSOR_TYPES:
self.stations[mac][ATTR_LAST_DATA][attr_type] = None
if not self._entry_setup_complete:
self._hass.config_entries.async_setup_platforms(self._entry, PLATFORMS)
self._entry_setup_complete = True

def on_data(self, mac: str, data: dict) -> None:
self.station: dict[str, Any] = {}

self.station[ATTR_MAC] = entry.data[CONF_MAC]
self.station[ATTR_NAME] = entry.data[CONF_NAME]
self.station.setdefault(ATTR_LAST_DATA, {})
self.station.setdefault(ATTR_KNOWN_SENSORS, [])
self.station[ATTR_SENSOR_UPDATE_IN_PROGRESS] = False
self.station[ATTR_STATIONTYPE] = ""
self._storage_key = f"{STORAGE_KEY}_{self.station[ATTR_MAC]}"
self._store: Store = Store(hass, STORAGE_VERSION, self._storage_key)
self._update_event_handle = f"{DOMAIN}_data_update_{self.station[ATTR_MAC]}"

@property
def update_event_handle(self) -> str:
"""Returns the update event handle for the instance"""
return self._update_event_handle

async def async_load(self) -> None:
"""Load data for station from datastore"""
_LOGGER.info("Loading data for known sensors")
if (data := await self._store.async_load()) is not None:
_LOGGER.info("Data being restored: %s", data)
self.station[ATTR_KNOWN_SENSORS] = data

async def async_unload(self) -> None:
"""Remove all data for the station from the datastore"""
_LOGGER.info("Removing data for known sensors")
await self._store.async_remove()

async def async_on_data(self, mac: str, data: dict) -> None:
"""Processes the data from the incoming service call to update the sensors."""
_LOGGER.info("Processing data")
_LOGGER.info("MAC address: %s", mac)
_LOGGER.debug("New data received: %s", data)
self.station[ATTR_STATIONTYPE] = data.get(ATTR_STATIONTYPE, "")
extracted_data = {
key: value
for key, value in data.items()
if key in (SUPPORTED_SENSOR_TYPES + SUPPORTED_BINARY_SENSOR_TYPES)
}
if extracted_data == self.stations[mac][ATTR_LAST_DATA]:
if (
extracted_data == self.station[ATTR_LAST_DATA]
and not self.station[ATTR_SENSOR_UPDATE_IN_PROGRESS]
):
_LOGGER.info("Data received is the same as last received, not updating")
return
self.stations[mac][ATTR_LAST_DATA] = extracted_data
async_dispatcher_send(self._hass, f"{DOMAIN}_data_update_{mac}")
self.station[ATTR_LAST_DATA] = extracted_data
known_calc_sensors = [
key
for key, value in CALCULATED_SENSOR_TYPES.items()
if all(x in list(extracted_data.keys()) for x in value)
]
_LOGGER.debug("Known calc'd sensor types: %s", known_calc_sensors)
known_sensors_set = set(
self.station[ATTR_KNOWN_SENSORS]
+ known_calc_sensors
+ list(extracted_data.keys())
)
new_sensors = list(
known_sensors_set.difference(set(self.station[ATTR_KNOWN_SENSORS]))
)

_LOGGER.debug("New sensors to add: %s", new_sensors)
if new_sensors:
self.station[ATTR_KNOWN_SENSORS] = list(known_sensors_set)
await self._hass.config_entries.async_unload_platforms(
self._entry, PLATFORMS
)
await self._hass.config_entries.async_forward_entry_setups(
self._entry, PLATFORMS
)

# Store the data off
await self._store.async_save(self.station[ATTR_KNOWN_SENSORS])
async_dispatcher_send(self._hass, self.update_event_handle)

class AmbientWeatherEntity(Entity):

class AmbientWeatherEntity(RestoreEntity):
"""Define a base Ambient PWS entity."""

_attr_should_poll = False

def __init__(
self,
ambient: AmbientStation,
mac_address: str,
station_name: str,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
self._ambient = ambient

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)},
identifiers={(DOMAIN, ambient.station[ATTR_MAC])},
manufacturer="Ambient Weather",
name=station_name,
model="Weather Station",
sw_version=self._ambient.station[ATTR_STATIONTYPE],
name=ambient.station[ATTR_NAME],
)
self._attr_has_entity_name = True
self._attr_name = f"{description.name}"
self._attr_unique_id = f"{mac_address}_{description.key}"
self._mac_address = mac_address
self._attr_unique_id = f"{ambient.station[ATTR_MAC]}_{description.key}"
self._attr_available = False
self.entity_description = description

async def async_added_to_hass(self) -> None:
"""Register callbacks."""

@callback
def update() -> None:
"""Update the state."""
last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA]

if self.entity_description.key in CALCULATED_SENSOR_TYPES:
# if we are a calculated sensor type, report available only if all our dependencies
# are available
self._attr_available = all(
last_data.get(x) is not None
for x in CALCULATED_SENSOR_TYPES[self.entity_description.key]
)
else:
self._attr_available = (
last_data.get(self.entity_description.key) is not None
)

self.update_from_latest_data()
self.async_write_ha_state()

self.async_on_remove(
async_dispatcher_connect(
self.hass, f"{DOMAIN}_data_update_{self._mac_address}", update
)
async_dispatcher_connect(
self.hass,
self._ambient.update_event_handle,
self.update,
)

self.update_from_latest_data()

@callback
def update_from_latest_data(self) -> None:
"""Update the entity from the latest data."""
raise NotImplementedError

@callback
def update(self) -> None:
"""Update the state."""
last_data = self._ambient.station[ATTR_LAST_DATA]

self._attr_available = last_data.get(self.entity_description.key) is not None

self.update_from_latest_data()
self.async_write_ha_state()
Loading

0 comments on commit 0c0c2b4

Please sign in to comment.