From 901adf1be6d6908c2abbf023f176398115d86d0c Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 18:29:06 -0400 Subject: [PATCH] feat(homecore-migrate/p1): ADR-134 .storage parser + entity-registry import (19 tests pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 with full field mapping - device_registry: core.device_registry → Vec (P2 HOMECORE wiring stub) - config_entries: envelope read + domain count diagnostic (P2 plugin manifest conversion) - secrets: secrets.yaml → HashMap - 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 --- v2/Cargo.lock | 14 + v2/crates/homecore-migrate/Cargo.toml | 60 ++++ v2/crates/homecore-migrate/src/automations.rs | 130 +++++++++ v2/crates/homecore-migrate/src/cli.rs | 77 +++++ .../homecore-migrate/src/config_entries.rs | 128 +++++++++ .../homecore-migrate/src/device_registry.rs | 99 +++++++ .../homecore-migrate/src/entity_registry.rs | 269 ++++++++++++++++++ v2/crates/homecore-migrate/src/lib.rs | 76 +++++ v2/crates/homecore-migrate/src/main.rs | 103 +++++++ v2/crates/homecore-migrate/src/secrets.rs | 105 +++++++ v2/crates/homecore-migrate/src/storage.rs | 101 +++++++ .../src/storage_format/mod.rs | 13 + .../src/storage_format/v13.rs | 80 ++++++ 13 files changed, 1255 insertions(+) create mode 100644 v2/crates/homecore-migrate/Cargo.toml create mode 100644 v2/crates/homecore-migrate/src/automations.rs create mode 100644 v2/crates/homecore-migrate/src/cli.rs create mode 100644 v2/crates/homecore-migrate/src/config_entries.rs create mode 100644 v2/crates/homecore-migrate/src/device_registry.rs create mode 100644 v2/crates/homecore-migrate/src/entity_registry.rs create mode 100644 v2/crates/homecore-migrate/src/lib.rs create mode 100644 v2/crates/homecore-migrate/src/main.rs create mode 100644 v2/crates/homecore-migrate/src/secrets.rs create mode 100644 v2/crates/homecore-migrate/src/storage.rs create mode 100644 v2/crates/homecore-migrate/src/storage_format/mod.rs create mode 100644 v2/crates/homecore-migrate/src/storage_format/v13.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index bc892cd2..ba2c486f 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/crates/homecore-migrate/Cargo.toml b/v2/crates/homecore-migrate/Cargo.toml new file mode 100644 index 00000000..5063a96e --- /dev/null +++ b/v2/crates/homecore-migrate/Cargo.toml @@ -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` +# - 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 ", "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" diff --git a/v2/crates/homecore-migrate/src/automations.rs b/v2/crates/homecore-migrate/src/automations.rs new file mode 100644 index 00000000..7998d6e7 --- /dev/null +++ b/v2/crates/homecore-migrate/src/automations.rs @@ -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, +} + +/// Minimal identifying info for a single automation. +#[derive(Clone, Debug)] +pub struct AutomationIdent { + pub id: String, + pub alias: Option, +} + +#[derive(Debug, Deserialize)] +struct HaAutomationRow { + #[serde(default)] + id: String, + #[serde(default)] + alias: Option, + // 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 { + 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 = + 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::>(); + + 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); + } +} diff --git a/v2/crates/homecore-migrate/src/cli.rs b/v2/crates/homecore-migrate/src/cli.rs new file mode 100644 index 00000000..4a2fc7ba --- /dev/null +++ b/v2/crates/homecore-migrate/src/cli.rs @@ -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, +} diff --git a/v2/crates/homecore-migrate/src/config_entries.rs b/v2/crates/homecore-migrate/src/config_entries.rs new file mode 100644 index 00000000..786df579 --- /dev/null +++ b/v2/crates/homecore-migrate/src/config_entries.rs @@ -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, +} + +/// 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, + /// Source of the entry: "user" | "discovery" | "import" etc. + #[serde(default)] + #[allow(dead_code)] + source: Option, + /// State: "loaded" | "setup_error" etc. + #[serde(default)] + #[allow(dead_code)] + state: Option, +} + +#[derive(Debug, Deserialize)] +struct HaConfigEntriesData { + entries: Vec, +} + +/// Read `core.config_entries` from `path` and return a diagnostic summary. +pub fn inspect_config_entries(path: &Path) -> Result { + 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 = 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, .. } + )); + } +} diff --git a/v2/crates/homecore-migrate/src/device_registry.rs b/v2/crates/homecore-migrate/src/device_registry.rs new file mode 100644 index 00000000..39ccfd93 --- /dev/null +++ b/v2/crates/homecore-migrate/src/device_registry.rs @@ -0,0 +1,99 @@ +//! Parser for `core.device_registry` (HA storage schema v1, minor_version 1–13). +//! +//! P1: deserializes the envelope and returns `Vec`. +//! 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, + #[serde(default)] + pub manufacturer: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub name: Option, + /// `identifiers` — list of `[integration, id]` pairs. Preserved as raw + /// JSON for P2 consumption; not yet mapped to HOMECORE DeviceEntry. + #[serde(default)] + pub identifiers: Vec>, + #[serde(default)] + pub connections: Vec>, + #[serde(default)] + pub via_device_id: Option, + #[serde(default)] + pub area_id: Option, +} + +#[derive(Debug, Deserialize)] +struct HaDeviceRegistryData { + devices: Vec, + /// Deleted device tombstones — ignored in P1. + #[serde(default)] + #[allow(dead_code)] + deleted_devices: Vec, +} + +/// Read `core.device_registry` from `path` and return the raw import list. +pub fn read_device_registry(path: &Path) -> Result, 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"]]); + } +} diff --git a/v2/crates/homecore-migrate/src/entity_registry.rs b/v2/crates/homecore-migrate/src/entity_registry.rs new file mode 100644 index 00000000..1a92f585 --- /dev/null +++ b/v2/crates/homecore-migrate/src/entity_registry.rs @@ -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` 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, + /// Deleted-entity tombstones (ignored in P1 — forwarded as Q5 note). + #[serde(default)] + #[allow(dead_code)] + deleted_entities: Vec, +} + +/// A single row from `data.entities`. +#[derive(Debug, Serialize, Deserialize)] +struct HaEntityRow { + entity_id: String, + #[serde(default)] + unique_id: Option, + platform: String, + /// User-set display name (separate from HA-integration default name). + #[serde(default)] + name: Option, + #[serde(default)] + disabled_by: Option, + #[serde(default)] + area_id: Option, + #[serde(default)] + device_id: Option, + #[serde(default)] + entity_category: Option, + #[serde(default)] + config_entry_id: Option, + // Fields present in v13 that we capture but do not yet map to HOMECORE. + // Forwarded as Q5 items. + #[serde(default)] + hidden_by: Option, // v13: "user" | "integration" + #[serde(default)] + has_entity_name: Option, // v13: HA naming convention flag + #[serde(default)] + original_name: Option, // v13: integration-provided default name + #[serde(default)] + icon: Option, // v13: mdi:xxx icon override + #[serde(default)] + original_icon: Option, // v13: integration-provided icon + #[serde(default)] + aliases: Option>, // v13: user-set aliases for voice assist + #[serde(default)] + capabilities: Option, // v13: integration-specific caps + #[serde(default)] + supported_features: Option, // 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) -> Option { + 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) -> Option { + 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, 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}"); + } +} diff --git a/v2/crates/homecore-migrate/src/lib.rs b/v2/crates/homecore-migrate/src/lib.rs new file mode 100644 index 00000000..927babb0 --- /dev/null +++ b/v2/crates/homecore-migrate/src/lib.rs @@ -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` +//! - [`device_registry`] — `core.device_registry` → `Vec` (P1 stub) +//! - [`config_entries`] — `core.config_entries` diagnostic (count + domain list; P2 converts) +//! - [`secrets`] — `secrets.yaml` → `HashMap` +//! - [`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), +} diff --git a/v2/crates/homecore-migrate/src/main.rs b/v2/crates/homecore-migrate/src/main.rs new file mode 100644 index 00000000..68bf984b --- /dev/null +++ b/v2/crates/homecore-migrate/src/main.rs @@ -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!(" {} = ", 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("") + ); + } + } + } + + Ok(()) +} diff --git a/v2/crates/homecore-migrate/src/secrets.rs b/v2/crates/homecore-migrate/src/secrets.rs new file mode 100644 index 00000000..9a26613d --- /dev/null +++ b/v2/crates/homecore-migrate/src/secrets.rs @@ -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 ` 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, 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: "".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(|_| "".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); + } +} diff --git a/v2/crates/homecore-migrate/src/storage.rs b/v2/crates/homecore-migrate/src/storage.rs new file mode 100644 index 00000000..770c8d65 --- /dev/null +++ b/v2/crates/homecore-migrate/src/storage.rs @@ -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) -> 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 { + 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::("not json"); + assert!(result.is_err()); + } +} diff --git a/v2/crates/homecore-migrate/src/storage_format/mod.rs b/v2/crates/homecore-migrate/src/storage_format/mod.rs new file mode 100644 index 00000000..cfd847cc --- /dev/null +++ b/v2/crates/homecore-migrate/src/storage_format/mod.rs @@ -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.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; diff --git a/v2/crates/homecore-migrate/src/storage_format/v13.rs b/v2/crates/homecore-migrate/src/storage_format/v13.rs new file mode 100644 index 00000000..b822ca38 --- /dev/null +++ b/v2/crates/homecore-migrate/src/storage_format/v13.rs @@ -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}" + ); + } +}