Commit Graph
31 Commits
Author SHA1 Message Date
James Mackay 259a6c29bd feat(intents): the Intents options screen, the seed, the strings and the tests (t17, part 2)
Completes the intents feature: part 1 shipped the engine, the buttons and the
action; this is the surface a user actually touches, plus the evidence.

THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the
same editor shape as the Apps table, because HA has no table selector: a menu of
row actions, one row per step. Add and edit share ONE form, reached through a
select of the registered intents whose last option is an explicit "Add a new
intent" sentinel — so adding is a choice the user can see, not an empty-string
convention. The per-intent form is name, package, component OR action,
categories, a data template and the declared variables as text
(`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for
confirmation and takes nothing until the user confirms. "Scan an app's intents"
asks for ONE package (a dropdown of discovered apps with free text), runs the
device-side scan, and offers the candidates; each candidate is marked when it
looks like an internal hook, and a candidate whose path is a PATTERN prefills no
URL with a note saying why, because a pattern is not a link. A scan that cannot
run reports t16's status vocabulary in words a user can act on — ADB off, ADB
down, or the app really advertising nothing are three different sentences.

Nothing half-stored: a submission that fails writes NOTHING and the form comes
back naming each problem (a component without its slash, a package with a space,
a duplicate name, a data template using an undeclared placeholder, a fields line
that does not parse), and a successful edit REPLACES the row where it already is
instead of moving it to the end of the list under the user's eyes.

The screen's reader of `registered_intents` is a second implementation of the
runtime's one — deliberately, because config_flow must stay importable without
Home Assistant's media_player platform. A test drives BOTH over the same battery
of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and
requires identical results, so the two cannot drift into meaning different
things.

THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME
validator the form uses, so a seeded intent is identical to a typed one; a row it
refuses is dropped with a warning NAMING the row and the reason (a seed is
configuration.yaml: there is no form to show an error on, and a silent skip would
be indistinguishable from a typo). It is honoured only while the UI holds nothing
— at creation, because the Intents screen never writes a seed back and the import
step aborts for a known host first.

THE STRINGS (both files, byte-identical) carry every new step, field, menu row
and the action's own description, plus the exceptions the action raises. The two
shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate
`{service}`, because `send_intent` reuses them verbatim and "for this action" was
`adb_command`-specific copy.

THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in
test_command_routing.py). The command: the exact `am start` line per declared
type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted
argument (asserted through shlex, not by eyeballing), a value that looks like a
placeholder being data rather than a second substitution, `{id.__class__}` left
alone because there is no format()/eval() path at all, and each refusal naming
the field. The scan: the status sentences, the candidate prefill, the pattern
case, an internal-looking candidate still being offered. The seed: warned drops,
and the seeded row being byte-identical to the form's. The buttons: one entity
per registration, its identity keyed by the intent's id so a rename does not
orphan it, defaults sent, and a press that cannot reach the Shield RAISING
instead of doing nothing.

test_command_routing's registry test used to hand-write the dict and set the OLD
attribute name, which is exactly why it never noticed that the add hook registered
`None` for a real entry and that the remove hook dropped nothing. It now drives
the real hooks, and pins both registries plus the ruling that registered intents do
NOT require the adb_command opt-in (a raw shell door and a registered intent are
different consents).

Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new
tests are additive), ruff clean, both strings files byte-identical.
2026-10-12 12:54:41 +01:00
James Mackay 14c168c25e 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.
2026-10-12 12:15:58 +01:00
James Mackay 6aa3b6bfdd feat(adb_command): selectable target, strict resolution, and a real response (t14)
The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:

* ServiceRegistry.async_register has NO target parameter, so a target cannot be
  declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
  HA builds from services.yaml or from the runtime async_set_service_schema —
  and the runtime description WINS.

So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.

The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.

Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.

Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
2026-10-12 11:23:47 +01:00
James Mackay 7b32de789a t66: app names on a device that exposes none
The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.

  * the probe greps ON THE DEVICE for the three shapes Android prints
    (`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
    name, `null` and a resource id are not. The dump is never truncated.
  * after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
    is taken at its word: the sweep stops spending a shell per package
    (APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
    its retry — t57 UNIT 0 survives, driven by the app on screen).
  * ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
    by the media player, the Current App sensor, the discovery table, the source
    list and the apps editor: user map > device label > curated map > package id
    (the id shown ONCE, `name` empty when nothing names the app).
  * the curated table covers the user's 21 apps (community-maintained, not
    device truth) and the Configure row says when the device named nothing.
  * the real device shape is a test fixture (trimmed, and marked as such in the
    file: the raw capture is not in the repo) pinning that no label is
    extracted, that the probe count is bounded, and that `Netflix` surfaces.

Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.

No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
2026-10-12 10:54:38 +01:00
James Mackay a1ce28996e feat(media-player): flatten now-playing into separate attributes and name the tier
The user reads attributes as YAML — "These could do with being separate
attributes rather than an object in one?" — so the nested
`media_dag_now_playing` object is gone and every field it carried is its own
top-level attribute:

  now_playing_via, now_playing_state, now_playing_title, now_playing_artist,
  now_playing_album, now_playing_media_type, now_playing_series,
  now_playing_season, now_playing_episode, now_playing_duration,
  now_playing_position, now_playing_position_updated_at, now_playing_thumbnail,
  now_playing_media_info

Rules the surface now holds to:

- an unknown field is OMITTED, never published as None (`title: null` inside the
  object was exactly the noise this removes); 0 and False are real values
- a device reading `off` publishes no now-playing row at all — a Jellyfin session
  that has gone stale must not put a title beside a device that is off — and `on`
  (nothing reported) publishes none either, because there is no reading
- `now_playing_state` IS the entity's own state, so the ladder stays the single
  answer even while the foreground tier's last-known bridge serves a title
- the tier indicator is `now_playing_via` (`jellyfin` / `media_session` / `app`),
  NOT `source`: on a media_player `source`/`source_list` mean the selected app
  input (`Netflix`, `TiviMate`, …) and they still do — a tier named `source` is
  what made the reading look like media metadata from somewhere else. The internal
  reading key is renamed `via` as well, and the Jellyfin tier now names itself too
- every published name is declared unrecorded (G12): these change every poll

COMPATIBILITY: `media_dag_now_playing` is removed, not deprecated, and no alias is
kept — a nested copy would double every poll's payload. README documents the exact
names, these rules, the sensor's own flat set and a one-line template migration.

sensor.py: the Jellyfin now-playing sensor follows the same no-None rule (`title`
and `media_type` used to be published as null there).

Tests: 17 new cases pin the flattening, the tier rename, the five states, the
off/on gate, the playing->idle transition, the last-known bridge, privacy mode and
agreement with the Current App sensor; the tier-dict tests follow `via`.
2026-10-12 10:13:27 +01:00
engineer eb62011ded fix(media-player): the browse root carries a real string id (t63)
The live report: "Browse media seems to spin and never finish loading". The cause is
in what async_browse_media HANDS BACK, not in the method — it returns with no awaits,
so nothing inside it can hang. The root node's media_content_id was None, and HA core
never serves a node without a string id: across the 43 BrowseMedia constructions in
the installed core media_player platforms, zero pass None (the roots use a
literal/module constant — arcam_fmj's "root", "apps", BROWSE_ROOT,
TUNER_PRESETS_ROOT — or "" for a pure container). The media-browser frontend keys the
node it is loading on that id, so a null id left the dialog re-requesting the same
node for ever.

BROWSE_ROOT_ID = "root" is now what the entity serves for EVERY requested id — the
root id, None, or anything a stale frontend asks for — so a repeat request is
idempotent rather than a loop.

The type was reassessed as well: MediaType.APPS (t61's choice) is what core pairs
with children=[...] — a folder OF apps — so on a zero-child node it invited the
frontend to treat it as an unloaded collection. No MediaType member means "a
directory with nothing in it", so the node carries the class it already declares
(MediaClass.DIRECTORY -> "directory"), which is exactly what can_expand=False and
children=[] say.

Verified over the real path the frontend uses — websocket_browse_media ->
player.async_browse_media -> payload.as_dict() — by driving HA's own decorated
handler against a real entity (tests/test_ha_api_drift.py, which the CI drift job
runs with Home Assistant installed): the root is a genuine BrowseMedia, its as_dict()
survives json.dumps, the id is the string "root", and browse(root_id) == browse(None)
in shape. Red proof on the pre-fix source: the new real-HA test fails at
`assert isinstance(payload["media_content_id"], str)` -> `isinstance(None, str)`.

The frontend bundle is not installed here (no hass_frontend) and there is no browser
to drive, so the rendered dialog is NOT exercised: this pins the backend half of that
path, end to end through the handler's decorators.
2026-10-12 09:42:07 +01:00
engineer 431b7cd8f4 feat(media-player): a switch Receiver is ensured on, and apps leave the browser (t61)
Two halves of the same live-run report.

RECEIVER AS A SWITCH. The user: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". `_ensure_receiver_on()`
runs at the top of the three playback entry points — turn_on, play_media,
select_source — and calls switch.turn_on only when the configured switch
receiver reports `off`: an `on` switch gets no call (idempotent), an
unavailable/unknown/missing state is left alone with a debug line rather than
guessed at, and a failing service call is logged, never raised — a receiver that
cannot be switched on must not stop the Shield from playing. Nothing else about
the switch is touched (no volume, no toggle), and nothing switches it from a
state read, a poll or a listener, so an off receiver stays off while the Shield
is idle. A media_player receiver keeps its behaviour exactly. select_source now
dispatches through the private `_dispatch_play_media`, so one user action looks
at the same switch once; `play_media` keeps its public signature and behaviour.
The Receiver field already accepted switch.* at the validation/strings layer
(t56, pinned by the real-HA tests); the strings copy now says what playback does
with it.

MEDIA BROWSER. The user: "Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps".
`async_browse_media` no longer publishes the app directory: it returns a valid,
standard BrowseMedia root (device title, DIRECTORY class, no content id, no
children) instead of a second, inconsistent copy of the list. Apps stay reachable
through source_list/source/select_source and play_media("app", <package>), which
is unchanged and complete; nothing else on this entity is browsable, so there is
no dead entry to select, and the drift gate's t37 D1 browse assertions still hold.

Tests: tests/test_receiver_switch.py pins all four promised states — off →
exactly one switch.turn_on; on → none; unavailable/unknown/missing/failing →
playback continues and nothing raises; media_player receiver → never switched —
plus the idle-Shield rule. The browse pins move from the removed tree to the
surviving surfaces (source_list, _source_package, _app_directory), and the harness
gains the real hass.states shape (FakeStates) and the real switch state constants.
README documents both changes; both strings files stay byte-identical.
2026-10-12 08:34:53 +01:00
James Mackay ef42682250 fix(media-player): ON means no power or playback fact reported (t58 UNIT 0)
The t59 F2 ruling, implemented. The ladder said ON was for "no source reported
anything at all" but tested for something looser — no transport data — so a
source that ANSWERED "not playing" still fell through to ON. A source that
answers is not a source that is silent, and `on` is the "we cannot know" state.

The ladder now tracks an explicit `reported` flag, set by every source that gave
us a power or playback fact, and the last tier is `IDLE if reported else ON`.
Each case the ruling names now lands on IDLE:

  * a configured Jellyfin entity in ANY state — idle, off, standby, unavailable,
    or even absent from the state machine. It reports "not playing" without an
    early return, so a silent Jellyfin still cannot mask a live media session
    below it (both directions pinned);
  * a live media session that carries no playback flag — the app publishes a
    session but is not playing, paused or buffering;
  * a bare foreground app: either transport's answer counts (the protocol's
    current_app push or the ADB mResumedActivity parse);
  * a configured power entity, whatever it currently says;
  * protocol is_on / ADB awake, plugged or not.

`on` survives only for an entry where none of those exists. The t55 rules are
untouched: an off power entity still outranks a playing session, and no source
may mask playing/paused/buffering.

The module docstring's tier 6, the `state` docstring and the README's ladder
(moving "no data" to the precise wording) all say the same thing now, and the
README's one-line state table was aligned with it.

RED PROOF (the new tests against the parent tree): five of the reporting cases
read `on` where they must read `idle` — `assert 'on' == 'idle'` — plus the
"ON is unreachable with any source configured" shape test.

SIMULATED vs EXECUTED: no real Shield was available, so these are the documented
source shapes, not captures; the state=6 → buffering mapping stays marked
inferred. No browser exists either, so the README pins are textual and the
production behaviour is exercised against real HA's state machine in
tests/test_ha_api_drift.py.
2026-10-12 08:04:06 +01:00
James Mackay 9461d69469 fix(connection): a label dump that returned nothing is a FAILED fetch (t57 UNIT 0)
t59 F1 (HIGH). `ShieldAdbPoller.shell` swallows its error and answers None
(adb_client.py:144-155), and `_fetch_app_label` read that as "the dump
ran and found no label" → `cache_app_label(pkg, None)` → `('', stamp,
resolved=True)`. That is a DEFINITIVE negative for APP_LABEL_TTL = 24 h,
so after ONE transient failure `app_label()` never re-enqueued, exactly
one `pm dump` was ever attempted, and a later good dump could not
recover the label — `sensor.native_value` and `media_player.app_name`
stayed on the package id for every later render.

The dump now carries an exit marker (`; echo __NSH_EXIT:$?`), so the
three outcomes are distinguishable:

* output with an `application-label` → cache the label;
* output that ran and carried none (the marker is still there) → cache
  the empty string, the definitive negative that keeps t24 F1's
  zero-round-trip rule for a genuinely label-less package;
* no output at all (`None`, or blank) → cache NOTHING and let the next
  render re-enqueue, logged at debug.

The `resolved` flag goes with it: `cache_app_label` was its only writer
and always passed True, so an entry in `_app_labels` is now simply "a
fetch RAN" and the dead third field (`(label, stamp, True)`) is gone.

Tests drive the FakePoller-returns-None AND returns-'' shapes through
the sensor and through `media_player.app_name`, plus the label-less
package that must still be negatively cached. The stub poller now models
both real outcomes of a dump (an answer, or a transport that delivered
nothing) instead of conflating them — which is why the old tests could
only pin the raising-shell branch that already worked. 4 of the new
tests fail on the pre-fix tree, showing `('', stamp, True)` stamped.

Also in this commit:
- t59 F3: running_apps.py set `app["state"] = "paused"` twice — one line.
- t59 F4: no captured fixture contains state=6, so the "the Shield's own
  sessions report 6 like stock Android" wording in README and the
  media_session/media_player docstrings is reworded as INFERRED from the
  `state=PlaybackState {state=N, …}` line shape. A `git notes` entry on
  07682ff records the same correction against the t55 commit body.
2026-10-12 07:21:34 +01:00
James Mackay ccde3cff71 fix(media_player): the state ladder is idle/playing/buffering/off; ON means no data (t55)
The live report: the card showed "On" where the user expected Idle,
Playing, Buffering or Off, and "On" appeared to override playing states.

Root cause: `state` returned MediaPlayerState.ON for two different
situations — "powered on with an app open and nothing playing" (tier 3.5)
and "we have no idea" (the fallthrough) — while the power gate above the
media tiers returned IDLE whenever power resolved to None, which is
exactly the case in which Jellyfin a media session DO know what is
playing. Media truth was therefore lost whenever the protocol push and
the ADB wake poll had not reported yet.

The ladder is now:

  1. OFF     — the power entity says off, or the protocol is_on push is
               False. Explicit power outranks media, so a stale Jellyfin
               session cannot resurrect playback on a device that is off.
  2. intent  — a just-sent play/pause/stop.
  3. Jellyfin — playing / paused.
  4. session  — the ADB media session: buffering / playing / paused.
  5. IDLE     — ADB standby (powered but asleep), or the device is on
               (protocol is_on / ADB awake / the plug) with nothing
               playing. "Powered on, app open, nothing playing" is IDLE.
  6. ON       — nothing reported anything at all: no transport, no power
               entity, no Jellyfin, no session. ON never masks a
               playing/paused/buffering source and never appears while
               any source reported a power or media state.

Buffering also carries the playback controls: a buffering session
advertises PLAY/PAUSE/STOP exactly like a playing one, and the
now-playing dict names it "buffering" instead of collapsing it into
"playing" and contradicting the entity state.

The module docstring documents the ladder and the ON-only-with-no-data
rule, and the new TestStateModelT55 / TestCurrentAppNameEndToEndT55 pin
one test per source (Jellyfin, intent, media session, power entity,
protocol push, ADB wake) that a reporting source outranks ON/IDLE.

Red-proof against the previous tree (new tests, old source): 21 failed /
17 passed, with the three headline symptoms visible as literal asserts —
"on" where "idle" belongs, "idle" where "on" belongs (no data), and
"playing" lost because power was unknown.
2026-10-12 06:53:04 +01:00
James Mackay 89c05f1dcd feat(media_player): per-command script overrides (t44)
The user's remaining universal-media-player dependency: some commands need hardware
this integration deliberately does not drive — a speaker switch that owns the
volume, an AVR input that has to move with the Shield, a mute that has to hit two
devices. One script per command is the escape hatch, and the rule is absolute: a
configured command calls its script INSTEAD of the native protocol/ADB path, and
with nothing configured every command behaves exactly as it did (the whole existing
suite passes untouched, which is what "byte-identical" means here).

* const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command),
  SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set,
  volume_mute, select_source) and command_scripts(), the ONE normalizer of the
  stored shape — options round-trip through HA storage and can be hand-edited, so a
  value that is not a non-empty string for a known command is dropped instead of
  reaching the service layer.
* media_player.py: async_run_command_script() calls script.turn_on with the script
  entity and its variables, blocking=True so a failure surfaces HERE — a mapped
  command that cannot run raises HomeAssistantError naming the command and script
  rather than falling back silently or doing nothing. Every command method checks
  its script first; volume_set passes `level` and select_source passes `app_name`.
* volume_mute also passes `is_volume_muted`: without it a mute script cannot tell
  muting from unmuting, which is the case the user asked for.
* volume_set and select_source have NO native path (C-VOL; app launching goes
  through play_media), so they are advertised only when configured — the feature
  flag is what makes HA offer the command at all. Unset leaves the feature set
  exactly as it was.
* remote.py shares async_run_command_script for POWER: turn_on/turn_off is one
  configuration and the remote entity is the other route to it, so a script that
  owns the power sequence owns it on both.
* config_flow.py: one entity selector per command (domain: script), stored as the
  single dict option, prefilled from the entry's options and cleared by saving
  without a value — the editor is the truth, as with the app map editor.

Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no
SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine
disagree with a wrong name), and the stub `homeassistant.exceptions` had no
HomeAssistantError, so the stub now mirrors the real inheritance
(ServiceValidationError derives from it).

tests/test_command_scripts.py covers each command mapped and unmapped, the exact
variable payloads, the native path untouched when unmapped (protocol key, then the
ADB fallthrough with the protocol down), the rejecting script's error, hand-edited
option shapes, and the feature set. test_config_flow_ux.py covers the selectors,
the stored shape, prefill and clearing. New tests leaked the harness into the next
file at first (a missing per-file scope); that is fixed, and
tests/test_harness_scoping.py now asserts that every file loading the harness scopes
it, so the next such mistake fails where it is made.

Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order
401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
2026-10-11 06:47:20 +01:00
James Mackay 2e1d162ef4 test(harness): make the stub scoping independent of file order (t44)
A subset of test files in an order the suite was not written for failed 3
test_init.py::TestImportStep tests with ModuleNotFoundError: voluptuous, while the
full suite stayed green. Two order-only mechanisms, both now fixed at the source:

1. entity_stublib.install() guarded on ONE sentinel
   (`homeassistant.components.media_player`). A file whose teardown popped
   `voluptuous` but left the sentinel turned the next file's install() into a no-op,
   so importing config_flow failed. The guard now checks every face a file scopes on
   (`_STUB_FACES`, derived from ENTITIES_STUB_NAMES so the two cannot drift) and
   rebuilds the set when anything is missing. install() also registers the
   `homeassistant.helpers.selector` face itself, which the per-file installers had
   been adding separately — without it the derived list would never be complete.

2. Teardowns restored only the names their file had listed, so a module the file
   loaded (`nsh.diagnostics`, `nsh.media_player`) survived into the next file still
   holding stub objects — a stub exception class, the redact helper — that had since
   been replaced; comparisons against the new objects then failed (5 tests in
   test_command_routing on an unlucky order). Every file now ends through
   entity_stublib.snapshot_modules()/restore_modules(), which drops EVERYTHING the
   file created under the harness namespaces (homeassistant/voluptuous/nsh) and puts
   back everything it replaced. Hand-written lists were the bug, so there is none to
   keep in sync.

The t7 no-leak discipline is unchanged: a file's stubs still never reach another
file, and a file's loaded modules still do not survive it. A session-scoped baseline
install was tried first and rejected: files whose own installer guards on "the stubs
are already there" skipped installing their richer faces, which produced exactly the
inconsistent state a baseline is meant to prevent (10 test_standalone_setup failures).

tests/test_harness_scoping.py: the sentinel poisoning, the wholesale-rebuild
invariant, a teardown that removes what the file loaded, the stale-object case
(diagnostics vs the redact helper), and — because order is the thing under test —
two subprocess runs of the reported orderings.

Verified: the reported order (test_command_routing, test_protocol_transport,
test_standalone_setup, test_init) 152 passed / 1 skipped; the minimal pair both ways;
the previously failing 6-test order now 167 passed; every test file in REVERSE
alphabetical order 369 passed / 2 skipped; two scrambled thirds green; full suite
369 / 2 and 370 / 1 with androidtvremote2; ruff clean.
2026-10-11 06:41:56 +01:00
James Mackay aea9f9f9d2 test(config): pin the Jellyfin name tiers and the address redaction (t41)
Two acceptance items that had no test of their own:

* the Jellyfin auto-detect tiers. t41 replaced the entity-derived search key
  (the Android TV Remote entity's friendly_name) with the names setup already
  knows — the name the user typed, then the discovery device name. Three tests
  pin it, the third being the regression: a Jellyfin player matching the *atv*
  friendly_name, with a serving state, used to be auto-detected; now only the
  typed/discovered names search. Red proof on the pre-t41 tree (60d884b):
  `_find_jellyfin_entity(hass, "jellyfin shield")` DID match
  media_player.jellyfin_shield there.

* diagnostics redaction of the renamed address field. The G14 field list already
  carried "host" (alongside the legacy spellings), but the test only exercised
  the legacy keys — so a future edit could have dropped the live one unnoticed.
  The fixture and assertion now cover both spellings.

Verified: 337 passed / 2 skipped, 338 / 1 with androidtvremote2, ruff clean.
2026-10-11 06:26:42 +01:00
James Mackay 86aa2827a2 refactor(config): drop the androidtv media_player entity path end-to-end (t41)
The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.

Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
  offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
  and, with it, every registry lookup for another integration's devices — the
  user step now sets NO unique_id at all; the protocol MAC learned during
  pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
  now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
  resolve_app_name chain, the Current App sensor, the sensor's state-change
  listener and its `_androidtv_state` helper) — the manager's store is the only
  source;
* the "transitional external entity" command tier in every path: `_cmd` and the
  pan-media_player service call are gone, and so is the fallback in _media_cmd,
  _remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
  (an intent no transport served now surrenders to the truth instead of
  delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
  intent" check no longer watches another integration's entity; the power
  LADDER — protocol is_on → ADB wake → power switch — is unchanged).

turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.

helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.

Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.

Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
2026-10-11 06:09:43 +01:00
James Mackay 60d884b76a fix(availability): F3 — any usable transport keeps the entities up (t40)
Reported from a live install: with ADB enabled but unreachable and a paired,
healthy protocol transport, the media player went unavailable even though the
protocol still served power/app/volume.

The old rule was ``manager.connected or manager.awake is not None or not
manager.available`` — i.e. "a configured ADB manager that is down ⇒ the whole
entity is down" (t5-F2), which ignored transport B entirely. The ladder is now:

* protocol transport wired and not reported unreachable — usable;
* or ADB connected — usable;
* or fresh wake data (the poller has spoken) — usable;
* or a passive manager (no ADB configured at all) — usable, because nothing to
  transport is not a broken transport.

Unavailable only when a CONFIGURED transport is actively down and no usable data
exists. One implementation, ``entity_available`` (media_player.py), is used by the
media player, the remote (whose ladder previously duplicated a weaker version of
this rule) and the diagnostics payload.

Diagnostics grew ``transport.entity_available`` plus ``adb.configured`` so a
support dump answers "why is my entity unavailable?" — the ladder inputs and its
verdict are now in the same place.

Pinned by tests: the exact user case (ADB configured+down, protocol up ⇒
available), all-down (⇒ unavailable), wake-data rescue, connected ADB without a
protocol, the passive manager, a configured-but-down transport with no data, the
remote following the same ladder, and the diagnostics verdict.

Verified: 310 passed / 2 skipped, 311 passed / 1 skipped (androidtvremote2),
16 passed in the HA drift suite, ruff clean.
2026-10-11 06:02:46 +01:00
James Mackay dd23d01a88 fix(sensor): F2 — the Current App sensor shows the resolved app name (t40)
Reported from a live install: the sensor showed ``ar.tvplayer.tv`` while the
media player showed "TiviMate" — the same foreground app, two identities.

The sensor's ``native_value`` returned ``manager.foreground_app`` (the raw
package push) or ``running_apps[0]["package"]`` as-is, while the media player
resolved names through apps map → cached manifest label → androidtv entity tier.
Both now call ONE function, ``resolve_app_name`` (media_player.py), so the two
entities cannot disagree again; the sensor keeps the raw package as its
``app_id`` attribute and ``app_name`` carries the shown name.

Per the contract:
* apps map wins, then the cached manifest label, then the androidtv entity's
  ``app_name``, then the package text itself;
* a bare or unknown package falls back to the package text, NOT None (it used to
  be possible for the value to vanish while the app was plainly running);
* pinned by tests: map tier, label tier, entity tier, the ADB-recents source, the
  unknown-package fallback, and sensor == media player for the same inputs.

Verified: 302 passed / 2 skipped, ruff clean.
2026-10-11 06:02:30 +01:00
James Mackay bd72ae3170 fix(diagnostics): D2 — redact through homeassistant.helpers.redact (t37)
`homeassistant.util.diagnostic` does not exist — verified against a real
install: `import homeassistant.util.diagnostic` raises ModuleNotFoundError, and
the helper has always lived in `homeassistant.helpers.redact`. The import sat
inside a `try/except ImportError` that set the helper to None, so the
documented G14 pass — the one carrying `_TO_REDACT`, hosts included — silently
did nothing; only the marker scan (pairing-/key-shaped names) masked anything,
which is why the existing test's pairing-code assertion stayed green.

The helper is now imported at MODULE scope from homeassistant.helpers.redact and
applied unconditionally after the marker-name pass. A missing module is a loud
import error at integration load instead of a silent downgrade.

* the diagnostics test now asserts `adb_host` and `protocol_host` really are
  masked in the emitted payload (options and a nested data value);
* TestFaithfulHaStubs asserts the module USES the helper from that path
  (`nsh.diagnostics.async_redact_data is homeassistant.helpers.redact.async_redact_data`),
  that no production module imports the non-existent module (AST), and that the
  try/except ImportError fallback is gone;
* `entity_stublib` gains a faithful `homeassistant.helpers.redact` stub (same
  masking, same nested mapping/list recursion, same None/empty-string skip) so
  the harness meets the import the way a real install does.

Verified: 287 passed, 2 skipped (D2 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
2026-10-10 21:20:04 +01:00
James Mackay 37b2accfda fix(media_player): D1 — browse builds real BrowseMedia nodes, not BrowseMediaSource (t37)
`homeassistant.components.media_player` has not exported `BrowseMediaSource`
for years: the class lives in `homeassistant.components.media_source.models`,
where it exists to build `media_source://<domain>` ids and is the only place a
`domain` parameter belongs (t30 G18's note was about THAT class). The local
import therefore raised ImportError, and with it every real browse failed —
while the suite stayed green because the harness stubbed a `BrowseMediaSource`
that real HA does not expose from there.

`async_browse_media` now builds the standard tree exactly as core's own
androidtv_remote/media_player.py does: `BrowseMedia` imported from
homeassistant.components.media_player at module scope, every node carrying an
explicit media_content_id/media_content_type (MediaType.APP/APPS),
`children_media_class=MediaClass.APP` on the directory, and no `domain`.
Verified against a real install: the old import raises ImportError, the old
`domain=` kwarg raises TypeError, and the new node constructs.

The harness is now faithful so this cannot hide again:
* `entity_stublib` exposes a `BrowseMedia` with the real keyword-only
  signature, `as_dict()` and `calculate_children_class()`, plus `MediaType`,
  and deliberately NOT `BrowseMediaSource` — so both the drifted import and the
  drifted kwarg fail in-suite;
* the app-directory test asserts the standard shape (classes, explicit ids and
  types, children_media_class, the serialised payload) instead of the removed
  `domain` attribute;
* TestFaithfulHaStubs pins the harness contract on the AST (no production
  module imports BrowseMediaSource) rather than on prose.

Verified: 284 passed, 2 skipped (D1 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
2026-10-10 21:19:53 +01:00
James Mackay 2e70587871 feat: standards polish — G7 sensor translation_key naming + entity strings, G10 protocol-push device identity on the registry card, G12 unrecorded poll-driven attributes (media player DAG internals + sensors), G14 async_redact_data with an explicit redaction list behind the marker scan (t30) 2026-10-09 07:22:23 +01:00
James Mackay 194a97ce34 feat(button,media_player): Pair ADB carries entity_category CONFIG (t30 G11) + BrowseMediaSource domain parameter at root and children (t30 G18, 2026-05-20 blog contract); stubs/tests pin both 2026-10-09 07:17:14 +01:00
James Mackay 3e0208789a feat: adb_command is a conforming action — always registered in async_setup (option gates USE), services.yaml with required command/entity, strings services+exceptions, ServiceValidationError on empty/unknown-target/ADB-down; participant registry stays (t29 G8) 2026-10-09 06:59:44 +01:00
James Mackay 2e63135745 test(media_player): spied sleep proves a non-default digit delay (0.3 s) reaches the channel sequence — option consumed per digit (t26 F3) 2026-10-09 06:11:28 +01:00
James Mackay a3b397920e test(config_flow,media_player,remote): digit-delay/send-enter options (store + window/error validation), IME-first text entry with ADB fallback tier, icon column parse/prefill/store (P5-2) 2026-10-09 06:00:17 +01:00
James Mackay b37a14b920 fix(media_player): the adb_command participant registry key is computed once at registration and stored on the entity — add/remove always match (F4) 2026-10-08 11:21:09 +01:00
James Mackay 326f02cb59 feat(media_player): BrowseMedia app directory — root lists the options apps map (curated TiviMate/Wholphin/Netflix/YouTube fallback), selection launches via play_media(app) (P4-2c) 2026-10-08 11:12:37 +01:00
James Mackay 3b8e97fe5b fix(media_player): privacy bare-app identity outranks the channel-title fallback in the foreground tier (F3) 2026-10-08 10:52:24 +01:00
James Mackay 392553ee26 fix(media_player): channel-title fallback gated to the channel app (TIVIMATE_PACKAGE or audio_playback membership) — launcher never shows a channel number (F1) 2026-10-08 10:52:08 +01:00
James Mackay bc457baae7 feat(media_player): foreground tier in the state ladder with app-source now-playing (last-known → last_channel → audio hint), sensor reads the store's foreground priority chain (P3-3a,c) 2026-10-08 10:45:58 +01:00
James Mackay 34d09e9f70 feat(media_player): app_id/app_name prefer the androidtv entity, fall back to the protocol current_app push when absent/unavailable; manifest androidtvremote2 floor aligned to >=0.2.0 (P3-1b,c) 2026-10-08 10:28:56 +01:00
James Mackay 5f8c3290cc fix: flow VERSION=2 for new entries; optional ENTER gains the ADB keyevent-66 fallback; CI installs androidtvremote2 for the real-library tests (F4, F5, F6) 2026-10-08 10:12:12 +01:00
James Mackay b451638c65 test: command-routing and play_media(channel) orchestration — digit sequence with cancel guard, fallback ordering protocol→ADB→legacy, remote send_command holds/repeats, volume readout, is_on ladder 2026-10-08 09:54:23 +01:00