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:
ruv 2026-05-25 18:29:06 -04:00
parent 347aad9bfa
commit 901adf1be6
13 changed files with 1255 additions and 0 deletions

14
v2/Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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);
}
}

View File

@ -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,
}

View File

@ -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, .. }
));
}
}

View File

@ -0,0 +1,99 @@
//! Parser for `core.device_registry` (HA storage schema v1, minor_version 113).
//!
//! 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"]]);
}
}

View File

@ -0,0 +1,269 @@
//! Parser for `core.entity_registry` (HA storage schema v1, minor_version 113).
//!
//! 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 113
/// - `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}");
}
}

View File

@ -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),
}

View File

@ -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(())
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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;

View File

@ -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}"
);
}
}