Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
4de7be7
New registry scanning / startup procedure.
dirixmjm Aug 2, 2025
c4c84e2
check if cache is available before prune operation
dirixmjm Aug 3, 2025
9101dbf
remove awake queue
dirixmjm Aug 3, 2025
0314620
fixup: mdi_CPRegistry Python code reformatted using Ruff
Aug 3, 2025
d4043e1
ruff format
dirixmjm Aug 3, 2025
1979523
simplify motion config dirty state
dirixmjm Aug 4, 2025
34280e9
simply bool retrieval from cache
dirixmjm Aug 4, 2025
9095919
improve readability circle.py
dirixmjm Aug 4, 2025
387b63a
initial fixes to SED node testing
dirixmjm Aug 4, 2025
76c260d
update node loading strategy, maybe this can be improved to load inst…
dirixmjm Aug 4, 2025
5f25732
update and fix testing
dirixmjm Aug 4, 2025
c3cb297
fix unreachable code
dirixmjm Aug 4, 2025
a5654b1
fix mistake
dirixmjm Aug 4, 2025
5e083d9
create test release
dirixmjm Aug 4, 2025
34123d7
CR: Fix list modification during iteration
dirixmjm Aug 4, 2025
78109e6
CR: Fix typo in docstring
dirixmjm Aug 4, 2025
b67e900
remove test-debug message
dirixmjm Aug 5, 2025
a0f4370
remove temporary test-escape
dirixmjm Aug 5, 2025
8468489
remove temporary test-escape
dirixmjm Aug 5, 2025
ac98798
Address @bouwew comments
dirixmjm Aug 5, 2025
76e61cc
CR: Nitpicks
dirixmjm Aug 5, 2025
70c0ba4
CR: make function type explicit
dirixmjm Aug 5, 2025
c75e7b7
generate testpypi
dirixmjm Aug 5, 2025
ec58deb
fix introduced cache storage bug of sensitivity level
dirixmjm Aug 5, 2025
88a06e7
second attempt to fix, according to @bouwew recommendation
dirixmjm Aug 6, 2025
460f7fa
fixup: mdi_CPRegistry Python code reformatted using Ruff
Aug 6, 2025
b6aa462
bugfix ()=>[]
dirixmjm Aug 6, 2025
66d0402
Fix removed constant
bouwew Aug 6, 2025
3ab90e7
Change sensitivity_level to int
bouwew Aug 6, 2025
7ff1f7e
Fix sensitivity_level from cache
bouwew Aug 6, 2025
d9adc2c
reintegrate delay_task call in order to let awake response finish fir…
dirixmjm Aug 7, 2025
1111866
fix variable name
dirixmjm Aug 7, 2025
f0e24c0
generate testpypi
dirixmjm Aug 7, 2025
80691ec
Improve MotionSensitivity class
bouwew Aug 7, 2025
c599e72
Adapt scan.py
bouwew Aug 7, 2025
d98f1ba
Revert sens-level cache value
bouwew Aug 7, 2025
496a5e9
Fix typo
bouwew Aug 7, 2025
254970b
Ruffed
bouwew Aug 7, 2025
bcf644e
Bump to a3 for testpypi update
bouwew Aug 7, 2025
e2f1903
CR: Fix consistency self._registry type
dirixmjm Aug 9, 2025
0637e23
remove asserts combined with pytest.raises
dirixmjm Aug 9, 2025
f8f0b93
CR: nitpick on debug message
dirixmjm Aug 9, 2025
23cec39
More adaptions to registry-change
bouwew Aug 9, 2025
b0eb709
Bump to a5
bouwew Aug 9, 2025
3ed0bc0
CR: fix function template, not sure what to do with the bool though
dirixmjm Aug 9, 2025
83fe27e
fix public API structure
dirixmjm Aug 9, 2025
02ec48b
Merge branch 'mdi_CPRegistry' of github.com:plugwise/python-plugwise-…
dirixmjm Aug 9, 2025
f853846
call list attribute as list instead of function
dirixmjm Aug 9, 2025
3387acc
docstring fixes
dirixmjm Aug 9, 2025
ad7aec9
CR: Proposed fix to not block while-sleep in case registry scan is no…
dirixmjm Aug 9, 2025
18c99aa
CR: remove dead code
dirixmjm Aug 9, 2025
e6b84d5
fake_cache only to return string or bool
dirixmjm Aug 9, 2025
3e21940
Fix missing await in test
bouwew Aug 10, 2025
0788b3d
CR nitpicks
bouwew Aug 10, 2025
19de152
Hope this solves the SQ complaint
dirixmjm Aug 10, 2025
3bd2fa9
bump testpypi to a6
dirixmjm Aug 10, 2025
e7d1cd6
remove dead code
dirixmjm Aug 10, 2025
b244436
Fix sensitivity_level typing and description
bouwew Aug 10, 2025
15ae851
CR requested change and nitpick
bouwew Aug 10, 2025
eef119c
CR: harden discovery callback during full scan
bouwew Aug 10, 2025
105c992
Correct varname, implement docstring suggestion
bouwew Aug 10, 2025
71a497a
fixup: mdi_CPRegistry Python code reformatted using Ruff
Aug 10, 2025
9b69955
Ruff fixes
bouwew Aug 10, 2025
576dc34
Revert back to simple docstring
bouwew Aug 10, 2025
a301ed3
Retry adding descriptive docstring
bouwew Aug 10, 2025
efafd7d
fixup: mdi_CPRegistry Python code reformatted using Ruff
Aug 10, 2025
445bb9a
Fixup 2
bouwew Aug 11, 2025
5e01db3
bump to v0.44.10
dirixmjm Aug 11, 2025
d753f36
ruff CHANGELOG
dirixmjm Aug 11, 2025
86207db
improve wording
dirixmjm Aug 11, 2025
413c998
one more try
dirixmjm Aug 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v0.44.10 - 2025-08-11

- PR [302](https://github.com/plugwise/python-plugwise-usb/pull/302) Improve registry discovery and SED/SCAN configuration management
- Fix for [#296](https://github.com/plugwise/plugwise_usb-beta/issues/296) Improve C+ registry collection and node discovery
- Improve SED and SCAN configuration handling, include dirty bool to indicate that the configuration has changed but the node configuration has not yet.

## v0.44.9 - 2025-07-24

- Fix for [#293](https://github.com/plugwise/plugwise_usb-beta/issues/293) via PR [299](https://github.com/plugwise/python-plugwise-usb/pull/299)
Expand Down
19 changes: 11 additions & 8 deletions plugwise_usb/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from dataclasses import dataclass
from datetime import datetime
from enum import Enum, auto
from enum import Enum, IntEnum, auto
import logging
from typing import Any, Protocol

Expand All @@ -19,12 +19,12 @@ class StickEvent(Enum):
NETWORK_ONLINE = auto()


class MotionSensitivity(Enum):
class MotionSensitivity(IntEnum):
"""Motion sensitivity levels for Scan devices."""

HIGH = auto()
MEDIUM = auto()
OFF = auto()
HIGH = 20
MEDIUM = 30
OFF = 255


class NodeEvent(Enum):
Expand Down Expand Up @@ -118,6 +118,7 @@ class BatteryConfig:
clock_sync: bool | None: Indicate if the internal clock must be synced.
maintenance_interval: int | None: Interval in minutes a battery powered devices is awake for maintenance purposes.
sleep_duration: int | None: Interval in minutes a battery powered devices is sleeping.
dirty: bool: Settings changed, device update pending

"""

Expand All @@ -126,6 +127,7 @@ class BatteryConfig:
clock_sync: bool | None = None
maintenance_interval: int | None = None
sleep_duration: int | None = None
dirty: bool = False


@dataclass
Expand All @@ -145,7 +147,6 @@ class NodeInfo:
"""Node hardware information."""

mac: str
zigbee_address: int
is_battery_powered: bool = False
features: tuple[NodeFeature, ...] = (NodeFeature.INFO,)
firmware: datetime | None = None
Expand Down Expand Up @@ -232,13 +233,15 @@ class MotionConfig:
Attributes:
reset_timer: int | None: Motion reset timer in minutes before the motion detection is switched off.
daylight_mode: bool | None: Motion detection when light level is below threshold.
sensitivity_level: MotionSensitivity | None: Motion sensitivity level.
sensitivity_level: int | None: Motion sensitivity level.
dirty: bool: Settings changed, device update pending

"""

daylight_mode: bool | None = None
reset_timer: int | None = None
sensitivity_level: MotionSensitivity | None = None
sensitivity_level: int | None = None
dirty: bool = False


@dataclass
Expand Down
96 changes: 34 additions & 62 deletions plugwise_usb/network/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def __init__(
self._unsubscribe_node_rejoin: Callable[[], None] | None = None

self._discover_sed_tasks: dict[str, Task[bool]] = {}
self._registry_stragglers: dict[int, str] = {}
self._registry_stragglers: list[str] = []
self._discover_stragglers_task: Task[None] | None = None
self._load_stragglers_task: Task[None] | None = None

Expand Down Expand Up @@ -146,7 +146,7 @@ def nodes(
return self._nodes

@property
def registry(self) -> dict[int, tuple[str, NodeType | None]]:
def registry(self) -> list[str]:
"""Return dictionary with all registered (joined) nodes."""
return self._register.registry

Expand Down Expand Up @@ -232,10 +232,7 @@ async def node_awake_message(self, response: PlugwiseResponse) -> None:
self._awake_discovery[mac] = response.timestamp
return

if (address := self._register.network_address(mac)) is None:
if self._register.scan_completed:
return

if not self._register.node_is_registered(mac):
_LOGGER.debug(
"Skip node awake message for %s because network registry address is unknown",
mac,
Expand All @@ -248,7 +245,7 @@ async def node_awake_message(self, response: PlugwiseResponse) -> None:
or self._discover_sed_tasks[mac].done()
):
self._discover_sed_tasks[mac] = create_task(
self._discover_battery_powered_node(address, mac)
self._discover_battery_powered_node(mac)
)
else:
_LOGGER.debug("duplicate maintenance awake discovery for %s", mac)
Expand Down Expand Up @@ -280,15 +277,14 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool:
f"Invalid response message type ({response.__class__.__name__}) received, expected NodeRejoinResponse"
)
mac = response.mac_decoded
if (address := self._register.network_address(mac)) is None:
if (address := self._register.update_node_registration(mac)) is None:
raise NodeError(f"Failed to obtain address for node {mac}")

if self._nodes.get(mac) is None:
if (
self._register.update_node_registration(mac)
and self._nodes.get(mac) is None
):
task = self._discover_sed_tasks.get(mac)
if task is None or task.done():
self._discover_sed_tasks[mac] = create_task(
self._discover_battery_powered_node(address, mac)
self._discover_battery_powered_node(mac)
)
else:
_LOGGER.debug("duplicate awake discovery for %s", mac)
Expand Down Expand Up @@ -335,7 +331,7 @@ async def discover_network_coordinator(self, load: bool = False) -> bool:
return False

if await self._discover_node(
-1, self._controller.mac_coordinator, None, ping_first=False
self._controller.mac_coordinator, None, ping_first=False
):
if load:
return await self._load_node(self._controller.mac_coordinator)
Expand All @@ -349,7 +345,6 @@ async def discover_network_coordinator(self, load: bool = False) -> bool:
async def _create_node_object(
self,
mac: str,
address: int,
node_type: NodeType,
) -> None:
"""Create node object and update network registry."""
Expand All @@ -361,7 +356,6 @@ async def _create_node_object(
return
node = get_plugwise_node(
mac,
address,
self._controller,
self._notify_node_event_subscribers,
node_type,
Expand All @@ -371,7 +365,7 @@ async def _create_node_object(
return
self._nodes[mac] = node
_LOGGER.debug("%s node %s added", node.__class__.__name__, mac)
await self._register.update_network_registration(address, mac, node_type)
await self._register.update_network_nodetype(mac, node_type)

if self._cache_enabled:
_LOGGER.debug(
Expand All @@ -385,16 +379,13 @@ async def _create_node_object(

async def _discover_battery_powered_node(
self,
address: int,
mac: str,
) -> bool:
"""Discover a battery powered node and add it to list of nodes.

Return True if discovery succeeded.
"""
if not await self._discover_node(
address, mac, node_type=None, ping_first=False
):
if not await self._discover_node(mac, node_type=None, ping_first=False):
return False
if await self._load_node(mac):
await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac)
Expand All @@ -403,7 +394,6 @@ async def _discover_battery_powered_node(

async def _discover_node(
self,
address: int,
mac: str,
node_type: NodeType | None,
ping_first: bool = True,
Expand All @@ -420,16 +410,18 @@ async def _discover_node(
return True

if node_type is not None:
await self._create_node_object(mac, address, node_type)
await self._create_node_object(mac, node_type)
await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac)
return True

# Node type is unknown, so we need to discover it first
_LOGGER.debug("Starting the discovery of node %s with unknown NodeType", mac)
node_info, node_ping = await self._controller.get_node_details(mac, ping_first)
if node_info is None:
_LOGGER.debug("Node %s with unknown NodeType not responding", mac)
self._registry_stragglers.append(mac)
return False
await self._create_node_object(mac, address, node_info.node_type)
await self._create_node_object(mac, node_info.node_type)

# Forward received NodeInfoResponse message to node
await self._nodes[mac].message_for_node(node_info)
Expand All @@ -438,41 +430,16 @@ async def _discover_node(
await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac)
return True

async def _discover_registered_nodes(self) -> None:
"""Discover nodes."""
_LOGGER.debug("Start discovery of registered nodes")
registered_counter = 0
for address, registration in self._register.registry.items():
mac, node_type = registration
if mac != "":
if self._nodes.get(mac) is None:
if not await self._discover_node(address, mac, node_type):
self._registry_stragglers[address] = mac
registered_counter += 1
await sleep(0)
if len(self._registry_stragglers) > 0 and (
self._discover_stragglers_task is None
or self._discover_stragglers_task.done()
):
self._discover_stragglers_task = create_task(self._discover_stragglers())
_LOGGER.debug(
"Total %s online of %s registered node(s)",
str(len(self._nodes)),
str(registered_counter),
)

async def _discover_stragglers(self) -> None:
"""Repeat Discovery of Nodes with unknown NodeType."""
while len(self._registry_stragglers) > 0:
await sleep(NODE_RETRY_DISCOVER_INTERVAL)
stragglers: dict[int, str] = {}
for address, mac in self._registry_stragglers.items():
if not await self._discover_node(address, mac, None):
stragglers[address] = mac
self._registry_stragglers = stragglers
for mac in self._registry_stragglers.copy():
if await self._discover_node(mac, None):
self._registry_stragglers.remove(mac)
_LOGGER.debug(
"Total %s nodes unreachable having unknown NodeType",
str(len(stragglers)),
str(len(self._registry_stragglers)),
)

async def _load_node(self, mac: str) -> bool:
Expand Down Expand Up @@ -515,6 +482,10 @@ async def _load_discovered_nodes(self) -> bool:
)
result_index += 1
_LOGGER.debug("_load_discovered_nodes | END")
if not all(load_result) and (
self._load_stragglers_task is None or self._load_stragglers_task.done()
):
self._load_stragglers_task = create_task(self._load_stragglers())
return all(load_result)

async def _unload_discovered_nodes(self) -> None:
Expand All @@ -524,25 +495,26 @@ async def _unload_discovered_nodes(self) -> None:
# endregion

# region - Network instance
async def start(self) -> None:
async def start(self, load: bool = True) -> None:
"""Start and activate network."""
self._register.quick_scan_finished(self._discover_registered_nodes)
self._register.full_scan_finished(self._discover_registered_nodes)
self._register.start_node_discover(self._discover_node)
if load:
self._register.scan_completed_callback(self._load_discovered_nodes)
await self._register.start()
self._subscribe_to_protocol_events()
await self._subscribe_to_node_events()
self._is_running = True
if len(self._registry_stragglers) > 0 and (
self._discover_stragglers_task is None
or self._discover_stragglers_task.done()
):
self._discover_stragglers_task = create_task(self._discover_stragglers())

async def discover_nodes(self, load: bool = True) -> bool:
"""Discover nodes."""
await self.discover_network_coordinator(load=load)
if not self._is_running:
await self.start()
await self._discover_registered_nodes()
if load and not await self._load_discovered_nodes():
self._load_stragglers_task = create_task(self._load_stragglers())
return False

await self.start(load=load)
return True

async def stop(self) -> None:
Expand Down
11 changes: 11 additions & 0 deletions plugwise_usb/network/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,14 @@ async def update_nodetypes(self, mac: str, node_type: NodeType | None) -> None:
def get_nodetype(self, mac: str) -> NodeType | None:
"""Return NodeType from cache."""
return self._nodetypes.get(mac)

async def prune_cache(self, registry: list[str]) -> None:
"""Remove items from cache which are not found in registry scan."""
new_nodetypes: dict[str, NodeType] = {}
for mac in registry:
if mac == "":
continue
if (node_type := self.get_nodetype(mac)) is not None:
new_nodetypes[mac] = node_type
self._nodetypes = new_nodetypes
await self.save_cache()
Loading