Skip to content

fix: do not align ahead of the accessory on secondary key reports - #275

Open
lonsdaleite wants to merge 3 commits into
malmeloo:mainfrom
lonsdaleite:fix/secondary-key-alignment
Open

lonsdaleite wants to merge 3 commits into
malmeloo:mainfrom
lonsdaleite:fix/secondary-key-alignment

Conversation

@lonsdaleite

@lonsdaleite lonsdaleite commented Oct 7, 2026 •

Copy link
Copy Markdown

Part of #273: the overshoot from secondary key reports, as suggested in this comment. It does not fix the tag reported in #273 itself, see Not covered.

Problem

A secondary key is shared by up to two days of primary indices: _secondary_keys_at returns ind // 96 + 1 and + 2, so S(s) is generated for every primary index in [(s - 2) * 96, s * 96 - 1]. On a report under a secondary key, _fetch_accessory_reports aligned to the highest of the indices it had generated the key for.

That overshoot also grows over repeated fetches. The next fetch starts its search at get_max_index(now), which is the alignment index plus the time since the report. Reports arrive late, so the search ceiling is now above the previous alignment, the same secondary key is matched at a higher index, and the alignment moves further ahead. It stops only when the key's range runs out at s * 96 - 1. Because update_alignment never moves back, a correct observation of the primary key cannot undo it.

Observed

An official AirTag (paired 2022), tracked through hass-FindMy with local BLE matching and an account fetching reports every 15 minutes:

  • While near its owner, the nearby keys gave index - (unix_time // 900) = −1827446 for days, matching the alignment record from the iCloud export (−1827447). The tag's primary index runs in step with the clock.
  • Every day at 04:00 local time, the tag switched to secondary key primary // 96 + 2 (at primary % 96 = 17–18), as expected.
  • On two mornings, the alignment moved ahead in steps of 7 to 21 indices, one step per fetch, while the tag broadcast its secondary key: by +153 on 2026-09-26 and by +154 on 2026-10-03, within about four hours each time.
  • hass-FindMy matches local advertisements against keys from −36 h to +12 h around the alignment. With the alignment ~38 h ahead, the current keys fell outside that window, and the tag was reported away for about 24 hours in total while it was at home. Each time it resolved itself only once the tag's real index caught up with the stuck alignment.

Change

_align_to_report aligns to the lowest index sharing the matched key, with the alignment date moved back by the width of the range (high - low intervals):

For a primary key the range is a single index, so primary reports align exactly as before. update_alignment still ignores anything older than the current alignment date, so a secondary report no longer moves an alignment that came from a newer primary observation.

key_to_ind only holds the indices generated so far, because keys are generated in batches from the ceiling down, so _key_index_bounds extends the range of a secondary key to all indices keys_at returns it for (at most 192). That takes up to 192 keys_at calls (about 30 ms), and a fetch can return many reports for one secondary key, so the range is kept per key for the duration of the fetch.

Not covered

  • The tag in Old AirTag: alignment from the first fetch is ~40 h ahead of what the tag broadcasts; with the BLE-observed alignment no reports are found #273 used a secondary key 3–4 above primary // 96, beyond the +1 / +2 that _secondary_keys_at generates. For such a tag the lower end of the key's range is still above the index it uses, so the alignment would still end up ahead of it. That is a separate gap in the key model; this change does not search further ahead, and the invariant above only holds for tags that use one of the keys keys_at returns.
  • An alignment that already moved ahead is not moved back: update_alignment only accepts higher indices. Such an accessory stays misaligned until its real index catches up (up to about two days), or until the alignment is corrected by hand.
  • A secondary report is only accepted when the current alignment is older than the backdated date, i.e. there was no primary observation for about two days. The alignment then carries up to two days of uncertainty until the next primary observation. For local matching around the alignment (hass-FindMy uses −36 h / +12 h) that is about 48 more candidate indices than with an exact primary alignment.

Testing

  • tests/test_alignment.py:
    • index bounds of primary and secondary keys, and that the bounds of a secondary key are searched once per fetch;
    • ten fetches 15 minutes apart that keep finding the current secondary key, with reports two hours late, starting from a fresh alignment: the alignment must not move ahead of the accessory. With the previous code the minimum index overtakes the accessory at the 9th fetch (960037 > 960029);
    • the same starting from a week-old alignment: the alignment must move up to within two days of the accessory without passing it. This fails with the previous code and with no alignment update at all.
  • pytest, ruff check, ruff format --check and basedpyright (versions from uv.lock) pass.
  • Not yet run against Apple's servers: I would run it in Home Assistant through hass-FindMy and report back.

A secondary key is shared by up to two days of primary indices, and
`_fetch_accessory_reports` aligned to the highest of them. The next fetch
starts its search at the maximum index derived from that alignment, so it
matched the same secondary key at a higher index again, and the alignment
kept moving ahead of the accessory: by about 150 indices (38 hours) in a
few hours for an AirTag that was away from its owner in the morning.

Align to the lowest index sharing the key, with the alignment date moved
back by the width of the range, so that the minimum index never exceeds
the index the accessory used and the maximum index never falls below it.
For a primary key the range is a single index and nothing changes.
…lignment

A fetch can return many reports for the same secondary key, and finding
its range takes up to 192 `keys_at` calls (about 30 ms), so the range is
now kept per key for the duration of the fetch. Primary keys belong to a
single index and are not cached.

The existing test started from an alignment newer than the backdated
date, so every update was rejected and it passed even without any
alignment. Add a test that starts from a week-old alignment, which a
secondary report has to move up to the lower end of the key's range
without passing the accessory.
@lonsdaleite
lonsdaleite marked this pull request as ready for review October 10, 2026 10:06
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.

1 participant