diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..53f42ee --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements_test.txt + - run: python -m pip install -r requirements_test.txt + - run: ruff check custom_components tests + - run: ruff format --check custom_components tests + - run: pytest diff --git a/.gitignore b/.gitignore index 67b2747..abe1047 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ app/build/ .DS_Store __pycache__/ *.py[cod] +.pytest_cache/ +.ruff_cache/ # Local reverse-engineering inputs extracted from device firmware. # Keep these out of the public repository unless you have explicit rights to redistribute them. diff --git a/README.md b/README.md index 038ee81..7dac21f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # XGIMI Control Bridge +Die Home-Assistant-Integration in Version `0.1.18` ist mit Home Assistant +`2026.8` kompatibel und gegen `2026.8.1` getestet. + Kleine Android-App für XGIMI H20 / Android 14. Sie nimmt ADB-Broadcasts entgegen und ruft über Reflection die private XGIMI-API `GmTvManager` auf. diff --git a/custom_components/xgimi_control_bridge/__init__.py b/custom_components/xgimi_control_bridge/__init__.py index 7cf4990..deb12bd 100644 --- a/custom_components/xgimi_control_bridge/__init__.py +++ b/custom_components/xgimi_control_bridge/__init__.py @@ -7,13 +7,13 @@ import re import shlex +import homeassistant.helpers.config_validation as cv import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_SOURCE, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ACTION_GET_EXT_PQ_SETTINGS, @@ -73,7 +73,6 @@ PQ_SERVICE_NAME, SIGNAL_STATUS_UPDATED, ) -from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) @@ -89,9 +88,7 @@ ATTR_MODE = "mode" ATTR_VALUE = "value" -_BROADCAST_DATA_RE = re.compile( - r'data=(?P["\'])(?P.*)(?P=quote)', re.DOTALL -) +_BROADCAST_DATA_RE = re.compile(r'data=(?P["\'])(?P.*)(?P=quote)', re.DOTALL) _NATIVE_PQ_PERSTREAM_KEYS = { "AI_PQ", @@ -176,6 +173,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _async_register_services(hass: HomeAssistant) -> None: """Register integration services once.""" + async def set_picture_mode(call: ServiceCall) -> None: if ATTR_MODE not in call.data and ATTR_VALUE not in call.data: raise HomeAssistantError("Either mode or value is required") @@ -352,7 +350,7 @@ async def adb_action( def _entity_ids_from_call(call: ServiceCall) -> list[str]: """Return entity ids from service data or target.""" - entity_ids = call.data.get(ATTR_ENTITY_ID) or call.target.get(ATTR_ENTITY_ID) + entity_ids = call.data.get(ATTR_ENTITY_ID) if entity_ids is None: raise HomeAssistantError("A media_player target or entity_id is required") if isinstance(entity_ids, str): @@ -514,9 +512,7 @@ async def async_set_osd_picture_mode( """Set the visible picture mode through OSD navigation.""" current_mode = _osd_picture_mode_from_runtime(hass, entity_id) if current_mode is None: - raise HomeAssistantError( - "Sync the current OSD picture mode before using OSD Picture Mode" - ) + raise HomeAssistantError("Sync the current OSD picture mode before using OSD Picture Mode") steps, step_command = _osd_picture_mode_steps(current_mode, mode) if steps: diff --git a/custom_components/xgimi_control_bridge/button.py b/custom_components/xgimi_control_bridge/button.py index f8423e1..0ae0c3d 100644 --- a/custom_components/xgimi_control_bridge/button.py +++ b/custom_components/xgimi_control_bridge/button.py @@ -5,7 +5,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( async_get_ext_pq_status, @@ -16,13 +16,13 @@ config_entry_media_player_entity_id, ) from .const import ( + ACTION_GET_STATUS, ADB_COMMAND_AUTOFOCUS, ADB_COMMAND_OSD_BACK, ADB_COMMAND_OSD_CONFIRM, ADB_COMMAND_OSD_PICTURE_MODE_NEXT, ADB_COMMAND_OSD_PICTURE_MODE_OPEN, ADB_COMMAND_OSD_PICTURE_MODE_PREVIOUS, - ACTION_GET_STATUS, DOMAIN, ) @@ -30,7 +30,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities.""" async_add_entities( diff --git a/custom_components/xgimi_control_bridge/config_flow.py b/custom_components/xgimi_control_bridge/config_flow.py index 8281690..6302afa 100644 --- a/custom_components/xgimi_control_bridge/config_flow.py +++ b/custom_components/xgimi_control_bridge/config_flow.py @@ -5,11 +5,11 @@ from typing import Any import voluptuous as vol - -from homeassistant import config_entries from homeassistant.const import Platform from homeassistant.helpers import selector +from homeassistant import config_entries + from .const import CONF_MEDIA_PLAYER_ENTITY_ID, DOMAIN diff --git a/custom_components/xgimi_control_bridge/manifest.json b/custom_components/xgimi_control_bridge/manifest.json index ae5517f..2af6267 100644 --- a/custom_components/xgimi_control_bridge/manifest.json +++ b/custom_components/xgimi_control_bridge/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "issue_tracker": "https://github.com/dr-apple/XgimiControlBridge/issues", - "version": "0.1.17" + "version": "0.1.18" } diff --git a/custom_components/xgimi_control_bridge/number.py b/custom_components/xgimi_control_bridge/number.py index 1f5d684..7d5e441 100644 --- a/custom_components/xgimi_control_bridge/number.py +++ b/custom_components/xgimi_control_bridge/number.py @@ -9,7 +9,7 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( async_set_native_pq_value, @@ -71,7 +71,7 @@ class NativePqNumberDescription: async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up native PQ number entities.""" media_player_entity_id = config_entry_media_player_entity_id(entry) diff --git a/custom_components/xgimi_control_bridge/select.py b/custom_components/xgimi_control_bridge/select.py index fba909f..2fdc664 100644 --- a/custom_components/xgimi_control_bridge/select.py +++ b/custom_components/xgimi_control_bridge/select.py @@ -6,12 +6,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( - async_set_osd_picture_mode, - async_set_native_pq_value, async_send_bridge_command, + async_set_native_pq_value, + async_set_osd_picture_mode, config_entry_media_player_entity_id, config_entry_runtime_data, ) @@ -30,7 +30,6 @@ SIGNAL_STATUS_UPDATED, ) - NATIVE_PQ_SELECTS: tuple[dict[str, object], ...] = ( { "key": "native_pq_gamma", @@ -73,7 +72,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities.""" entity_id = config_entry_media_player_entity_id(entry) @@ -279,8 +278,6 @@ def _handle_status_update(self) -> None: """Update the current option from stored native PQ status.""" status = config_entry_runtime_data(self.hass, self._entry).get("status", {}) option = status.get(self._status_key) - if option in self.options: - self._attr_current_option = option - elif isinstance(option, str): + if option in self.options or isinstance(option, str): self._attr_current_option = option self.schedule_update_ha_state() diff --git a/custom_components/xgimi_control_bridge/sensor.py b/custom_components/xgimi_control_bridge/sensor.py index ef96fb2..8d399fb 100644 --- a/custom_components/xgimi_control_bridge/sensor.py +++ b/custom_components/xgimi_control_bridge/sensor.py @@ -6,12 +6,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import config_entry_runtime_data from .const import ( - ATTR_LAST_RESPONSE, ATTR_LAST_ACTION, + ATTR_LAST_RESPONSE, ATTR_MEMC, ATTR_OK, ATTR_OSD_PICTURE_MODE, @@ -47,7 +47,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" async_add_entities( diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1532825 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "SIM", "UP"] diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..517d730 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,4 @@ +homeassistant==2026.8.1 +pytest==9.0.3 +pytest-homeassistant-custom-component==0.13.355 +ruff==0.16.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..98913ee --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Fixtures for XGIMI Control Bridge tests.""" diff --git a/tests/test_ha_compat.py b/tests/test_ha_compat.py new file mode 100644 index 0000000..8da2498 --- /dev/null +++ b/tests/test_ha_compat.py @@ -0,0 +1,65 @@ +"""Home Assistant 2026.8 compatibility tests.""" + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.xgimi_control_bridge import ( + _entity_ids_from_call, + _parse_broadcast_response, + _parse_service_value, +) +from custom_components.xgimi_control_bridge.const import ( + CONF_MEDIA_PLAYER_ENTITY_ID, + DOMAIN, +) + + +def test_service_call_entity_targets(hass: HomeAssistant) -> None: + """Service targets use the entity IDs carried in modern ServiceCall data.""" + call = ServiceCall( + hass, + DOMAIN, + "get_status", + {ATTR_ENTITY_ID: ["media_player.projector"]}, + ) + + assert _entity_ids_from_call(call) == ["media_player.projector"] + + +def test_response_and_scalar_parsing() -> None: + """ADB responses and service values keep their supported scalar types.""" + assert _parse_broadcast_response('{"ok":true,"source":1}') == { + "ok": True, + "source": 1, + } + assert _parse_service_value("42") == 42 + assert _parse_service_value("true") is True + assert _parse_service_value("plain text") == "plain text" + + +async def test_setup_creates_entities_on_one_config_entry_device( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """All bridge controls attach to a device owned by only this config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="XGIMI Control Bridge", + unique_id="media_player.projector", + data={CONF_MEDIA_PLAYER_ENTITY_ID: "media_player.projector"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + assert len(entities) == 49 + devices = dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + assert len(devices) == 1 + assert devices[0].config_entry_id == entry.entry_id + + assert await hass.config_entries.async_unload(entry.entry_id)