mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +00:00
feat(homecore): complete migration and startup restore
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,131 @@
|
||||
//! 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.
|
||||
//! 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)]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user