feat(remote): the app/activity picker, and a protocol transport that really connects (t15)
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.
This commit is contained in:
1 parent
6aa3b6bfdd
commit
14c168c25e
14 files changed
+2101
-136
No files matched your search
@@ -9,31 +9,58 @@ remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
|
||||
long presses are expressed as the START_LONG/END_LONG pair instead;
|
||||
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
|
||||
``hold_secs`` parameter;
|
||||
- ``activity`` = the protocol current-app push (package; name resolution is
|
||||
deferred app-map work);
|
||||
- turn on/off send the POWER key and honor the is_on push (no superfluous
|
||||
toggling when the state already matches).
|
||||
- ``RemoteEntityFeature.ACTIVITY`` with an ``activity_list`` built from OUR app
|
||||
directory — the library exposes NO app-list API (measured), which is exactly why
|
||||
core's list is user-maintained too — so the app/activity picker matches core;
|
||||
- ``current_activity`` resolves the foreground push (protocol first, ADB parse as
|
||||
the fallback) through the one ``resolve_app_name`` chain, and stays None while
|
||||
nothing is known: no fabricated activity, ever;
|
||||
- ``turn_on(activity=...)`` wakes when the device says it is off and then launches
|
||||
through the same path ``select_source`` uses; an activity name the directory does
|
||||
not carry is a ``ServiceValidationError``, never a silent no-op;
|
||||
- the transport's REAL state is published in ``extra_state_attributes`` so a
|
||||
never-connected transport cannot look like a paired one (no ``is_paired`` exists:
|
||||
a session or a push is the only proof);
|
||||
- a configured command script still owns turn_on/turn_off and never touches the
|
||||
protocol, so a press that worked through it says NOTHING about pairing — the
|
||||
attributes name which route each power command takes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_ACTIVITY,
|
||||
RemoteEntity,
|
||||
RemoteEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_APP_NAME_MAP,
|
||||
DOMAIN,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
command_scripts,
|
||||
remote_command_script,
|
||||
)
|
||||
from .media_player import async_call_script, async_run_command_script, entity_available
|
||||
from .helpers import protocol_cert_paths
|
||||
from .media_player import (
|
||||
activity_names,
|
||||
app_directory,
|
||||
async_call_script,
|
||||
async_run_command_script,
|
||||
entity_available,
|
||||
resolve_app_name,
|
||||
)
|
||||
|
||||
_START_PREFIX = "START_LONG:"
|
||||
_END_PREFIX = "END_LONG:"
|
||||
@@ -64,6 +91,19 @@ def _key_code_name(command: str) -> str:
|
||||
return upper # unknown names: the library reports/validates them
|
||||
|
||||
|
||||
def _looks_launchable(value: str) -> bool:
|
||||
"""True for a package id or a detail link — forms the launch path accepts (t15).
|
||||
|
||||
The rule is the repo's own definition of a package (``_valid_app_row``: dotted,
|
||||
no spaces) plus URL-shaped deep links, which is exactly what
|
||||
``send_launch_app_command`` handles. It is deliberately NOT a guess that an id
|
||||
exists: the device answers that. It exists so "Netflx" (a typo of a listed name)
|
||||
is refused while an app the directory does not carry can still be launched.
|
||||
"""
|
||||
if not value or " " in value:
|
||||
return False
|
||||
return "." in value or "://" in value
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -74,11 +114,21 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
"""Remote entity over the manager's androidtvremote2 transport."""
|
||||
"""Remote entity over the manager's androidtvremote2 transport.
|
||||
|
||||
t15: this is the entity the app/activity picker hangs off. HA renders the picker
|
||||
only when ``supported_features`` carries ``RemoteEntityFeature.ACTIVITY`` — the
|
||||
user's "the UI doesn't actually show any buttons like the android tv remote
|
||||
does" — and the list itself has to come from OUR directory, because
|
||||
``AndroidTVRemote`` exposes no app-list API at all (measured on the installed
|
||||
library; core androidtv_remote keeps a user-maintained map for the same reason).
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
#: The feature flag that makes HA offer the activity/app picker at all.
|
||||
_attr_supported_features = RemoteEntityFeature.ACTIVITY
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
@@ -87,10 +137,58 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
entry, "runtime_data", None
|
||||
)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
|
||||
# t15: the activity list is available from the FIRST render (a real-HA state
|
||||
# write needs no entity platform), and is refreshed with discovered apps once
|
||||
# ADB can answer (``async_added_to_hass``).
|
||||
self._attr_activity_list = activity_names(self._entry.options)
|
||||
self._attr_current_activity = self._resolve_activity_name()
|
||||
#: Which protocol pushes have EVER arrived — the only pairing evidence the
|
||||
#: library can offer (it has no is_paired). Sticky on purpose: the store
|
||||
#: expires every field after STATE_STORE_TTL, so "has it ever arrived?"
|
||||
#: cannot be read from the store.
|
||||
self._protocol_pushes_seen: set[str] = set()
|
||||
self._note_protocol_pushes()
|
||||
#: "present"/"missing"/"unknown" — the shared setup certificate, which is
|
||||
#: why enabling the transport on a paired entry asks for no new PIN.
|
||||
self._protocol_certificate = "unknown"
|
||||
self._activity_refresh_task = None
|
||||
|
||||
def _manager_of(self) -> ShieldConnectionManager | None:
|
||||
return getattr(self._entry, "runtime_data", None)
|
||||
|
||||
def _resolve_activity_name(self) -> str | None:
|
||||
"""The foreground app as a friendly name (t15), or None when nothing is known.
|
||||
|
||||
The same chain as the media player's ``app_name`` and the Current App sensor
|
||||
(``resolve_app_name``): the user's map, then the cached device label, then the
|
||||
curated table, then the raw id — so an activity and a source can never
|
||||
disagree about what is on screen. The foreground identity prefers the protocol
|
||||
push and falls back to the ADB parse, which keeps this entity useful on an
|
||||
ADB-only setup (its protocol surface alone degrades).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return None
|
||||
return resolve_app_name(
|
||||
manager.foreground_app,
|
||||
self._entry.options.get(CONF_APP_NAME_MAP) or {},
|
||||
manager.app_label,
|
||||
)
|
||||
|
||||
def _note_protocol_pushes(self) -> None:
|
||||
"""Remember which device pushes have arrived (t15) — sticky, see __init__."""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if manager.current_app is not None:
|
||||
self._protocol_pushes_seen.add("current_app")
|
||||
if manager.is_on is not None:
|
||||
self._protocol_pushes_seen.add("is_on")
|
||||
if manager.is_available is not None:
|
||||
self._protocol_pushes_seen.add("is_available")
|
||||
if manager.volume_info is not None:
|
||||
self._protocol_pushes_seen.add("volume")
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
@@ -113,24 +211,158 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
|
||||
@property
|
||||
def activity(self) -> str | None:
|
||||
"""Current foreground app reported by the protocol push."""
|
||||
"""The raw foreground package (protocol push; ADB parse as the fallback).
|
||||
|
||||
Kept alongside HA's ``current_activity``: this is the id (what
|
||||
``turn_on(activity=...)`` accepts and what the Current App sensor stamps),
|
||||
while ``current_activity`` is the friendly name HA displays.
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
return manager.current_app
|
||||
return manager.foreground_app
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The transport's real state — the pairing answer, readable from HA (t15).
|
||||
|
||||
There is no "is it paired?" API to call (the library exposes none either,
|
||||
measured), so everything here is a fact that can be PROVEN:
|
||||
|
||||
* ``protocol_state`` — ``off`` / ``never_connected`` / ``connected`` /
|
||||
``disconnected`` / ``auth_rejected``. ``never_connected`` is the state
|
||||
that used to be permanent (``keep_reconnecting()`` alone never connects),
|
||||
and it must never look like ``connected``.
|
||||
* ``protocol_connected`` / ``protocol_ever_authenticated`` — a session or a
|
||||
device push is the only proof of an authenticated (paired) client.
|
||||
* ``protocol_current_app_seen`` / ``protocol_is_on_seen`` /
|
||||
``protocol_volume_seen`` — whether each device push has EVER arrived.
|
||||
* ``protocol_last_error`` / ``protocol_failures`` — a send that could not
|
||||
run is recorded here and logged, never swallowed into a debug line.
|
||||
* ``protocol_certificate`` — the setup pairing's shared ``client.pem`` is
|
||||
still on disk, which is exactly why enabling the transport asks for no
|
||||
new PIN (the other Android TV integration pairs its own client).
|
||||
* ``power_on_route`` / ``power_off_route`` — ``script:<id>`` when a command
|
||||
script owns that command. That path never touches the protocol, so a
|
||||
success through it says NOTHING about pairing.
|
||||
"""
|
||||
return {
|
||||
"activity": self.activity,
|
||||
# t15: which tier produced it — a protocol push must never win SILENTLY
|
||||
# over the ADB parse the user's setup is built on (see the manager's
|
||||
# ``foreground_source``).
|
||||
"activity_source": self._protocol_read("foreground_source"),
|
||||
"power_on_route": self._power_route("turn_on"),
|
||||
"power_off_route": self._power_route("turn_off"),
|
||||
"protocol_active": self._protocol_active(),
|
||||
"protocol_state": self._protocol_state(),
|
||||
"protocol_connected": self._protocol_flag("protocol_connected"),
|
||||
"protocol_ever_authenticated": self._protocol_flag(
|
||||
"protocol_ever_authenticated"
|
||||
),
|
||||
"protocol_last_error": self._protocol_read("protocol_last_error"),
|
||||
"protocol_failures": self._protocol_read("protocol_failures"),
|
||||
"protocol_certificate": self._protocol_certificate,
|
||||
"protocol_current_app_seen": "current_app" in self._protocol_pushes_seen,
|
||||
"protocol_is_on_seen": "is_on" in self._protocol_pushes_seen,
|
||||
"protocol_volume_seen": "volume" in self._protocol_pushes_seen,
|
||||
}
|
||||
|
||||
def _protocol_read(self, name: str, default=None):
|
||||
"""Read one transport fact off the manager, or the default (stub managers)."""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return default
|
||||
return getattr(manager, name, default)
|
||||
|
||||
def _protocol_active(self) -> bool:
|
||||
return bool(self._protocol_read("protocol_active", False))
|
||||
|
||||
def _protocol_state(self) -> str:
|
||||
"""The transport state, with ``off`` for a manager that carries no client."""
|
||||
if not self._protocol_active():
|
||||
return "off"
|
||||
return self._protocol_read("protocol_state", "never_connected")
|
||||
|
||||
def _protocol_flag(self, name: str) -> bool:
|
||||
return bool(self._protocol_read(name, False))
|
||||
|
||||
def _power_route(self, command: str) -> str:
|
||||
"""Which path ``turn_on``/``turn_off`` takes: a script, or the protocol (t15).
|
||||
|
||||
A configured command script owns the sequence and never touches the
|
||||
protocol — so "the power press worked" through it is NOT evidence that the
|
||||
transport is paired, or even connected. That is the conclusion the user drew
|
||||
and then doubted, made readable instead of inferred.
|
||||
"""
|
||||
script = (command_scripts(self._entry.options) or {}).get(command)
|
||||
return f"script:{script}" if script else "protocol"
|
||||
|
||||
|
||||
@callback
|
||||
def _refresh_state(self) -> None:
|
||||
"""Manager store changed — re-evaluate state (activity/available)."""
|
||||
"""Manager store changed — re-resolve activity, availability, transport."""
|
||||
self._manager = self._manager_of()
|
||||
self._note_protocol_pushes()
|
||||
self._attr_current_activity = self._resolve_activity_name()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
self._manager_unregistered = manager.register_listener(
|
||||
self._refresh_state
|
||||
)
|
||||
self._manager_unregistered = manager.register_listener(self._refresh_state)
|
||||
self._note_protocol_pushes()
|
||||
await self._async_note_certificate()
|
||||
self._schedule_activity_refresh()
|
||||
|
||||
async def _async_note_certificate(self) -> None:
|
||||
"""Record whether the shared pairing certificate exists (t15).
|
||||
|
||||
This is the "no new PIN is expected" evidence, and it is a design fact, not a
|
||||
guess: ``protocol_cert_paths`` is shared between the config flow's pairing step
|
||||
and this runtime, so an entry paired at setup reuses that ``client.pem`` when
|
||||
the transport is (re-)enabled. The OTHER Android TV integration pairs its own
|
||||
client and shares nothing with us.
|
||||
"""
|
||||
add_executor_job = getattr(self.hass, "async_add_executor_job", None)
|
||||
if add_executor_job is None:
|
||||
return # a hass stand-in: leave it "unknown" rather than guess
|
||||
try:
|
||||
certfile, keyfile = await add_executor_job(protocol_cert_paths, self.hass)
|
||||
except Exception: # pragma: no cover - a filesystem we cannot read
|
||||
return
|
||||
self._protocol_certificate = (
|
||||
"present" if os.path.exists(certfile) and os.path.exists(keyfile) else "missing"
|
||||
)
|
||||
|
||||
def _schedule_activity_refresh(self) -> None:
|
||||
"""Add the apps ADB discovery actually found to the activity list (t15).
|
||||
|
||||
One bounded sweep, on demand — the t57 path the options editor uses — never a
|
||||
per-state-write shell. The list is already complete for a configured
|
||||
directory, and a sweep that cannot run changes nothing (no fabrication).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
create_task = getattr(self.hass, "async_create_task", None)
|
||||
if create_task is None or not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if not callable(getattr(manager, "async_refresh_installed_apps", None)):
|
||||
return # a manager stand-in: the directory alone is the honest list
|
||||
self._activity_refresh_task = create_task(
|
||||
self._async_refresh_activity_list(manager),
|
||||
"nvidia_shield_remote_activities",
|
||||
)
|
||||
|
||||
async def _async_refresh_activity_list(self, manager) -> None:
|
||||
"""Fold discovered apps into the activity list (t15) — one write if it grew."""
|
||||
try:
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
except Exception: # pragma: no cover - discovery never breaks the entity
|
||||
return
|
||||
names = activity_names(self._entry.options, rows)
|
||||
if names != self._attr_activity_list:
|
||||
self._attr_activity_list = names
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
unregistered = getattr(self, "_manager_unregistered", None)
|
||||
@@ -139,9 +371,31 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
self._manager_unregistered = None
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Wake the device — via the configured script, else the POWER key."""
|
||||
# t44: `turn_on` is one configuration, and the remote entity is the other
|
||||
# route to it; a script that owns the power sequence owns it here too.
|
||||
"""Wake the device, and launch a requested activity (t15).
|
||||
|
||||
With NO activity — or an empty one — this behaves exactly as before: the
|
||||
configured script owns the wake sequence when there is one, otherwise POWER
|
||||
goes out only when the device says it is off (unknown counts as "maybe on",
|
||||
so nothing is toggled blindly).
|
||||
|
||||
With ``activity``, the same wake happens first (a script that owns turn_on
|
||||
still owns it — it never touches the protocol), and then the app is launched
|
||||
over the protocol through the same path ``select_source`` uses. A name the
|
||||
activity list does not carry, and that is not a package/detail link either,
|
||||
raises ``ServiceValidationError``: an activity request that cannot launch
|
||||
must never look like it worked.
|
||||
"""
|
||||
activity = str(kwargs.get(ATTR_ACTIVITY) or "").strip()
|
||||
await self._async_wake()
|
||||
if activity:
|
||||
await self._async_launch_activity(activity)
|
||||
|
||||
async def _async_wake(self) -> None:
|
||||
"""Turn the device on: the configured script first, else the POWER key (t44).
|
||||
|
||||
t44: `turn_on` is one configuration, and the remote entity is the other route
|
||||
to it; a script that owns the power sequence owns it here too.
|
||||
"""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
@@ -152,6 +406,51 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
return
|
||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||
|
||||
async def _async_launch_activity(self, activity: str) -> None:
|
||||
"""Launch an activity through the shared launch path (t15).
|
||||
|
||||
The target comes from the same directory the activity list is built from, so
|
||||
anything the picker offers resolves. A package id or a deep link passes
|
||||
through: that is how an app the directory does not carry can still be
|
||||
launched, and what core does with an activity it does not know. Anything else
|
||||
is a request nothing can launch and raises instead of doing nothing.
|
||||
"""
|
||||
target = self._activity_target(activity)
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="protocol_unavailable",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
if not await manager.launch_app(target):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="activity_launch_failed",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
|
||||
def _activity_target(self, activity: str) -> str:
|
||||
"""Activity name (or id) → what the launch path takes (t15).
|
||||
|
||||
A listed NAME wins, compared case-insensitively — the same rule the
|
||||
``source_list``/``select_source`` pair uses, so the picker can never offer a
|
||||
name this does not resolve. A ``CONF_APP_NAME_MAP``-less entry still resolves
|
||||
the curated names, because both read the one ``app_directory``.
|
||||
"""
|
||||
directory = app_directory(self._entry.options)
|
||||
wanted = activity.casefold()
|
||||
for package, name in directory.items():
|
||||
if name.casefold() == wanted:
|
||||
return package
|
||||
if activity in directory or _looks_launchable(activity):
|
||||
return activity
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_activity",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Sleep the device — via the configured script, else the POWER key."""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
|
||||
|
||||
Reference in new issue
Block a user