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",
|
"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]]
|
[[package]]
|
||||||
name = "homecore-automation"
|
name = "homecore-automation"
|
||||||
version = "0.1.0-alpha.0"
|
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