Skip to content

Grouped accessories: parent/child model, location backfill, Device Manager UI#11

Merged
manonstreet merged 16 commits into
v1.4-betafrom
feature/airpods-group-naming
May 6, 2026
Merged

Grouped accessories: parent/child model, location backfill, Device Manager UI#11
manonstreet merged 16 commits into
v1.4-betafrom
feature/airpods-group-naming

Conversation

@manonstreet

Copy link
Copy Markdown
Owner

Summary

Apple's Find My data models accessory pairs (e.g. AirPods Pro) as a parent device in Devices.data plus N children in Items.data joined by groupIdentifier. FMS+ historically threw the relationship away — children surfaced as flat top-level entities ("Case", "Left Bud", "Right Bud") and parent groups appeared as a separate fourth entry, with no signal connecting them. Users with multiple pairs saw indistinguishable duplicates, and parent entities reported stale (isOld: true) locations even when their children had fresh GPS data (closes #10).

This change makes the parent/child relationship first-class:

  • Parser (CacheDecryptor.parseDeviceArray): accepts a groupParentIDs map and stamps parentID onto child DevicePoints whose groupIdentifier resolves to a known parent.
  • DevicePoint: gains a parentID: String? field, preserved through with().
  • SyncEngine.readAndParseCaches: restructured into 4 phases — read+decrypt all caches into raw, build the parent map from Devices.data itemGroup entries, parse to DevicePoint with the map applied to items, then backfill any parent whose location is stale (isOld: true or older than the freshest child by ≥ 60 s) with the freshest child's lat/lon/accuracy.
  • Posting layer: drops unaliased grouped children before posting. The parent group is the canonical entity for HA; sub-items only publish if the user has explicitly aliased them. Aliased children continue to publish — existing user setups keep working with zero migration.
  • Device Manager UI: parent rows show a hover-revealed disclosure pill (accent-blue chevron in a faint pill) on the right; clicking expands to indented children. Aliased grouped parents stay visible in the Unassigned list with a green "Assigned" badge and disabled "Assign" button — chevron still works so users can drill in and alias individual buds (e.g. for finding a lost one).
  • Bumps MARKETING_VERSION to 1.4.0b — this is a feature line, not a patch.

Backward compatibility

  • Existing aliases keep working. Aliased UUIDs (parent or child) continue to publish exactly as before.
  • No settings migration. hideGroupedChildren was prototyped, then dropped — the per-parent disclosure pill plus the unconditional posting filter cover both UI hide and posting concerns more cleanly than a global toggle.

Test plan

  • 70 unit tests across 5 files, all passing — including:
    • parentID plumbing on DevicePoint, parser, with()
    • filterUnaliasedGroupedChildren: keeps parents, drops unaliased children, keeps aliased children, ignores ungrouped items
    • backfillParentLocations: replaces isOld parent location, leaves fresh parent alone, replaces parent older than child by ≥ 60 s, no-op when no children
  • Manual: live AirPods Pro pair on Apple Silicon — parent shows in Device Manager with chevron, expanding shows Case/Left/Right indented; backfilled parent location matches the freshest child's; existing AirPods alias still publishes; aliasing the parent leaves it visible with the "Assigned" badge.

Documentation

  • Docs/DEVICE-MANAGEMENT.md — new "Grouped Accessories" section
  • CLAUDE.md — test count, parentID note, convention line
  • README.md — Features bullet

manonstreet and others added 15 commits May 5, 2026 19:25
Children of a grouped device (e.g. AirPods Case/Left/Right Bud whose
groupIdentifier matches a parent's baUUID) carry their parent's id so
upstream can reason about hierarchy. Defaulted to nil so existing call
sites compile unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
parseDeviceArray accepts an optional groupParentIDs map (child's
groupIdentifier -> parent's id). Children whose groupIdentifier resolves
to a known parent receive parentID; everything else stays nil. The
function remains pure — naming/visibility decisions live upstream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
readAndParseCaches now reads+decrypts all candidates into raw dicts in
phase 1, builds a groupIdentifier->parentID map from Devices.data parent
groups in phase 2, then parses to DevicePoint with the map applied to
items in phase 3. Children of grouped accessories now carry parentID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Controls whether unaliased grouped children (e.g. AirPods Case/Left/Right
Bud) appear in the Device Manager and publish to HA. Aliased children
always remain visible/published. Defaulted on so new and upgrading users
see a tidy Device Manager out of the box.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds filterHiddenGroupedChildren helper and applies it inside
buildPlanAndLog before constructing PlanPhase. Aliased children are
always kept; only unaliased entries with parentID set are dropped.
Logs the dropped count at debug level.

New SyncEngineGroupingTests target file (added to project.pbxproj
since the test group is an explicit PBXGroup, not a synchronized one).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a parent group's location is flagged isOld=true or is older than
its freshest child by 60s+, replace it with the freshest child's lat/
lon/accuracy. Preserves parent's id, name, battery, prsId, parentID.
Wired into readAndParseCaches as a Phase 4 step after parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds baseUnassignedAfterGroupHide derived property and routes
filteredUnassigned through it. Aliased children are already excluded
from baseUnassigned, so the toggle only drops remaining (unaliased)
children with parentID set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds ParentDisclosureRow view; ephemeral collapsedParents @State on
DeviceManagerView. unassignedList partitions filteredUnassigned into
top-level entries and children grouped by parentID; parents with kids
get a chevron and indented children below.

Orphans (children whose parent isn't in the filtered list, e.g. when
the parent has been aliased and is no longer in the unassigned list)
fall back to flat top-level rendering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compact checkbox bound to settings.hideGroupedChildren, placed alongside
the existing source filter picker in the Unassigned section header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ildren

The per-parent disclosure chevron in the Device Manager already covers
the UI hide/show concern, so the global toggle was redundant. Posting
behavior is now unconditional: unaliased grouped children are always
dropped from the post list. Aliased children continue to publish, so
existing user setups keep working.

Removed:
- SettingsStore.hideGroupedChildren
- DeviceManagerView "Hide sub-items" toggle and its onChange
- baseUnassignedAfterGroupHide derived property
- filterHiddenGroupedChildren's hideEnabled param

Added/changed:
- SyncEngine.filterUnaliasedGroupedChildren (pure unconditional filter)
- DeviceManagerView state inverted: expandedParents (default empty) so
  parents are collapsed by default. Click chevron to expand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Parent row chevron is hidden at rest and appears in a faint gray pill
on hover, with an accent-blue chevron inside. Pill stays visible while
expanded so users can collapse without re-hovering. Childless rows
unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CLAUDE.md: bump test count 58->70, add SyncEngineGroupingTests, note
  parentID on DevicePoint, add convention line on grouped accessory
  recognition.
- README.md: add Features bullet under Device Manager.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a grouped accessory's parent is aliased, the parent stays in the
Unassigned list (instead of disappearing and orphaning its children).
The row gets a green "Assigned" pill next to the source badge, the
Assign button is disabled, and the disclosure pill still works so
users can expand and individually alias children (e.g. for finding a
lost AirPod).

Suppresses the rotated-UUID "Update" label when the UUID is already
in the alias — clicking would no-op and reading "Update" was misleading.
The actual UUID rotation flow still works because rotation produces a
new UUID, which is not aliased.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Grouped accessory support is a new feature, not a patch — bump the
minor version to mark the 1.4 line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@manonstreet manonstreet changed the base branch from v1.3-beta to v1.4-beta May 6, 2026 01:05
AccessSettingsView had two pre-existing long tooltip/message strings
exceeding the 200-char line_length error threshold. Splits each across
multiple lines via string concatenation. No behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@manonstreet manonstreet merged commit 9ccad5a into v1.4-beta May 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Device Manager Question

1 participant