Skip to content

About

Which of your Home Assistant custom integrations stop working, and in which future release. Daily crawl of every HACS integration for deprecated HA APIs + a HACS-installable integration that reports on your own box.

Topics

Resources

Contributing

Security policy

Stars

21 stars

Watchers

0 watching

Forks

Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

Breakage Radar for Home Assistant

Which of my custom integrations stop working, and in which Home Assistant release?

Home Assistant announces API removals a year ahead on the developer blog and marks them in code with breaks_in_ha_version=. Core integrations get migrated. Custom integrations mostly do not — and the author of the integration you installed from HACS two years ago may not be reading the developer blog at all.

Worse, the warning never reaches you. In homeassistant/helpers/frame.py:

def report_usage(
    what: str,
    *,
    breaks_in_ha_version: str | None = None,
    core_behavior: ReportBehavior = ReportBehavior.ERROR,
    core_integration_behavior: ReportBehavior = ReportBehavior.LOG,
    custom_integration_behavior: ReportBehavior = ReportBehavior.LOG,
    ...
) -> None:

custom_integration_behavior defaults to LOG. A custom integration that uses a doomed API gets one line in home-assistant.log — while it still works. On upgrade day it simply stops. And Repairs deliberately will not carry the warning; from the architecture discussion that approved the legacy device tracker removal (#1375):

"Repairs must be user actionable, and in this case, they can't solve it." — Frenck

Breakage Radar closes that gap. It reads the removals out of Home Assistant's own source, crawls every custom integration in the HACS catalogue for them, publishes the result as a public index, and ships a Home Assistant integration that tells you which of your installed integrations are on the list and when they die.

📊 Board: https://booyaka101.github.io/hass-breakage-radar/ — what breaks when: anything already past its release date first, then what breaks within 90 days, with everything later collapsed below. Every release heading carries its estimated date ("Home Assistant 2026.10 - 7 October 2026 - in 46 days", first Wednesday of the month per Home Assistant's published schedule). 🤖 Index: https://booyaka101.github.io/hass-breakage-radar/index.json (schema 1, with release_date and days_until on every entry and release) 📡 Feed: https://booyaka101.github.io/hass-breakage-radar/feed.xml — one item per Home Assistant release, carrying what that release removes and which integrations still use it, so you can follow along without polling the index. Paste it into a feed reader, or into Home Assistant's own feedreader integration; open it in a browser and it renders as a page.

In the published index right now: all 4 009 HACS repositories crawled (3 243 integrations and 766 Lovelace plugins, 27 unreachable), 826 affected, 2 158 findings, across 8 Home Assistant releases: 10 in 2026.10, 16 in 2026.11, 11 in 2027.5, 45 in 2027.6, 26 in 2027.7, 588 in 2027.8, 134 in 2027.9 and 216 in 2027.10 (counted by distinct integration domain). 64 of the 124 announced removals have a matcher behind them; the board says so on itself, and lists the other 60 by release under "Announced removals with no detector", for the deadline alone. Three markers are refused as too vague to match, which the board also states: InfraredEntity, a class name too short to match on its own, and the two English words the extractor used to mistake for keyword names; a short name pinned to its module or scoped to its entity base class is matched anyway. Every number comes from a real crawl; nothing is seeded or simulated. The daily job keeps these moving, and coverage in index.json is always authoritative.

The public board: a count of integrations breaking within 90 days, then each Home Assistant release with its estimated date and the time remaining

A Home Assistant repair notification listing which custom integrations break in which release, with dates


How this differs from Spook, and from reading the log

Two questions come up every time this gets posted, so they belong here rather than in a comment thread.

Spook inspects the instance you are running and reports what is wrong with it now: entities an automation references that no longer exist, action calls that point at nothing, orphaned entities an integration left behind, YAML it cannot parse. It surfaces those through Repairs and will often fix them for you. It does not read the source of your custom integrations, and it has no opinion about a core API that works perfectly today and is deleted in 2027.8. That second thing is the only thing Breakage Radar looks at, so the two barely touch. Run both. If Spook has since grown a check that overlaps, open an issue and this paragraph gets corrected.

The log is the closer call, and it is a real answer. Home Assistant calls report_usage() when deprecated code actually runs, and for a custom integration that lands in home-assistant.log naming the release that removes it. Grep your log and you will catch a lot of this.

What a log cannot report is a call that did not happen. A deprecated call inside an error handler, or behind a config option you never set, stays silent until the day it fires, which may well be the upgrade that removes it. A log also cannot tell you about an integration you have not installed yet, and it cannot help an author check their own repository without a running instance. Static analysis reads every branch of the source, covers the whole HACS catalogue, and runs in a checkout.

Where the log wins is precision, and that is worth saying plainly: it observed the call, so there is nothing to argue about, while a finding here is a static match and can be wrong. #25 tracks running a real log against the rule set as an answer key, in both directions.


Two halves in one repository

What it is Where it runs
A. The crawler tools/ — extracts rules from Home Assistant core, fetches the HACS integration and plugin catalogues, scans each repository's custom_components/**/*.py and card JavaScript, publishes docs/index.json + a static board GitHub Actions, daily
B. The integration custom_components/breakage_radar/ — downloads the index every 12 h, matches it against what is installed, runs the same matchers over your own installed source and cards, exposes one sensor and raises a repairs issue Your Home Assistant box

No server, no account, no API key, no third-party runtime dependency. The integration's manifest.json declares "requirements": [].

Inside custom_components/breakage_radar/, one job per module:

Module Job
__init__.py, config_flow.py, const.py standard Home Assistant wiring
coordinator.py fetch the index, drive the scan off the event loop, cache it
discovery.py what is installed ({domain: version} from each manifest.json)
scanner.py run the matchers over installed source; count what it could not read
report.py decide what index + local scan add up to, and at which level
repairs.py, sensor.py how that surfaces in Home Assistant
rules_engine.py the AST matchers, vendored byte-for-byte from tools/
schedule.py release label → estimated date, vendored the same way

discovery.py, scanner.py and report.py import no homeassistant symbols at all, which is why the exact code that runs on your box is unit-tested without a Home Assistant install.


Install (Home Assistant side)

Via HACS (custom repository)

Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.

Or by hand:

  1. HACS → ⋮ → Custom repositories
  2. URL https://github.com/Booyaka101/hass-breakage-radar, category Integration
  3. Install Breakage Radar, restart Home Assistant
  4. Settings → Devices & Services → Add Integration → Breakage Radar → Submit

Manually

Copy custom_components/breakage_radar/ into your Home Assistant config/custom_components/ directory and restart, then add the integration from the UI.

The config flow has a single confirmation step. Two settings are available afterwards under Configure: how far ahead a deadline gets its own notification, and any integrations you want left out of the report entirely.

What you get

sensor.breakage_radar_affected — the number of installed custom integrations with at least one finding. The full report, including every message and link, is one click away under Download diagnostics on the integration's page.

Findings come from two sources, and every findings entry says which:

  • source: local — the integration parsed the installed bytes in your own custom_components/ directory with the same ten AST matchers the crawler uses. Forks, renamed copies and integrations installed outside HACS get a real verdict this way, and there is no version skew: the scanned version is the installed version.
  • source: index — the published index's verdict, used where the local scan could not reach one (for example a file over the size caps).
state: 2
attributes:
  schedule:
    - release: "2027.5"
      due: "May 2027, about 9 months away"
      when: imminent
      domains: [some_tracker]
      count: 1
  findings:
    - domain: some_tracker
      rule_id: legacy-device-tracker-platform
      breaks_in: "2027.5"
      file: custom_components/some_tracker/device_tracker.py
      line: 12
      confidence: high
      source: local
      when: imminent        # broken_now | imminent | upcoming
      days_until: 21
  index_generated_utc: "2026-08-11T03:57:51Z"
  affected_domains: [some_tracker, another_integration]
  broken_now: {}
  broken_now_count: 0
  imminent:
    some_tracker: {release: "2027.5", days: 21}
  imminent_count: 1
  alert_window_days: 30
  not_analysed: [broken_thing]
  clean_count: 4
  files_scanned: 412
  unparsed_files: 1
  skipped_files: 0
  earliest_release: "2027.5"
  total_findings: 5

A domain that is nowhere in the index but parses clean locally lands in the clean count; only a domain neither side could analyse stays in not_analysed. files_scanned, unparsed_files and skipped_files make a truncated scan visible — it is never silently reported as clean. The scan runs in an executor and is cached on each integration's file count, newest mtime, total size and the rules fingerprint, so the 12-hourly refresh re-parses nothing that has not changed.

Three levels, so a year-out deadline is not shouted at you

Every finding is sorted by how soon it bites, and that decides how loudly it appears:

Level When What you see
broken_now your running Home Assistant already reached the deadline one ERROR Repairs issue per integration
imminent the release is due inside the alert window (30 days by default) one WARNING Repairs issue per integration, up to five
upcoming further out one summary issue listing what breaks in each release

The summary tells you which integration breaks when, not just how many:

2026.10 - October 2026, about 2 months away: argoclima, miele, thermia
2026.11 - November 2026, about 3 months away: bosch, octopus_energy, spook
2027.8  - August 2027, about a year away: yandex_station

The Home Assistant repairs panel showing one notification per imminent integration plus a single summary

Each per-integration notification links to that integration's releases page and to a search for an existing report of the same deprecation, so you can add a reaction instead of filing a duplicate:

A notification for a single integration, linking to its releases, to existing reports and to the Home Assistant change

Change the window under Settings → Devices & Services → Breakage Radar → Configure: 30, 60 or 90 days, 6 months or a year. It applies immediately. However wide the window, at most five notifications are raised at once, since the summary lists every date anyway.

The options dialog, choosing how far ahead to be alerted

The same dialog takes a list of integrations to leave out. It offers whatever is currently reported on your system, and anything you pick stops producing findings, notifications and counts altogether. Nothing is ever excluded on your behalf: the list starts empty, including for HACS itself, which is affected like anything else.

The point of the split is that Repairs has no snooze button. A dozen cards for deadlines a year away would only teach you to ignore the panel — which is where every other integration raises things you genuinely must act on. So distant deadlines stay grouped and summarised, and a card only appears when there is something to do this month: update the integration, replace it, or raise it upstream while the maintainer still has time.

Home Assistant releases on the first Wednesday of every month, so a release label maps to an exact expected date — the rule matches all eight 2026 releases to the day, and a test pins that. It is computed rather than fetched, so a one-off rescheduled release would shift it; broken_now never relies on it either way — it is decided by comparing your running version to the deadline. Upgrading past a deadline makes a finding more visible, never less.

Each issue links to the board and clears itself — when an updated version no longer contains the removed API, when the integration is uninstalled, or when a deadline stops being imminent. None of them are fixable in place: the code lives in someone else's repository.

Automate on it:

automation:
  - alias: Warn me about doomed custom integrations
    triggers:
      - trigger: numeric_state
        entity_id: sensor.breakage_radar_affected
        above: 0
    actions:
      - action: notify.persistent_notification
        data:
          title: "{{ states('sensor.breakage_radar_affected') }} integrations will break"
          message: >-
            First deadline: Home Assistant
            {{ state_attr('sensor.breakage_radar_affected', 'earliest_release') }}.
            {{ state_attr('sensor.breakage_radar_affected', 'affected_domains') | join(', ') }}

Run the crawler yourself

Python 3.12+ (3.14 strongly recommended — see Which Python below). No dependencies.

git clone https://github.com/Booyaka101/hass-breakage-radar
cd hass-breakage-radar

python tools/extract_rules.py     # -> data/rules.json      (from HA core source)
python tools/blog_rules.py        # -> merges blog + data/manual_rules.json
python tools/catalog.py           # -> data/catalog.json    (every HACS integration)
python tools/scan.py --limit 400  # -> data/findings.json + state/crawl.json
python tools/build_index.py       # -> docs/index.json + docs/index.html

Run them in that order. extract_rules.py rewrites data/rules.json from core alone, so blog_rules.py must follow it to merge the hand-curated and prose rules back in. The GitHub Actions workflow does exactly this.

Whether a rule is still pending is decided against the latest released core version, not the dev branch the rules are read from. Dev bumps to N+1 as soon as the N branch is cut, about two weeks before N ships, so during an RC window dev is two releases ahead of what anybody runs, and comparing against it would retire the RC release's rules in exactly the week a user can still act (#46). The released version comes from PyPI (info.version of the homeassistant package), cached on disk for six hours. When that lookup fails or the run is offline, the last release PyPI did report is reused, and with nothing remembered at all the floor becomes dev minus one. Both can only keep a just-shipped release listed a little longer, never hide an unshipped one, and both say so in the run's output. Holding the remembered value also keeps a failed request from moving rules_hash and queuing a catalogue-wide rescan that would reverse itself the next day.

The same response lists every version PyPI holds, so a 2026.9.0b1 sitting alongside a newest release of 2026.8.3 says 2026.9 is in its release candidate period. The board shows that as its own tile and index.json publishes it as rc_release, next to the pending_floor and pending_floor_source the filters actually used. That is the window worth acting in: the removals in an RC release land within days, not months.

Real output from tools/extract_rules.py on this machine:

INFO breakage_radar.tools: core version in tarball: 2026.9 (sha256 3b8456c44b40)
INFO breakage_radar.tools: scanned 9865 core files, 145 deprecation call sites
INFO breakage_radar.tools: wrote data/rules.json: 114 rules (62 future, 23 matchable)
INFO breakage_radar.tools:   2026.11    async_import_statistics(missing unit_class)
INFO breakage_radar.tools:   2027.1     async_register_info
INFO breakage_radar.tools:   2027.2     async_generate_entity_id
INFO breakage_radar.tools:   2027.6     FlowHandler.show_advanced_options
INFO breakage_radar.tools:   2027.8     async_device_info_to_link_from_entity
INFO breakage_radar.tools:   2027.8     async_remove_stale_devices_links_keep_entity_device

Real output from tools/scan.py and tools/build_index.py. This is the v1.0.0 crawl slice, kept as a worked example of what a run prints — the published index has since been widened by the daily job, so its totals are larger (see the figures at the top):

INFO breakage_radar.tools: 32 matchable rules (core 2026.9, rules_hash 0708f404a48c72d3)
INFO breakage_radar.tools: 3088/3088 repositories need a scan; this slice takes 1300
INFO breakage_radar.tools: [14/1300] 404GamerNotFound/vserver-ssh-stats  scanned  5 finding(s)  refs/tags/v1.5.6
INFO breakage_radar.tools: slice done in 1349s: scanned=1295, unreachable=3, error=2, with_findings=270, findings=659

INFO breakage_radar.tools: wrote docs/index.json and index.html: 270 affected of 1300 scanned (659 findings, 81 rules)
INFO breakage_radar.tools:   2026.10: 4 integration(s)
INFO breakage_radar.tools:   2026.11: 36 integration(s)
INFO breakage_radar.tools:   2027.5: 6 integration(s)
INFO breakage_radar.tools:   2027.7: 18 integration(s)
INFO breakage_radar.tools:   2027.8: 218 integration(s)
INFO breakage_radar.tools: rule hit-rates (repos hit / repos scanned):
INFO breakage_radar.tools:   device-registry-async-get-device               152 repo(s)   11.7%
INFO breakage_radar.tools:   device-info-via-device                          89 repo(s)    6.8%
INFO breakage_radar.tools:   device-registry-config-entry-mutation-params     34 repo(s)    2.6%
INFO breakage_radar.tools:   device-tracker-battery-level                    12 repo(s)    0.9%
INFO breakage_radar.tools:   legacy-device-tracker-platform                   6 repo(s)    0.5%

Real finds from that crawl, each hand-verified against the repository's own source:

Integration Breaks in Why
XiaoMi/ha_xiaomi_home (22 k ★) 2027.7 battery_level and location_name properties on a device tracker entity
WulfgarW/homeassistant-pycupra 2027.5 module-level async_setup_scanner in device_tracker.py
PaulAnnekov/home-assistant-padavan-tracker 2027.5 get_scanner and a DeviceScanner subclass
404GamerNotFound/vserver-ssh-stats 2027.8 async_update_device(remove_config_entry_id=…) and DeviceInfo(via_device=…)

state/crawl.json remembers what was scanned at which version, so the next run only revisits repositories that actually changed. --limit caps a run; least-recently-scanned repositories go first, so coverage rotates on its own.

A slice is almost entirely waiting on the network: the 1.12.0 rescan measured under ten percent CPU. Tarballs are therefore downloaded --workers at a time, ahead of the scan, and every tag tarball is kept under .cache/tarballs/ once fetched. A tag never moves, so a cached one is right for as long as the catalogue points at it, and a rules or engine change that requeues the whole catalogue becomes a local job of minutes rather than a day of downloads. The whole catalogue is about 2.4 GB on disk, and branches are never cached. CI passes --no-tarball-cache, because a fresh runner would spend longer uploading the cache than it saved.

The crawl conflicts with open pull requests, and that hides checks

The crawl commits data/rules.json, docs/ and state/ daily. A branch that touches any of them conflicts within hours, and GitHub cannot build refs/pull/N/merge for a conflicted pull request, so it skips that pull request's checks entirely. They do not fail, they never run, and the page shows no checks rather than a red one. It reads like a branch nobody pushed to.

Two things address it, because the cause cannot be removed entirely:

  • tools/rules_changed.py keeps data/rules.json out of the commit when only its provenance stamps moved, which was the commonest trigger.
  • tools/pr_health.py, run daily by .github/workflows/pr-health.yml just after the crawl, labels any open pull request GitHub reports as CONFLICTING and explains once what the missing checks mean. It runs from the default branch, because a workflow inside a skipped pull request cannot report on itself. UNKNOWN mergeability is left alone rather than guessed at, in either direction.

If a board PR conflicts, take main's crawl output and regenerate rather than resolving docs/index.json by hand:

git rebase origin/main
git checkout origin/main -- docs/index.json docs/index.html docs/feed.xml
python tools/build_index.py

Which Python

Home Assistant's dev branch tracks the newest CPython syntax. As of core 2026.9 dev it uses PEP 758 unparenthesized except A, B:, which needs Python 3.14. Measured on the same tarball:

Interpreter Core files that fail to parse Deprecation call sites found
3.11 23 98
3.12 11 115
3.14 0 145

Running on an older interpreter silently loses rules from device_registry.py, device_tracker/legacy.py, trigger.py and config_entries.py. The extractor never hides this: rules.json records extractor_python, counts.core_files_unparsed and the full unparsed_core_files list, and logs a warning. The GitHub Actions workflow pins 3.14.


How rules are chosen

There are three sources, and they are not equally trusted.

1. Extracted from core (origin: core-ast). Every call in homeassistant/**/*.py that passes a string literal to breaks_in_ha_version. The message is then turned into a matcher only when it names something specific enough:

  • "calls async_device_info_to_link_from_entity, which is deprecated…" → a call matcher, pinned to the module that defines it.
  • "doesn't specify unit_class when calling async_import_statistics" → read off the if the marker sits under, not off the sentence. if "unit_class" not in metadata is a key of the metadata argument, so it becomes a call_missing_arg_key matcher; if new_unit_of_measurement is not UNDEFINED and new_unit_class is UNDEFINED is a real keyword, and the first half of it becomes requires. The prose cannot tell those apart, and reading it as a keyword is what shipped 99 wrong findings in 1.11.0: unit_class= is not a keyword async_import_statistics accepts at all, so the matcher fired on every caller, correct ones included. The enclosing def is also the target, because core's mean_type marker inside async_add_external_statistics names async_import_statistics. A guard neither shape fits is published as prose and recorded in discarded_markers as unreadable_guard.
  • "calls async_listen which is deprecated" → a call matcher pinned to homeassistant.components.labs.helpers. async_listen is 12 characters and everybody has one, so the pin is what makes it a rule: the engine only fires where the file's imports prove the call reaches that module, and self.async_listen(...) or from .bus import async_listen never do.
  • a marker on a property of a Home Assistant entity base class → an attr matcher scoped to that class. The 2026.9 removal of vacuum battery_level reads as {"type": "attr", "names": ["battery_level"], "in_class_base": ["StateVacuumEntity"]}, so a class deriving from StateVacuumEntity is a finding and class Foo: battery_level = 50 is not.

A bare auto-derived symbol must still be at least 18 characters and survive a denylist, because on its own it would match everybody's own helper of the same name. Bare means neither pinned to the core module that defines it nor scoped to the class it is deprecated on; in both cases what identifies the deprecation is the pair, not the word, and the length gate does not apply. Since every call matcher carries its module, the gate is left with deprecated class names, which have no such proof. Everything else is published for the board but never claims a repository is affected, and the count of markers dropped that way is on the board, in counts.markers_discarded, and in what tools/check_local.py prints before its verdict.

Scoping only fires for classes integrations are meant to subclass, which Home Assistant names <Domain>Entity. A deprecation on ConfigFlow or DeviceRegistry gets no scoped rule: nobody overrides those, so the rule would never match anything. That is a deliberate undercount.

A keyword the prose supplies gets the same treatment. Core writes "calls async_get_or_create with a via_device referencing the device itself", and reading "calls X with Y" off that sentence yields the keyword a. Until 1.12.0 two such rules shipped matchable and could never fire. A derived keyword now has to be plausible as a Python one: never a stopword, and if it is under four characters or carries no underscore it has to appear verbatim as a parameter name somewhere in the same core file. Rejected markers are published as prose and counted in counts.markers_discarded, so the gap is a number rather than a silence.

The same pass also reads core's other removal mechanism, which has nothing to do with report_usage. A module declares _DEPRECATED_TrackerEntity = DeprecatedAlias(_TrackerEntity, "homeassistant.components.device_tracker.TrackerEntity", "2027.6") and hands its __getattr__ to check_if_deprecated_constant, so the warning fires on the import. Those become import_from rules at high confidence: a named import from an exact module has no receiver to infer, so the 18-character gate does not apply. They are keyed on the deprecating module, never the symbol alone, because the same name imported from the replacement path is the fix rather than the problem.

Core deletes a deprecation shim in the release it removes the API in, so the marker a rule was read from disappears at the moment the rule matters most: the API is already gone on dev, and the repositories still calling it break on the release everybody is about to install. A rule that goes missing that way is carried forward with retained_since set to the core version it vanished in, as long as it is matchable and its breaks_in has not shipped yet. It retires by itself at that release, counts.retained says how many are being carried, and the crawl annotates the run so the deprecation can be written into data/manual_rules.json before then. A marker this run's own gates refused is not carried, so tightening a gate still takes a rule out, and a core file the interpreter cannot parse aborts the run before any of this: unreadable is not deleted.

2. Hand-curated (origin: manual, data/manual_rules.json). Removals announced in prose with no report_usage call behind them — the legacy device tracker platform API, the device registry single-config-entry changes, the device tracker property removals. Each one quotes its source post.

A rule can also carry reports_in, the release the API starts logging a warning in when that is earlier than the release it is removed in. DeviceEntry.config_entries warns from 2026.10 and is removed in 2027.10, and a board showing only one of those either hides the logs filling up next month or moves a two-year deadline forward by two years. It is a second date on the same rule, never a second deadline: ordering, bucketing and retirement all key off breaks_in. A rule whose two releases are the same carries no reports_in at all, and renders exactly as every other rule does.

A rule can also carry search, which replaces the term its repositories are searched for upstream. The term is otherwise the last dotted part of the symbol, so DeviceRegistry.devices becomes devices. Asking GitHub for both terms across all 125 repositories the rule affects, the bare term finds a report in 53 of them and device_registry.devices in 16, and only 13 of those are the same issue. The other 40 are about something else: "no devices in home tab", "Devices duplicated after upgrade". device-registry-devices-mapping asks for the dotted term instead.

Core, and the blog itself, sometimes carry a marker for the same removal whose message is prose the extractor cannot turn into a matcher. A hand-written rule can name those ids in supersedes, and the merge drops them: two board entries for one deprecation, one of them with no matcher and no advice, reads as two problems.

3. Blog prose (origin: blog). Every removal sentence found on https://developers.home-assistant.io/blog/, published as matchable: false so the board shows the deadline even when no static check exists. A post that only dates the end of a support window counts as one too, unless the removal written beside it lands a release later, which makes the window an early warning about that removal rather than a deadline of its own.

Why matching resolves imports

A rule written from a spec is a hypothesis, and this one was wrong on the first crawl slice. entity_registry.async_generate_entity_id is removed in 2027.2; entity.async_generate_entity_id is fine. They share a name. A naive matcher flagged 0xAlon/dolphin, which imports the healthy one.

So every call matcher can be pinned to a module, and the engine builds an import map before it fires:

from homeassistant.helpers.entity import async_generate_entity_id           # no finding
from homeassistant.helpers.entity_registry import async_generate_entity_id  # finding
from homeassistant.helpers import entity_registry as er                     # finding
from .my_own_registry import async_get_device                               # no finding

Where the receiver is only known at runtime — registry.async_get_device(...) — the import graph cannot prove anything, so those rules opt in explicitly with allow_unresolved_attribute and are published at confidence: medium.

docs/index.json publishes every rule's measured repos_hit, so a rule that fires on an implausible fraction of the catalogue is visible rather than quietly taxing everyone.

Matcher types

Type Fires on
moduledef a module-level def/async def with one of names
classbase a class deriving from one of bases
attr a property or _attr_ assignment named in names
attr_access reading something.<name>
attr_access_typed reading something.<name> where the receiver is first proved, by single-file inference, to come from the helper module the matcher names — built for DeviceEntry.config_entries, whose name collides with hass.config_entries
container_use a deprecated use of a container attribute on a proved registry: subscription, a lookup method, or membership by device id on registry.devices. Iterating the very same attribute stays supported
call a call to one of names
call_kwarg a call to one of names passing any keyword in kwargs
call_missing_kwarg a call to one of names not passing kwarg, and passing every keyword in requires if there is one — core arms some of these checks only when a related keyword is present
call_missing_arg_key a call to one of names whose mapping argument (arg, arg_index) provably does not set key. For options core takes inside a TypedDict argument rather than as keywords: a dict literal, a constructors call, or a local or module-level name that resolves to one. A mapping the file cannot read in full — spread from **, built by a helper, mutated through update(), handed in as a parameter — is never a finding
call_hass_argument a call to one of names that passes hass — for @deprecated_hass_argument, where the argument is deprecated, not the function
import_from from <module in modules> import <name in names> — for core's other removal mechanism, _DEPRECATED_X = DeprecatedAlias(...) behind a module __getattr__, where the import itself is what breaks. The module is the whole rule: the same name imported from the replacement path is the fix
js an anchored token in .js/.ts/.mjs source, comments stripped, only in files that reference the WebSocket API. Built for the device registry WebSocket deprecations, which break Lovelace cards rather than Python integrations

Any matcher can be narrowed with files (exact basenames); attr matchers can also require in_class_base.

attr_access_typed and container_use need to know what counts as proof, so they carry the helper module they trust rather than hard-coding one. Both walk the same scope inference. The keys, as used by device-entry-config-entries and device-registry-devices-mapping in data/manual_rules.json:

Key Meaning
module the helper module every proof below has to resolve to
registry_factory function returning the registry, e.g. async_get for dr.async_get(hass)
registry_types type names that prove a registry when used as an annotation
entry_types type names that prove an entry when used as an annotation
entry_methods registry methods returning an entry, called on a proved receiver
entry_containers mappings on a proved registry whose values are entries, e.g. devices
entry_functions module-level functions returning entries, resolved through the import map
entry_params {function name: 1-based parameter} typed by a platform contract rather than by an annotation
container (container_use) the attribute on the registry the rule is about, e.g. devices
uses (container_use) which uses of it break: subscript, method, membership, or any for an attribute deprecated outright
methods (container_use) the lookup methods that count, e.g. get, values, keys

Names are proved per scope, nested scopes inherit their enclosing one, and a registry assigned to an attribute (self._registry = dr.async_get(hass)) is proved for the whole class, since that assignment usually lives in __init__ and the lookups do not.

container_use exists because registry.devices is not deprecated. Using it as a mapping is. Core's replacement is a view whose __getitem__, __contains__ and __getattr__ report, while __iter__ and __len__ do not, so reg.devices[device_id] and reg.devices.get(x) break in 2027.9 while for device in reg.devices and len(reg.devices) do not. Membership fires only where the left operand reads like a device id, meaning a string literal or a name ending _id, because core reports string membership only, and device_entry in reg.devices is the supported form.

The engine lives in tools/rules_engine.py and is vendored byte-for-byte at custom_components/breakage_radar/rules_engine.py, so the crawler and the integration's local scan can never disagree about the same source — a test asserts the two copies are identical.


The worked example

A custom integration on the legacy device tracker platform API:

# custom_components/fixture_tracker/device_tracker.py
"""Fixture: a custom integration still on the legacy device tracker platform API.

A module-level ``setup_scanner`` in a file named ``device_tracker.py`` is the
legacy platform entry point Home Assistant removes in the 2027.5 release.
"""

from homeassistant.const import CONF_HOST

DOMAIN = "fixture_tracker"


def setup_scanner(hass, config, see, discovery_info=None):   # <- line 12
    ...

produces exactly one finding:

{
  "rule_id": "legacy-device-tracker-platform",
  "breaks_in": "2027.5",
  "file": "custom_components/fixture_tracker/device_tracker.py",
  "line": 12,
  "confidence": "high"
}

and with it installed the sensor reads 1 with schedule[0] == {release: "2027.5", domains: ["fixture_tracker"], ...}.

Lookalikes produce zero findings — setup_scanner inside a class body is a method, and setup_scanner in sensor.py is just a function with an unlucky name. Both are pinned in tests/test_scanner.py.

The four legacy entry points the rule looks for are not guessed. They are the exact list core dispatches on in homeassistant/components/device_tracker/legacy.py:

"async_get_scanner",
"get_scanner",
"async_setup_scanner",
"setup_scanner",

Lovelace cards are covered too

On 2026-08-19 Home Assistant deprecated parts of the device registry WebSocket API. The device fields config_entries, config_entries_subentries and primary_config_entry are replaced by config_entry_id and config_subentry_id (removed in Core 2027.8), and the command config/device_registry/remove_config_entry is replaced by config/device_registry/remove (removed in Core 2027.9). Nothing is broken today, since core derives the old fields from the new ones until then. What breaks on the deadline is not Python: it is the HACS Lovelace cards that read device registry results over the WebSocket.

So the crawl now takes the HACS plugin category too, and matches .js, .ts and .mjs source with anchored token rules: comments are stripped first, and a rule only fires in a file that also references the WebSocket API (callWS, sendMessagePromise, subscribeDeviceRegistry, or a config/device_registry string). A card whose only mention of a field is in a doc comment produces nothing.

How skipped bundles are counted. Many card repositories publish only a compiled bundle. A .min.js file, or any file with a line over 5000 characters, is never matched. It is counted as skipped_minified, and node_modules/vendored paths as skipped_vendor, per repository and in the index's coverage. The board shows the total, so "0 findings" on a repo whose only file was skipped reads as not analysed, never as clean. A TypeScript card that ships both its source and its bundle is deduplicated to one finding per rule, pointing at the source file.

On your own box the integration scans www/community/**, where HACS installs cards, with the same rules, and raises the same Repairs issues with the card name in the title. A card installed only as a minified bundle falls back to the index's verdict on its source repository.


Index format (schema 1)

{
  "schema": 1,
  "generated_utc": "2026-08-08T12:00:00Z",
  "core_version": "2026.10",
  "latest_release": "2026.8",
  "rc_release": "2026.9",
  "pending_floor": "2026.9",
  "pending_floor_source": "pypi",
  "coverage": { "catalog_total": 3088, "repos_scanned": 900, "repos_affected": 190, ... },
  "releases": { "2027.5": ["some_tracker"], "2027.8": ["another"] },
  "release_dates": { "2027.5": { "release_date": "2027-05-05", "days_until": 256 }, ... },
  "rules": [
    { "id": "legacy-device-tracker-platform", "breaks_in": "2027.5",
      "message": "...", "source": "https://developers.home-assistant.io/blog/...",
      "confidence": "high", "matchable": true, "hits": 3, "repos_hit": 3,
      "match": { "type": "moduledef",
                 "names": ["async_get_scanner", "get_scanner",
                           "async_setup_scanner", "setup_scanner"],
                 "files": ["device_tracker.py"] } }
  ],
  "integrations": [
    { "full_name": "someone/some-tracker", "domain": "some_tracker",
      "version": "1.4.0", "stargazers_count": 42, "earliest_breaks_in": "2027.5",
      "release_date": "2027-05-05", "days_until": 256,
      "findings": [ { "rule_id": "...", "breaks_in": "2027.5",
                      "file": "...", "line": 12, "confidence": "high" } ] }
  ],
  "clean_domains": ["..."],
  "unreachable_domains": ["..."]
}

integrations lists only repositories with findings. clean_domains lists the ones scanned and found clean, so a consumer can tell "no problems" from "not looked at yet".

An affected integration may also carry upstream, recording what its own repository says: whether it is archived, whether it accepts issues, and the most relevant existing report if there is one. That is what lets a notification say "already reported, add a reaction there" instead of sending everybody to open the same issue. It is optional, so an older index simply lacks it.

A search hit only becomes that report if its title says so, by naming the deprecated symbol, by using a word like "deprecated" or "removed", or by naming a release core has not shipped yet. A symbol pasted into the body of an unrelated bug report is not evidence, and neither is "Not working on 2021.12" filed against a 2027 removal.

The fact records checked_utc, and a crawl re-asks every affected repository whose fact is more than a week old, up to a few hundred a run. Facts found under a term their rule no longer asks for go first, then the oldest. A repository that never cuts another release is never rescanned, so without that its issue link would stay published for good, however wrong it had gone. When the search does not come back with the report already on file, that issue is fetched by number: an issue drops out of a ten-hit search on its own, but a 404 means it is really gone. What survives a 404 is only what says not to file there, that the repository was archived or had issues turned off. The report link goes with the tracker it pointed into.

Every matchable: true rule ships its matcher as the nested match object — that is what lets the integration run the same rules over locally installed code without the index changing shape for it.


Configuration

The Home Assistant integration has one setting, under Settings → Devices & Services → Breakage Radar → Configure: how far ahead a deadline gets its own notification (30, 60 or 90 days, 6 months, or a year). Everything outside that window is listed in the summary instead.

For the crawler:

Setting Where Default
Repos per run tools/scan.py --limit N 400 (upstream lookups cap at 400)
Rescan everything tools/scan.py --force off
One repository tools/scan.py --only owner/repo —
Politeness pause tools/scan.py --sleep 0.25 0
Downloads in flight tools/scan.py --workers 16 8
Tag tarball cache tools/scan.py --tarball-cache DIR, --no-tarball-cache .cache/tarballs
Core branch tools/extract_rules.py --ref dev dev
Skip the blog crawl tools/blog_rules.py --no-network off
Force the catalogue fallback tools/catalog.py --force-fallback off
Working directory BREAKAGE_RADAR_ROOT env var the repo checkout
Check one local checkout tools/check_local.py <path> .
Check offline tools/check_local.py <path> --rules data/rules.json fetches the index

Index URL and poll interval live in custom_components/breakage_radar/const.py (INDEX_URL, UPDATE_INTERVAL) if you want to point the integration at your own crawl.


Failure behaviour

Everything below is covered by a test.

Situation What happens
Repository tag missing falls back to v-tag, then main, then master; then status: unreachable, crawl continues
PyPI release lookup fails, or --offline the last release it reported is reused, or dev minus one if none is remembered; the degradation is printed and the run continues
Repository has no custom_components/ recorded as scanned with zero findings
SyntaxError in third-party source that file is skipped and counted; the run never aborts
GitHub returns 429 exponential backoff (1 s, 2 s, 4 s), then the slice ends cleanly with state committed
Non-retryable HTTP status one clear error line, no traceback
Corrupt tarball status: error on that repo, crawl continues
Crawl interrupted state and findings are checkpointed every 25 repositories
index.json unreachable from HA the last good report is kept and the sensor goes unavailable; last_error says why
index.json is not schema 1 rejected with a message rather than half-read
custom_components/ missing or unreadable empty report, no exception
An installed manifest.json is corrupt the component still counts as installed, with an empty version
An installed integration's file will not parse or decode counted in unparsed_files; the domain stays unknown with a reason, never falsely clean
An installed integration exceeds the local scan caps counted in skipped_files; the index verdict is used if there is one
The local scan itself fails unexpectedly logged, and the report falls back to index-only matching
The local scan is slow (many integrations, slow disk) nothing waits for it: setup and refreshes return index results immediately, local results follow when the background scan finishes
Home Assistant is upgraded past a finding's deadline the finding stays, reclassified broken_now, and Repairs escalates to ERROR
A release label cannot be turned into a date no imminent opinion is formed; the finding is summarised rather than guessed at
The index ships no matchable rules domains scan unknown with a reason — an empty rule set never reads as clean
The index ships a rule this installed engine is too old to run the local scan reports which rule IDs it ran; any index finding it could not look for survives, attributed to the index. A local clean only speaks for the rules it actually ran
An integration directory is renamed or forked matched by the domain its manifest.json declares, not the directory name
Very affected system details caps at 100 entries and sets details_truncated

Tests

python -m pytest              # offline; the whole suite
python -m pytest --run-network  # also hits codeload/GitHub for the live-core test

The extractor has a golden test against tests/fixtures/core_mini.tar.gz — five real files copied verbatim out of home-assistant/core dev, with the archive's sha256 pinned in the test so the fixture cannot drift silently.

The Home Assistant integration is tested without installing Home Assistant: tests/conftest.py registers minimal stand-ins for the handful of symbols the integration imports, so tests/test_integration.py exercises the real shipped BreakageRadarSensor, not a copy of its logic. If Home Assistant is installed, the real package is used instead.


Limitations

  • A finding is static analysis, not a guarantee. confidence: medium rules match a method name on an object whose type is only known at runtime.
  • Index coverage is partial by design. One slice is capped so the daily job stays inside GitHub's rate limits; coverage.repos_scanned always states how much of the 4 009-repo catalogue has been visited so far. Since 1.1.0 this matters less on your own box: whatever the crawl has not reached, the integration scans locally with the same rules.
  • The local scan is bounded. Per integration it reads at most 400 Python files of up to 1 MB each; anything beyond that is counted in skipped_files and the domain is reported unknown rather than clean.
  • The local scan parses with your box's own Python. An installed file using syntax newer than your interpreter (for example PEP 695 type aliases on Python 3.11) is counted in unparsed_files and the domain falls back to the index verdict — the crawler parses with 3.14, so the index side never has this gap.
  • attr_access_typed infers types per scope, and errs towards silence. It reports DeviceEntry.config_entries only where the receiver is proved in the scope that reads it, so a device entry built in another file, or handed over by a helper this file cannot see, is missed rather than guessed at. Measured on home-assistant/core: 51 of the 54 device-entry reads found, against 5 963 textual .config_entries occurrences, with no hass.config_entries among them. A missed call is a rule that stays quiet; a wrong one would waste a maintainer's afternoon, so the matcher is built to under-report. A receiver proved to be a DeletedDeviceEntry or a ChildDeviceEntry counts the same way, because both carry the properties. Nothing in the catalogue matches that way yet: every integration that has touched the 2026.9 classes reaches them through getattr(dr, "ChildDeviceEntry", ()) so it still runs on 2026.8, and a name fetched at runtime is not a type any AST can prove.
  • A scoped attr rule resolves base classes one level, inside one file. A class deriving from StateVacuumEntity is matched whether it names the base directly, under an import alias, dotted as vacuum.StateVacuumEntity, among several bases, or through an intermediate class defined in the same file. A chain that leaves the file (from .base import BaseVacuum) is not followed, because nothing in the file proves what .base derives from. Undercounting, again on purpose.
  • imminent is computed from the release schedule, broken_now is not. The first-Wednesday rule is Home Assistant's published schedule and has been exact all year, but it is applied locally, not fetched — a release moved for a one-off reason would shift the window by a few days. Whether a deadline has already passed is decided by version comparison alone, so that half can never be wrong.
  • Card matching is text-level, not parsed. The js rules anchor on the deprecated token, strip comments first, and require the file to reference the WebSocket API, but there is no JavaScript parser behind them. A minified bundle is never guessed at: it is skipped and counted instead.
  • Themes and AppDaemon apps are out of scope. Integrations and Lovelace plugins are covered; the other HACS categories are not.
  • No automatic fixing. v1 tells you what breaks and when; it does not rewrite code.

Prior art

custom-components/breaking_changes did something adjacent and was archived on 2022-05-28, its own README noting "At the time of archiving the integration has not worked in over a year." It compared installed components against published breaking changes — after the fact. Breakage Radar looks at removals that have not happened yet, from a static analysis of the integration's own source, so there is time to act.

Spook is the tool people most often expect to cover this; it does not, for the reasons in How this differs from Spook, and from reading the log.


Changelog

Every release is documented in CHANGELOG.md, including what each fix actually changed about the verdict you see. Releases are tagged on GitHub.


Are you the author of a flagged integration?

Read guides/for-integration-authors.md — what a listing claims (and what it does not), how to check your own checkout before you release, how a listing clears itself once you ship a fix, and how to report a finding you think is wrong.

The self-check runs against any checkout, including forks and private integrations the daily crawl can never see:

python tools/check_local.py /path/to/your-integration

It exits 0 clean, 1 with findings, 2 if it could not check — so it works as a release gate in your own CI. A checkout with custom_components/ is read as an integration, one without is read as a Lovelace card repository; you do not have to say which.

The same scan runs as a GitHub Action, which is the only way this project reaches the person who can actually fix a finding:

- uses: actions/checkout@v7
- uses: Booyaka101/hass-breakage-radar@v1.10.0

It annotates the exact line and writes a job summary, and by default it does not fail the job — a removal scheduled for 2027.8 turning an unrelated pull request red is how a check gets deleted from a workflow file. fail-on: imminent gates on what is close; fail-on: any is the strict gate. Inputs are documented in the authors' guide.


Questions, findings and false positives

  • Discussions — questions, "is this finding right?", and anything you would rather not file as a bug.
  • Issues — false positives and false negatives especially. A finding names the exact file and line, so a report that quotes them is immediately actionable, and a wrong rule is worth fixing fast: it is a tax on every user it fires on.

Contributing a rule

Add it to data/manual_rules.json with a source URL that states the removal release, then run python tools/blog_rules.py && python tools/scan.py --limit 50 --force and check the hit rate in python tools/build_index.py output. A rule that fires on a large fraction of the catalogue is a tax, not a signal — tighten it or ship it as matchable: false.


License

MIT — see LICENSE.

About

Which of your Home Assistant custom integrations stop working, and in which future release. Daily crawl of every HACS integration for deprecated HA APIs + a HACS-installable integration that reports on your own box.

Topics

Resources

Contributing

Security policy

Stars

21 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages