feat(homecore-migrate/p1): ADR-134 .storage parser + entity-registry import (19 tests pass)
- HaStorageEnvelope: outer {version, minor_version, key, data} shape for all .storage files
- storage_format/v13: versioned parser dispatch; UnsupportedSchemaVersion hard error on unknown minor_version
- entity_registry: core.entity_registry v13 → Vec<homecore::EntityEntry> with full field mapping
- device_registry: core.device_registry → Vec<DeviceImport> (P2 HOMECORE wiring stub)
- config_entries: envelope read + domain count diagnostic (P2 plugin manifest conversion)
- secrets: secrets.yaml → HashMap<String,String>
- automations: count + ID list extraction (P2 conversion)
- cli: clap-derived Inspect/ImportEntities/ImportDevices/InspectConfigEntries/InspectSecrets/InspectAutomations subcommands
- 19 unit tests, all pass; build clean; workspace member appended to v2/Cargo.toml
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
347aad9bfa
commit
901adf1be6
|
|
@ -3457,6 +3457,20 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "homecore-assist"
|
||||
version = "0.1.0-alpha.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"homecore",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "homecore-automation"
|
||||
version = "0.1.0-alpha.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
# Implements ADR-134 (HOMECORE-MIGRATE), P1 scaffold:
|
||||
# - HaStorageDir + HaStorageEnvelope: reads `.storage/*.json` files
|
||||
# - Versioned format parsers under `storage_format::v<N>`
|
||||
# - entity_registry, device_registry, config_entries parsers
|
||||
# - secrets.yaml + automations.yaml parsers
|
||||
# - CLI: `homecore-migrate inspect` / `homecore-migrate import-entities`
|
||||
#
|
||||
# P2 will add homecore-recorder side-by-side DB export (feature-gated).
|
||||
|
||||
[package]
|
||||
name = "homecore-migrate"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Migration tooling from Python Home Assistant to HOMECORE (ADR-134 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[[bin]]
|
||||
name = "homecore-migrate"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "homecore_migrate"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: enable when homecore-recorder ships (ADR-132). Exports side-by-side DB.
|
||||
recorder = []
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE state machine — local path (ADR-127).
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime.
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Serialisation — JSON for .storage files, YAML for secrets/automations.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Error handling.
|
||||
thiserror = "1"
|
||||
|
||||
# Tracing/logging.
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
# CLI argument parsing.
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Error handling in main.rs
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
tempfile = "3"
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
//! Parser for `automations.yaml`.
|
||||
//!
|
||||
//! P1: reads the YAML, validates the top-level structure, and emits a count
|
||||
//! plus the list of automation IDs/aliases.
|
||||
//!
|
||||
//! Conversion to `homecore-automation` YAML format is deferred to P2.
|
||||
//!
|
||||
//! HA `automations.yaml` is a YAML sequence of automation objects:
|
||||
//!
|
||||
//! ```yaml
|
||||
//! - id: '1620000000001'
|
||||
//! alias: "Turn on lights at sunset"
|
||||
//! trigger: [...]
|
||||
//! condition: []
|
||||
//! action: [...]
|
||||
//! - id: '1620000000002'
|
||||
//! alias: "Turn off lights at midnight"
|
||||
//! trigger: [...]
|
||||
//! action: [...]
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::MigrateError;
|
||||
|
||||
/// Diagnostic summary of `automations.yaml`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutomationsSummary {
|
||||
pub count: usize,
|
||||
/// `(id, alias)` pairs. `id` defaults to an empty string if absent.
|
||||
pub automations: Vec<AutomationIdent>,
|
||||
}
|
||||
|
||||
/// Minimal identifying info for a single automation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutomationIdent {
|
||||
pub id: String,
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaAutomationRow {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
alias: Option<String>,
|
||||
// All other fields (trigger, condition, action, mode, etc.) ignored in P1.
|
||||
#[allow(dead_code)]
|
||||
#[serde(flatten)]
|
||||
_rest: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Read `automations.yaml` from `path` and return a summary.
|
||||
pub fn read_automations(path: &Path) -> Result<AutomationsSummary, MigrateError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| MigrateError::Io {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(AutomationsSummary { count: 0, automations: vec![] });
|
||||
}
|
||||
|
||||
let rows: Vec<HaAutomationRow> =
|
||||
serde_yaml::from_str(&raw).map_err(|e| MigrateError::YamlParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let automations = rows
|
||||
.iter()
|
||||
.map(|r| AutomationIdent { id: r.id.clone(), alias: r.alias.clone() })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(AutomationsSummary { count: rows.len(), automations })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const FIXTURE: &str = r#"
|
||||
- id: '1620000000001'
|
||||
alias: "Turn on lights at sunset"
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunset
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
|
||||
- id: '1620000000002'
|
||||
alias: "Turn off lights at midnight"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "00:00:00"
|
||||
action:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: all
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_automation_count_and_ids() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(FIXTURE.as_bytes()).unwrap();
|
||||
let summary = read_automations(f.path()).unwrap();
|
||||
assert_eq!(summary.count, 2);
|
||||
assert_eq!(summary.automations.len(), 2);
|
||||
assert_eq!(summary.automations[0].id, "1620000000001");
|
||||
assert_eq!(
|
||||
summary.automations[0].alias.as_deref(),
|
||||
Some("Turn on lights at sunset")
|
||||
);
|
||||
assert_eq!(summary.automations[1].id, "1620000000002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_automations_returns_zero_count() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(b"").unwrap();
|
||||
let summary = read_automations(f.path()).unwrap();
|
||||
assert_eq!(summary.count, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
//! CLI argument types for `homecore-migrate`.
|
||||
//!
|
||||
//! Shared between `src/main.rs` and integration tests. The `clap`-derived
|
||||
//! `Cli` struct is the entry-point; `Command` is the subcommand enum.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// homecore-migrate — migrate from Python Home Assistant to HOMECORE.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "homecore-migrate", version, about)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Inspect what is in the HA .storage directory and flag unsupported versions.
|
||||
Inspect(InspectArgs),
|
||||
/// Import entity registry from HA into a HOMECORE storage directory.
|
||||
ImportEntities(ImportEntitiesArgs),
|
||||
/// Import device registry (P1: parses and reports; wiring to HOMECORE P2).
|
||||
ImportDevices(ImportDevicesArgs),
|
||||
/// Inspect config entries (P1: count + domain list; conversion is P2).
|
||||
InspectConfigEntries(InspectConfigEntriesArgs),
|
||||
/// Parse secrets.yaml and report secret names (values redacted).
|
||||
InspectSecrets(InspectSecretsArgs),
|
||||
/// Count and list automations from automations.yaml (conversion is P2).
|
||||
InspectAutomations(InspectAutomationsArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ImportEntitiesArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
/// Path to the HOMECORE storage directory (destination).
|
||||
#[arg(long)]
|
||||
pub to: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ImportDevicesArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectConfigEntriesArgs {
|
||||
/// Path to the HA `.storage/` directory.
|
||||
#[arg(long)]
|
||||
pub storage: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectSecretsArgs {
|
||||
/// Path to the HA config directory (contains `secrets.yaml`).
|
||||
#[arg(long)]
|
||||
pub config_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct InspectAutomationsArgs {
|
||||
/// Path to the HA config directory (contains `automations.yaml`).
|
||||
#[arg(long)]
|
||||
pub config_dir: PathBuf,
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies).
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5, `.storage/core.config_entries` format is undocumented
|
||||
//! and version-gated. P1 reads the envelope and emits:
|
||||
//! - count of config entries
|
||||
//! - list of integration domains represented
|
||||
//!
|
||||
//! Conversion to HOMECORE plugin manifests is P2.
|
||||
//!
|
||||
//! Note: `config_entries` uses a different `minor_version` track from
|
||||
//! `entity_registry`. As of HA 2025.1 it is typically minor_version=1 or 2.
|
||||
//! We accept any minor_version ≤ MAX_SUPPORTED_MINOR and hard-error above it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{storage::read_envelope, MigrateError};
|
||||
|
||||
/// Maximum `minor_version` we claim to understand for config_entries.
|
||||
const MAX_SUPPORTED_MINOR: u32 = 4;
|
||||
|
||||
/// Diagnostic summary produced by P1 inspection.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ConfigEntriesSummary {
|
||||
pub count: usize,
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Minimal fields we read from each config-entry row.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaConfigEntryRow {
|
||||
domain: String,
|
||||
#[allow(dead_code)]
|
||||
entry_id: String,
|
||||
/// Title shown in HA UI (informational only in P1).
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
title: Option<String>,
|
||||
/// Source of the entry: "user" | "discovery" | "import" etc.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
source: Option<String>,
|
||||
/// State: "loaded" | "setup_error" etc.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaConfigEntriesData {
|
||||
entries: Vec<HaConfigEntryRow>,
|
||||
}
|
||||
|
||||
/// Read `core.config_entries` from `path` and return a diagnostic summary.
|
||||
pub fn inspect_config_entries(path: &Path) -> Result<ConfigEntriesSummary, MigrateError> {
|
||||
let env = read_envelope(path)?;
|
||||
let file_str = path.display().to_string();
|
||||
|
||||
// config_entries has version=1 and minor_version in 1..MAX_SUPPORTED_MINOR.
|
||||
if env.version != 1 || env.minor_version > MAX_SUPPORTED_MINOR {
|
||||
return Err(MigrateError::UnsupportedSchemaVersion {
|
||||
file: file_str.clone(),
|
||||
version: env.version,
|
||||
minor_version: env.minor_version,
|
||||
});
|
||||
}
|
||||
|
||||
let data: HaConfigEntriesData =
|
||||
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
|
||||
path: file_str,
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut domains: Vec<String> = data.entries.iter().map(|e| e.domain.clone()).collect();
|
||||
domains.sort();
|
||||
domains.dedup();
|
||||
|
||||
Ok(ConfigEntriesSummary {
|
||||
count: data.entries.len(),
|
||||
domains,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.config_entries",
|
||||
"data": {
|
||||
"entries": [
|
||||
{"domain": "hue", "entry_id": "ce_001", "title": "Philips Hue", "source": "user", "state": "loaded"},
|
||||
{"domain": "zha", "entry_id": "ce_002", "title": "ZHA", "source": "user", "state": "loaded"},
|
||||
{"domain": "hue", "entry_id": "ce_003", "title": "Hue 2", "source": "user", "state": "setup_error"}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn inspect_emits_count_and_domains() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(FIXTURE.as_bytes()).unwrap();
|
||||
let summary = inspect_config_entries(f.path()).unwrap();
|
||||
assert_eq!(summary.count, 3);
|
||||
assert_eq!(summary.domains, vec!["hue", "zha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_minor_version_hard_errors() {
|
||||
let json = r#"{
|
||||
"version": 1, "minor_version": 99,
|
||||
"key": "core.config_entries",
|
||||
"data": {"entries": []}
|
||||
}"#;
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(json.as_bytes()).unwrap();
|
||||
let err = inspect_config_entries(f.path()).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
//! Parser for `core.device_registry` (HA storage schema v1, minor_version 1–13).
|
||||
//!
|
||||
//! P1: deserializes the envelope and returns `Vec<DeviceImport>`.
|
||||
//! HOMECORE's device registry isn't fully wired yet (ADR-127 §2.5 deferred
|
||||
//! to P2), so `DeviceImport` is a staging type for the future hand-off.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
|
||||
|
||||
/// Staging type for a device imported from HA. Not yet wired to HOMECORE's
|
||||
/// device registry (ADR-127 §2.5 — deferred to P2).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceImport {
|
||||
pub id: String,
|
||||
pub config_entries: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub manufacturer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// `identifiers` — list of `[integration, id]` pairs. Preserved as raw
|
||||
/// JSON for P2 consumption; not yet mapped to HOMECORE DeviceEntry.
|
||||
#[serde(default)]
|
||||
pub identifiers: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub connections: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub via_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaDeviceRegistryData {
|
||||
devices: Vec<DeviceImport>,
|
||||
/// Deleted device tombstones — ignored in P1.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
deleted_devices: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Read `core.device_registry` from `path` and return the raw import list.
|
||||
pub fn read_device_registry(path: &Path) -> Result<Vec<DeviceImport>, MigrateError> {
|
||||
let env = read_envelope(path)?;
|
||||
let file_str = path.display().to_string();
|
||||
v13::require_supported(&file_str, env.version, env.minor_version)?;
|
||||
|
||||
let data: HaDeviceRegistryData =
|
||||
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
|
||||
path: file_str,
|
||||
source: e,
|
||||
})?;
|
||||
Ok(data.devices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 13,
|
||||
"key": "core.device_registry",
|
||||
"data": {
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev_abc",
|
||||
"config_entries": ["ce_001"],
|
||||
"manufacturer": "Philips",
|
||||
"model": "Hue Bridge",
|
||||
"name": "Philips Hue Bridge",
|
||||
"identifiers": [["hue", "001788FFFE3D4B13"]],
|
||||
"connections": [["mac", "00:17:88:ff:fe:3d:4b:13"]],
|
||||
"via_device_id": null,
|
||||
"area_id": null
|
||||
}
|
||||
],
|
||||
"deleted_devices": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_device_registry() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(FIXTURE.as_bytes()).unwrap();
|
||||
let devices = read_device_registry(f.path()).unwrap();
|
||||
assert_eq!(devices.len(), 1);
|
||||
let d = &devices[0];
|
||||
assert_eq!(d.id, "dev_abc");
|
||||
assert_eq!(d.manufacturer.as_deref(), Some("Philips"));
|
||||
assert_eq!(d.identifiers, vec![vec!["hue", "001788FFFE3D4B13"]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
//! Parser for `core.entity_registry` (HA storage schema v1, minor_version 1–13).
|
||||
//!
|
||||
//! Reads the `.storage/core.entity_registry` file and converts it into a
|
||||
//! `Vec<homecore::EntityEntry>` that can be loaded directly into the HOMECORE
|
||||
//! in-memory entity registry.
|
||||
//!
|
||||
//! Schema as of HA 2025.1 (minor_version=13):
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 1, "minor_version": 13, "key": "core.entity_registry",
|
||||
//! "data": {
|
||||
//! "entities": [
|
||||
//! {
|
||||
//! "entity_id": "light.kitchen",
|
||||
//! "unique_id": "hue_lamp_42",
|
||||
//! "platform": "hue",
|
||||
//! "name": "Kitchen lamp",
|
||||
//! "disabled_by": null,
|
||||
//! "area_id": "kitchen",
|
||||
//! "device_id": "abc123",
|
||||
//! "entity_category": null,
|
||||
//! "config_entry_id": "ce_001"
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
|
||||
|
||||
use crate::{
|
||||
storage::read_envelope,
|
||||
storage_format::v13,
|
||||
MigrateError,
|
||||
};
|
||||
|
||||
// Key used by `inspect` subcommand when scanning the directory.
|
||||
#[allow(dead_code)]
|
||||
const FILE_KEY: &str = "core.entity_registry";
|
||||
|
||||
/// Raw HA entity registry data block (the `data` field in the envelope).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaEntityRegistryData {
|
||||
entities: Vec<HaEntityRow>,
|
||||
/// Deleted-entity tombstones (ignored in P1 — forwarded as Q5 note).
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
deleted_entities: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A single row from `data.entities`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct HaEntityRow {
|
||||
entity_id: String,
|
||||
#[serde(default)]
|
||||
unique_id: Option<String>,
|
||||
platform: String,
|
||||
/// User-set display name (separate from HA-integration default name).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
disabled_by: Option<HaDisabledBy>,
|
||||
#[serde(default)]
|
||||
area_id: Option<String>,
|
||||
#[serde(default)]
|
||||
device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
entity_category: Option<HaEntityCategory>,
|
||||
#[serde(default)]
|
||||
config_entry_id: Option<String>,
|
||||
// Fields present in v13 that we capture but do not yet map to HOMECORE.
|
||||
// Forwarded as Q5 items.
|
||||
#[serde(default)]
|
||||
hidden_by: Option<String>, // v13: "user" | "integration"
|
||||
#[serde(default)]
|
||||
has_entity_name: Option<bool>, // v13: HA naming convention flag
|
||||
#[serde(default)]
|
||||
original_name: Option<String>, // v13: integration-provided default name
|
||||
#[serde(default)]
|
||||
icon: Option<String>, // v13: mdi:xxx icon override
|
||||
#[serde(default)]
|
||||
original_icon: Option<String>, // v13: integration-provided icon
|
||||
#[serde(default)]
|
||||
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
|
||||
#[serde(default)]
|
||||
capabilities: Option<serde_json::Value>, // v13: integration-specific caps
|
||||
#[serde(default)]
|
||||
supported_features: Option<u64>, // v13: bitmask
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum HaDisabledBy {
|
||||
User,
|
||||
Integration,
|
||||
ConfigEntry,
|
||||
Device,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum HaEntityCategory {
|
||||
Config,
|
||||
Diagnostic,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn map_disabled_by(v: Option<HaDisabledBy>) -> Option<DisabledBy> {
|
||||
v.and_then(|d| match d {
|
||||
HaDisabledBy::User => Some(DisabledBy::User),
|
||||
HaDisabledBy::Integration => Some(DisabledBy::Integration),
|
||||
HaDisabledBy::ConfigEntry => Some(DisabledBy::ConfigEntry),
|
||||
HaDisabledBy::Device => Some(DisabledBy::Device),
|
||||
HaDisabledBy::Unknown => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_entity_category(v: Option<HaEntityCategory>) -> Option<EntityCategory> {
|
||||
v.and_then(|c| match c {
|
||||
HaEntityCategory::Config => Some(EntityCategory::Config),
|
||||
HaEntityCategory::Diagnostic => Some(EntityCategory::Diagnostic),
|
||||
HaEntityCategory::Unknown => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `core.entity_registry` from `path` and return HOMECORE entries.
|
||||
///
|
||||
/// Errors:
|
||||
/// - `MigrateError::Io` if the file cannot be read
|
||||
/// - `MigrateError::JsonParse` if the JSON is malformed
|
||||
/// - `MigrateError::UnsupportedSchemaVersion` if minor_version is not 1–13
|
||||
/// - `MigrateError::EntityId` if any `entity_id` string is invalid
|
||||
pub fn read_entity_registry(path: &Path) -> Result<Vec<EntityEntry>, MigrateError> {
|
||||
let env = read_envelope(path)?;
|
||||
let file_str = path.display().to_string();
|
||||
v13::require_supported(&file_str, env.version, env.minor_version)?;
|
||||
|
||||
let data: HaEntityRegistryData =
|
||||
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
|
||||
path: file_str.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut entries = Vec::with_capacity(data.entities.len());
|
||||
for row in data.entities {
|
||||
let entity_id = EntityId::parse(&row.entity_id)?;
|
||||
entries.push(EntityEntry {
|
||||
entity_id,
|
||||
unique_id: row.unique_id,
|
||||
platform: row.platform,
|
||||
name: row.name,
|
||||
disabled_by: map_disabled_by(row.disabled_by),
|
||||
area_id: row.area_id,
|
||||
device_id: row.device_id,
|
||||
entity_category: map_entity_category(row.entity_category),
|
||||
config_entry_id: row.config_entry_id,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn write_fixture(json: &str) -> NamedTempFile {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(json.as_bytes()).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
const FIXTURE_V13: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 13,
|
||||
"key": "core.entity_registry",
|
||||
"data": {
|
||||
"entities": [
|
||||
{
|
||||
"entity_id": "light.kitchen",
|
||||
"unique_id": "hue_lamp_42",
|
||||
"platform": "hue",
|
||||
"name": "Kitchen lamp",
|
||||
"disabled_by": null,
|
||||
"area_id": "kitchen",
|
||||
"device_id": "abc123",
|
||||
"entity_category": null,
|
||||
"config_entry_id": "ce_001"
|
||||
},
|
||||
{
|
||||
"entity_id": "sensor.bedroom_temperature",
|
||||
"unique_id": "zigbee_temp_01",
|
||||
"platform": "zha",
|
||||
"name": null,
|
||||
"disabled_by": "integration",
|
||||
"area_id": null,
|
||||
"device_id": "dev_02",
|
||||
"entity_category": "diagnostic",
|
||||
"config_entry_id": "ce_002",
|
||||
"hidden_by": null,
|
||||
"has_entity_name": true,
|
||||
"original_name": "Temperature",
|
||||
"aliases": ["room temp"],
|
||||
"supported_features": 0
|
||||
}
|
||||
],
|
||||
"deleted_entities": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_v13_entity_registry() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_fields_round_trip_correctly() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
let light = entries.iter().find(|e| e.entity_id.as_str() == "light.kitchen").unwrap();
|
||||
assert_eq!(light.unique_id.as_deref(), Some("hue_lamp_42"));
|
||||
assert_eq!(light.platform, "hue");
|
||||
assert_eq!(light.name.as_deref(), Some("Kitchen lamp"));
|
||||
assert!(light.disabled_by.is_none());
|
||||
assert_eq!(light.area_id.as_deref(), Some("kitchen"));
|
||||
assert_eq!(light.device_id.as_deref(), Some("abc123"));
|
||||
assert!(light.entity_category.is_none());
|
||||
assert_eq!(light.config_entry_id.as_deref(), Some("ce_001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_by_maps_to_homecore() {
|
||||
let f = write_fixture(FIXTURE_V13);
|
||||
let entries = read_entity_registry(f.path()).unwrap();
|
||||
let sensor = entries
|
||||
.iter()
|
||||
.find(|e| e.entity_id.as_str() == "sensor.bedroom_temperature")
|
||||
.unwrap();
|
||||
assert_eq!(sensor.disabled_by, Some(DisabledBy::Integration));
|
||||
assert_eq!(sensor.entity_category, Some(EntityCategory::Diagnostic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_minor_version_raises_error() {
|
||||
let json = r#"{
|
||||
"version": 1, "minor_version": 99,
|
||||
"key": "core.entity_registry",
|
||||
"data": {"entities": [], "deleted_entities": []}
|
||||
}"#;
|
||||
let f = write_fixture(json);
|
||||
let err = read_entity_registry(f.path()).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }),
|
||||
"got: {err}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("minor_version=99"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
//! homecore-migrate — Migration tooling from Python Home Assistant.
|
||||
//!
|
||||
//! Implements [ADR-134](../../docs/adr/ADR-134-homecore-migration-from-python-ha.md)
|
||||
//! (referenced via ADR-126 §4, series map row ADR-134 HOMECORE-MIGRATE).
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! - [`storage`] — `HaStorageDir`, `HaStorageEnvelope`; `read_envelope(path)`
|
||||
//! - [`storage_format`] — versioned format parsers (`v13`); unknown minor_version → hard error
|
||||
//! - [`entity_registry`] — `core.entity_registry` → `Vec<homecore::EntityEntry>`
|
||||
//! - [`device_registry`] — `core.device_registry` → `Vec<DeviceImport>` (P1 stub)
|
||||
//! - [`config_entries`] — `core.config_entries` diagnostic (count + domain list; P2 converts)
|
||||
//! - [`secrets`] — `secrets.yaml` → `HashMap<String, String>`
|
||||
//! - [`automations`] — `automations.yaml` count + ID list (P2 converts)
|
||||
//! - [`cli`] — `clap`-derived subcommand types shared between `src/main.rs` and tests
|
||||
//!
|
||||
//! ## What is NOT here yet (deferred to P2+)
|
||||
//!
|
||||
//! - Conversion of `config_entries` to HOMECORE plugin manifests
|
||||
//! - Conversion of `automations.yaml` to `homecore-automation` YAML
|
||||
//! - Side-by-side runtime mode (requires `homecore-recorder`, ADR-132)
|
||||
//! - `!secret` reference resolution in non-secrets YAML files
|
||||
|
||||
pub mod automations;
|
||||
pub mod cli;
|
||||
pub mod config_entries;
|
||||
pub mod device_registry;
|
||||
pub mod entity_registry;
|
||||
pub mod secrets;
|
||||
pub mod storage;
|
||||
pub mod storage_format;
|
||||
|
||||
/// Crate-level error type. Each module exposes `MigrateError` variants.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MigrateError {
|
||||
#[error("I/O error reading {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("JSON parse error in {path}: {source}")]
|
||||
JsonParse {
|
||||
path: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error("YAML parse error in {path}: {source}")]
|
||||
YamlParse {
|
||||
path: String,
|
||||
#[source]
|
||||
source: serde_yaml::Error,
|
||||
},
|
||||
|
||||
/// Fired when the outer `{version, minor_version}` envelope version is
|
||||
/// known but the `minor_version` is not supported by any compiled parser.
|
||||
/// Per ADR-134 §6 Q5: hard error on unknown minor_version.
|
||||
#[error(
|
||||
"unsupported schema version in {file}: \
|
||||
version={version} minor_version={minor_version}. \
|
||||
Upgrade homecore-migrate or downgrade HA to a supported release."
|
||||
)]
|
||||
UnsupportedSchemaVersion {
|
||||
file: String,
|
||||
version: u32,
|
||||
minor_version: u32,
|
||||
},
|
||||
|
||||
#[error("missing required field '{field}' in {context}")]
|
||||
MissingField { field: String, context: String },
|
||||
|
||||
#[error("entity_id parse error: {0}")]
|
||||
EntityId(#[from] homecore::EntityIdError),
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
//! `homecore-migrate` binary — CLI entry point.
|
||||
|
||||
use clap::Parser;
|
||||
use homecore_migrate::cli::{Cli, Command};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Inspect(args) => {
|
||||
println!("Inspecting HA .storage directory: {}", args.storage.display());
|
||||
// Probe entity_registry
|
||||
let entity_path = args.storage.join("core.entity_registry");
|
||||
if entity_path.exists() {
|
||||
match homecore_migrate::entity_registry::read_entity_registry(&entity_path) {
|
||||
Ok(entries) => println!(" core.entity_registry: {} entities", entries.len()),
|
||||
Err(e) => println!(" core.entity_registry: ERROR — {e}"),
|
||||
}
|
||||
}
|
||||
// Probe device_registry
|
||||
let device_path = args.storage.join("core.device_registry");
|
||||
if device_path.exists() {
|
||||
match homecore_migrate::device_registry::read_device_registry(&device_path) {
|
||||
Ok(devices) => println!(" core.device_registry: {} devices", devices.len()),
|
||||
Err(e) => println!(" core.device_registry: ERROR — {e}"),
|
||||
}
|
||||
}
|
||||
// Probe config_entries
|
||||
let ce_path = args.storage.join("core.config_entries");
|
||||
if ce_path.exists() {
|
||||
match homecore_migrate::config_entries::inspect_config_entries(&ce_path) {
|
||||
Ok(s) => println!(
|
||||
" core.config_entries: {} entries, domains: {}",
|
||||
s.count,
|
||||
s.domains.join(", ")
|
||||
),
|
||||
Err(e) => println!(" core.config_entries: ERROR — {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Command::ImportEntities(args) => {
|
||||
let entity_path = args.storage.join("core.entity_registry");
|
||||
let entries =
|
||||
homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
|
||||
println!("Imported {} entity entries (P1: in-memory only)", entries.len());
|
||||
println!(" Destination: {} (P2 persistence)", args.to.display());
|
||||
for e in &entries {
|
||||
println!(
|
||||
" {} ({}{})",
|
||||
e.entity_id.as_str(),
|
||||
e.platform,
|
||||
if e.disabled_by.is_some() { " DISABLED" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Command::ImportDevices(args) => {
|
||||
let device_path = args.storage.join("core.device_registry");
|
||||
let devices =
|
||||
homecore_migrate::device_registry::read_device_registry(&device_path)?;
|
||||
println!("Parsed {} device entries (P1: staging only, wiring to HOMECORE is P2)", devices.len());
|
||||
}
|
||||
|
||||
Command::InspectConfigEntries(args) => {
|
||||
let ce_path = args.storage.join("core.config_entries");
|
||||
let summary =
|
||||
homecore_migrate::config_entries::inspect_config_entries(&ce_path)?;
|
||||
println!(
|
||||
"config_entries: {} total, domains: {}",
|
||||
summary.count,
|
||||
summary.domains.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Command::InspectSecrets(args) => {
|
||||
let secrets_path = args.config_dir.join("secrets.yaml");
|
||||
let secrets = homecore_migrate::secrets::read_secrets(&secrets_path)?;
|
||||
println!("{} secrets found:", secrets.len());
|
||||
let mut keys: Vec<_> = secrets.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
println!(" {} = <redacted>", k);
|
||||
}
|
||||
}
|
||||
|
||||
Command::InspectAutomations(args) => {
|
||||
let auto_path = args.config_dir.join("automations.yaml");
|
||||
let summary = homecore_migrate::automations::read_automations(&auto_path)?;
|
||||
println!("{} automations:", summary.count);
|
||||
for a in &summary.automations {
|
||||
println!(
|
||||
" id={} alias={}",
|
||||
a.id,
|
||||
a.alias.as_deref().unwrap_or("<unnamed>")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
//! Parser for HA `secrets.yaml`.
|
||||
//!
|
||||
//! `secrets.yaml` is a flat YAML key→value map at the root of the HA
|
||||
//! config directory (NOT inside `.storage/`). Example:
|
||||
//!
|
||||
//! ```yaml
|
||||
//! mqtt_password: hunter2
|
||||
//! latitude: 51.5074
|
||||
//! longitude: -0.1278
|
||||
//! ```
|
||||
//!
|
||||
//! Values are always strings in HA (even numeric-looking ones are quoted in
|
||||
//! practice). We parse all values as strings to avoid type-mismatch errors.
|
||||
//!
|
||||
//! `!secret <name>` reference resolution (i.e., checking that every secret
|
||||
//! referenced in other YAML files exists here) is deferred to P2.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::MigrateError;
|
||||
|
||||
/// Read `secrets.yaml` from `path` and return a `name → value` map.
|
||||
///
|
||||
/// Returns an empty map if the file is empty (HA allows that).
|
||||
pub fn read_secrets(path: &Path) -> Result<HashMap<String, String>, MigrateError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| MigrateError::Io {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&raw).map_err(|e| MigrateError::YamlParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let map = match parsed {
|
||||
serde_yaml::Value::Mapping(m) => m,
|
||||
_ => {
|
||||
return Err(MigrateError::MissingField {
|
||||
field: "<root mapping>".into(),
|
||||
context: path.display().to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let mut result = HashMap::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let key = match k {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
let value = match v {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
serde_yaml::Value::Number(n) => n.to_string(),
|
||||
serde_yaml::Value::Bool(b) => b.to_string(),
|
||||
serde_yaml::Value::Null => String::new(),
|
||||
other => serde_yaml::to_string(&other)
|
||||
.unwrap_or_else(|_| "<unparseable>".into())
|
||||
.trim()
|
||||
.to_string(),
|
||||
};
|
||||
result.insert(key, value);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_key_value_map() {
|
||||
let yaml = "mqtt_password: hunter2\nlatitude: 51.5074\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
let secrets = read_secrets(f.path()).unwrap();
|
||||
assert_eq!(secrets.get("mqtt_password").map(String::as_str), Some("hunter2"));
|
||||
assert_eq!(secrets.get("latitude").map(String::as_str), Some("51.5074"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_secrets_file_returns_empty_map() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(b"").unwrap();
|
||||
let secrets = read_secrets(f.path()).unwrap();
|
||||
assert!(secrets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_count_is_correct() {
|
||||
let yaml = "a: 1\nb: 2\nc: 3\n";
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(yaml.as_bytes()).unwrap();
|
||||
let secrets = read_secrets(f.path()).unwrap();
|
||||
assert_eq!(secrets.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
//! HA `.storage/` directory abstraction and the outer storage envelope.
|
||||
//!
|
||||
//! Every file in `.storage/` shares the same outer JSON shape:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 1,
|
||||
//! "minor_version": 3,
|
||||
//! "key": "core.entity_registry",
|
||||
//! "data": { ... }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! `read_envelope` reads and validates this outer wrapper. The `data` field is
|
||||
//! left as `serde_json::Value` — version-specific parsers in `storage_format`
|
||||
//! are responsible for further deserialization.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::MigrateError;
|
||||
|
||||
/// Points to a HA `.storage/` directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HaStorageDir {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl HaStorageDir {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// Returns the full path to a named storage file.
|
||||
pub fn file_path(&self, name: &str) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The outer JSON envelope that wraps every HA `.storage/*.json` file.
|
||||
/// Source: `homeassistant/helpers/storage.py` `Store._write_data`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HaStorageEnvelope {
|
||||
pub version: u32,
|
||||
/// Introduced in HA 2022.x for backwards-compatible schema additions.
|
||||
#[serde(default)]
|
||||
pub minor_version: u32,
|
||||
pub key: String,
|
||||
/// Inner payload. Parsed by versioned format-specific code.
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Read and deserialize a `.storage/*.json` envelope from `path`.
|
||||
///
|
||||
/// Returns `MigrateError::Io` if the file cannot be read, or
|
||||
/// `MigrateError::JsonParse` if the JSON is malformed.
|
||||
pub fn read_envelope(path: &Path) -> Result<HaStorageEnvelope, MigrateError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| MigrateError::Io {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_json::from_str(&raw).map_err(|e| MigrateError::JsonParse {
|
||||
path: path.display().to_string(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WELL_FORMED: &str = r#"{
|
||||
"version": 1,
|
||||
"minor_version": 3,
|
||||
"key": "core.entity_registry",
|
||||
"data": {"entities": []}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn envelope_parses_well_formed() {
|
||||
let env: HaStorageEnvelope = serde_json::from_str(WELL_FORMED).unwrap();
|
||||
assert_eq!(env.version, 1);
|
||||
assert_eq!(env.minor_version, 3);
|
||||
assert_eq!(env.key, "core.entity_registry");
|
||||
assert!(env.data.get("entities").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_missing_minor_version_defaults_to_zero() {
|
||||
let json = r#"{"version": 1, "key": "core.config_entries", "data": {}}"#;
|
||||
let env: HaStorageEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(env.minor_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_malformed_json() {
|
||||
let result = serde_json::from_str::<HaStorageEnvelope>("not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
//! Versioned format parsers for HA `.storage/` files.
|
||||
//!
|
||||
//! Each sub-module handles one `(version, minor_version)` generation of a
|
||||
//! particular storage key. Adding support for a new HA schema version means
|
||||
//! adding a new `v<N>.rs` module; the dispatch function in each parser module
|
||||
//! routes to the right implementation.
|
||||
//!
|
||||
//! Per ADR-134 §6 Q5: unknown `minor_version` values produce a hard
|
||||
//! `MigrateError::UnsupportedSchemaVersion` — we do NOT silently fall back
|
||||
//! to an older parser, because schema changes can be load-bearing (new fields,
|
||||
//! renamed keys, semantic reinterpretations).
|
||||
|
||||
pub mod v13;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
//! Versioned format parser for HA storage schema version 13.
|
||||
//!
|
||||
//! Applies to (as of HA 2025.1):
|
||||
//! - `core.entity_registry` — `version=1, minor_version=13`
|
||||
//! - `core.device_registry` — `version=1, minor_version=13`
|
||||
//!
|
||||
//! Source: `homeassistant/helpers/entity_registry.py` `STORAGE_VERSION_MINOR`
|
||||
//! and `homeassistant/helpers/device_registry.py` `STORAGE_VERSION_MINOR`.
|
||||
//!
|
||||
//! `core.config_entries` uses a different versioning scheme; see
|
||||
//! `config_entries.rs` for details.
|
||||
|
||||
/// The major storage `version` this module handles.
|
||||
pub const MAJOR_VERSION: u32 = 1;
|
||||
|
||||
/// The `minor_version` values this module handles.
|
||||
/// Any value outside this set raises `MigrateError::UnsupportedSchemaVersion`.
|
||||
pub const SUPPORTED_MINOR_VERSIONS: &[u32] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
||||
|
||||
/// Return `true` if the given envelope header is handled by this module.
|
||||
pub fn handles(version: u32, minor_version: u32) -> bool {
|
||||
version == MAJOR_VERSION && SUPPORTED_MINOR_VERSIONS.contains(&minor_version)
|
||||
}
|
||||
|
||||
/// Validate that `(version, minor_version)` is supported; return the error
|
||||
/// with the given `file` path embedded if not.
|
||||
///
|
||||
/// Call this at the top of every parser that routes through v13 before
|
||||
/// attempting any field access.
|
||||
pub fn require_supported(
|
||||
file: &str,
|
||||
version: u32,
|
||||
minor_version: u32,
|
||||
) -> Result<(), crate::MigrateError> {
|
||||
if !handles(version, minor_version) {
|
||||
return Err(crate::MigrateError::UnsupportedSchemaVersion {
|
||||
file: file.to_owned(),
|
||||
version,
|
||||
minor_version,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn handles_all_supported_minor_versions() {
|
||||
for &mv in SUPPORTED_MINOR_VERSIONS {
|
||||
assert!(handles(1, mv), "minor_version {mv} should be supported");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_minor_version() {
|
||||
assert!(!handles(1, 99));
|
||||
assert!(!handles(2, 13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_supported_ok_for_v13() {
|
||||
assert!(require_supported("core.entity_registry", 1, 13).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_supported_err_carries_file_name() {
|
||||
let err = require_supported("core.entity_registry", 1, 99).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("core.entity_registry"),
|
||||
"error should contain file name: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("minor_version=99"),
|
||||
"error should contain minor_version: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue