Discovery Catalogue

What it is

The discovery catalogue is a single document describing the hardware and accounts Predbat's components have actually found on your installation - inverters, EV chargers, cars, electricity meters and solar forecast providers - built once at startup from what each component reports about itself.

It exists because a real installation is rarely as simple as one inverter and one tariff: several components can each see part of the same physical setup (a GivEnergy inverter visible through both GivTCP and GE Cloud, say), or claim the same car slot, and today those situations are resolved silently by whichever component happens to run first. The catalogue does not change that behaviour

  • nothing about it writes to your configuration, or changes anything Predbat does. It only makes what was already happening visible, so a maintainer reading a bug report can see the whole picture at a glance instead of reconstructing it from log lines, and so a genuine ambiguity (two components both claiming the same inverter) is recorded as an observation rather than staying an invisible coincidence of startup order.

Where to find it

The Discovery page in the web UI. The quickest way to look: open Discovery in the Predbat web interface. It shows the per-section record counts, any conflicts, a status for every component, and a card per record, with the entity map collapsed behind a toggle and the whole document available at the bottom in the same YAML form a debug dump carries.

The page is redacted by default - what you see is what a bug report would contain. "Show raw values" (?raw=1) switches to the unredacted document, so you can confirm your own serial number or MPAN was captured correctly. The raw view is labelled on screen and is not safe to share: Predbat's web interface has no authentication of its own, so treat raw mode the way you already treat the Apps page.

A debug dump. Every predbat_debug.yaml (written to debug/ when switch.predbat_debug_enable is on, and what you normally attach to a bug report) carries the full catalogue under a top-level discovery: key. This is the complete document, and the redacted form described below - safe to attach to a public GitHub issue.

sensor.predbat_discovery. This entity carries a summary rather than the full document: its state is the total number of records found, and its attributes carry schema_version, generated, a per-section count, a status for every component Predbat knows about, and any conflicts observed (see below). The full per-section data - which inverter has which entities, and so on - is deliberately not in the entity's attributes; Home Assistant's recorder writes every attribute change to disk, and the full catalogue is tens of kilobytes even on a modest install. Read the debug dump for the complete picture.

The latest report from each component is kept - a new one replaces that component's previous report rather than adding to it - and the catalogue is re-assembled fresh each time it is actually read. A debug dump always reflects the latest state, including anything reported after Predbat started - a rediscovered inverter, a changed tariff, a newly-discovered forecast site, a retry that succeeded on a later cycle - not only what was known at the "discovery barrier" (the point straight after every component has started or timed out). sensor.predbat_discovery is different: its summary is published only once, at that same discovery barrier, and does not refresh itself afterwards even though the underlying document it was built from keeps moving - restart Predbat, or read a fresh debug dump, to see anything reported later than startup.

What each section describes

Section What it holds
inverters Battery inverters and PV-only devices - type, composition (direct/gateway/EMS), which functions it serves (solar, battery), its behaviour (capabilities), its fixed ratings under Predbat's own setting names, and every setting Predbat's automatic configuration binds for it (entities)
chargers EV chargers, cross-linked to the cars they serve
cars Electric vehicles, cross-linked to the charger that charges them
meters Electricity (and gas) supply points, each with a direction (import/export) and, where known, a nested tariff record
forecasts Solar forecast providers (Solcast, forecast.solar, Open-Meteo, or your own HA sensors) and what each one covers
programmes Flexibility enrolments (a VPP, a saving session, a free-electricity event) that emit events and may constrain Predbat, cross-linked to the meter they apply to

Deye and Sunsynk cannot tell a PV-only inverter from a hybrid: their APIs give no signal, and their automatic configuration treats every discovered inverter as a battery inverter. Their records follow that configuration - solar and battery on every inverter - rather than evidence about the hardware, and Solis's solar works the same way, since every Solis inverter is configured as a PV source. A PV-only unit that has been configured as a battery inverter will usually show it in its record: battery among its functions, but no battery_capacity_ah rating and no soc_max entity.

No reporter (GivTCP, GE Cloud, Octopus, Ohme, Solcast, Fox, AlphaESS, Solis, Deye, Sunsynk) populates programmes yet - it is part of the schema for a future Axle/VPP-style reporter - so today it is always present as an empty list rather than missing from the document.

Every record carries a source field naming the component that reported it, a device_id unique within that component, and whichever typed containers below the component chose to populate. Two extra top-level sections describe the fleet as a whole rather than any one device:

  • components - a status (ok, no_report, not_started or load_error) for each component that either reported, or was expected to and did not. A component that should be describing your hardware but is not shows up as clearly as one that is. load_error says only that the component failed to start; the reason is an arbitrary exception message, which is free text this document deliberately does not carry - look in the Predbat log, which records it in full alongside a traceback.

    Two kinds of component are deliberately left out, because their silence carries no information: one you never configured (its absence is its status - the full registry is in components.py), and one that is running fine but has no reporter at all. Most components are in that second group - web, ha, chat, carbon, temperature and the rest are not hardware sources and will never report. Listing them all made the map thirty-odd entries of which a handful said anything, and buried the case that matters: a component that can report and did not - observations - things noticed about the assembled picture rather than about any one component: conflicts (see below) and resulting_config, the handful of apps.yaml keys (num_inverters, num_cars, inverter_type) discovery can be compared against

Conflicts

observations.conflicts records situations that today resolve silently by whichever component happened to run first - the catalogue observes them, it does not resolve them:

Kind Fires when
duplicate_serial Two components claim the same hardware serial - including a serial a gateway record fronts on behalf of the batteries behind it, not only the serial identifying the record itself
multiple_inverter_sources More than one component reports an inverter record at all (e.g. both GivTCP and GE Cloud)
multiple_import_meters More than one component reports an import meter
contested_car_slots A car is reported by a component that reports no charger, alongside a component that reports both a charger and a car - two components with a different idea of what should occupy a car slot

What is, and is not, redacted

The document you get from a debug dump or sensor.predbat_discovery has already been through redaction - the catalogue in memory is never published in its raw form. Two classes of information are treated very differently, deliberately:

Pseudonymised - never appears in the clear: account and site identifiers that trace back to you specifically - an MPAN, an Octopus/utility account number, a Solcast site id. Each is replaced with a short stable token (# followed by 8 hex characters) derived from the value and a random salt generated once per installation, so the same identifier always maps to the same token within one install's dumps (letting you correlate two records that share an account), but a different installation's token for the identical MPAN is completely unrelated - there is nothing to compare across users. Anywhere that identifier would otherwise be echoed elsewhere in the document - inside an entity id, a free-text note - is caught and replaced too, including a case- or -/_-folded form the same value can appear in (either direction: an upper-cased echo of a lower-cased original is caught exactly as a lower-cased echo of an upper-cased one is). This echo-substitution only fires for an original of six characters or more - anything shorter would corrupt more ordinary text as a false-positive substring match than it would ever hide, so a very short identifier is only ever replaced where it appears whole, not embedded inside a longer string.

Kept readable, deliberately - a hardware serial number, a firmware version string, a device model name, and a tariff or product code. None of these identify you; they identify a public product or a specific physical device, and stripping them would make a bug report undiagnosable - "my GivEnergy inverter won't discharge" is a much harder bug to chase without knowing which inverter, which firmware, or which tariff is in play. Entity ids are published verbatim too - exactly the sensor./number./... id Home Assistant knows the entity by - since that is what lets you match a catalogue record back to something you can see on your own dashboard; a component is expected to build its entity ids from public naming, never from a value this catalogue treats as sensitive (and where one is, the redactor still catches it - see the guards below).

A handful of further guards run regardless of which container a value landed in: anything that looks like a credential by its field name (api_key, password, token, ...) is refused outright, wherever it is nested; a value that looks like a misfiled identifier is pseudonymised even inside a container that is not supposed to hold one - a 10-or-more-digit run is enough anywhere it turns up in a clear container's value (embedded in a longer string too, e.g. "MPAN 1234567890123"), except inside hardware_ids, where a value is only flagged when it is nothing but digits, so a letter-prefixed vendor serial like HV2160123456 stays readable. A serial a record declares - hardware_ids.serial, or an entry in serials - is never flagged, whatever its shape: Solis, Deye and Sunsynk serials are nothing but digits, and they stay readable wherever they appear as a whole token, in a device_id built from one or a duplicate_serial observation that names one. Only the serial itself is let through - a digit run left beside it, or one it merely sits inside, is still checked. Finally, a field literally named latitude, longitude or postcode is pseudonymised regardless of what it contains, since a location cannot otherwise be recognised from one value alone. A debug dump is safe to attach to a public issue; the equivalent in-process, unredacted view exists only for Predbat's own internal diagnostics and must never be written anywhere.

One visible side effect of pseudonymisation worth knowing about when reading a dump: a device_id built from a sensitive identifier is replaced wholesale by its pseudonym token rather than having just that part of it swapped out. Octopus's meter device_id is "octopus:{mpan}", so a pseudonymised one loses its "octopus:" prefix entirely and reads as a bare # token, unlike a device_id in most other sections. Nothing is lost functionally - cross-links between records still resolve to the same token, and the record is still tagged with its source - but a maintainer comparing sections will notice the inconsistency and should not have to wonder whether it is a bug. A device_id built from the record's own serial is not replaced, even when the record also carries account_ids: Deye's "deye:{serial}" stays readable beside its pseudonymised station id.

For developers: the report schema

A component describes what it found by implementing build_discovery(), returning a plain dict:

def build_discovery(self):
    return {
        "automatic": self.automatic,   # whether THIS component wired apps.yaml to what it found
        "inverters": [...],            # any section keys you have records for - omit the rest
    }

and reporting it - typically once per run() cycle - via the base class helper:

self.report_discovery(report)

which routes to Coordinator.report(component_name, report) if a coordinator is running, and is a safe no-op otherwise (the standalone CLI harnesses have no registry at all). The coordinator validates the report - see the container model below - keyed on component_name (ComponentBase sets this to your registry key automatically), and a later report() call for the same component fully replaces its previous one; there is no need to compute a diff yourself.

A record needs a device_id (a string, unique within your component - anything else is dropped with a warning) plus whichever structural fields that section defines (inverter_type, direction, serves_cars, ...) and containers you have facts for.

The container model, and why it is typed

Rather than trying to name every field a future reporter might need, the schema instead defines a small set of containers, each declaring the type of value it accepts and the redaction class that value gets:

Container Accepts Redaction
hardware_ids Short strings Clear
info Short strings, no @ Clear
ratings Numbers and booleans, keyed by Predbat's setting name where one exists (inverter_limit, export_limit, import_limit, battery_rate_max, soc_max, battery_min_soc) Clear
coverage A number/boolean, or a list of lowercase tokens Clear
entities An entity descriptor: access (rw or r, required), exactly one of entity_id or a fixed value, and typed fields such as domain, unit, format, min/max, invert Clear
capabilities true/false for the seven behaviour keys of an inverter definition (support_charge_freeze, support_discharge_freeze, support_feedin_first, can_span_midnight, charge_discharge_with_rate, charge_control_immediate, target_soc_used_for_discharge) Clear
account_ids Any scalar Pseudonymised
functions / flags / effects Lists of short lowercase tokens Clear

A value that does not fit its container's declared type is silently dropped, not coerced and not raised as an error - a string offered to ratings, or free text over 64 characters offered to info, simply never reaches the assembled catalogue. This is the actual safety property the design rests on: it does not depend on enumerating every dangerous field name a component might one day introduce, because a container that only accepts numbers structurally cannot carry a name, an address or a pasted credential, however the schema grows. Choose whichever container matches the kind of fact you are reporting, not the one that happens to accept the value you have.

Inverter records and inverter definitions

An inverter record holds enough to rebuild that inverter's definition - the per-type table (INVERTER_DEF) that tells Predbat how to drive it. capabilities carries its behaviour; whether it has a reserve, a target SoC, charge/discharge enable switches, idle times or a timed pause follows from which settings entities binds to a writable (rw) entity; and protocol detail such as the time format or whether the charge rate is set in watts or amps is read from those entities' descriptors. A setting Predbat replaces with a placeholder for this inverter type - SolisCloud's reserve, say - is left out of entities. See the design in docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md.

Writing a reporter

A component reports by defining build_discovery(), returning its report dict - or None when it has not discovered enough to describe yet (no serial, no account) - and calling self.refresh_discovery() once per run() cycle, unconditionally. That is the whole contract. ComponentBase.refresh_discovery() owns the reporting loop itself, and with it three rules the first five reporters each had to remember, and between them got wrong more than once:

  • A report is rebuilt and compared in full on every call, never keyed on a hand-maintained snapshot of whatever build_discovery() reads. Such a key has to be kept in step with the build by hand, and when it drifts the report freezes in its first, incomplete state for the life of the process. Comparing the built report cannot drift, and needs no completeness check either: a report that fills in later simply differs from the stored one and replaces it. Builds are dict work over data already in hand, so rebuilding to compare costs nothing measurable, and nothing is filed unless something actually moved.
  • The marker only advances after a report is filed, never on the failure path, so a transient failure is retried next cycle. This is why the call must sit outside any one-shot if first: gate: first flips to False forever the instant run() returns True, so a single failed cycle inside it could never be retried.
  • A failure is caught and logged, never raised. An observer must not be able to degrade the health of the component it observes - an exception escaping run() withholds the success timestamp and eventually pushes a healthy component toward unhealthy. It is logged only, never through non_fatal_error_occurred(), which would set had_errors and suppress record_status().

Two rules remain yours to follow:

  1. Report only entities that actually exist in the state store, never every entry in a static table. An entity spec describes what a component can publish, not what it has published on this particular install with this particular firmware version. Pass your descriptors through self.discovery_entities(descriptors), which keeps only those Home Assistant has actually seen - claiming an entity exists that Home Assistant has never seen is worse than omitting it.

  2. Never invent a record to resolve a cross-link - a dangling one is fine. A device can point at another with a cross-link field (measures_meter, charged_by, ...) without the thing on the other end existing as a record in its own right. GE Cloud's CT-clamp cross-link is the clearest example: a battery can report which meter serial its own clamp measures without a meters section record ever being fabricated for that clamp, because a CT clamp is not itself a billing supply point. Resolving a dangling cross-link against a genuine record reported by another component is exactly what observations is for - it is not this reporter's job to guess one into existence.

For the inverters section, build each record with inverter_record() from coordinator.py rather than assembling the dict by hand. It takes the section's fields as keyword-only parameters and omits whatever is unset or empty, so a reporter can pass everything it gathered without writing its own if info: record["info"] = info ladder. A mistyped field name is a TypeError at the call site rather than a key silently dropped from a user's dump.