Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 52 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,77 @@
name: CI

on:
# Run action when pushed to master, or for commits in a pull request.
push:
branches:
- master
branches: [main, master]
pull_request:
branches:
- master
branches: [main, master]

defaults:
run:
shell: bash

jobs:
checks:
name: Code checks
runs-on: ${{ matrix.os }}
build-and-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
python-version: [ "3.10", "3.11", "3.12", "3.13" ]
steps:
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
- name: Check out code
uses: actions/checkout@v4

- name: Install poetry
run: pipx install poetry
- name: Show GitHub context variables
run: |
echo "GITHUB_SHA: $GITHUB_SHA"
echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY"

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
# ensure activation of poetry cache after poetry installation

- name: Install dependencies
run: poetry install --no-interaction
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry

- name: Run checks
run: make check
- name: Configure Poetry
run: |
poetry config virtualenvs.create true
poetry config virtualenvs.in-project true

- name: Cache Poetry dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-poetry-

- name: Install dependencies (with dev)
run: poetry install --with dev --no-interaction

- name: Run code quality checks
run: |
echo "Running pylint..."
make check-pylint
echo "Running black format check..."
make check-black

- name: Run tests with coverage (if tests exist)
run: |
if [ -d "tests" ] && find tests -name "test_*.py" | grep -q .; then
echo "Running tests..."
make test
else
echo "No test files found, skipping tests"
fi

- name: Upload coverage to Codecov (optional)
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: false
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
check: check-pylint check-black

check-pylint:
@poetry run pylint aiocomfoconnect/*.py
@poetry run pylint --load-plugins=pylint_protobuf aiocomfoconnect/*.py

check-black:
@poetry run black --check aiocomfoconnect/*.py
Expand All @@ -11,7 +11,7 @@ codefix:
@poetry run black aiocomfoconnect/*.py

test:
@poetry run pytest
@poetry run pytest --cov=aiocomfoconnect --cov-report=term --cov-report=xml

build:
docker build -t aiocomfoconnect .
Expand Down
58 changes: 47 additions & 11 deletions aiocomfoconnect/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import logging
from typing import Any, List, Union

import netifaces

from .bridge import Bridge
from .protobuf import zehnder_pb2

Expand All @@ -15,7 +17,7 @@
class BridgeDiscoveryProtocol(asyncio.DatagramProtocol):
"""UDP Protocol for the ComfoConnect LAN C bridge discovery."""

def __init__(self, target: str = None, timeout: int = 5):
def __init__(self, target: str | None = None, timeout: int = 5):
loop = asyncio.get_running_loop()

self._bridges: List[Bridge] = []
Expand All @@ -33,8 +35,17 @@ def connection_made(self, transport: asyncio.transports.DatagramTransport):
_LOGGER.debug("Sending discovery request to %s:%d", self._target, Bridge.PORT)
self.transport.sendto(b"\x0a\x00", (self._target, Bridge.PORT))
else:
_LOGGER.debug("Sending discovery request to broadcast:%d", Bridge.PORT)
self.transport.sendto(b"\x0a\x00", ("<broadcast>", Bridge.PORT))
# Determine broadcast address programmatically
try:
gws = netifaces.gateways()
default_iface = gws["default"][netifaces.AF_INET][1]
addrs = netifaces.ifaddresses(default_iface)
broadcast_addr = addrs[netifaces.AF_INET][0].get("broadcast", "255.255.255.255")
except (KeyError, ValueError, OSError) as e:
_LOGGER.warning("Could not determine broadcast address, using 255.255.255.255: %s", e)
broadcast_addr = "255.255.255.255"
_LOGGER.debug("Sending discovery request to broadcast:%d (%s)", Bridge.PORT, broadcast_addr)
self.transport.sendto(b"\x0a\x00", (broadcast_addr, Bridge.PORT))

def datagram_received(self, data: Union[bytes, str], addr: tuple[str | Any, int]):
"""Called when some datagram is received."""
Expand All @@ -43,12 +54,15 @@ def datagram_received(self, data: Union[bytes, str], addr: tuple[str | Any, int]
return

_LOGGER.debug("Data received from %s: %s", addr, data)

# Decode the response
parser = zehnder_pb2.DiscoveryOperation() # pylint: disable=no-member
parser.ParseFromString(data)

self._bridges.append(Bridge(host=parser.searchGatewayResponse.ipaddress, uuid=parser.searchGatewayResponse.uuid.hex()))
try:
# Decode the response
parser = zehnder_pb2.DiscoveryOperation() # pylint: disable=no-member
parser.ParseFromString(data)

self._bridges.append(Bridge(host=parser.searchGatewayResponse.ipaddress, uuid=parser.searchGatewayResponse.uuid.hex()))
except (ValueError, AttributeError, TypeError) as exc:
_LOGGER.error("Failed to parse discovery response from %s: %s", addr, exc)
return

# When we have passed a target, we only want to listen for that one
if self._target:
Expand All @@ -66,8 +80,30 @@ def get_bridges(self):
return self._future


async def discover_bridges(host: str = None, timeout: int = 1, loop=None) -> List[Bridge]:
"""Discover a bridge by IP."""
async def discover_bridges(host: str | None = None, timeout: int = 1, loop=None) -> List[Bridge]:
"""
Discover ComfoConnect bridges on the local network or at a specified host.

This asynchronous function sends a UDP broadcast (or unicast if a host is specified)
to discover available ComfoConnect bridges. It returns a list of discovered Bridge
instances.

Args:
host (str | None): The IP address of a specific bridge to discover. If None,
a broadcast is sent to discover all available bridges. Defaults to None.
timeout (int): The time in seconds to wait for responses. Defaults to 1.
loop (asyncio.AbstractEventLoop, optional): The event loop to use. If None,
the default event loop is used.

Returns:
List[Bridge]: A list of discovered Bridge objects.

Raises:
Any exceptions raised by the underlying asyncio transport or protocol.

Example:
bridges = await discover_bridges(timeout=2)
"""

if loop is None:
loop = asyncio.get_event_loop()
Expand Down
Loading