Skip to content
Merged
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
5 changes: 0 additions & 5 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,6 @@ ENV NVM_DIR=$HOME/.nvm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh | bash
RUN . "$NVM_DIR/nvm.sh" && nvm install --lts

# Create default python virtual env
ENV VIRTUAL_ENV="$HOME/.local/ha-venv"
RUN uv venv ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:$PATH"

WORKDIR /workspaces/HacsRfPlayer

# Set the default shell to bash instead of sh
Expand Down
3 changes: 1 addition & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
"privileged": true, // required to access real hardware for testing
"postCreateCommand": "scripts/setup",
"containerEnv": {
"PYTHONASYNCIODEBUG": "1",
"UV_PROJECT_ENVIRONMENT": "/home/vscode/.local/ha-venv"
"PYTHONASYNCIODEBUG": "1"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:

- name: "Typing"
run: |
uv run mypy custom_components tests
uv run ty check custom_components tests

- name: "Tests"
run: |
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ repos:

- repo: local
hooks:
- id: mypy
name: mypy
entry: "uv run mypy"
- id: typing
name: typing
entry: "uv run ty check"
language: system
types: [python]
# use require_serial so that script
Expand Down
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"name": "Hass",
"type": "debugpy",
"request": "launch",
"program": "/home/vscode/.local/ha-venv/bin/hass",
"program": ".venv/bin/hass",
"args": ["--config", "${workspaceFolder}/config", "--debug"],
"console": "integratedTerminal",
"justMyCode": false
Expand Down
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@
}
},
"python.testing.pytestArgs": ["tests"],
// Use the project's virtualenv python for the Python extension and debugger
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true,
"python-envs.defaultEnvManager": "ms-python.python:venv"
}
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ RFPlayer is a Home Assistant integration for the GCE RF Player gateway. It provi
### Setup

```bash
source /home/vscode/.local/ha-venv/bin/activate
source .venv/bin/activate
cd /workspaces/HacsRfPlayer
```

Expand Down Expand Up @@ -108,7 +108,7 @@ All platforms share common setup via `async_forward_entry_setups(entry, PLATFORM

- **Home Assistant**: ≥2025.1.0 (from hacs.json)
- **Python**: ≥3.13.2 (from pyproject.toml)
- **Key libs**: pydantic (data validation), jsonpath-ng (device profile queries), pyserial-asyncio-fast (USB communication)
- **Key libs**: pydantic (data validation), jsonpath-ng (device profile queries), serialx (USB communication)
- USB device detection via `serial.tools.list_ports.comports()`

## Common Gotchas
Expand Down
3 changes: 1 addition & 2 deletions custom_components/rfplayer/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Support for RfPlayer binary sensors."""

import logging
from typing import cast

from custom_components.rfplayer.const import COMMAND_GROUP_LIST, COMMAND_OFF_LIST, COMMAND_ON_LIST
from custom_components.rfplayer.device_profiles import AnyRfpPlatformConfig, RfpPlatformConfig, RfpSensorConfig
Expand Down Expand Up @@ -78,7 +77,7 @@ def __init__(
super().__init__(device_id=device, profile_name=platform_config.name, event_data=event_data, verbose=verbose)
self.entity_description = entity_description
assert isinstance(platform_config, RfpSensorConfig)
self._config = cast(RfpSensorConfig, platform_config)
self._config = platform_config
self._event_data = event_data

def _apply_event(self, event_data: RfPlayerEventData) -> bool:
Expand Down
3 changes: 1 addition & 2 deletions custom_components/rfplayer/climate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Support for RfPlayer lights."""

import logging
from typing import cast

from custom_components.rfplayer.const import COMMAND_OFF_LIST, COMMAND_ON_LIST
from custom_components.rfplayer.device_profiles import (
Expand Down Expand Up @@ -89,7 +88,7 @@ def __init__(
super().__init__(device_id=device, profile_name=platform_config.name, event_data=event_data, verbose=verbose)
self.entity_description = entity_description
assert isinstance(platform_config, RfpClimateConfig)
self._config = cast(RfpClimateConfig, platform_config)
self._config = platform_config
self._event_data = event_data
self._attr_preset_modes = list(self._config.preset_modes.values())
self._attr_preset_mode = None
Expand Down
2 changes: 1 addition & 1 deletion custom_components/rfplayer/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os
from typing import Any, cast

from serial.tools import list_ports
from serialx.tools import list_ports
import voluptuous as vol

from custom_components.rfplayer.const import (
Expand Down
9 changes: 6 additions & 3 deletions custom_components/rfplayer/cover.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Support for RfPlayer covers."""

import logging
from typing import Any, cast
from typing import Any

from custom_components.rfplayer.device_profiles import AnyRfpPlatformConfig, RfpCoverConfig, RfpPlatformConfig
from custom_components.rfplayer.entity import RfDeviceEntity, async_setup_platform_entry
Expand Down Expand Up @@ -70,10 +70,13 @@ def __init__(
super().__init__(device_id=device, profile_name=platform_config.name, event_data=event_data, verbose=verbose)
self.entity_description = entity_description
assert isinstance(platform_config, RfpCoverConfig)
self._config = cast(RfpCoverConfig, platform_config)
self._config = platform_config
self._event_data = event_data
if self._config.cmd_stop:
self._attr_supported_features |= CoverEntityFeature.STOP
if self._attr_supported_features is None:
self._attr_supported_features = CoverEntityFeature.STOP
else:
self._attr_supported_features |= CoverEntityFeature.STOP

async def async_added_to_hass(self) -> None:
"""Restore device state."""
Expand Down
6 changes: 5 additions & 1 deletion custom_components/rfplayer/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import copy
from datetime import datetime
import json
import logging
from typing import cast
Expand Down Expand Up @@ -224,7 +225,10 @@ async def _connect_gateway(self):
# Connection to RfPlayer gateway is lost, make entities unavailable
async_dispatcher_send(self.hass, SIGNAL_RFPLAYER_AVAILABILITY, False) # type: ignore[has-type]

reconnect_job = HassJob(self._reconnect_gateway, "RfPlayer reconnect", cancel_on_shutdown=True)
async def connect_target(when: datetime) -> None:
await self._connect_gateway()

reconnect_job = HassJob(target=connect_target, name="RfPlayer reconnect", cancel_on_shutdown=True)
async_call_later(self.hass, reconnect_interval, reconnect_job)

raise ConfigEntryNotReady(f"Failed to connect gateway: {exc!s}") from exc
Expand Down
4 changes: 2 additions & 2 deletions custom_components/rfplayer/light.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Support for RfPlayer lights."""

import logging
from typing import Any, cast
from typing import Any

from custom_components.rfplayer.const import COMMAND_GROUP_LIST, COMMAND_OFF_LIST, COMMAND_ON_LIST
from custom_components.rfplayer.device_profiles import AnyRfpPlatformConfig, RfpLightConfig, RfpPlatformConfig
Expand Down Expand Up @@ -72,7 +72,7 @@ def __init__(
super().__init__(device_id=device, profile_name=platform_config.name, event_data=event_data, verbose=verbose)
self.entity_description = entity_description
assert isinstance(platform_config, RfpLightConfig)
self._config = cast(RfpLightConfig, platform_config)
self._config = platform_config
self._event_data = event_data

async def async_added_to_hass(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion custom_components/rfplayer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/gce-electronics/HA_RFPlayer/issues",
"loggers": ["custom_components.rfplayer"],
"requirements": ["pydantic", "pyserial-asyncio-fast", "jsonpath-ng"],
"requirements": ["pydantic", "serialx", "jsonpath-ng"],
"version": "0.0.0"
}
9 changes: 4 additions & 5 deletions custom_components/rfplayer/rfplayerlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
import logging
from typing import cast

from serial import SerialException
from serial_asyncio_fast import create_serial_connection
from serialx import SerialException, create_serial_connection

from .device import RfDeviceEvent, RfDeviceEventAdapter
from .protocol import RfPlayerEventData, RfplayerProtocol
Expand Down Expand Up @@ -176,7 +175,7 @@ async def _make_serial_protocol(self, protocol_factory: Callable[[], RfplayerPro
try:
(_, protocol) = await create_serial_connection(self.loop, protocol_factory, self.port, RFPLAYER_BAUD_RATE)
return cast(RfplayerProtocol, protocol)
except (SerialException, OSError) as err:
except (SerialException, FileNotFoundError, OSError) as err:
raise RfPlayerException("Failed to create serial connection") from err

async def _make_tcp_protocol(self, protocol_factory: Callable[[], RfplayerProtocol]) -> RfplayerProtocol:
Expand All @@ -191,8 +190,8 @@ async def _make_tcp_protocol(self, protocol_factory: Callable[[], RfplayerProtoc
raise RfPlayerException("Invalid TCP port, port must be an integer") from err

try:
protocol = await self.loop.create_connection(protocol_factory, host, port)
return cast(RfplayerProtocol, protocol)
(_, protocol) = await self.loop.create_connection(protocol_factory, host, port)
return cast(RfplayerProtocol, protocol) # ty: ignore[redundant-cast]
except OSError as err:
raise RfPlayerException("Failed to create TCP connection") from err

Expand Down
6 changes: 3 additions & 3 deletions custom_components/rfplayer/rfplayerlib/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from collections.abc import Callable
import json
import logging
from typing import cast
from typing import Any, cast

_LOGGER = logging.getLogger(__name__)

Expand All @@ -13,8 +13,8 @@
MINIMUM_SCRIPT = ["FORMAT JSON"]


class RfPlayerEventData(dict):
"""RfPlayer JSON packet event data."""
RfPlayerEventData = dict[str, Any]
"""RfPlayer JSON packet event data."""


def _valid_packet(line: str):
Expand Down
3 changes: 1 addition & 2 deletions custom_components/rfplayer/sensor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Support for RfPlayer sensors."""

import logging
from typing import cast

from custom_components.rfplayer.device_profiles import AnyRfpPlatformConfig, RfpPlatformConfig, RfpSensorConfig
from custom_components.rfplayer.entity import RfDeviceEntity, async_setup_platform_entry
Expand Down Expand Up @@ -78,7 +77,7 @@ def __init__(
super().__init__(device_id=device, profile_name=platform_config.name, event_data=event_data, verbose=verbose)
self.entity_description = entity_description
assert isinstance(platform_config, RfpSensorConfig)
self._config = cast(RfpSensorConfig, platform_config)
self._config = platform_config
self._event_data = event_data

def _apply_event(self, event_data: RfPlayerEventData) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions custom_components/rfplayer/switch.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Support for RfPlayer lights."""

import logging
from typing import Any, cast
from typing import Any

from custom_components.rfplayer.const import COMMAND_GROUP_LIST, COMMAND_OFF_LIST, COMMAND_ON_LIST
from custom_components.rfplayer.device_profiles import AnyRfpPlatformConfig, RfpPlatformConfig, RfpSwitchConfig
Expand Down Expand Up @@ -69,7 +69,7 @@ def __init__(
super().__init__(device_id=device, profile_name=platform_config.name, event_data=event_data, verbose=verbose)
self.entity_description = entity_description
assert isinstance(platform_config, RfpSwitchConfig)
self._config = cast(RfpSwitchConfig, platform_config)
self._config = platform_config

async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ dependencies = [
"homeassistant~=2026.6",
"pydantic~=2.12",
"jsonpath-ng~=1.7",
"pyserial-asyncio-fast<1.0",
"serialx>=1.8.2",
]
classifiers = [
# How mature is this project? Common values are
Expand Down Expand Up @@ -36,8 +36,8 @@ dev = [
"pytest-html~=4.1",
"colorlog~=6.8",
"ruff<1.0",
"mypy~=1.19",
"types-PyYAML~=6.0"
"types-PyYAML~=6.0",
"ty>=0.0.56",
]

[build-system]
Expand Down
3 changes: 2 additions & 1 deletion tests/rfplayer/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ def serial_connection_mock(mocker: MockerFixture, test_protocol: RfplayerProtoco
def tcp_connection_mock(mocker: MockerFixture, test_client: RfPlayerClient, test_protocol: RfplayerProtocol) -> Mock:
"""Patch create_tcp_connection to return mock protocol."""

return mocker.patch.object(test_client.loop, "create_connection", return_value=test_protocol)
test_transport = Mock(spec=asyncio.WriteTransport)
return mocker.patch.object(test_client.loop, "create_connection", return_value=(test_transport, test_protocol))


@pytest.fixture
Expand Down
8 changes: 4 additions & 4 deletions tests/rfplayer/rfplayerlib/test_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Unit tests for rfplayer client."""

from typing import cast
from unittest.mock import Mock
from unittest.mock import ANY, Mock

import pytest
from pytest_mock import MockerFixture
Expand All @@ -19,15 +19,15 @@ async def test_connect_serial(
serial_connection_mock: Mock,
):
# GIVEN
# test_client
test_client.port = "/dev/ttyUSB0"

# WHEN
await test_client.connect()

# THEN
assert test_client.protocol == test_protocol
assert test_client.connected
serial_connection_mock.assert_called_once()
serial_connection_mock.assert_called_with(test_client.loop, ANY, "/dev/ttyUSB0", 115200)


@pytest.mark.asyncio
Expand All @@ -45,7 +45,7 @@ async def test_connect_tcp(
# THEN
assert test_client.protocol == test_protocol
assert test_client.connected
tcp_connection_mock.assert_called_once()
tcp_connection_mock.assert_called_with(ANY, "localhost", 1234)


@pytest.mark.asyncio
Expand Down
3 changes: 2 additions & 1 deletion tests/rfplayer/test_bus_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from custom_components.rfplayer.bus_publisher import BusPublisher, DeviceHandler
from custom_components.rfplayer.rfplayerlib.device import RfDeviceEvent, RfDeviceId
from homeassistant.core import EventBus, HomeAssistant


Expand All @@ -20,7 +21,7 @@ async def test_bus_publisher():
publisher.register_handler(non_matching_handler)

# Create a mock event
event = {"key": "value"}
event = RfDeviceEvent(device=RfDeviceId("protocol", "1"), data={"foo": "bar"})

# Set up the handlers
non_matching_handler.match.return_value = False
Expand Down
2 changes: 1 addition & 1 deletion tests/rfplayer/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async def start_options_flow(hass: HomeAssistant, entry: MockConfigEntry) -> Con


@pytest.mark.asyncio
@patch("serial.tools.list_ports.comports", return_value=[com_port()])
@patch("serialx.tools.list_ports.comports", return_value=[com_port()])
async def test_setup_serial(serial_connection_mock: Mock, hass: HomeAssistant) -> None:
"""Test we can setup serial."""
port = com_port()
Expand Down
2 changes: 1 addition & 1 deletion tests/rfplayer/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pytest
from pytest_homeassistant_custom_component.typing import WebSocketGenerator
from pytest_mock import MockerFixture
from serial import SerialException
from serialx import SerialException

from custom_components.rfplayer.const import DOMAIN, RFPLAYER_CLIENT, SIGNAL_RFPLAYER_EVENT
from custom_components.rfplayer.rfplayerlib import RfPlayerClient
Expand Down
Loading