Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
80e2e57
Add plus_pair_request function
bouwew Feb 2, 2026
0d76cf0
Document pairing process
bouwew Feb 7, 2026
91a070e
Add 0001-0002 req-resp-pair
bouwew Feb 7, 2026
7cf2483
Add init stick
bouwew Feb 7, 2026
b50e2ad
Improve docstring
bouwew Feb 7, 2026
3f439e5
Improve pair_plus_device()
bouwew Feb 7, 2026
ed5480e
Add todo for maybe needed functionality
bouwew Feb 7, 2026
c9fe588
Fix typos, return type
bouwew Feb 7, 2026
29ebc4a
Correct imports, improve docstring
bouwew Feb 7, 2026
077e9c2
Ruff fixes
bouwew Feb 7, 2026
093db61
Make sure the Stick is ready to pair, as suggested
bouwew Feb 8, 2026
a8223cf
Fix spelling
bouwew Feb 8, 2026
a0a7e32
Remove quotes, move
bouwew Feb 8, 2026
a637c9d
Set output as bool and use
bouwew Feb 8, 2026
75a63f1
Add missing await
bouwew Feb 8, 2026
bc0a384
Add 0003 response to docstring
bouwew Feb 8, 2026
9e85edd
Start adding pairing test
bouwew Feb 8, 2026
1fe39ba
Add missing connected()
bouwew Feb 8, 2026
18d8db4
Add pairing-test
bouwew Feb 8, 2026
6a19332
Link to stick_pair_data
bouwew Feb 8, 2026
bed2a00
Fix stick_pair_data
bouwew Feb 8, 2026
0bbe076
Set network to offline
bouwew Feb 8, 2026
ff377a5
Try
bouwew Feb 8, 2026
0dc072a
Try 2
bouwew Feb 8, 2026
2b37197
Try 3
bouwew Feb 8, 2026
d5ca147
Add StickInitShortResponse
bouwew Feb 8, 2026
a698db0
Remove commented-out in response
bouwew Feb 8, 2026
3be0172
Update length
bouwew Feb 8, 2026
a014b79
Adapt StickInitRequest send()
bouwew Feb 8, 2026
531bea2
Update length StickInitResponse
bouwew Feb 8, 2026
191d138
Clean up
bouwew Feb 8, 2026
f876bbe
Full test-output - test_pairing
bouwew Feb 8, 2026
d22bf38
Allow init to fail
bouwew Feb 8, 2026
305bebc
Add sleep
bouwew Feb 8, 2026
8d970af
Call pair_plus_request()
bouwew Feb 8, 2026
d524948
Connected and initialized is not required
bouwew Feb 8, 2026
9d4b8e5
fixup: pair-plus Python code fixed using Ruff
Feb 8, 2026
0a95d82
There can only be one response
bouwew Feb 8, 2026
347f7b9
Use inheritance for StickInitResponse
bouwew Feb 8, 2026
2bcc2cd
Move pair_plus_device() to connection
bouwew Feb 8, 2026
4415e63
Correct plus-mac
bouwew Feb 8, 2026
b4c446f
Add stick-mac to 0002 response
bouwew Feb 8, 2026
78eb303
Move RESPONSE_MESSAGES
bouwew Feb 9, 2026
d3528d6
Don't test network down first
bouwew Feb 9, 2026
f16b4b2
Add missing import
bouwew Feb 9, 2026
3e6f9ed
Connect first
bouwew Feb 9, 2026
907c63b
Try
bouwew Feb 9, 2026
c32bdf4
fixup: pair-plus Python code fixed using Ruff
Feb 9, 2026
4830494
Try 3
bouwew Feb 10, 2026
0d774a3
Try 4
bouwew Feb 10, 2026
5c25fe1
Try not allowed
bouwew Feb 10, 2026
5269be2
Extra bit
bouwew Feb 10, 2026
5afef3a
CirclePlusConnectReqyest: shorter args
bouwew Feb 10, 2026
4062dd6
fixup: pair-plus Python code fixed using Ruff
Feb 10, 2026
01b0a63
Add stick-mac to 0005-response, remove extra bit
bouwew Feb 10, 2026
fe7e64a
Ruffed
bouwew Feb 10, 2026
1d94842
Shorten args, must be length=16
bouwew Feb 10, 2026
d10e7b4
Add missing CRC, can be corrected later
bouwew Feb 10, 2026
d91928b
Try
bouwew Feb 12, 2026
1da5ab9
Try 2
bouwew Feb 13, 2026
31c8e62
Fixes
bouwew Feb 13, 2026
fbb9495
Correct CRC
bouwew Feb 13, 2026
1e8ef50
Try allowed
bouwew Feb 13, 2026
78f5ec7
Change to Circle+ mac in 0005-response
bouwew Feb 13, 2026
527b73e
Ruff-cleanup
bouwew Feb 13, 2026
d0befab
Bump to v0.48.0a1 test-version
bouwew Feb 13, 2026
6e958d9
Update CHANGELOG
bouwew Feb 13, 2026
987d30c
Implement StickInitShortResponse-handling in class StickController
bouwew Feb 16, 2026
35c166b
Back to full test-output
bouwew Feb 16, 2026
59d9e42
Improve
bouwew Feb 16, 2026
f41bb81
Correct CHANGELOG after rebase
bouwew Feb 16, 2026
cca233e
Bump to v0.48.0a2 test-version
bouwew Feb 16, 2026
f8e6635
Run all test-files in case of failure
bouwew Feb 16, 2026
64eb055
Revert back to python 3.13
bouwew Feb 17, 2026
aabcc30
Refix except brackets
bouwew Feb 17, 2026
f72a4ac
Bump to a3
bouwew Feb 17, 2026
70d0604
Try-except stick-initialize
bouwew Feb 17, 2026
2e67414
Ruff fix
bouwew Feb 17, 2026
03b3d9b
Add log-warning
bouwew Feb 17, 2026
a691dd6
Bump to a4
bouwew Feb 17, 2026
2a6abcb
Remove is_connected requirement for mac_stick
bouwew Feb 18, 2026
0864005
Bump to a5
bouwew Feb 18, 2026
b34a20b
Disable now invalid test
bouwew Feb 18, 2026
a4daff0
More debug-logging
bouwew Feb 19, 2026
afd2d11
Bump to a6
bouwew Feb 19, 2026
e80c783
Move debug message
bouwew Feb 19, 2026
65ab3c2
Bump to a7
bouwew Feb 19, 2026
e270893
Revert adding try-except
bouwew Feb 19, 2026
58ee1c2
Replace debuggers by distinct message
bouwew Feb 20, 2026
ca9df05
Bump to a8
bouwew Feb 20, 2026
3f4a8e0
Remove unneeded StickError raises
bouwew Feb 20, 2026
b543515
Update relevant test-asserts
bouwew Feb 20, 2026
3c98822
Ruff fixes
bouwew Feb 20, 2026
c616544
Correct -update test_stick_network_down()
bouwew Feb 20, 2026
45e1d2d
Fix pylint warnings
bouwew Feb 20, 2026
f1276ad
More adapting to StickInitShortResponse
bouwew Feb 20, 2026
c62c703
Bump to a9
bouwew Feb 20, 2026
4f92993
Update Stick properties mac_stick, mac_coordinator and name
bouwew Feb 20, 2026
37a0930
Update docstring
bouwew Feb 20, 2026
593d062
Update network_online docstring
bouwew Feb 20, 2026
7b2391e
Bump to a10
bouwew Feb 20, 2026
cacef48
fixup: pair-plus Python code fixed using Ruff
Feb 20, 2026
02b3555
Re-add except brackets
bouwew Feb 20, 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/merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ name: Latest release

env:
CACHE_VERSION: 22
DEFAULT_PYTHON: "3.14"
DEFAULT_PYTHON: "3.13"

# Only run on merges
on:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ name: Latest commit

env:
CACHE_VERSION: 1
DEFAULT_PYTHON: "3.14"
DEFAULT_PYTHON: "3.13"
PRE_COMMIT_HOME: ~/.cache/pre-commit
VENV: venv

Expand Down Expand Up @@ -133,7 +133,7 @@ jobs:
- commitcheck
strategy:
matrix:
python-version: ["3.14"]
python-version: ["3.13"]
steps:
- name: Check out committed code
uses: actions/checkout@v6
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Ongoing updates

- 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
- PR [405](https://github.com/plugwise/python-plugwise-usb/pull/405): Try adding plus-device pairing (untested!!)

## 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
111 changes: 78 additions & 33 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 @@ -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,77 @@ 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
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.

According to https://roheve.wordpress.com/author/roheve/page/2/
The pairing process should look like:
0001 - 0002 (- 0003): StickNetworkInfoRequest - StickNetworkInfoResponse - (PlugwiseQueryCirclePlusEndResponse - @SevenW),
000A - 0011: StickInitRequest - StickInitResponse,
0004 - 0005: CirclePlusConnectRequest - CirclePlusConnectResponse,
the Plus-device will then send a NodeRejoinResponse (0061).

Todo(?): Does this need repeating until pairing is successful?
"""
_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, None)
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
_LOGGER.debug("HOI NetworkInfoRequest done")

# Init Stick
try:
await self.initialize_stick()
except StickError as exc:
raise NodeError(
f"Pairing failed, failed to initialize Stick: {exc}"
) from exc
_LOGGER.debug("HOI Init done")

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")

_LOGGER.debug("HOI PlusConnectRequest done")

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 +279,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
9 changes: 5 additions & 4 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 @@ -514,7 +515,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 @@ -528,17 +529,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