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.
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.
| 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.
Or by hand:
- HACS → ⋮ → Custom repositories
- URL
https://github.com/Booyaka101/hass-breakage-radar, category Integration - Install Breakage Radar, restart Home Assistant
- Settings → Devices & Services → Add Integration → Breakage Radar → Submit
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.
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 owncustom_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: 5A 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.
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
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:
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 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(', ') }}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.htmlRun 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 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.pykeepsdata/rules.jsonout 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.ymljust after the crawl, labels any open pull request GitHub reports asCONFLICTINGand 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.UNKNOWNmergeability 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.pyHome 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.
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…"→ acallmatcher, pinned to the module that defines it."doesn't specify unit_class when calling async_import_statistics"→ read off theifthe marker sits under, not off the sentence.if "unit_class" not in metadatais a key of themetadataargument, so it becomes acall_missing_arg_keymatcher;if new_unit_of_measurement is not UNDEFINED and new_unit_class is UNDEFINEDis a real keyword, and the first half of it becomesrequires. 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 keywordasync_import_statisticsaccepts at all, so the matcher fired on every caller, correct ones included. The enclosingdefis also the target, because core'smean_typemarker insideasync_add_external_statisticsnamesasync_import_statistics. A guard neither shape fits is published as prose and recorded indiscarded_markersasunreadable_guard."callsasync_listenwhich is deprecated"→ acallmatcher pinned tohomeassistant.components.labs.helpers.async_listenis 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, andself.async_listen(...)orfrom .bus import async_listennever do.- a marker on a property of a Home Assistant entity base class → an
attrmatcher scoped to that class. The 2026.9 removal of vacuumbattery_levelreads as{"type": "attr", "names": ["battery_level"], "in_class_base": ["StateVacuumEntity"]}, so a class deriving fromStateVacuumEntityis a finding andclass Foo: battery_level = 50is 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.
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 findingWhere 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.
| 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.
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",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.
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.
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.
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 |
python -m pytest # offline; the whole suite
python -m pytest --run-network # also hits codeload/GitHub for the live-core testThe 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.
- A finding is static analysis, not a guarantee.
confidence: mediumrules 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_scannedalways 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_filesand 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
typealiases on Python 3.11) is counted inunparsed_filesand the domain falls back to the index verdict — the crawler parses with 3.14, so the index side never has this gap. attr_access_typedinfers types per scope, and errs towards silence. It reportsDeviceEntry.config_entriesonly 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_entriesoccurrences, with nohass.config_entriesamong 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 aDeletedDeviceEntryor aChildDeviceEntrycounts 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 throughgetattr(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
attrrule resolves base classes one level, inside one file. A class deriving fromStateVacuumEntityis matched whether it names the base directly, under an import alias, dotted asvacuum.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.basederives from. Undercounting, again on purpose. imminentis computed from the release schedule,broken_nowis 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
jsrules 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.
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.
Every release is documented in CHANGELOG.md, including what each fix actually changed about the verdict you see. Releases are tagged on GitHub.
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-integrationIt 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.0It 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.
- 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.
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.
MIT — see LICENSE.





{ "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": ["..."] }