Skip to content

Commit 3187190

Browse files
committed
trying to rediscover ips missed by HA's zeroconf discovery
1 parent 146bbe6 commit 3187190

File tree

10 files changed

+379
-151
lines changed

10 files changed

+379
-151
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import asyncio
2+
3+
class AsyncSet:
4+
"""Thread/async-safe set."""
5+
6+
def __init__(self):
7+
self._items = set()
8+
self._lock = asyncio.Lock()
9+
10+
async def add(self, item):
11+
"""Add item to set."""
12+
async with self._lock:
13+
self._items.add(item)
14+
15+
async def discard(self, item):
16+
"""Remove item if present."""
17+
async with self._lock:
18+
self._items.discard(item)
19+
20+
async def remove(self, item):
21+
"""Remove item, raise KeyError if not present."""
22+
async with self._lock:
23+
self._items.remove(item)
24+
25+
async def pop(self):
26+
"""Remove and return arbitrary item."""
27+
async with self._lock:
28+
return self._items.pop()
29+
30+
async def clear(self):
31+
"""Remove all items."""
32+
async with self._lock:
33+
self._items.clear()
34+
35+
async def copy(self):
36+
"""Return a copy of the set."""
37+
async with self._lock:
38+
return self._items.copy()
39+
40+
def __contains__(self, item):
41+
"""Check if item in set."""
42+
return item in self._items
43+
44+
def __len__(self):
45+
"""Get size of set."""
46+
return len(self._items)
47+
48+
def __bool__(self):
49+
"""Check if set is empty in pythonic way"""
50+
return bool(self._items)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from datetime import timedelta
2+
from typing import Callable
3+
4+
from homeassistant.components.sensor import SensorEntity
5+
from homeassistant.core import HomeAssistant, callback
6+
from homeassistant.helpers.device_registry import DeviceInfo
7+
from homeassistant.helpers import device_registry as dr
8+
from homeassistant.helpers import entity_registry as er
9+
from homeassistant.helpers.dispatcher import async_dispatcher_connect
10+
from homeassistant.helpers.event import async_track_point_in_utc_time
11+
from homeassistant.util.dt import utcnow
12+
from .PlugMeasurements import PlugMeasurements
13+
from .SensorMeasurements import SensorMeasurements
14+
from .const import DOMAIN, POWER_SENSOR_UPDATE_SIGNAL
15+
16+
import logging
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
class PowersensorEntity(SensorEntity):
20+
"""Powersensor Plug Class--designed to handle all measurements of the plug--perhaps less expressive"""
21+
def __init__(self, hass: HomeAssistant, mac : str,
22+
get_config_by_measurement_type: Callable[[SensorMeasurements|PlugMeasurements], dict],
23+
measurement_type: SensorMeasurements|PlugMeasurements, timeout_seconds: int = 60):
24+
"""Initialize the sensor."""
25+
self.role = None
26+
self._has_recently_received_update_message = False
27+
self._attr_native_value = 0.0
28+
self._hass = hass
29+
self._mac = mac
30+
self._model = f"PowersensorDevice"
31+
self._device_name = f'PowersensorDevice MAC address: ({self._mac})'
32+
self._measurement_name= None
33+
self._remove_unavailability_tracker = None
34+
self._timeout = timedelta(seconds=timeout_seconds) # Adjust as needed
35+
36+
self.measurement_type = measurement_type
37+
config = get_config_by_measurement_type(measurement_type)
38+
self._attr_unique_id = f"powersensor_{mac}_{measurement_type}"
39+
self._attr_device_class = config["device_class"]
40+
self._attr_native_unit_of_measurement = config["unit"]
41+
self._attr_device_info = self.device_info
42+
self._attr_suggested_display_precision = config["precision"]
43+
self._signal = f"{POWER_SENSOR_UPDATE_SIGNAL}_{self._mac}_{config['event']}"
44+
if 'state_class' in config.keys():
45+
self._attr_state_class = config['state_class']
46+
self._message_key = config.get('message_key', None)
47+
self._message_callback = config.get('callback', None)
48+
49+
@property
50+
def device_info(self) -> DeviceInfo:
51+
raise NotImplementedError
52+
53+
@property
54+
def available(self) -> bool:
55+
"""Does data exist for this sensor type"""
56+
return self._has_recently_received_update_message
57+
58+
def _schedule_unavailable(self):
59+
"""Schedule entity to become unavailable."""
60+
if self._remove_unavailability_tracker:
61+
self._remove_unavailability_tracker()
62+
63+
self._remove_unavailability_tracker = async_track_point_in_utc_time(
64+
self.hass,
65+
self._async_make_unavailable,
66+
utcnow() + self._timeout
67+
)
68+
69+
async def _async_make_unavailable(self, _now):
70+
"""Mark entity as unavailable."""
71+
self._has_recently_received_update_message = False
72+
self.async_write_ha_state()
73+
74+
async def async_added_to_hass(self) -> None:
75+
"""Subscribe to messages when added to home assistant"""
76+
self._has_recently_received_update_message = False
77+
self.async_on_remove(async_dispatcher_connect(
78+
self._hass,
79+
self._signal,
80+
self._handle_update
81+
))
82+
83+
async def async_will_remove_from_hass(self):
84+
"""Clean up."""
85+
if self._remove_unavailability_tracker:
86+
self._remove_unavailability_tracker()
87+
88+
def _rename_based_on_role(self):
89+
return False
90+
91+
@callback
92+
def _handle_update(self, event, message):
93+
"""handle pushed data."""
94+
name_updated = False
95+
if event =="remove_sensor":
96+
self.async_remove()
97+
elif event =="mark_unavailable":
98+
self._has_recently_received_update_message = False
99+
else:
100+
self._has_recently_received_update_message = True
101+
if not self.role:
102+
if 'role' in message.keys():
103+
self.role = message['role']
104+
name_updated = self._rename_based_on_role()
105+
106+
107+
if self._message_key in message.keys():
108+
if self._message_callback:
109+
self._attr_native_value = self._message_callback( message[self._message_key])
110+
else:
111+
self._attr_native_value = message[self._message_key]
112+
self._schedule_unavailable()
113+
114+
if name_updated:
115+
device_registry = dr.async_get(self.hass)
116+
device = device_registry.async_get_device(
117+
identifiers={(DOMAIN, self._mac)}
118+
)
119+
120+
if device and device.name != self._device_name:
121+
# Update the device name
122+
device_registry.async_update_device(
123+
device.id,
124+
name=self._device_name
125+
)
126+
127+
entity_registry = er.async_get(self.hass)
128+
entity_registry.async_update_entity(
129+
self.entity_id,
130+
name = self._attr_name
131+
)
132+
self.async_write_ha_state()
133+

custom_components/powersensor/PowersensorMessageDispatacher.py

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1+
import asyncio
12
import logging
23

34
from homeassistant.core import HomeAssistant, callback
45
from homeassistant.helpers.dispatcher import async_dispatcher_send, async_dispatcher_connect
56

67
from powersensor_local import PlugApi, VirtualHousehold
78

8-
from custom_components.powersensor.const import POWER_SENSOR_UPDATE_SIGNAL, DOMAIN
9+
from custom_components.powersensor.AsyncSet import AsyncSet
10+
from custom_components.powersensor.const import POWER_SENSOR_UPDATE_SIGNAL, DOMAIN, DEFAULT_PORT
911

1012
_LOGGER = logging.getLogger(__name__)
1113
class PowersensorMessageDispatcher:
1214
def __init__(self, hass: HomeAssistant, vhh: VirtualHousehold):
1315
self._hass = hass
1416
self._vhh = vhh
1517
self.plugs = dict()
18+
self._known_plugs = set()
1619
self.sensors = dict()
1720
self.on_start_sensor_queue = dict()
1821
self._unsubscribe_from_signals = [
@@ -27,15 +30,75 @@ def __init__(self, hass: HomeAssistant, vhh: VirtualHousehold):
2730
self._plug_updated),
2831
async_dispatcher_connect(self._hass,
2932
f"{DOMAIN}_zeroconf_remove_plug",
30-
self._plug_remove)
33+
self._plug_remove),
34+
async_dispatcher_connect(self._hass,
35+
f"{DOMAIN}_plug_added_to_homeassistant",
36+
self._acknowledge_plug_added_to_homeassistant),
3137
]
3238

33-
34-
def add_api(self, mac, network_info):
35-
36-
_LOGGER.info(f"Adding API for mac={network_info['mac']}, ip={network_info['host']}, port={network_info['port']}")
37-
api = PlugApi(mac=network_info['mac'], ip=network_info['host'], port=network_info['port'])
38-
self.plugs[mac] = api
39+
self._monitor_add_plug_queue = None
40+
self._stop_task = False
41+
self._plug_added_queue = AsyncSet()
42+
self._safe_to_process_plug_queue = False
43+
44+
async def enqueue_plug_for_adding(self, network_info: dict):
45+
_LOGGER.debug(f"Adding to plug processing queue: {network_info}")
46+
await self._plug_added_queue.add((network_info['mac'], network_info['host'], network_info['port']))
47+
48+
async def process_plug_queue(self):
49+
"""Start the background task if not already running."""
50+
self._safe_to_process_plug_queue = True
51+
if self._monitor_add_plug_queue is None or self._monitor_add_plug_queue.done():
52+
self._stop_task = False
53+
self._monitor_add_plug_queue = self._hass.async_create_background_task(self._monitor_plug_queue(), name="plug_queue_monitor")
54+
_LOGGER.debug("Background task started")
55+
56+
def _plug_has_been_seen(self, mac_address)->bool:
57+
return mac_address in self.plugs or mac_address in self._known_plugs
58+
59+
async def _monitor_plug_queue(self):
60+
"""The actual background task loop."""
61+
try:
62+
while not self._stop_task and self._plug_added_queue:
63+
queue_snapshot = await self._plug_added_queue.copy()
64+
for mac_address, host, port in queue_snapshot:
65+
if not self._plug_has_been_seen(mac_address):
66+
async_dispatcher_send(self._hass, f"{DOMAIN}_create_plug",
67+
mac_address, host, port)
68+
else:
69+
_LOGGER.debug(f"Plug: {mac_address} has already been created as an entity in Home Assistant."
70+
f" Skipping and flushing from queue.")
71+
await self._plug_added_queue.remove((mac_address, host, port))
72+
73+
74+
await asyncio.sleep(5)
75+
_LOGGER.debug("Plug queue has been processed!")
76+
77+
except asyncio.CancelledError:
78+
_LOGGER.debug("Plug queue processing cancelled")
79+
raise
80+
except Exception as e:
81+
_LOGGER.error(f"Error in Plug queue processing task: {e}")
82+
finally:
83+
self._monitor_add_plug_queue = None
84+
85+
async def stop_processing_plug_queue(self):
86+
"""Stop the background task."""
87+
self._stop_task = True
88+
if self._monitor_add_plug_queue and not self._monitor_add_plug_queue.done():
89+
self._monitor_add_plug_queue.cancel()
90+
try:
91+
await self._monitor_add_plug_queue
92+
except asyncio.CancelledError:
93+
pass
94+
_LOGGER.debug("Background task stopped")
95+
self._monitor_add_plug_queue = None
96+
97+
def _create_api(self, mac_address, ip, port):
98+
_LOGGER.info(f"Adding API for mac={mac_address}, ip={ip}, port={port}")
99+
api = PlugApi(mac=mac_address, ip=ip, port=port)
100+
self.plugs[mac_address] = api
101+
self._known_plugs.add(mac_address)
39102
known_evs = [
40103
'exception',
41104
'average_flow',
@@ -53,6 +116,10 @@ def add_api(self, mac, network_info):
53116
api.subscribe(ev, lambda event, message: self.handle_message( event, message))
54117
api.connect()
55118

119+
def add_api(self, network_info):
120+
self._create_api(mac_address=network_info['mac'], ip=network_info['host'], port=network_info['port'])
121+
122+
56123
async def handle_message(self, event: str, message: dict):
57124
mac = message['mac']
58125
if mac not in self.plugs.keys():
@@ -79,18 +146,55 @@ async def disconnect(self):
79146
if unsubscribe is not None:
80147
unsubscribe()
81148

149+
await self.stop_processing_plug_queue()
150+
82151
@callback
83152
def _acknowledge_sensor_added_to_homeassistant(self,mac, role):
84153
self.sensors[mac] = role
85154

86155
@callback
87-
def _plug_added(self, info):
88-
_LOGGER.error(f" Request to add plug received: {info}")
156+
async def _acknowledge_plug_added_to_homeassistant(self, mac_address, host, port):
157+
self._create_api(mac_address, host, port)
158+
await self._plug_added_queue.remove((mac_address, host, port))
159+
160+
@callback
161+
async def _plug_added(self, info):
162+
_LOGGER.debug(f" Request to add plug received: {info}")
163+
network_info = dict()
164+
network_info['mac'] = info['properties'][b'id'].decode('utf-8')
165+
network_info['host'] = info['addresses'][0]
166+
network_info['port'] = info['port']
167+
168+
if self._safe_to_process_plug_queue:
169+
await self.enqueue_plug_for_adding(network_info)
170+
await self.process_plug_queue()
171+
else:
172+
await self.enqueue_plug_for_adding(network_info)
89173

90174
@callback
91-
def _plug_updated(self, info):
92-
_LOGGER.error(f" Request to update plug received: {info}")
175+
async def _plug_updated(self, info):
176+
_LOGGER.debug(f" Request to update plug received: {info}")
177+
mac = info['properties'][b'id'].decode('utf-8')
178+
host = info['addresses'][0]
179+
port = info['port']
180+
if mac in self.plugs:
181+
self.plugs[mac].disconnect()
182+
183+
if mac in self._known_plugs:
184+
self._create_api(mac, host, port)
185+
else:
186+
network_info = dict()
187+
network_info['mac'] = mac
188+
network_info['host'] = host
189+
network_info['port'] = port
190+
await self.enqueue_plug_for_adding(network_info)
191+
await self.process_plug_queue()
93192

94193
@callback
95194
def _plug_remove(self,name, info):
96-
_LOGGER.error(f" Request to delete plug received: {info}")
195+
_LOGGER.debug(f" Request to delete plug received: {info}")
196+
mac = info['properties'][b'id'].decode('utf-8')
197+
if mac in self.plugs:
198+
self.plugs[mac].disconnect()
199+
200+
del self.plugs[mac]

0 commit comments

Comments
 (0)