feat(homecore): complete migration and startup restore

This commit is contained in:
ruv
2026-07-27 14:14:04 -04:00
parent 3136f1305b
commit 0a8e72e762
24 changed files with 1552 additions and 481 deletions
+17 -2
View File
@@ -21,10 +21,12 @@ pub enum Command {
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).
/// Import the device registry into HOMECORE storage.
ImportDevices(ImportDevicesArgs),
/// Inspect config entries (P1: count + domain list; conversion is P2).
/// Inspect config entries without writing.
InspectConfigEntries(InspectConfigEntriesArgs),
/// Import config entries losslessly into versioned HOMECORE storage.
ImportConfigEntries(ImportConfigEntriesArgs),
/// Parse secrets.yaml and report secret names (values redacted).
InspectSecrets(InspectSecretsArgs),
/// Count and list automations from automations.yaml (conversion is P2).
@@ -53,6 +55,19 @@ pub struct ImportDevicesArgs {
/// 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 ImportConfigEntriesArgs {
/// 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)]
+267 -85
View File
@@ -1,84 +1,236 @@
//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies).
//! Lossless conversion of HA `core.config_entries` into HOMECORE storage.
//!
//! Per ADR-165 §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.
//! HOMECORE cannot yet execute arbitrary HA integrations. Every source row is
//! therefore retained verbatim while portable identity/config fields are
//! projected for future plugin setup. Typed warnings make the unsupported
//! surface machine-readable instead of silently dropping it.
use std::path::Path;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{storage::read_envelope, MigrateError};
use crate::{
storage::{read_envelope, write_json_atomic_noclobber, HaStorageEnvelope},
MigrateError,
};
/// Maximum `minor_version` we claim to understand for config_entries.
const SOURCE_KEY: &str = "core.config_entries";
pub const DESTINATION_KEY: &str = "homecore.config_entries";
const MAX_SUPPORTED_MINOR: u32 = 4;
const HOMECORE_CONFIG_VERSION: u32 = 1;
/// Diagnostic summary produced by P1 inspection.
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HomeCoreConfigEntry {
pub entry_id: String,
pub domain: String,
pub title: String,
#[serde(default)]
pub data: serde_json::Value,
/// Exact HA row, including unsupported and future fields.
pub source: serde_json::Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum MigrationWarning {
UnsupportedDomain { entry_id: String, domain: String },
UnsupportedField { entry_id: String, field: String },
UnsupportedRootField { field: String },
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HomeCoreConfigData {
pub source_version: u32,
pub source_minor_version: u32,
/// Exact non-entry fields from the HA `data` object.
#[serde(default)]
pub source_extra: BTreeMap<String, serde_json::Value>,
pub entries: Vec<HomeCoreConfigEntry>,
pub warnings: Vec<MigrationWarning>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HomeCoreConfigEnvelope {
pub version: u32,
pub minor_version: u32,
pub key: String,
pub data: HomeCoreConfigData,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConfigEntriesSummary {
pub count: usize,
pub domains: Vec<String>,
pub warning_count: usize,
pub destination: Option<PathBuf>,
}
/// 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.
fn validate_source(env: &HaStorageEnvelope, path: &Path) -> Result<(), MigrateError> {
if env.version != 1 || env.minor_version > MAX_SUPPORTED_MINOR {
return Err(MigrateError::UnsupportedSchemaVersion {
file: file_str.clone(),
file: path.display().to_string(),
version: env.version,
minor_version: env.minor_version,
});
}
if env.key != SOURCE_KEY {
return Err(MigrateError::UnexpectedStorageKey {
path: path.display().to_string(),
expected: SOURCE_KEY.to_owned(),
actual: env.key.clone(),
});
}
Ok(())
}
let data: HaConfigEntriesData =
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
path: file_str,
source: e,
pub fn convert_config_entries(path: &Path) -> Result<HomeCoreConfigEnvelope, MigrateError> {
let env = read_envelope(path)?;
validate_source(&env, path)?;
let entries = env
.data
.get("entries")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| MigrateError::MissingField {
field: "entries".to_owned(),
context: path.display().to_string(),
})?;
let source_extra: BTreeMap<String, serde_json::Value> = env
.data
.as_object()
.into_iter()
.flat_map(|object| object.iter())
.filter(|(field, _)| field.as_str() != "entries")
.map(|(field, value)| (field.clone(), value.clone()))
.collect();
let mut domains: Vec<String> = data.entries.iter().map(|e| e.domain.clone()).collect();
domains.sort();
domains.dedup();
let portable = BTreeSet::from(["entry_id", "domain", "title", "data"]);
let mut converted = Vec::with_capacity(entries.len());
let mut warnings = source_extra
.keys()
.map(|field| MigrationWarning::UnsupportedRootField {
field: field.clone(),
})
.collect::<Vec<_>>();
for (index, source) in entries.iter().enumerate() {
let object = source
.as_object()
.ok_or_else(|| MigrateError::MissingField {
field: "object row".to_owned(),
context: format!("{} data.entries[{index}]", path.display()),
})?;
let string_field = |field: &str| -> Result<String, MigrateError> {
object
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.ok_or_else(|| MigrateError::MissingField {
field: field.to_owned(),
context: format!("{} data.entries[{index}]", path.display()),
})
};
let entry_id = string_field("entry_id")?;
let domain = string_field("domain")?;
let title = object
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or(&domain)
.to_owned();
let data = object
.get("data")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
// No HA domain is implicitly claimed executable by HOMECORE. The
// source is retained so a matching plugin can consume it later.
warnings.push(MigrationWarning::UnsupportedDomain {
entry_id: entry_id.clone(),
domain: domain.clone(),
});
for field in object
.keys()
.filter(|field| !portable.contains(field.as_str()))
{
warnings.push(MigrationWarning::UnsupportedField {
entry_id: entry_id.clone(),
field: field.clone(),
});
}
converted.push(HomeCoreConfigEntry {
entry_id,
domain,
title,
data,
source: source.clone(),
});
}
warnings.sort_by_key(|warning| serde_json::to_string(warning).unwrap_or_default());
Ok(HomeCoreConfigEnvelope {
version: HOMECORE_CONFIG_VERSION,
minor_version: 0,
key: DESTINATION_KEY.to_owned(),
data: HomeCoreConfigData {
source_version: env.version,
source_minor_version: env.minor_version,
source_extra,
entries: converted,
warnings,
},
})
}
pub fn write_config_entries(
storage_dir: &Path,
envelope: &HomeCoreConfigEnvelope,
) -> Result<PathBuf, MigrateError> {
let target = storage_dir.join(DESTINATION_KEY);
write_json_atomic_noclobber(&target, envelope)
}
pub fn read_homecore_config_entries(path: &Path) -> Result<HomeCoreConfigEnvelope, MigrateError> {
let raw = std::fs::read_to_string(path).map_err(|source| MigrateError::Io {
path: path.display().to_string(),
source,
})?;
let envelope: HomeCoreConfigEnvelope =
serde_json::from_str(&raw).map_err(|source| MigrateError::JsonParse {
path: path.display().to_string(),
source,
})?;
if envelope.version != HOMECORE_CONFIG_VERSION || envelope.minor_version != 0 {
return Err(MigrateError::UnsupportedSchemaVersion {
file: path.display().to_string(),
version: envelope.version,
minor_version: envelope.minor_version,
});
}
if envelope.key != DESTINATION_KEY {
return Err(MigrateError::UnexpectedStorageKey {
path: path.display().to_string(),
expected: DESTINATION_KEY.to_owned(),
actual: envelope.key,
});
}
Ok(envelope)
}
pub fn inspect_config_entries(path: &Path) -> Result<ConfigEntriesSummary, MigrateError> {
let converted = convert_config_entries(path)?;
let domains: BTreeMap<&str, usize> =
converted
.data
.entries
.iter()
.fold(BTreeMap::new(), |mut map, entry| {
*map.entry(entry.domain.as_str()).or_default() += 1;
map
});
Ok(ConfigEntriesSummary {
count: data.entries.len(),
domains,
count: converted.data.entries.len(),
domains: domains.keys().map(|value| (*value).to_owned()).collect(),
warning_count: converted.data.warnings.len(),
destination: None,
})
}
@@ -89,40 +241,70 @@ mod tests {
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"}
]
}
"version":1,"minor_version":2,"key":"core.config_entries",
"data":{"future_root":{"kept":true},"entries":[{
"domain":"future_hub","entry_id":"ce_001","title":"Future Hub",
"source":"user","state":"loaded","data":{"host":"10.0.0.2"},
"options":{"scan":true},"future_field":{"nested":[1,2,3]}
}]}
}"#;
#[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"]);
fn fixture() -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(FIXTURE.as_bytes()).unwrap();
file
}
#[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();
fn unknown_domain_and_fields_are_lossless_with_typed_warnings() {
let source = fixture();
let converted = convert_config_entries(source.path()).unwrap();
assert_eq!(
converted.data.entries[0].source["future_field"]["nested"][2],
3
);
assert_eq!(converted.data.source_extra["future_root"]["kept"], true);
assert!(converted.data.warnings.iter().any(|warning| matches!(
warning,
MigrationWarning::UnsupportedDomain { domain, .. } if domain == "future_hub"
)));
assert!(converted.data.warnings.iter().any(|warning| matches!(
warning,
MigrationWarning::UnsupportedField { field, .. } if field == "future_field"
)));
let dir = tempfile::tempdir().unwrap();
let path = write_config_entries(dir.path(), &converted).unwrap();
let restored = read_homecore_config_entries(&path).unwrap();
assert_eq!(restored, converted);
}
#[test]
fn unknown_destination_version_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DESTINATION_KEY);
std::fs::write(
&path,
r#"{"version":99,"minor_version":0,"key":"homecore.config_entries","data":{"source_version":1,"source_minor_version":1,"entries":[],"warnings":[]}}"#,
)
.unwrap();
assert!(matches!(
err,
MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }
read_homecore_config_entries(&path),
Err(MigrateError::UnsupportedSchemaVersion { version: 99, .. })
));
}
#[test]
fn malformed_entry_is_an_error_not_a_partial_write() {
let mut source = NamedTempFile::new().unwrap();
source
.write_all(
br#"{"version":1,"minor_version":1,"key":"core.config_entries","data":{"entries":[{"domain":"hue"}]}}"#,
)
.unwrap();
assert!(matches!(
convert_config_entries(source.path()),
Err(MigrateError::MissingField { .. })
));
}
}
+155 -71
View File
@@ -1,60 +1,131 @@
//! 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.
//! Conversion for HA `core.device_registry` schema v1/minor 1-13.
use std::path::Path;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use homecore::DeviceEntry;
use serde::Deserialize;
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
use crate::{
storage::{read_envelope, write_json_atomic_noclobber},
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>,
}
const FILE_KEY: &str = "core.device_registry";
#[derive(Debug, Deserialize)]
struct HaDeviceRegistryData {
devices: Vec<DeviceImport>,
/// Deleted device tombstones — ignored in P1.
devices: Vec<HaDeviceRow>,
#[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)?;
#[derive(Debug, Deserialize)]
struct HaDeviceRow {
id: String,
#[serde(default)]
config_entries: HashSet<String>,
#[serde(default)]
identifiers: HashSet<(String, String)>,
#[serde(default)]
connections: HashSet<(String, String)>,
#[serde(default)]
manufacturer: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
model_id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
name_by_user: Option<String>,
#[serde(default)]
sw_version: Option<String>,
#[serde(default)]
hw_version: Option<String>,
#[serde(default)]
serial_number: Option<String>,
#[serde(default)]
via_device_id: Option<String>,
#[serde(default)]
area_id: Option<String>,
#[serde(default)]
entry_type: Option<String>,
#[serde(default)]
disabled_by: Option<String>,
#[serde(default)]
configuration_url: Option<String>,
#[serde(default)]
labels: HashSet<String>,
#[serde(default)]
primary_config_entry: Option<String>,
#[serde(default, flatten)]
extra: BTreeMap<String, serde_json::Value>,
}
impl From<HaDeviceRow> for DeviceEntry {
fn from(row: HaDeviceRow) -> Self {
Self {
id: row.id,
config_entries: row.config_entries,
identifiers: row.identifiers,
connections: row.connections,
manufacturer: row.manufacturer,
model: row.model,
model_id: row.model_id,
name: row.name,
name_by_user: row.name_by_user,
sw_version: row.sw_version,
hw_version: row.hw_version,
serial_number: row.serial_number,
via_device_id: row.via_device_id,
area_id: row.area_id,
entry_type: row.entry_type,
disabled_by: row.disabled_by,
configuration_url: row.configuration_url,
labels: row.labels,
primary_config_entry: row.primary_config_entry,
extra: row.extra,
}
}
}
pub fn read_device_registry(path: &Path) -> Result<Vec<DeviceEntry>, MigrateError> {
let env = read_envelope(path)?;
let file = path.display().to_string();
v13::require_supported(&file, env.version, env.minor_version)?;
if env.key != FILE_KEY {
return Err(MigrateError::UnexpectedStorageKey {
path: file,
expected: FILE_KEY.to_owned(),
actual: env.key,
});
}
let data: HaDeviceRegistryData =
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
path: file_str,
source: e,
serde_json::from_value(env.data).map_err(|source| MigrateError::JsonParse {
path: path.display().to_string(),
source,
})?;
Ok(data.devices)
let _preserved_tombstone_count = data.deleted_devices.len();
Ok(data.devices.into_iter().map(DeviceEntry::from).collect())
}
pub fn write_device_registry(
storage_dir: &Path,
devices: &[DeviceEntry],
) -> Result<PathBuf, MigrateError> {
let target = storage_dir.join(FILE_KEY);
let payload = serde_json::json!({
"version": 1,
"minor_version": 13,
"key": FILE_KEY,
"data": {
"devices": devices,
"deleted_devices": []
}
});
write_json_atomic_noclobber(&target, &payload)
}
#[cfg(test)]
@@ -64,36 +135,49 @@ mod tests {
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": []
}
"version":1,"minor_version":13,"key":"core.device_registry",
"data":{"devices":[{
"id":"dev_abc","config_entries":["ce_001"],
"manufacturer":"Philips","model":"Hue Bridge","model_id":"BSB002",
"name":"Hue","name_by_user":"Downstairs Hue",
"sw_version":"1.2","hw_version":"3","serial_number":"SN42",
"identifiers":[["hue","001788FFFE3D4B13"]],
"connections":[["mac","00:17:88:ff:fe:3d:4b:13"]],
"via_device_id":"gateway","area_id":"living_room",
"entry_type":"service","disabled_by":"user",
"configuration_url":"http://hue.local","labels":["lighting"],
"primary_config_entry":"ce_001","created_at":1735689600.0
}],"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"]]);
fn all_supported_fields_round_trip() {
let mut source = NamedTempFile::new().unwrap();
source.write_all(FIXTURE.as_bytes()).unwrap();
let devices = read_device_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
let path = write_device_registry(destination.path(), &devices).unwrap();
let imported = read_device_registry(&path).unwrap();
assert_eq!(imported, devices);
assert_eq!(imported[0].serial_number.as_deref(), Some("SN42"));
assert!(imported[0].labels.contains("lighting"));
assert_eq!(imported[0].extra["created_at"], 1735689600.0);
}
#[test]
fn destination_is_never_overwritten() {
let mut source = NamedTempFile::new().unwrap();
source.write_all(FIXTURE.as_bytes()).unwrap();
let devices = read_device_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
write_device_registry(destination.path(), &devices).unwrap();
let error = write_device_registry(destination.path(), &[]).unwrap_err();
assert!(error.to_string().contains("refusing to overwrite"));
assert_eq!(
read_device_registry(&destination.path().join(FILE_KEY))
.unwrap()
.len(),
1
);
}
}
@@ -26,15 +26,17 @@
//! }
//! ```
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
use crate::{
storage::{read_envelope, write_json_atomic_noclobber},
storage_format::v13,
MigrateError,
};
// Key used by `inspect` subcommand when scanning the directory.
#[allow(dead_code)]
@@ -173,21 +175,7 @@ pub fn write_entity_registry(
storage_dir: &Path,
entries: &[EntityEntry],
) -> Result<PathBuf, MigrateError> {
fs::create_dir_all(storage_dir).map_err(|source| MigrateError::Io {
path: storage_dir.display().to_string(),
source,
})?;
let target = storage_dir.join(FILE_KEY);
if target.exists() {
return Err(MigrateError::Io {
path: target.display().to_string(),
source: std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"destination exists; refusing to overwrite",
),
});
}
let temp = storage_dir.join(format!(".{FILE_KEY}.{}.tmp", std::process::id()));
let payload = serde_json::json!({
"version": 1,
"minor_version": 13,
@@ -197,29 +185,7 @@ pub fn write_entity_registry(
"deleted_entities": []
}
});
let bytes = serde_json::to_vec_pretty(&payload).map_err(|source| MigrateError::JsonParse {
path: target.display().to_string(),
source,
})?;
let result = (|| {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp)?;
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
fs::rename(&temp, &target)
})();
if let Err(source) = result {
let _ = fs::remove_file(&temp);
return Err(MigrateError::Io {
path: target.display().to_string(),
source,
});
}
Ok(target)
write_json_atomic_noclobber(&target, &payload)
}
#[cfg(test)]
+13 -5
View File
@@ -4,20 +4,21 @@
//! (HOMECORE-MIGRATE; ADR-126 §4 series map labels the role "ADR-134 HOMECORE-MIGRATE",
//! but on-disk ADR-134 is CIR — the migrate decision was renumbered to ADR-165. See ADR-164).
//!
//! ## P1 scope
//! ## Implemented 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)
//! - [`device_registry`] — full supported HA v13 device fields → `homecore::DeviceEntry`
//! - [`config_entries`] — lossless, versioned HOMECORE representation + typed warnings
//! - [`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+)
//! ## Remaining limitations
//!
//! - Conversion of `config_entries` to HOMECORE plugin manifests
//! - Imported config entries are durable but do not make an HA integration executable;
//! a matching HOMECORE plugin must consume the preserved source payload.
//! - 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
@@ -88,6 +89,13 @@ pub enum MigrateError {
minor_version: u32,
},
#[error("unexpected storage key in {path}: expected {expected}, got {actual}")]
UnexpectedStorageKey {
path: String,
expected: String,
actual: String,
},
#[error("missing required field '{field}' in {context}")]
MissingField { field: String, context: String },
+60 -18
View File
@@ -2,6 +2,21 @@
use clap::Parser;
use homecore_migrate::cli::{Cli, Command};
use serde::Serialize;
#[derive(Serialize)]
struct ImportSummary {
kind: &'static str,
imported: usize,
warning_count: usize,
warnings: Vec<serde_json::Value>,
destination: std::path::PathBuf,
}
fn print_summary(summary: &ImportSummary) -> anyhow::Result<()> {
println!("{}", serde_json::to_string(summary)?);
Ok(())
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
@@ -9,7 +24,10 @@ fn main() -> anyhow::Result<()> {
match cli.command {
Command::Inspect(args) => {
println!("Inspecting HA .storage directory: {}", args.storage.display());
println!(
"Inspecting HA .storage directory: {}",
args.storage.display()
);
// Probe entity_registry
let entity_path = args.storage.join("core.entity_registry");
if entity_path.exists() {
@@ -42,33 +60,35 @@ fn main() -> anyhow::Result<()> {
Command::ImportEntities(args) => {
let entity_path = args.storage.join("core.entity_registry");
let entries =
homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
let entries = homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
let destination =
homecore_migrate::entity_registry::write_entity_registry(&args.to, &entries)?;
println!("Imported {} entity entries", entries.len());
println!(" Destination: {}", destination.display());
for e in &entries {
println!(
" {} ({}{})",
e.entity_id.as_str(),
e.platform,
if e.disabled_by.is_some() { " DISABLED" } else { "" }
);
}
print_summary(&ImportSummary {
kind: "entity_registry",
imported: entries.len(),
warning_count: 0,
warnings: vec![],
destination,
})?;
}
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());
let devices = homecore_migrate::device_registry::read_device_registry(&device_path)?;
let destination =
homecore_migrate::device_registry::write_device_registry(&args.to, &devices)?;
print_summary(&ImportSummary {
kind: "device_registry",
imported: devices.len(),
warning_count: 0,
warnings: vec![],
destination,
})?;
}
Command::InspectConfigEntries(args) => {
let ce_path = args.storage.join("core.config_entries");
let summary =
homecore_migrate::config_entries::inspect_config_entries(&ce_path)?;
let summary = homecore_migrate::config_entries::inspect_config_entries(&ce_path)?;
println!(
"config_entries: {} total, domains: {}",
summary.count,
@@ -76,6 +96,28 @@ fn main() -> anyhow::Result<()> {
);
}
Command::ImportConfigEntries(args) => {
let source = args.storage.join("core.config_entries");
let converted = homecore_migrate::config_entries::convert_config_entries(&source)?;
let imported = converted.data.entries.len();
let warning_count = converted.data.warnings.len();
let warnings = converted
.data
.warnings
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
let destination =
homecore_migrate::config_entries::write_config_entries(&args.to, &converted)?;
print_summary(&ImportSummary {
kind: "config_entries",
imported,
warning_count,
warnings,
destination,
})?;
}
Command::InspectSecrets(args) => {
let secrets_path = args.config_dir.join("secrets.yaml");
let secrets = homecore_migrate::secrets::read_secrets(&secrets_path)?;
+69
View File
@@ -15,12 +15,17 @@
//! left as `serde_json::Value` — version-specific parsers in `storage_format`
//! are responsible for further deserialization.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use crate::MigrateError;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
/// Points to a HA `.storage/` directory.
#[derive(Clone, Debug)]
pub struct HaStorageDir {
@@ -66,6 +71,70 @@ pub fn read_envelope(path: &Path) -> Result<HaStorageEnvelope, MigrateError> {
})
}
/// Durably publish JSON at `target` without ever replacing an existing file.
///
/// Bytes are synced in a same-directory temporary file, then exposed with an
/// atomic hard-link create. `hard_link` fails with `AlreadyExists` if another
/// process won the destination race, unlike a POSIX rename which would replace
/// the destination after a check-then-rename sequence.
pub fn write_json_atomic_noclobber<T: Serialize>(
target: &Path,
value: &T,
) -> Result<PathBuf, MigrateError> {
let parent = target.parent().ok_or_else(|| MigrateError::Io {
path: target.display().to_string(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"destination has no parent directory",
),
})?;
fs::create_dir_all(parent).map_err(|source| MigrateError::Io {
path: parent.display().to_string(),
source,
})?;
let bytes = serde_json::to_vec_pretty(value).map_err(|source| MigrateError::JsonParse {
path: target.display().to_string(),
source,
})?;
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage");
let temp = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), sequence));
let result = (|| -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp)?;
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
fs::hard_link(&temp, target)?;
fs::remove_file(&temp)?;
Ok(())
})();
if let Err(source) = result {
let _ = fs::remove_file(&temp);
let source = if source.kind() == std::io::ErrorKind::AlreadyExists {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"destination exists; refusing to overwrite",
)
} else {
source
};
return Err(MigrateError::Io {
path: target.display().to_string(),
source,
});
}
Ok(target.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;