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
23 changes: 23 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
16 changes: 6 additions & 10 deletions custom_components/xgimi_control_bridge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,7 +73,6 @@
PQ_SERVICE_NAME,
SIGNAL_STATUS_UPDATED,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send

_LOGGER = logging.getLogger(__name__)

Expand All @@ -89,9 +88,7 @@
ATTR_MODE = "mode"
ATTR_VALUE = "value"

_BROADCAST_DATA_RE = re.compile(
r'data=(?P<quote>["\'])(?P<data>.*)(?P=quote)', re.DOTALL
)
_BROADCAST_DATA_RE = re.compile(r'data=(?P<quote>["\'])(?P<data>.*)(?P=quote)', re.DOTALL)

_NATIVE_PQ_PERSTREAM_KEYS = {
"AI_PQ",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions custom_components/xgimi_control_bridge/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,21 +16,21 @@
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,
)


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(
Expand Down
4 changes: 2 additions & 2 deletions custom_components/xgimi_control_bridge/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion custom_components/xgimi_control_bridge/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 2 additions & 2 deletions custom_components/xgimi_control_bridge/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 5 additions & 8 deletions custom_components/xgimi_control_bridge/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -30,7 +30,6 @@
SIGNAL_STATUS_UPDATED,
)


NATIVE_PQ_SELECTS: tuple[dict[str, object], ...] = (
{
"key": "native_pq_gamma",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
6 changes: 3 additions & 3 deletions custom_components/xgimi_control_bridge/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 4 additions & 0 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
homeassistant==2026.8.1
pytest==9.0.3
pytest-homeassistant-custom-component==0.13.355
ruff==0.16.2
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Fixtures for XGIMI Control Bridge tests."""
65 changes: 65 additions & 0 deletions tests/test_ha_compat.py
Original file line number Diff line number Diff line change
@@ -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)
Loading