The user's remote displayed but offered no app picker, and the protocol transport was never actually switched on. Both halves are fixed here. REMOTE PARITY. The entity declares RemoteEntityFeature.ACTIVITY — exactly what core's androidtv_remote declares — and publishes activity_list, which is what makes the frontend render an activity picker at all. The list is the app directory (the user's apps map, else the curated table as a floor) in map order, then the apps ADB discovery found and something can name, in the one name chain (user map > device label > curated > package id). Nothing nameless is listed as a bare package id, and current_activity is None until something is known: no activity is invented. remote.turn_on honours activity= by waking the device when it reports itself off and then launching through the existing launch path; a listed name resolves case-insensitively, a package id or a detail link passes through, and anything else raises unknown_activity naming the value instead of silently doing nothing. An empty/absent activity keeps the previous behaviour exactly, async_turn_off is untouched, and a configured command script still owns the sequence. WHY THE TRANSPORT WAS DEAD, MEASURED. androidtvremote2's keep_reconnecting only starts _async_reconnect, whose whole body is `while self._remote_message_protocol:` — false on the first check with nothing connected, so the task returned immediately: no connect attempt, no retry, and the auth callback was never reachable. HA core connects FIRST and only then keeps it alive. Our _start_protocol called keep_reconnecting after generating a certificate and never called async_connect anywhere, so every send_key_command/send_launch_app_command raised ConnectionClosed internally, the helpers swallowed it into `return False` plus a debug line, and no push (is_available/is_on/current_app/volume_info) ever arrived. The fix is a background connect loop: async_connect under a bounded timeout, network failures retried with the ADB watchdog's jitter and backoff, InvalidAuth routed into the EXISTING reauth callback (entry.async_start_reauth), and keep_reconnecting only after a session is up. Setup never blocks on a transport. A send that cannot run is no longer silent: it is counted, its error recorded verbatim, and logged at WARNING with the transport state, and the remote publishes protocol_state / protocol_connected / protocol_ever_authenticated / protocol_last_error / protocol_failures / protocol_certificate plus sticky per-push *_seen flags — so "sent" and "never switched on" can never look alike again. CASE (b) IS FIXED, NOT DOCUMENTED: switching the transport on in the options flow for an entry that never paired now routes into the real async_step_protocol_pair, and "continue without pairing" persists the transport OFF. One marker carries the pairing (CONF_PROTOCOL_PAIRED), written by the setup pairing step, the reauth finisher and the reconfigure finisher, so no entry can sit enabled-but-unpaired. The certificate from the first pairing is reused by design, so a paired entry is asked for no new PIN — which is also why the marker, not certificate material, is the source of truth. ADB STAYS THE PRIMARY STATE SOURCE. Arming the protocol introduces pushes that were never live before, so the reading now names its tier: manager.foreground_source drives the media player's app_source and the remote's activity_source, and the new tests prove a full protocol push set leaves the media state, the now-playing reading and every ADB store field untouched, while an ADB-only entry still serves media player, sensors, apps, scripts and receiver with only the protocol surface degraded. Copy: unknown_activity, activity_launch_failed and protocol_unavailable in BOTH strings.json and translations/en.json, pinned by a test; the README documents the picker, activity resolution, how to read the transport-state attributes, the script bypass (a script-routed press is never evidence of pairing), the connect-then-keep-alive lifecycle and cert reuse versus the other integration's own pairing. Evidence: 6 new real-HA FlowManager tests in tests/test_config_flow_real_ha.py (unpaired -> pairs, PIN -> marker, skip -> off, paired -> not asked again, and the setup flow's marker on success and on skip), a real-HA entity contract test in tests/test_ha_api_drift.py (exact supported_features equality with core, activity_list/current_activity reaching the state machine, live _attr_ updates) and new suites in tests/test_protocol_transport.py (connect-first, keep_reconnecting alone is a no-op, retry, auth rejection, honest send failures, ADB-primary precedence) and tests/test_command_routing.py (the activity contract, turn_on(activity=), script ownership, the transport attribute states). No device and no browser exist here: device behaviour is inferred from the library and core source, and every UI-facing claim is proven at the entity/registry/metadata level under real Home Assistant. Verify: suites 719/4 and 720/3; real-HA glob (tests/test_adb_command_real_ha.py + tests/test_config_flow_real_ha.py) 56 passed, 0 skipped; ruff clean; drift exit 0 (25 modules, 76 imports, 206 relative imports, 1322 statement blocks, 57 call arguments) with the floors raised 40->70, 100->195, 500->1300 and 52->55.
904 lines
34 KiB
Python
904 lines
34 KiB
Python
"""Home-Assistant object stubs sized to unit-test the entity modules (t10).
|
|
|
|
Import-only stubs: MediaPlayerEntity/RemoteEntity bases, the feature/state
|
|
enum values the integration actually uses, and the event helpers. Tests load
|
|
their entity module through the synthetic ``nsh`` package AFTER calling
|
|
:func:`install` (and the per-file fixture pops everything at teardown).
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import enum
|
|
import sys
|
|
import types
|
|
from collections.abc import Mapping
|
|
|
|
import voluptuous_mirror
|
|
|
|
ENTITIES_STUB_NAMES = (
|
|
"voluptuous",
|
|
"homeassistant",
|
|
"homeassistant.config_entries",
|
|
"homeassistant.exceptions",
|
|
"homeassistant.core",
|
|
"homeassistant.const",
|
|
"homeassistant.components",
|
|
"homeassistant.components.media_player",
|
|
"homeassistant.components.remote",
|
|
"homeassistant.components.button",
|
|
"homeassistant.components.image",
|
|
"homeassistant.helpers",
|
|
"homeassistant.helpers.service",
|
|
"homeassistant.helpers.config_validation",
|
|
"homeassistant.helpers.entity_registry",
|
|
"homeassistant.helpers.device_registry",
|
|
"homeassistant.helpers.selector",
|
|
"homeassistant.helpers.event",
|
|
"homeassistant.helpers.redact",
|
|
"homeassistant.helpers.entity",
|
|
"homeassistant.helpers.entity_platform",
|
|
"homeassistant.util",
|
|
"homeassistant.util.dt",
|
|
"nsh.helpers",
|
|
"nsh.power",
|
|
"nsh.jellyfin",
|
|
"nsh.media_player",
|
|
"nsh.diagnostics",
|
|
"nsh.remote",
|
|
"nsh.image",
|
|
)
|
|
#: t14: every runtime service description registered through the stub face below,
|
|
#: as ``(domain, service, schema)``. A test clears it to see only its own call.
|
|
SERVICE_SCHEMA_CALLS: list = []
|
|
|
|
class EntitySelectorStub:
|
|
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
|
|
|
|
Real HA answers a cleared entity field with "Schema validation failed at …"
|
|
(t46 F1), so a stub that accepted anything would hide exactly the trap the flow
|
|
has to tolerate; tests/test_config_flow_ux.py validates a schema with both
|
|
values to prove it.
|
|
"""
|
|
|
|
def __init__(self, config=None):
|
|
self.config = config
|
|
|
|
def __call__(self, data):
|
|
if not isinstance(data, str) or not data.strip():
|
|
raise ValueError("Entity is not a valid entity id")
|
|
return data
|
|
|
|
|
|
def install_selector_faces(selector_module) -> None:
|
|
"""Populate a ``homeassistant.helpers.selector`` stub module.
|
|
|
|
One definition for every installer (entity_stublib and the four per-file
|
|
installers in the flow tests), so the stubs cannot drift apart.
|
|
"""
|
|
selector_module.EntitySelector = EntitySelectorStub
|
|
|
|
class _Config(types.SimpleNamespace):
|
|
"""Selector configs keep their kwargs (real HA stores them as fields).
|
|
|
|
The stub needs them inspectable: which domains a selector offers is a
|
|
user-visible contract (t56 — the Receiver field accepts a switch), and a
|
|
lambda throwing the value away cannot pin it.
|
|
"""
|
|
|
|
selector_module.EntitySelectorConfig = _Config
|
|
selector_module.SelectSelector = lambda *args, **kwargs: None
|
|
selector_module.SelectSelectorConfig = _Config
|
|
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
|
# t64: the apps table is a MULTILINE text field, and the row editor picks an
|
|
# app out of a select whose options are label/value pairs. Both are real HA
|
|
# shapes; ``SelectOptionDict`` is a TypedDict there (a plain dict at runtime).
|
|
selector_module.SelectOptionDict = dict
|
|
selector_module.TextSelector = TextSelectorStub
|
|
selector_module.TextSelectorConfig = _Config
|
|
|
|
|
|
class TextSelectorStub:
|
|
"""Stand-in for HA's TextSelector: accepts a string, rejects anything else.
|
|
|
|
Real HA validates with ``probatio.Schema(str)`` (so ``None`` and numbers are
|
|
Invalid) and carries ``multiline`` as a CONFIG flag — the flag is what makes
|
|
the browser render a textarea, which is the t64 fix, so it is inspectable
|
|
here the same way the entity selectors' domains are.
|
|
"""
|
|
|
|
def __init__(self, config=None):
|
|
self.config = config
|
|
|
|
def __call__(self, data):
|
|
if not isinstance(data, str):
|
|
raise ValueError("Value is not a string")
|
|
return data
|
|
|
|
|
|
class MediaPlayerState(enum.StrEnum):
|
|
OFF = "off"
|
|
ON = "on"
|
|
IDLE = "idle"
|
|
PLAYING = "playing"
|
|
PAUSED = "paused"
|
|
STANDBY = "standby"
|
|
BUFFERING = "buffering"
|
|
|
|
|
|
class MediaPlayerEntityFeature(enum.IntFlag):
|
|
PLAY = 1
|
|
PAUSE = 2
|
|
STOP = 4
|
|
VOLUME_MUTE = 8
|
|
VOLUME_STEP = 16
|
|
VOLUME_SET = 32
|
|
BROWSE_MEDIA = 4096
|
|
TURN_ON = 64
|
|
TURN_OFF = 128
|
|
PLAY_MEDIA = 256
|
|
NEXT_TRACK = 512
|
|
PREVIOUS_TRACK = 1024
|
|
SELECT_SOURCE = 2048
|
|
|
|
|
|
class MediaClass:
|
|
"""homeassistant.components.media_player.MediaClass (values we use)."""
|
|
|
|
APP = "app"
|
|
DIRECTORY = "directory"
|
|
|
|
|
|
class MediaType:
|
|
"""homeassistant.components.media_player.MediaType (values we use)."""
|
|
|
|
APP = "app"
|
|
APPS = "apps"
|
|
|
|
|
|
class BrowseMedia:
|
|
"""The REAL media_player.BrowseMedia shape (t37 D1).
|
|
|
|
Keyword-only with the six arguments real HA requires, the optional ones it
|
|
accepts, a faithful ``as_dict`` and — crucially — NO ``domain`` parameter:
|
|
``BrowseMediaSource`` lives in
|
|
``homeassistant.components.media_source.models`` (its contract is the
|
|
``media_source://<domain>`` id) and is NOT importable from
|
|
``homeassistant.components.media_player`` at all. The old import and the
|
|
old ``domain=`` kwarg therefore fail IN-SUITE instead of hiding behind a
|
|
permissive stub.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
media_class,
|
|
media_content_id,
|
|
media_content_type,
|
|
title,
|
|
can_play,
|
|
can_expand,
|
|
children=None,
|
|
children_media_class=None,
|
|
thumbnail=None,
|
|
not_shown=0,
|
|
can_search=False,
|
|
search_media_classes=None,
|
|
):
|
|
self.media_class = media_class
|
|
self.media_content_id = media_content_id
|
|
self.media_content_type = media_content_type
|
|
self.title = title
|
|
self.can_play = can_play
|
|
self.can_expand = can_expand
|
|
self.children = children
|
|
self.children_media_class = children_media_class
|
|
self.thumbnail = thumbnail
|
|
self.not_shown = not_shown
|
|
self.can_search = can_search
|
|
self.search_media_classes = search_media_classes
|
|
|
|
def as_dict(self, *, parent=True):
|
|
"""Real payload conversion — what the frontend actually consumes."""
|
|
if self.children_media_class is None and self.children:
|
|
self.calculate_children_class()
|
|
response = {
|
|
"title": self.title,
|
|
"media_class": self.media_class,
|
|
"media_content_type": self.media_content_type,
|
|
"media_content_id": self.media_content_id,
|
|
"children_media_class": self.children_media_class,
|
|
"can_play": self.can_play,
|
|
"can_expand": self.can_expand,
|
|
"can_search": self.can_search,
|
|
"search_media_classes": self.search_media_classes,
|
|
"thumbnail": self.thumbnail,
|
|
}
|
|
if not parent:
|
|
return response
|
|
response["not_shown"] = self.not_shown
|
|
response["children"] = (
|
|
[child.as_dict(parent=False) for child in self.children]
|
|
if self.children
|
|
else []
|
|
)
|
|
return response
|
|
|
|
def calculate_children_class(self):
|
|
"""Real helper: the children's common class, else DIRECTORY."""
|
|
self.children_media_class = MediaClass.DIRECTORY
|
|
assert self.children is not None
|
|
proposed_class = self.children[0].media_class
|
|
if all(child.media_class == proposed_class for child in self.children):
|
|
self.children_media_class = proposed_class
|
|
|
|
|
|
class MediaEntityManager:
|
|
"""trivial stand-in for the HA platform internals we don't exercise."""
|
|
|
|
|
|
class MediaPlayerEntity:
|
|
"""Base entity: attributes only; state writing is a no-op."""
|
|
|
|
def async_write_ha_state(self) -> None:
|
|
return None
|
|
|
|
|
|
class RemoteEntityFeature(enum.IntFlag):
|
|
"""homeassistant.components.remote.RemoteEntityFeature (the real values)."""
|
|
|
|
LEARN_COMMAND = 1
|
|
DELETE_COMMAND = 2
|
|
ACTIVITY = 4
|
|
|
|
|
|
class RemoteEntity:
|
|
"""The RemoteEntity surface HA reads, backed by the ``_attr_*`` convention.
|
|
|
|
t15: HA renders the app/activity picker off ``supported_features`` and reads
|
|
``activity_list``/``current_activity``; both are cached properties on the real
|
|
base, reading ``_attr_*``. The stub carries that contract (the same shape as the
|
|
ImageEntity face above) so a test can read what the entity publishes instead of
|
|
asserting against our own attribute names.
|
|
"""
|
|
|
|
_attr_activity_list: list | None = None
|
|
_attr_current_activity: str | None = None
|
|
_attr_supported_features = RemoteEntityFeature(0)
|
|
|
|
@property
|
|
def supported_features(self):
|
|
return self._attr_supported_features
|
|
|
|
@property
|
|
def activity_list(self):
|
|
return self._attr_activity_list
|
|
|
|
@property
|
|
def current_activity(self):
|
|
return self._attr_current_activity
|
|
|
|
def async_write_ha_state(self) -> None:
|
|
return None
|
|
|
|
|
|
#: Every face install() registers. The guard below checks the WHOLE set, not one
|
|
#: sentinel: test files scope their stubs per file (snapshot + pop at teardown, the
|
|
#: t7 discipline), so one file can leave `homeassistant.components.media_player`
|
|
#: behind while another name it popped — e.g. `voluptuous` — is gone. A sentinel
|
|
#: guard then made install() a no-op in the next file, and whichever module needed
|
|
#: the missing face failed with ModuleNotFoundError depending on file order (t44).
|
|
#: The faces install() provides, derived from the list every test file scopes on.
|
|
#: The ``nsh.*`` entries there are the integration modules the harness loads, not
|
|
#: stub faces, so they are filtered out here.
|
|
_STUB_FACES = tuple(
|
|
name for name in ENTITIES_STUB_NAMES if not name.startswith("nsh.")
|
|
)
|
|
|
|
|
|
def install() -> None:
|
|
"""Register the stub modules (idempotent, and self-healing).
|
|
|
|
Callers must be able to rely on this regardless of what a previous test file
|
|
popped: a missing face rebuilds the whole set, so any subset or file order
|
|
works. A complete set is left alone — a file that installed richer faces of
|
|
its own (tests/test_config_flow_ux.py's voluptuous mirror, the discovery
|
|
stub) keeps them.
|
|
"""
|
|
if all(name in sys.modules for name in _STUB_FACES):
|
|
return
|
|
|
|
vol = types.ModuleType("voluptuous")
|
|
# t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
|
|
# stub cannot tell a schema WITH a default from one without.
|
|
voluptuous_mirror.install(vol)
|
|
sys.modules["voluptuous"] = vol
|
|
|
|
ha = types.ModuleType("homeassistant")
|
|
sys.modules["homeassistant"] = ha
|
|
|
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
|
async def _stub_set_unique_id(self, unique_id):
|
|
# hub shape: record; a full flow test file scopes its own version
|
|
self._unique_id = unique_id
|
|
|
|
def _noop_abort(self, *, error=None, **kwargs):
|
|
return None
|
|
|
|
class _ConfigFlowStub:
|
|
"""ConfigFlow shape used by entity tests (t29 G3a API included).
|
|
|
|
Late loads under the hub stubs (e.g. test_init designs run AFTER the
|
|
other flow files) still get the base plumbing the flow modules rely
|
|
on — show/create/abort/unique-id — so the flow classes never import
|
|
against a bare base.
|
|
"""
|
|
|
|
async_set_unique_id = _stub_set_unique_id
|
|
_abort_if_unique_id_configured = _noop_abort
|
|
|
|
async def _async_handle_discovery_without_unique_id(self):
|
|
# t37 D3: the real helper is a COROUTINE and core awaits it — a
|
|
# sync stub here would turn the production `await` into a
|
|
# TypeError. The abort semantics live in
|
|
# tests/test_standalone_setup.py, whose stub owns entry/in-progress
|
|
# state; at hub scope there is nothing to abort on.
|
|
return None
|
|
|
|
def async_show_form(
|
|
self, *, step_id, data_schema=None, errors=None,
|
|
description_placeholders=None,
|
|
):
|
|
return {
|
|
"type": "form",
|
|
"step_id": step_id,
|
|
"errors": dict(errors or {}),
|
|
"data_schema": data_schema,
|
|
"description_placeholders": dict(description_placeholders or {}),
|
|
}
|
|
|
|
def async_create_entry(self, *, title, data, options=None):
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
def async_abort(self, *, reason):
|
|
return {"type": "abort", "reason": reason}
|
|
|
|
def _get_reauth_entry(self, entry=None):
|
|
if entry is not None:
|
|
return entry
|
|
return None # the caller's cached entry is used
|
|
|
|
def _get_reconfigure_entry(self, entry=None):
|
|
if entry is not None:
|
|
return entry
|
|
return None # the caller's cached entry is used
|
|
|
|
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
|
|
# which updates, schedules the reload and aborts; the stub records both.
|
|
|
|
def async_update_reload_and_abort(self, entry, **kwargs):
|
|
"""Real-HA semantics: update, schedule the reload, abort."""
|
|
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
|
|
for key, value in updates.items():
|
|
if key in ("options", "data", "unique_id"):
|
|
setattr(entry, key, value)
|
|
else:
|
|
getattr(entry, "options", {}).setdefault(key, value) # best effort
|
|
scheduler = getattr(self.hass, "config_entries", None)
|
|
if scheduler is not None and hasattr(
|
|
scheduler, "async_schedule_reload"
|
|
):
|
|
scheduler.async_schedule_reload(entry.entry_id)
|
|
return self.async_abort(reason="reauth_successful")
|
|
|
|
def _config_flow_init(self, *args, **kwargs):
|
|
self.context = getattr(self, "context", {}) or {}
|
|
self.hass = getattr(self, "hass", None)
|
|
|
|
_config_flow_base = type(
|
|
"ConfigFlow",
|
|
(_ConfigFlowStub,),
|
|
{
|
|
"__init_subclass__": classmethod(
|
|
lambda cls, domain=None, **kwargs: None
|
|
),
|
|
"__init__": _config_flow_init,
|
|
},
|
|
)
|
|
config_entries.ConfigFlow = _config_flow_base
|
|
class _OptionsFlowStub:
|
|
def __init__(self):
|
|
self.config_entry = None
|
|
|
|
def add_suggested_values_to_schema(self, schema, suggested):
|
|
# real HA merges hints; the stub records them on the schema dict
|
|
schema_dict = getattr(schema, "schema", None) or (
|
|
schema if isinstance(schema, dict) else {}
|
|
)
|
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
|
return schema
|
|
|
|
class _OptionsFlowWithReload(_OptionsFlowStub):
|
|
"""Options flow that reloads after a successful save (real HA)."""
|
|
|
|
def async_create_entry(self, *, title, data, options=None):
|
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
|
if entry_id is not None and scheduler is not None and hasattr(
|
|
scheduler, "async_schedule_reload"
|
|
):
|
|
scheduler.async_schedule_reload(entry_id)
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
config_entries.OptionsFlow = _OptionsFlowStub
|
|
config_entries.SOURCE_IMPORT = "import"
|
|
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
|
|
sys.modules["homeassistant.config_entries"] = config_entries
|
|
|
|
exceptions = types.ModuleType("homeassistant.exceptions")
|
|
|
|
class HomeAssistantErrorStubError(Exception):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args or (kwargs.get("translation_key"),))
|
|
self.translation_domain = kwargs.get("translation_domain")
|
|
self.translation_key = kwargs.get("translation_key")
|
|
self.translation_placeholders = kwargs.get("translation_placeholders")
|
|
|
|
class ServiceValidationErrorStubError(HomeAssistantErrorStubError):
|
|
"""Real HA: ServiceValidationError derives from HomeAssistantError (t44)."""
|
|
|
|
# t44: media_player raises HomeAssistantError when a configured command script
|
|
# fails, so both names have to exist — and with the real inheritance.
|
|
exceptions.HomeAssistantError = HomeAssistantErrorStubError
|
|
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
|
sys.modules["homeassistant.exceptions"] = exceptions
|
|
|
|
core = types.ModuleType("homeassistant.core")
|
|
core.HomeAssistant = object
|
|
core.callback = lambda fn: fn
|
|
core.CALLBACK_TYPE = object
|
|
# t14: the adb_command registration asks for OPTIONAL response support (the
|
|
# handler returns the command's output). The stub keeps HA's three values
|
|
# distinct so a test can tell "optional" from "only".
|
|
core.SupportsResponse = types.SimpleNamespace(
|
|
NONE="none", OPTIONAL="optional", ONLY="only"
|
|
)
|
|
core.Event = object
|
|
sys.modules["homeassistant.core"] = core
|
|
|
|
ha_const = types.ModuleType("homeassistant.const")
|
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
|
ha_const.STATE_PLAYING = "playing"
|
|
ha_const.STATE_PAUSED = "paused"
|
|
# t61: a receiver switch is read and switched by the media player, so the
|
|
# two switch states have to exist with their real values.
|
|
ha_const.STATE_ON = "on"
|
|
ha_const.STATE_OFF = "off"
|
|
# t14: the adb_command target arrives as these two service-call fields (the UI's
|
|
# target picker merges the selection in as entity_id, an automation targeting a
|
|
# device sends device_id), so the entity service schema names them.
|
|
ha_const.ATTR_ENTITY_ID = "entity_id"
|
|
ha_const.ATTR_DEVICE_ID = "device_id"
|
|
ha_const.Platform = types.SimpleNamespace(
|
|
MEDIA_PLAYER="media_player",
|
|
SENSOR="sensor",
|
|
REMOTE="remote",
|
|
BUTTON="button",
|
|
IMAGE="image",
|
|
)
|
|
sys.modules["homeassistant.const"] = ha_const
|
|
|
|
components = types.ModuleType("homeassistant.components")
|
|
components.__path__ = []
|
|
sys.modules["homeassistant.components"] = components
|
|
|
|
mp = types.ModuleType("homeassistant.components.media_player")
|
|
mp.MediaPlayerEntity = MediaPlayerEntity
|
|
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
|
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
|
mp.BrowseMedia = BrowseMedia
|
|
mp.MediaClass = MediaClass
|
|
mp.MediaType = MediaType
|
|
# NOTE (t37 D1): homeassistant.components.media_player exposes NO
|
|
# BrowseMediaSource — deliberately not stubbed, so the drifted import
|
|
# fails here exactly as it does in a real install.
|
|
mp.MediaPlayerState = MediaPlayerState
|
|
sys.modules["homeassistant.components.media_player"] = mp
|
|
|
|
remote = types.ModuleType("homeassistant.components.remote")
|
|
remote.RemoteEntity = RemoteEntity
|
|
# t15: the feature flag that makes HA offer the activity/app picker, the service
|
|
# field the picker submits, and the two attributes HA reads off the base class.
|
|
remote.RemoteEntityFeature = RemoteEntityFeature
|
|
remote.ATTR_ACTIVITY = "activity"
|
|
remote.RemoteEntityStateAttribute = types.SimpleNamespace(
|
|
ACTIVITY_LIST="activity_list",
|
|
CURRENT_ACTIVITY="current_activity",
|
|
)
|
|
sys.modules["homeassistant.components.remote"] = remote
|
|
|
|
button = types.ModuleType("homeassistant.components.button")
|
|
button.ButtonEntity = type("ButtonEntity", (), {})
|
|
sys.modules["homeassistant.components.button"] = button
|
|
sensor_platform = types.ModuleType("homeassistant.components.sensor")
|
|
sensor_platform.SensorEntity = type("SensorEntity", (), {})
|
|
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
|
|
sys.modules["homeassistant.components.sensor"] = sensor_platform
|
|
|
|
# t66: homeassistant.components.image.ImageEntity — the slice the screen
|
|
# capture entity uses, carrying the REAL contract that matters here: the
|
|
# attributes HA reads (``content_type``, ``image_last_updated``), the real
|
|
# ``__init__(hass, verify_ssl=False)`` signature, ``async_image`` as the
|
|
# bytes seam, state writing as a no-op (the harness writes no states), and
|
|
# HA 2026.10's own ``_attr_should_poll = False`` ("No need to poll image
|
|
# entities"). A subclass that forgets its base ``__init__`` fails here too.
|
|
image_platform = types.ModuleType("homeassistant.components.image")
|
|
|
|
class _EntityFace:
|
|
"""The Entity surface HA reads, backed by the ``_attr_*`` convention.
|
|
|
|
The real ImageEntity inherits all of this from Entity; the stub carries
|
|
the properties so a test can READ what the entity publishes (naming,
|
|
device, polling, availability) without a state machine.
|
|
"""
|
|
|
|
_attr_available = True
|
|
_attr_should_poll = True
|
|
_attr_translation_key = None
|
|
_attr_unique_id = None
|
|
_attr_device_info = None
|
|
_attr_extra_state_attributes = None
|
|
|
|
def async_write_ha_state(self) -> None:
|
|
return None
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
return self._attr_available
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
return self._attr_should_poll
|
|
|
|
@property
|
|
def translation_key(self):
|
|
return self._attr_translation_key
|
|
|
|
@property
|
|
def unique_id(self):
|
|
return self._attr_unique_id
|
|
|
|
@property
|
|
def device_info(self):
|
|
return self._attr_device_info
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
return self._attr_extra_state_attributes
|
|
|
|
class ImageEntity(_EntityFace):
|
|
_attr_content_type = "image/jpeg"
|
|
_attr_image_last_updated = None
|
|
_attr_should_poll = False
|
|
|
|
def __init__(self, hass, verify_ssl: bool = False) -> None:
|
|
self.hass = hass
|
|
|
|
@property
|
|
def content_type(self) -> str:
|
|
return self._attr_content_type
|
|
|
|
@property
|
|
def image_last_updated(self):
|
|
return self._attr_image_last_updated
|
|
|
|
async def async_image(self) -> bytes | None:
|
|
raise NotImplementedError
|
|
|
|
image_platform.ImageEntity = ImageEntity
|
|
sys.modules["homeassistant.components.image"] = image_platform
|
|
|
|
# t66: homeassistant.util.dt — the timestamp helper the image entity stamps
|
|
# ``image_last_updated`` with. ``utcnow()`` really is UTC-aware, as in HA.
|
|
ha_util = types.ModuleType("homeassistant.util")
|
|
ha_util.__path__ = []
|
|
dt_util = types.ModuleType("homeassistant.util.dt")
|
|
|
|
def _utcnow():
|
|
return datetime.datetime.now(datetime.UTC)
|
|
|
|
dt_util.utcnow = _utcnow
|
|
ha_util.dt = dt_util
|
|
sys.modules["homeassistant.util"] = ha_util
|
|
sys.modules["homeassistant.util.dt"] = dt_util
|
|
|
|
|
|
helpers = types.ModuleType("homeassistant.helpers")
|
|
|
|
# t14: ``async_set_service_schema`` publishes the runtime service DESCRIPTION —
|
|
# the thing the frontend renders the target picker from (HA's cache is what
|
|
# get_services serves). The stub records the call instead, so a test can assert
|
|
# what the integration told HA to offer for nvidia_shield.adb_command.
|
|
service = types.ModuleType("homeassistant.helpers.service")
|
|
|
|
def _stub_set_service_schema(hass, domain, service_name, schema):
|
|
SERVICE_SCHEMA_CALLS.append((domain, service_name, schema))
|
|
|
|
service.async_set_service_schema = _stub_set_service_schema
|
|
helpers.service = service
|
|
sys.modules["homeassistant.helpers.service"] = service
|
|
# t14: the entity service schema names its two target fields through
|
|
# ``cv.entity_ids`` / ``cv.ensure_list_csv``. The stub keeps HA's SHAPE (None ->
|
|
# [], text or list -> list, comma text -> split) without the real entity-id
|
|
# format check — that check belongs to the installed HA and is exercised by
|
|
# tests/test_adb_command_real_ha.py. The schema is not executed in the stub
|
|
# suite anyway: those tests call the handler directly.
|
|
config_validation = types.ModuleType("homeassistant.helpers.config_validation")
|
|
|
|
def _ensure_list(value):
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, (list, tuple)):
|
|
return list(value)
|
|
return [value]
|
|
|
|
def _ensure_list_csv(value):
|
|
if isinstance(value, str):
|
|
return [part.strip() for part in value.split(",") if part.strip()]
|
|
return _ensure_list(value)
|
|
|
|
config_validation.ensure_list = _ensure_list
|
|
config_validation.ensure_list_csv = _ensure_list_csv
|
|
config_validation.entity_ids = _ensure_list_csv
|
|
helpers.config_validation = config_validation
|
|
sys.modules["homeassistant.helpers.config_validation"] = config_validation
|
|
|
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
|
|
|
class _RegistryEntry:
|
|
"""The two attributes the integration reads off a registry entry (t14)."""
|
|
|
|
def __init__(self, entity_id):
|
|
self.entity_id = entity_id
|
|
self.domain = entity_id.split(".", 1)[0]
|
|
|
|
class _Registry:
|
|
entities: dict = {}
|
|
#: t14: ``device_id -> [entity_id, ...]``, what HA's registry knows about a
|
|
#: device. Seeded by the test that needs a device target; ``async_get``
|
|
#: keeps returning a fresh instance, so the binding lives on the class.
|
|
bindings: dict = {}
|
|
|
|
def async_get(self, entity_id):
|
|
return None
|
|
|
|
def _stub_entries_for_device(registry, device_id, include_disabled_entities=False):
|
|
"""t14: HA's device->entities helper is a MODULE-LEVEL function, not a method
|
|
on the registry (measured on 2026.10.1) — the stub mirrors that shape, because
|
|
a stub that put it on the class hid the AttributeError the real API raised.
|
|
"""
|
|
del registry, include_disabled_entities
|
|
return [_RegistryEntry(entry) for entry in _Registry.bindings.get(device_id, [])]
|
|
|
|
entity_registry.async_get = lambda hass: _Registry()
|
|
entity_registry.async_entries_for_device = _stub_entries_for_device
|
|
|
|
|
|
# t14: how a test gives the stub registry a device -> entities binding (the real
|
|
# one is filled by HA's own entity registry) and how it takes it back out.
|
|
def _stub_bind_device(device_id, *entity_ids):
|
|
_Registry.bindings[device_id] = list(entity_ids)
|
|
|
|
entity_registry.bind_device = _stub_bind_device
|
|
entity_registry.reset_bindings = lambda: _Registry.bindings.clear()
|
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
|
|
|
class _DeviceRegistry:
|
|
def __init__(self):
|
|
self.devices: dict[str, object] = {}
|
|
|
|
def async_get(self, device_id):
|
|
return self.devices.get(device_id)
|
|
|
|
_device_registry_shared = _DeviceRegistry()
|
|
|
|
device_registry.async_get = lambda hass: _device_registry_shared
|
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
|
|
|
helpers.entity_registry = entity_registry
|
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
|
|
|
helpers.device_registry = device_registry
|
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
|
|
|
event = types.ModuleType("homeassistant.helpers.event")
|
|
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
|
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
|
helpers.event = event
|
|
helpers.selector = types.SimpleNamespace(
|
|
EntitySelector=EntitySelectorStub,
|
|
EntitySelectorConfig=lambda *a, **k: "",
|
|
SelectSelector=lambda *a, **k: None,
|
|
SelectSelectorConfig=lambda **k: "",
|
|
SelectSelectorMode=types.SimpleNamespace(DROPDOWN="dropdown"),
|
|
)
|
|
entity = types.ModuleType("homeassistant.helpers.entity")
|
|
entity.DeviceInfo = dict
|
|
entity.EntityCategory = types.SimpleNamespace(
|
|
CONFIG="config", DIAGNOSTIC="diagnostic"
|
|
)
|
|
helpers.entity = entity
|
|
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
|
entity_platform.AddEntitiesCallback = object
|
|
helpers.entity_platform = entity_platform
|
|
|
|
# t37 D2: homeassistant.helpers.redact.async_redact_data — the REAL helper
|
|
# (diagnostics.py imports it at module scope; `homeassistant.util.diagnostic`
|
|
# never existed). Modelled faithfully: the same masking of keys found in
|
|
# ``to_redact``, the same nested mapping/list recursion, the same skip of
|
|
# None/empty-string values.
|
|
redact = types.ModuleType("homeassistant.helpers.redact")
|
|
redact.REDACTED = "**REDACTED**"
|
|
|
|
def _stub_async_redact_data(data, to_redact):
|
|
if not isinstance(data, (Mapping, list)):
|
|
return data
|
|
if isinstance(data, list):
|
|
return [_stub_async_redact_data(val, to_redact) for val in data]
|
|
redacted = {**data}
|
|
for key, value in redacted.items():
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, str) and not value:
|
|
continue
|
|
if key in to_redact:
|
|
if isinstance(to_redact, Mapping):
|
|
redacted[key] = to_redact[key](value)
|
|
else:
|
|
redacted[key] = redact.REDACTED
|
|
elif isinstance(value, Mapping):
|
|
redacted[key] = _stub_async_redact_data(value, to_redact)
|
|
elif isinstance(value, list):
|
|
redacted[key] = [
|
|
_stub_async_redact_data(item, to_redact) for item in value
|
|
]
|
|
return redacted
|
|
|
|
redact.async_redact_data = _stub_async_redact_data
|
|
helpers.redact = redact
|
|
sys.modules["homeassistant.helpers.redact"] = redact
|
|
|
|
sys.modules["homeassistant.helpers.event"] = event
|
|
sys.modules["homeassistant.helpers.entity"] = entity
|
|
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
|
|
|
# t44: config_flow imports `selector` from this package, and the per-file stub
|
|
# installers register it as its own face. Installing it here too keeps
|
|
# `_STUB_FACES` honest: install() then really does provide every face a test
|
|
# file may have popped, which is what its guard checks.
|
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
|
install_selector_faces(selector)
|
|
selector.SelectSelector = lambda *args, **kwargs: None
|
|
selector.SelectSelectorConfig = lambda **kwargs: ""
|
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
|
helpers.selector = selector
|
|
sys.modules["homeassistant.helpers.selector"] = selector
|
|
|
|
sys.modules["homeassistant.helpers"] = helpers
|
|
|
|
|
|
#: Namespaces the stub harness owns. Anything under these prefixes that a test
|
|
#: file creates is harness state, so a file's teardown must remove it: a module
|
|
#: left behind keeps the stub objects it captured (an exception class, the redact
|
|
#: helper the diagnostics module holds) after the next file rebuilt them, which is
|
|
#: how a file-order-dependent failure looked (t44).
|
|
_TRACKED_PREFIXES = ("homeassistant", "voluptuous", "nsh")
|
|
|
|
|
|
def _tracked(name: str) -> bool:
|
|
return name.split(".", 1)[0] in _TRACKED_PREFIXES
|
|
|
|
|
|
def snapshot_modules() -> dict:
|
|
"""Every stub/module name live right now — for a leak-free teardown."""
|
|
return {name: module for name, module in sys.modules.items() if _tracked(name)}
|
|
|
|
|
|
def restore_modules(snapshot: dict) -> None:
|
|
"""Undo everything the current test file did to those namespaces.
|
|
|
|
Two halves, and the first is what a hand-written list of names kept getting
|
|
wrong: drop every tracked name the file created (so the next file loads it
|
|
fresh, against ITS stubs), then put back every object it replaced.
|
|
"""
|
|
for name in [name for name in sys.modules if _tracked(name) and name not in snapshot]:
|
|
del sys.modules[name]
|
|
sys.modules.update(snapshot)
|
|
|
|
|
|
def load_entity_modules() -> dict:
|
|
"""Load the integration's HA-free-ish entity modules under nsh."""
|
|
install()
|
|
from conftest import _SHIELD_DIR, _load_module
|
|
|
|
for name in (
|
|
"helpers",
|
|
"power",
|
|
"jellyfin",
|
|
"sensor",
|
|
"media_player",
|
|
"remote",
|
|
"diagnostics",
|
|
# t41: button.py was the file nobody loaded — a deleted helper it still
|
|
# imported shipped silently. It is a platform like the others, so it
|
|
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
|
|
# is what keeps this list from having to be complete.
|
|
"button",
|
|
# t66: the screen-capture platform is a platform like the others, so it
|
|
# loads here too (and the module-coverage sweep already walks every file)
|
|
"image",
|
|
"init",
|
|
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
|
if name == "init" and "nsh.init" in sys.modules:
|
|
continue
|
|
if f"nsh.{name}" not in sys.modules:
|
|
file_name = "__init__.py" if name == "init" else f"{name}.py"
|
|
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
|
|
return {
|
|
name: sys.modules[f"nsh.{name}"]
|
|
for name in (
|
|
"power",
|
|
"jellyfin",
|
|
"sensor",
|
|
"media_player",
|
|
"remote",
|
|
"diagnostics",
|
|
"image",
|
|
)
|
|
}
|
|
|
|
|
|
class FakeState:
|
|
"""The fields entity code actually reads off an HA ``State`` (t61)."""
|
|
|
|
def __init__(self, entity_id, state, attributes=None):
|
|
self.entity_id = entity_id
|
|
self.state = state
|
|
self.attributes = dict(attributes or {})
|
|
|
|
|
|
class FakeStates:
|
|
"""A dict-backed stand-in for HA's StateMachine (t61).
|
|
|
|
Only the read shape entities use: ``get(entity_id)`` returns the state
|
|
object, or None for an entity that does not exist — exactly what the real
|
|
machine returns. ``set`` is a test's way of giving an entity a state.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._states: dict = {}
|
|
|
|
def set(self, entity_id, state, attributes=None):
|
|
self._states[entity_id] = FakeState(entity_id, state, attributes)
|
|
|
|
def get(self, entity_id):
|
|
return self._states.get(entity_id)
|
|
|
|
|
|
class FakeHass:
|
|
"""Sized to entity unit tests: background tasks + no-op call_later.
|
|
|
|
``states`` is the real HomeAssistant shape (every hass has one), so entity
|
|
code that reads a linked entity works unmodified; it is empty until a test
|
|
sets something.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.states = FakeStates()
|
|
|
|
def async_create_task(self, coro):
|
|
return asyncio.create_task(coro)
|
|
|
|
def async_call_later(self, delay, callback_fn):
|
|
return lambda: None
|