Skip to content

03 โ€” The Data Model

Scope. Everything this integration persists, in one place: the two stores and why they are not alike, the store's real key set as opposed to its declared one, the identifiers that address it, and which of it is the only copy. Per-subsystem detail lives with each subsystem; this is the map.

Read 01 โ€” Architecture Overview first for the layers.


1. Two stores, and the split is about write shape

the store the tree
where one Home Assistant store document config/eufy_vacuum/
holds configuration and current state history and derived statistics
written whole, every time per record
grows with your rooms and maps with every run, forever
deleted with the integration yes no โ€” 39 ยง5

A single document rewritten on every change is exactly wrong for a growing history, and exactly right for configuration you want atomic. That is the whole reason there are two, and it is why a new subsystem should ask which shape its data has before choosing where to put it.

Mechanics: 32 โ€” The Store and 26 โ€” The Learning Record Store.


2. The store's declared schema is a minority of its real one

core/storage.py::async_load returns eight top-level keys for a fresh install:

vacuums ยท maps ยท theme ยท analytics ยท maintenance ยท dock_events ยท onboarding ยท error_tracker

A live install carries twenty-four. Measured by reading the store on the reference install rather than by grepping for writers โ€” the sixteen the loader does not declare are:

active_jobs ยท battery ยท capabilities ยท discovery ยท entity_overrides ยท learning_pending_runs ยท learning_processing_enabled ยท migrations ยท payloads ยท profiles ยท queue ยท room_history ยท room_rule_status ยท run_profiles ยท setup_progress ยท _pending_run_steps

โš  Neither the loader nor a grep for writers is the schema. Reading async_load tells you what a fresh install looks like. Grepping for data.setdefault(โ€ฆ) โ€” the obvious second source โ€” finds twenty of the twenty-four and silently misses entity_overrides, migrations, profiles and run_profiles, because not every key is created that way.

The only authority is a live store. That is worth knowing before trusting any list of keys, including this one: it is a measurement of one install on one date, and a subsystem that has never run there has never created its key.

Only one key gets a defensive backfill on load rather than lazy creation: the error tracker's, added for installs that predate it. That is the pattern the other sixteen do not follow.


3. The store nests the same way almost everywhere

Most sections key per vacuum, then per map:

maps
 โ””โ”€ vacuum.alfred
     โ””โ”€ "2"                     โ† map_id, always stringified
         โ”œโ”€ metadata
         โ”œโ”€ rooms
         โ”‚   โ””โ”€ "5"             โ† room_id, stringified at the boundary
         โ”‚       โ”œโ”€ room_id     (int)   โ”€โ”
         โ”‚       โ”œโ”€ map_id      (str)    โ”œโ”€ identity
         โ”‚       โ”œโ”€ name / slug          โ”˜
         โ”‚       โ”œโ”€ enabled / order
         โ”‚       โ””โ”€ clean_mode ยท fan_speed ยท water_level ยท clean_intensity โ€ฆ
         โ””โ”€ summary

Two conventions to know before reading any of it:

  • Ids are ints in code and strings in storage. JSON has no integer keys, so every map and room id is stringified on the way in and coerced back on the way out. A comparison that skips the coercion silently never matches.
  • A room's settings are a mix of framework metadata and brand vocabulary, and only the framework half may be defaulted without asking the adapter. Crossing that line stamped a brand axis onto every room of every brand (33 ยง4).

Room identity in full: 17 โ€” Room Identity.


4. The tree: one root per vacuum, six directories

config/eufy_vacuum/
โ”œโ”€ learning/<vacuum-slug>/
โ”‚   โ”œโ”€ jobs/            completed-job records โ€” and phase CHILDREN
โ”‚   โ”œโ”€ phases/          break records: a wait or a charge is not a job
โ”‚   โ”œโ”€ phased_jobs/     parents: the run a user actually started
โ”‚   โ”œโ”€ learned/         room_stats ยท job_stats ยท jobs_index ยท accuracy_stats
โ”‚   โ”œโ”€ exports/         jobs_flat.csv ยท rooms_flat.csv
โ”‚   โ””โ”€ live/            last_job_snapshot ยท incomplete_run ยท trouble_rooms
โ”œโ”€ maps/                uploaded map images, per variant
โ””โ”€ fonts/               drop-in user fonts โ€” [45 ยง5](45-the-shared-layer.md)

Three record kinds live here, each carrying an explicit record_type rather than being identified by its directory โ€” which is what lets one check refuse a non-job wherever the file came from.

A phase child stays an ordinary job record in jobs/. The two new directories hold new record kinds, not a new kind of job, so every existing reader of jobs/ kept working when phased runs arrived (26 ยง3).


5. Three of the identifiers are names, and names change

This is the hazard the data model has and does not announce.

identifier shape stable?
map_id brand-dependent not on every brand โ€” see below
room_id device-assigned int renumbers on re-segment โ€” 17
vacuum_entity_id an HA entity id user-renameable
slug derived from the room's name changes when the room is renamed

โš  map_id is a number on one shipped brand and a display NAME on the other. Measured on the reference install: the Eufy vacuums key on '7', '11', '12'; the Roborock keys on 'Main floor'. On that brand the map id is a user-renameable name, and it is the first component of the learning key โ€” so renaming a map in the vendor app orphans the learned statistics of every room on it at once, which is strictly worse than renaming one room.

The last three are used as storage addresses:

  • the tree's per-vacuum directory is derived from the vacuum's entity id โ€” and so are seventeen sections of the store, maps and run_profiles among them. Renaming the vacuum entity strands the configuration and the history together, and the record builder then creates a fresh empty one, so the vacuum returns as brand new (26 ยง7);
  • the learning key is map_id::slug::โ€ฆ, so renaming a room files its future runs under a new key while its past stays under the old one (28 ยง2);
  • and on a brand whose map id is a name, renaming the map does the same to every room on it at once.

A room rename is not silent about the event โ€” reconciliation detects it and shows it to the user โ€” but nothing rekeys the stores, so the history stays addressable only by a name nothing will ask for again. A vacuum rename and a map rename are not even detected.

Room ids renumbering is the hazard that is handled: identity is carried by slug, and the device's current id is re-resolved at dispatch (42 ยง1). The design that fixed the unstable id introduced the unstable name.


6. What is the only copy, and what can be rebuilt

Worth knowing before deleting anything, and before assuming a value is authoritative.

Authoritative โ€” the only copy: maps (rooms and their settings) ยท vacuums ยท theme ยท setup_progress ยท onboarding ยท maintenance reset baselines ยท every record under jobs/, phases/ and phased_jobs/

Derived โ€” reproducible from the above: learned/ in full (28) ยท exports/ ยท queue ยท payloads ยท capabilities ยท discovery ยท room_history

The derived half is why a corrupt statistics file is recoverable for a reader and fatal for a writer: the reader can rebuild it, so it may proceed; the writer must never overwrite the evidence that a rebuild would have used (32 ยง4, 26 ยง4).

โš  live/ is neither. Its three files are single-overwrite scratch โ€” the newest snapshot, the most recent incomplete run, the running trouble-room counters. Losing them costs a prompt, not history; but they are also not rebuildable, because nothing keeps what they summarised.


7. Common wrong assumptions

assumption reality
the schema is in async_load a live install carries 24; grepping for writers finds only 20 โ€” ยง2
map_id is a stable device id it is a display NAME on one of the two shipped brands โ€” ยง5
ids are integers in storage JSON has no integer keys; every id is stringified at the boundary โ€” ยง3
the store and the tree are two halves of one thing different write shapes, different lifetimes, and only one is deleted with the integration โ€” ยง1
a phase is a new kind of job children are ordinary job records; the parents and breaks are new record kinds โ€” ยง4
room ids are the risky identifier they renumber and that is handled; the unhandled ones are the two that are names โ€” ยง5
renaming is caught, so it is handled the event is detected and shown; nothing rekeys the stores โ€” ยง5
the learned statistics are authoritative they are derived and rebuildable, which is exactly why a reader may proceed past a corrupt one โ€” ยง6

Registries

00b-invariants.md ยท 00c-replicas.md