Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
124 commits
Select commit Hold shift + click to select a range
80bc21e
Add plus_pair_request function
bouwew Feb 2, 2026
301cf67
Document pairing process
bouwew Feb 7, 2026
a3211d5
Add 0001-0002 req-resp-pair
bouwew Feb 7, 2026
b127ebd
Add init stick
bouwew Feb 7, 2026
c550442
Improve docstring
bouwew Feb 7, 2026
b6e0f03
Improve pair_plus_device()
bouwew Feb 7, 2026
175bba4
Add todo for maybe needed functionality
bouwew Feb 7, 2026
01c7285
Fix typos, return type
bouwew Feb 7, 2026
28f4547
Correct imports, improve docstring
bouwew Feb 7, 2026
d5f7be0
Ruff fixes
bouwew Feb 7, 2026
a17acf5
Make sure the Stick is ready to pair, as suggested
bouwew Feb 8, 2026
9cd9a8f
Fix spelling
bouwew Feb 8, 2026
8c0aae9
Remove quotes, move
bouwew Feb 8, 2026
ed82743
Set output as bool and use
bouwew Feb 8, 2026
4f1c048
Add missing await
bouwew Feb 8, 2026
bab0bc7
Add 0003 response to docstring
bouwew Feb 8, 2026
19fc1ce
Start adding pairing test
bouwew Feb 8, 2026
83f24b6
Add missing connected()
bouwew Feb 8, 2026
c045e9d
Add pairing-test
bouwew Feb 8, 2026
ca2976f
Link to stick_pair_data
bouwew Feb 8, 2026
3305142
Fix stick_pair_data
bouwew Feb 8, 2026
a66f185
Set network to offline
bouwew Feb 8, 2026
971ba98
Try
bouwew Feb 8, 2026
b7cf04e
Try 2
bouwew Feb 8, 2026
05061b1
Try 3
bouwew Feb 8, 2026
6cb9f71
Add StickInitShortResponse
bouwew Feb 8, 2026
4d9f540
Remove commented-out in response
bouwew Feb 8, 2026
b7238ea
Update length
bouwew Feb 8, 2026
08f5b87
Adapt StickInitRequest send()
bouwew Feb 8, 2026
37c5024
Update length StickInitResponse
bouwew Feb 8, 2026
819b81a
Clean up
bouwew Feb 8, 2026
0b8e531
Full test-output - test_pairing
bouwew Feb 8, 2026
d5865d6
Allow init to fail
bouwew Feb 8, 2026
5348889
Add sleep
bouwew Feb 8, 2026
84553d1
Call pair_plus_request()
bouwew Feb 8, 2026
c7bfc48
Connected and initialized is not required
bouwew Feb 8, 2026
265b044
fixup: pair-plus Python code fixed using Ruff
Feb 8, 2026
ec62dc7
There can only be one response
bouwew Feb 8, 2026
3f6c766
Use inheritance for StickInitResponse
bouwew Feb 8, 2026
638c8c2
Move pair_plus_device() to connection
bouwew Feb 8, 2026
0e834ef
Correct plus-mac
bouwew Feb 8, 2026
a32088b
Add stick-mac to 0002 response
bouwew Feb 8, 2026
05841ac
Move RESPONSE_MESSAGES
bouwew Feb 9, 2026
f36b57c
Don't test network down first
bouwew Feb 9, 2026
a0d64a6
Add missing import
bouwew Feb 9, 2026
5f137d0
Connect first
bouwew Feb 9, 2026
0304ff8
Try
bouwew Feb 9, 2026
b818a65
fixup: pair-plus Python code fixed using Ruff
Feb 9, 2026
961bad8
Try 3
bouwew Feb 10, 2026
8e2aa67
Try 4
bouwew Feb 10, 2026
f77a069
Try not allowed
bouwew Feb 10, 2026
790d3d5
Extra bit
bouwew Feb 10, 2026
6c66bd7
CirclePlusConnectReqyest: shorter args
bouwew Feb 10, 2026
357c1d1
fixup: pair-plus Python code fixed using Ruff
Feb 10, 2026
8ac1afa
Add stick-mac to 0005-response, remove extra bit
bouwew Feb 10, 2026
46cbf71
Ruffed
bouwew Feb 10, 2026
eeeeec3
Shorten args, must be length=16
bouwew Feb 10, 2026
a1185bf
Add missing CRC, can be corrected later
bouwew Feb 10, 2026
dd1a541
Try
bouwew Feb 12, 2026
f4c7f7a
Try 2
bouwew Feb 13, 2026
7b7f898
Fixes
bouwew Feb 13, 2026
68cfc0f
Correct CRC
bouwew Feb 13, 2026
bf7dc74
Try allowed
bouwew Feb 13, 2026
99d99de
Change to Circle+ mac in 0005-response
bouwew Feb 13, 2026
892a604
Ruff-cleanup
bouwew Feb 13, 2026
3e72c36
Bump to v0.48.0a1 test-version
bouwew Feb 13, 2026
1c1045a
Update CHANGELOG
bouwew Feb 13, 2026
15fe55c
Implement StickInitShortResponse-handling in class StickController
bouwew Feb 16, 2026
9a36969
Back to full test-output
bouwew Feb 16, 2026
abd6b83
Improve
bouwew Feb 16, 2026
9900dc5
Correct CHANGELOG after rebase
bouwew Feb 16, 2026
6ac1ca3
Bump to v0.48.0a2 test-version
bouwew Feb 16, 2026
031146c
Run all test-files in case of failure
bouwew Feb 16, 2026
ca6bbd4
Revert back to python 3.13
bouwew Feb 17, 2026
2e99fad
Bump to a3
bouwew Feb 17, 2026
b4e1422
Try-except stick-initialize
bouwew Feb 17, 2026
422675c
Ruff fix
bouwew Feb 17, 2026
cddd7d6
Add log-warning
bouwew Feb 17, 2026
ec95cdf
Bump to a4
bouwew Feb 17, 2026
52ac648
Remove is_connected requirement for mac_stick
bouwew Feb 18, 2026
cffb654
Bump to a5
bouwew Feb 18, 2026
7d206dd
Disable now invalid test
bouwew Feb 18, 2026
8927ca7
More debug-logging
bouwew Feb 19, 2026
440627f
Bump to a6
bouwew Feb 19, 2026
4f95cfe
Move debug message
bouwew Feb 19, 2026
909d397
Bump to a7
bouwew Feb 19, 2026
cff79b8
Revert adding try-except
bouwew Feb 19, 2026
91c8059
Replace debuggers by distinct message
bouwew Feb 20, 2026
2a3e03c
Bump to a8
bouwew Feb 20, 2026
4f32ed7
Remove unneeded StickError raises
bouwew Feb 20, 2026
3ca609a
Update relevant test-asserts
bouwew Feb 20, 2026
910a9cf
Ruff fixes
bouwew Feb 20, 2026
f2bcc48
Correct -update test_stick_network_down()
bouwew Feb 20, 2026
7f6ed91
Fix pylint warnings
bouwew Feb 20, 2026
039d9aa
More adapting to StickInitShortResponse
bouwew Feb 20, 2026
2704fb1
Bump to a9
bouwew Feb 20, 2026
52dd842
Update Stick properties mac_stick, mac_coordinator and name
bouwew Feb 20, 2026
5836752
Update docstring
bouwew Feb 20, 2026
d39752a
Update network_online docstring
bouwew Feb 20, 2026
7195be1
Bump to a10
bouwew Feb 20, 2026
2298661
fixup: pair-plus Python code fixed using Ruff
Feb 20, 2026
0505825
Responses: line up Int() use
bouwew Feb 23, 2026
c232236
Add missing decode_mac=False
bouwew Feb 23, 2026
4f954d4
Correct 0002-format in response
bouwew Feb 23, 2026
fec606b
Bump to a11
bouwew Feb 23, 2026
2dcb472
Exit when network is not online
bouwew Feb 23, 2026
61a6c8e
Bump to a12
bouwew Feb 23, 2026
8274675
Revert "Exit when network is not online"
bouwew Feb 23, 2026
b1c453b
Remove logger-HOI-lines
bouwew Feb 23, 2026
430bd3f
Bump to a13
bouwew Feb 23, 2026
fb1a616
Don't collect NodeInfo during pairing
bouwew Feb 23, 2026
c3ce0f8
Update 0004-request
bouwew Feb 23, 2026
65eb807
Update test-related
bouwew Feb 23, 2026
ce1612f
Ruffed
bouwew Feb 23, 2026
cf438d5
Update docstring
bouwew Feb 23, 2026
987006f
Bump to a14
bouwew Feb 24, 2026
93c7be5
0005-response: add missing decode_mac=False
bouwew Feb 24, 2026
bcc666a
Improve docstring
bouwew Feb 24, 2026
6e6acb6
Adapt 0005-test-response
bouwew Mar 2, 2026
7bd6bd5
Add init to StickNetworkInfoRequest
bouwew Mar 3, 2026
ac8ec44
And adapt use
bouwew Mar 3, 2026
4abb97d
Disable guarding that breaks 0004-0005 sequence detection
bouwew Mar 4, 2026
b73848c
Fix CHANGELOG after rebase
bouwew Mar 4, 2026
b2e0a62
Update pair_plus_device() docsting
bouwew Sep 1, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:
- commitcheck
strategy:
matrix:
python-version: ["3.14"]
python-version: ["3.13"]
steps:
- name: Check out committed code
uses: actions/checkout@v7
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Ongoing

- PR [405](https://github.com/plugwise/python-plugwise-usb/pull/405): Try adding plus-device pairing (untested!!)

## v0.47.8 - 2026-07-01

PR [459](https://github.com/plugwise/python-plugwise-usb/pull/459): Reduce logging for SEDs
Expand All @@ -23,7 +27,7 @@ PR [422](https://github.com/plugwise/python-plugwise-usb/pull/422): Add missing
## v0.47.3 - 2026-03-04

- PR [418](https://github.com/plugwise/python-plugwise-usb/pull/418): Improve raise-message for better debugging
- PR [405](https://github.com/plugwise/python-plugwise-usb/pull/405): Fix recent Ruff errors
- PR [409](https://github.com/plugwise/python-plugwise-usb/pull/409): Fix recent Ruff errors

## v0.47.2 - 2026-01-29

Expand Down
37 changes: 28 additions & 9 deletions plugwise_usb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,27 @@ def hardware(self) -> str:
return self._controller.hardware_stick

@property
def mac_stick(self) -> str:
"""MAC address of USB-Stick. Raises StickError is connection is missing."""
def mac_stick(self) -> str | None:
"""MAC address of USB-Stick.

Returns None when the connection to the Stick fails.
"""
return self._controller.mac_stick

@property
def mac_coordinator(self) -> str:
"""MAC address of the network coordinator (Circle+). Raises StickError is connection is missing."""
def mac_coordinator(self) -> str | None:
"""MAC address of the network coordinator (Circle+).

Returns none when there is no connection, not paired, not present in the network.
"""
return self._controller.mac_coordinator

@property
def name(self) -> str:
"""Return name of Stick."""
def name(self) -> str | None:
"""Return name of Stick.

Returns None when the connection to the Stick fails.
"""
return self._controller.stick_name

@property
Expand Down Expand Up @@ -237,8 +246,8 @@ async def setup(self, discover: bool = True, load: bool = True) -> None:
if not self.is_connected:
await self.connect()
if not self.is_initialized:
await self.initialize()
if discover:
initialized = await self.initialize()
if initialized and discover:
await self.start_network()
await self.discover_coordinator()
await self.discover_nodes()
Expand Down Expand Up @@ -266,10 +275,18 @@ async def connect(self, port: str | None = None) -> None:
self._port,
)

async def plus_pair_request(self, mac: str) -> bool:
"""Send a pair request to a Plus device."""
return await self._controller.pair_plus_device(mac)

@raise_not_connected
async def initialize(self, create_root_cache_folder: bool = False) -> None:
async def initialize(self, create_root_cache_folder: bool = False) -> bool:
"""Initialize connection to USB-Stick."""
await self._controller.initialize_stick()
# Check if network is offline = StickInitShortResponse
if self._controller.mac_coordinator is None:
return False

if self._network is None:
self._network = StickNetwork(self._controller)
self._network.cache_folder = self._cache_folder
Expand All @@ -278,6 +295,8 @@ async def initialize(self, create_root_cache_folder: bool = False) -> None:
if self._cache_enabled:
await self._network.initialize_cache()

return True

@raise_not_connected
@raise_not_initialized
async def start_network(self) -> None:
Expand Down
118 changes: 83 additions & 35 deletions plugwise_usb/connection/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,22 @@

from ..api import StickEvent
from ..constants import UTF8
from ..exceptions import NodeError, StickError
from ..helpers.util import version_to_model
from ..exceptions import MessageError, NodeError, StickError
from ..helpers.util import validate_mac, version_to_model
from ..messages.requests import (
CirclePlusConnectRequest,
NodeInfoRequest,
NodePingRequest,
PlugwiseRequest,
StickInitRequest,
StickNetworkInfoRequest,
)
from ..messages.responses import (
NodeInfoResponse,
NodePingResponse,
PlugwiseResponse,
StickInitResponse,
StickInitShortResponse,
)
from .manager import StickConnectionManager
from .queue import StickQueue
Expand Down Expand Up @@ -69,38 +72,27 @@ def hardware_stick(self) -> str | None:
return self._hw_stick

@property
def mac_stick(self) -> str:
"""MAC address of USB-Stick. Raises StickError when not connected."""
if not self._manager.is_connected or self._mac_stick is None:
raise StickError(
"No mac address available. Connect and initialize USB-Stick first."
)
def mac_stick(self) -> str | None:
"""MAC address of USB-Stick."""
return self._mac_stick

@property
def mac_coordinator(self) -> str:
"""Return MAC address of the Zigbee network coordinator (Circle+).

Raises StickError when not connected.
"""
if not self._manager.is_connected or self._mac_nc is None:
raise StickError(
"No mac address available. Connect and initialize USB-Stick first."
)
def mac_coordinator(self) -> str | None:
"""Return MAC address of the Zigbee network coordinator (Circle+)."""
return self._mac_nc

@property
def network_id(self) -> int:
"""Returns the Zigbee network ID. Raises StickError when not connected."""
if not self._manager.is_connected or self._network_id is None:
raise StickError(
"No network ID available. Connect and initialize USB-Stick first."
)
def network_id(self) -> int | None:
"""Returns the Zigbee network ID."""
return self._network_id

@property
def network_online(self) -> bool:
"""Return the network state."""
"""Return the network state.

The ZigBee network is online when the Stick is connected and a
StickInitResponse indicates that the ZigBee network is online.
"""
if not self._manager.is_connected:
raise StickError(
"Network status not available. Connect and initialize USB-Stick first."
Expand Down Expand Up @@ -159,7 +151,7 @@ async def _handle_stick_event(self, event: StickEvent) -> None:
elif event == StickEvent.DISCONNECTED and self._queue.is_running:
await self._queue.stop()

async def initialize_stick(self) -> None:
async def initialize_stick(self, node_info=True) -> None:
"""Initialize connection to the USB-stick."""
if not self._manager.is_connected:
raise StickError(
Expand All @@ -170,7 +162,9 @@ async def initialize_stick(self) -> None:

try:
request = StickInitRequest(self.send)
init_response: StickInitResponse | None = await request.send()
init_response: (
StickInitResponse | StickInitShortResponse | None
) = await request.send()
except StickError as err:
raise StickError(
"No response from USB-Stick to initialization request."
Expand All @@ -186,26 +180,80 @@ async def initialize_stick(self) -> None:
self._mac_stick = init_response.mac_decoded
self.stick_name = f"Stick {self._mac_stick[-5:]}"
self._network_online = init_response.network_online
if self._network_online:
# Replace first 2 characters by 00 for mac of circle+ node
self._mac_nc = init_response.mac_network_controller
self._network_id = init_response.network_id

# Replace first 2 characters by 00 for mac of circle+ node
self._mac_nc = init_response.mac_network_controller
self._network_id = init_response.network_id
self._is_initialized = True

# Add Stick NodeInfoRequest
if not node_info:
return

# Collect Stick NodeInfo
node_info, _ = await self.get_node_details(self._mac_stick, ping_first=False)
if node_info is not None:
self._fw_stick = node_info.firmware
self._fw_stick = node_info.firmware # type: ignore
hardware, _ = version_to_model(node_info.hardware)
self._hw_stick = hardware

if not self._network_online:
raise StickError("Zigbee network connection to Circle+ is down.")
async def pair_plus_device(self, mac: str) -> bool:
"""Pair Plus-device to Plugwise Stick.

The pairing process should look like:
Repeat sending 0001 (& 000A) until 0002 response is receiced
0001 -> 0003, 000A -> 0011S, 0001 -> 0003, 000A - 0011S, 0001 -> 0002 & 0003, 000A - 0011S
After receiving 0002-response, start sending the first 0004-request:
0004 00001 -> 0061 & 0005, 0001 -> 0002, 000A -> 0011S
After receiving 0061 (NODE_REJOIN_ID), ~30 seconds later, send the 2nd 0004-request:
0004 0101 -> 0061 & 0005, 000A -> 0011, receiving a full StickInitResponse means the pairing was succesful.
Follow up requesting NodeInfo etc (0023, 0060)

0001 - 0002 - 0003: StickNetworkInfoRequest - StickNetworkInfoResponse - NodeSpecificResponse
000A - 0011S/0011: StickInitRequest - StickInitShortResponse/StickInitResponse
0004 - 0005: CirclePlusConnectRequest - CirclePlusConnectResponse, the Plus-device will then send a NodeRejoinResponse (0061).
"""
_LOGGER.debug("Pair Plus-device with mac: %s", mac)
if not validate_mac(mac):
raise NodeError(f"Pairing failed: MAC {mac} invalid")

# Collect network info
try:
request = StickNetworkInfoRequest(self.send)
info_response = await request.send()
except MessageError as exc:
raise NodeError(f"Pairing failed: {exc}") from exc
if info_response is None:
raise NodeError(
"Pairing failed, StickNetworkInfoResponse is None"
) from None

# Init Stick
try:
await self.initialize_stick(node_info=False)
except StickError as exc:
raise NodeError(
f"Pairing failed, failed to initialize Stick: {exc}"
) from exc

try:
request = CirclePlusConnectRequest(self.send, bytes(mac, UTF8))
response = await request.send()
except MessageError as exc:
raise NodeError(f"Pairing failed: {exc}") from exc
if response is None:
raise NodeError(
"Pairing failed, CirclePlusConnectResponse is None"
) from None
if response.allowed.value != 1:
raise NodeError("Pairing failed, not allowed")

return True

async def get_node_details(
self, mac: str, ping_first: bool
) -> tuple[NodeInfoResponse | None, NodePingResponse | None]:
"""Return node discovery type."""
"""Collect NodeInfo data from the Stick."""
ping_response: NodePingResponse | None = None
if ping_first:
# Define ping request with one retry
Expand Down Expand Up @@ -234,7 +282,7 @@ async def send(
return await self._queue.submit(request)
try:
return await self._queue.submit(request)
except (NodeError, StickError):
except NodeError, StickError:
return None

def _reset_states(self) -> None:
Expand Down
10 changes: 5 additions & 5 deletions plugwise_usb/connection/receiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,11 @@ async def _notify_node_response_subscribers(

notify_tasks: list[Coroutine[Any, Any, bool]] = []
for node_subscription in self._node_response_subscribers.values():
if (
node_subscription.mac is not None
and node_subscription.mac != node_response.mac
):
continue
# if (
# node_subscription.mac is not None
# and node_subscription.mac != node_response.mac
# ):
# continue
if (
node_subscription.response_ids is not None
and node_response.identifier not in node_subscription.response_ids
Expand Down
38 changes: 26 additions & 12 deletions plugwise_usb/messages/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
NodeSpecificResponse,
PlugwiseResponse,
StickInitResponse,
StickInitShortResponse,
StickNetworkInfoResponse,
StickResponse,
StickResponseType,
Expand Down Expand Up @@ -363,8 +364,18 @@ class StickNetworkInfoRequest(PlugwiseRequest):
_identifier = b"0001"
_reply_identifier = b"0002"

def __init__(
self,
send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]],
) -> None:
"""Initialize StickInitRequest message object."""
super().__init__(send_fn, None)
self._max_retries = 1

async def send(self) -> StickNetworkInfoResponse | None:
"""Send request."""
if self._send_fn is None:
raise MessageError("Send function missing")
result = await self._send_request()
if isinstance(result, StickNetworkInfoResponse):
return result
Expand Down Expand Up @@ -399,14 +410,17 @@ async def send(self) -> CirclePlusConnectResponse | None:
# This message has an exceptional format and therefore
# need to override the serialize method
def serialize(self) -> bytes:
"""Convert message to serialized list of bytes."""
# This command has
# args: byte
# key, byte
# network info.index, ulong
# network key = 0
args = b"00000000000000000000"
msg: bytes = self._identifier + args
"""Convert message to serialized list of bytes.

Parameters
----------
- special_id: byte - observed sequence with retry: b"0001", B"0101",
- and args: byte.

"""
special_id = b"0001"
args = b"0000000000000000"
msg: bytes = self._identifier + special_id + args
if self._mac is not None:
msg += self._mac
checksum = self.calculate_checksum(msg)
Expand Down Expand Up @@ -523,7 +537,7 @@ class StickInitRequest(PlugwiseRequest):
"""Initialize USB-Stick.

Supported protocols : 1.0, 2.0
Response message : StickInitResponse
Response message : StickInitResponse or StickInitShortResponse
"""

_identifier = b"000A"
Expand All @@ -537,17 +551,17 @@ def __init__(
super().__init__(send_fn, None)
self._max_retries = 1

async def send(self) -> StickInitResponse | None:
async def send(self) -> StickInitResponse | StickInitShortResponse | None:
"""Send request."""
if self._send_fn is None:
raise MessageError("Send function missing")
result = await self._send_request()
if isinstance(result, StickInitResponse):
if isinstance(result, StickInitResponse | StickInitShortResponse):
return result
if result is None:
return None
raise MessageError(
f"Invalid response message. Received {result.__class__.__name__}, expected StickInitResponse"
f"Invalid response message. Received {result.__class__.__name__}, expected StickInitResponse/StickInitShortResponse"
)


Expand Down
Loading
Loading