From 8ae89b22238518e62aac257877faf42b01e187f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Ve=C5=BEnaver?= Date: Tue, 3 Feb 2026 17:08:39 -0500 Subject: [PATCH 1/2] add NVRAM store for routers --- nvram_store.go | 194 ++++++++++++++++++++++++++++++ nvram_store_test.go | 287 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 nvram_store.go create mode 100644 nvram_store_test.go diff --git a/nvram_store.go b/nvram_store.go new file mode 100644 index 0000000..30ac8ad --- /dev/null +++ b/nvram_store.go @@ -0,0 +1,194 @@ +package hap + +import ( + "encoding/hex" + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/brutella/hap/log" +) + +const nvramPrefix = "hap_" + +// NvramStore implements the Store interface using router NVRAM. +// Each key is stored as a separate NVRAM variable. +// +// Commit Strategy: +// To minimise flash writes (flash has limited write cycles), we only call +// "nvram commit" when pairing data changes. Other data (uuid, keypair, schema, +// version, configHash) is written to NVRAM RAM but not committed to flash. +// This means: +// - Normal startup: 0 flash writes +// - Per pairing added: 1 flash write +// - Per pairing removed: 1 flash write +// +// If power is lost before first pairing, non-pairing data is regenerated on +// next startup (new uuid/keypair). Once paired, the commit includes all pending +// changes, so keypair and pairing stay in sync. +type NvramStore struct { + mu sync.RWMutex +} + +// NewNvramStore creates a new NVRAM-backed store. +func NewNvramStore() *NvramStore { + return &NvramStore{} +} + +// nvram command wrappers - can be replaced in tests +var ( + nvramGet = func(key string) (string, error) { + out, err := exec.Command("nvram", "get", key).Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil + } + + nvramSet = func(key, value string) error { + return exec.Command("nvram", "set", key+"="+value).Run() + } + + nvramUnset = func(key string) error { + return exec.Command("nvram", "unset", key).Run() + } + + nvramCommit = func() error { + log.Info.Println("Committing NVRAM to flash") + return exec.Command("nvram", "commit").Run() + } + + nvramShow = func() (string, error) { + out, err := exec.Command("nvram", "show").Output() + if err != nil { + return "", err + } + return string(out), nil + } +) + +// nvramKey converts a Store key to an NVRAM variable name. +// Pairing keys are converted from hex-encoded UUIDs to readable UUID strings. +func nvramKey(key string) string { + if strings.HasSuffix(key, ".pairing") { + hexName := strings.TrimSuffix(key, ".pairing") + name, err := hex.DecodeString(hexName) + if err != nil { + return nvramPrefix + key + } + return nvramPrefix + "p_" + string(name) + } + return nvramPrefix + key +} + +// Set stores a key-value pair in NVRAM. +// Text values are stored as-is, configHash is hex-encoded. +// Commits to flash only on pairing changes. +func (s *NvramStore) Set(key string, value []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + nkey := nvramKey(key) + + var encoded string + if key == "configHash" { + // configHash is binary (MD5 hash), hex encode it + encoded = hex.EncodeToString(value) + } else { + // All other values are text (JSON, strings) + encoded = string(value) + } + + if err := nvramSet(nkey, encoded); err != nil { + return fmt.Errorf("nvram set: %w", err) + } + + // Only commit to flash when pairing data changes to reduce flash writes + if strings.HasSuffix(key, ".pairing") { + return nvramCommit() + } + return nil +} + +// Get retrieves a value from NVRAM. +func (s *NvramStore) Get(key string) ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + nkey := nvramKey(key) + + value, err := nvramGet(nkey) + if err != nil { + return nil, err + } + if value == "" { + return nil, fmt.Errorf("no entry for key %s", key) + } + + if key == "configHash" { + // configHash is hex-encoded binary + return hex.DecodeString(value) + } + // All other values are plain text + return []byte(value), nil +} + +// Delete removes a key from NVRAM. +func (s *NvramStore) Delete(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + nkey := nvramKey(key) + + if err := nvramUnset(nkey); err != nil { + return err + } + + // Commit to flash when pairing data is deleted + if strings.HasSuffix(key, ".pairing") { + return nvramCommit() + } + return nil +} + +// KeysWithSuffix returns all keys ending with the given suffix. +func (s *NvramStore) KeysWithSuffix(suffix string) (keys []string, err error) { + s.mu.RLock() + defer s.mu.RUnlock() + + out, err := nvramShow() + if err != nil { + return nil, err + } + + for _, line := range strings.Split(out, "\n") { + // Skip empty lines + if line == "" { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + nkey := parts[0] + + if !strings.HasPrefix(nkey, nvramPrefix) { + continue + } + + if suffix == ".pairing" && strings.HasPrefix(nkey, nvramPrefix+"p_") { + // Extract UUID string and reconstruct original key + uuidStr := strings.TrimPrefix(nkey, nvramPrefix+"p_") + originalKey := hex.EncodeToString([]byte(uuidStr)) + ".pairing" + keys = append(keys, originalKey) + } else { + key := strings.TrimPrefix(nkey, nvramPrefix) + if strings.HasSuffix(key, suffix) { + keys = append(keys, key) + } + } + } + return +} diff --git a/nvram_store_test.go b/nvram_store_test.go new file mode 100644 index 0000000..93d0572 --- /dev/null +++ b/nvram_store_test.go @@ -0,0 +1,287 @@ +package hap + +import ( + "strings" + "testing" +) + +// mockNvram stores the original nvram functions and provides test data +type mockNvram struct { + data map[string]string + commitCount int +} + +// setupMockNvram replaces nvram functions with mock implementations +func setupMockNvram() *mockNvram { + m := &mockNvram{data: make(map[string]string)} + + nvramGet = func(key string) (string, error) { + return m.data[key], nil + } + nvramSet = func(key, value string) error { + m.data[key] = value + return nil + } + nvramUnset = func(key string) error { + delete(m.data, key) + return nil + } + nvramCommit = func() error { + m.commitCount++ + return nil + } + nvramShow = func() (string, error) { + var lines []string + for k, v := range m.data { + lines = append(lines, k+"="+v) + } + // Real nvram show outputs size line to stderr, not stdout + // So we don't include it here (matches real behaviour) + return strings.Join(lines, "\n"), nil + } + + return m +} + +func TestNvramStore_SetGet(t *testing.T) { + setupMockNvram() + store := NewNvramStore() + + // Test setting and getting a simple value + err := store.Set("uuid", []byte("AA:BB:CC:DD:EE:FF")) + if err != nil { + t.Fatalf("Set failed: %v", err) + } + + val, err := store.Get("uuid") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if string(val) != "AA:BB:CC:DD:EE:FF" { + t.Errorf("Expected AA:BB:CC:DD:EE:FF, got %s", val) + } +} + +func TestNvramStore_CommitOnPairing(t *testing.T) { + mock := setupMockNvram() + store := NewNvramStore() + + // Set non-pairing keys - should not commit + store.Set("uuid", []byte("AA:BB:CC:DD:EE:FF")) + store.Set("keypair", []byte(`{"Public":"...","Private":"..."}`)) + store.Set("schema", []byte("1")) + store.Set("version", []byte("2")) + + if mock.commitCount != 0 { + t.Errorf("Should not commit for non-pairing keys, got %d commits", mock.commitCount) + } + + // Set a pairing key - should commit + pairingKey := "33313046433135382d423239452d344635322d423542322d413734324344464345383141.pairing" + store.Set(pairingKey, []byte(`{"Name":"310FC158-B29E-4F52-B5B2-A742CDFCE81A"}`)) + + if mock.commitCount != 1 { + t.Errorf("Expected 1 commit after pairing, got %d", mock.commitCount) + } +} + +func TestNvramStore_Delete(t *testing.T) { + setupMockNvram() + store := NewNvramStore() + + store.Set("test", []byte("value")) + + val, err := store.Get("test") + if err != nil { + t.Fatalf("Get after Set failed: %v", err) + } + if string(val) != "value" { + t.Errorf("Expected value, got %s", val) + } + + store.Delete("test") + + _, err = store.Get("test") + if err == nil { + t.Error("Expected error after Delete, got nil") + } +} + +func TestNvramStore_DeletePairingCommits(t *testing.T) { + mock := setupMockNvram() + store := NewNvramStore() + + pairingKey := "33313046433135382d423239452d344635322d423542322d413734324344464345383141.pairing" + store.Set(pairingKey, []byte(`{"Name":"310FC158-B29E-4F52-B5B2-A742CDFCE81A"}`)) + + commitsBefore := mock.commitCount + store.Delete(pairingKey) + + if mock.commitCount != commitsBefore+1 { + t.Errorf("Expected commit after deleting pairing, got %d commits", mock.commitCount-commitsBefore) + } +} + +func TestNvramStore_KeysWithSuffix(t *testing.T) { + setupMockNvram() + store := NewNvramStore() + + // Set some regular keys + store.Set("uuid", []byte("test")) + store.Set("schema", []byte("1")) + + // Set pairing keys + pairingKey1 := "33313046433135382d423239452d344635322d423542322d413734324344464345383141.pairing" + store.Set(pairingKey1, []byte(`{"Name":"310FC158-B29E-4F52-B5B2-A742CDFCE81A"}`)) + + pairingKey2 := "33323046433135382d423239452d344635322d423542322d413734324344464345383142.pairing" + store.Set(pairingKey2, []byte(`{"Name":"320FC158-B29E-4F52-B5B2-A742CDFCE81B"}`)) + + // Test finding pairing keys + keys, err := store.KeysWithSuffix(".pairing") + if err != nil { + t.Fatalf("KeysWithSuffix failed: %v", err) + } + + if len(keys) != 2 { + t.Errorf("Expected 2 pairing keys, got %d", len(keys)) + } + + // Verify the keys can be used with Get + for _, key := range keys { + _, err := store.Get(key) + if err != nil { + t.Errorf("Get(%s) failed: %v", key, err) + } + } +} + +func TestNvramStore_BinaryData(t *testing.T) { + mock := setupMockNvram() + store := NewNvramStore() + + // Test with binary data including null bytes and high bytes (like MD5 hash) + binary := []byte{0x00, 0x01, 0xFF, 0xFE, 0x80, 0x7F} + + err := store.Set("configHash", binary) + if err != nil { + t.Fatalf("Set binary failed: %v", err) + } + + // Verify it's stored as hex + storedValue := mock.data["hap_configHash"] + expectedHex := "0001fffe807f" + if storedValue != expectedHex { + t.Errorf("Expected hex %s, got %s", expectedHex, storedValue) + } + + val, err := store.Get("configHash") + if err != nil { + t.Fatalf("Get binary failed: %v", err) + } + + if len(val) != len(binary) { + t.Fatalf("Binary length mismatch: expected %d, got %d", len(binary), len(val)) + } + + for i := range binary { + if val[i] != binary[i] { + t.Errorf("Binary mismatch at index %d: expected %02x, got %02x", i, binary[i], val[i]) + } + } +} + +func TestNvramStore_JSONData(t *testing.T) { + mock := setupMockNvram() + store := NewNvramStore() + + // Test with JSON data similar to what hap stores + keypair := `{"Public":"CCSWr6dPt0Nqo6OjmG21fQA0ysUED7/nXV9lTQ+4+Us=","Private":"ZmJmNjA4YmFmNzVmZGZkMTVjOWRhZWQ1NzU4NzY0MmMIJJavp0+3Q2qjo6OYbbV9ADTKxQQPv+ddX2VND7j5Sw=="}` + + err := store.Set("keypair", []byte(keypair)) + if err != nil { + t.Fatalf("Set keypair failed: %v", err) + } + + // Verify JSON is stored as-is (not encoded) + storedValue := mock.data["hap_keypair"] + if storedValue != keypair { + t.Errorf("Expected JSON stored as-is, got encoded value") + } + + val, err := store.Get("keypair") + if err != nil { + t.Fatalf("Get keypair failed: %v", err) + } + + if string(val) != keypair { + t.Errorf("Keypair mismatch:\nexpected: %s\ngot: %s", keypair, string(val)) + } +} + +func TestNvramStore_TextValuesStoredAsIs(t *testing.T) { + mock := setupMockNvram() + store := NewNvramStore() + + tests := []struct { + key string + value string + }{ + {"uuid", "A9:88:54:E4:92:0E"}, + {"schema", "1"}, + {"version", "2"}, + } + + for _, tt := range tests { + store.Set(tt.key, []byte(tt.value)) + + // Verify stored as-is + nkey := "hap_" + tt.key + if mock.data[nkey] != tt.value { + t.Errorf("%s: expected %q stored as-is, got %q", tt.key, tt.value, mock.data[nkey]) + } + + // Verify retrieved correctly + val, err := store.Get(tt.key) + if err != nil { + t.Errorf("%s: Get failed: %v", tt.key, err) + } + if string(val) != tt.value { + t.Errorf("%s: expected %q, got %q", tt.key, tt.value, string(val)) + } + } +} + +func TestNvramKey(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"uuid", "hap_uuid"}, + {"keypair", "hap_keypair"}, + {"schema", "hap_schema"}, + {"version", "hap_version"}, + {"configHash", "hap_configHash"}, + } + + for _, tt := range tests { + result := nvramKey(tt.input) + if result != tt.expected { + t.Errorf("nvramKey(%s) = %s, expected %s", tt.input, result, tt.expected) + } + } + + // Test pairing key - should be converted to readable UUID + pairingKey := "33313046433135382d423239452d344635322d423542322d413734324344464345383141.pairing" + result := nvramKey(pairingKey) + + expected := "hap_p_310FC158-B29E-4F52-B5B2-A742CDFCE81A" + if result != expected { + t.Errorf("nvramKey(%s) = %s, expected %s", pairingKey, result, expected) + } + + // Should be under 64 chars (NVRAM key limit) + if len(result) > 64 { + t.Errorf("Pairing key exceeds 64 char limit: %d chars", len(result)) + } +} From 3e7e1cd326a37596c131664a5f160fdf1fb191f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Ve=C5=BEnaver?= Date: Wed, 4 Feb 2026 14:28:26 -0500 Subject: [PATCH 2/2] Allow different prefix for nvram key names --- nvram_store.go | 35 +++++++++++++++--------------- nvram_store_test.go | 53 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/nvram_store.go b/nvram_store.go index 30ac8ad..653402d 100644 --- a/nvram_store.go +++ b/nvram_store.go @@ -10,8 +10,6 @@ import ( "github.com/brutella/hap/log" ) -const nvramPrefix = "hap_" - // NvramStore implements the Store interface using router NVRAM. // Each key is stored as a separate NVRAM variable. // @@ -28,12 +26,15 @@ const nvramPrefix = "hap_" // next startup (new uuid/keypair). Once paired, the commit includes all pending // changes, so keypair and pairing stay in sync. type NvramStore struct { - mu sync.RWMutex + mu sync.RWMutex + prefix string } -// NewNvramStore creates a new NVRAM-backed store. -func NewNvramStore() *NvramStore { - return &NvramStore{} +// NewNvramStore creates a new NVRAM-backed store with the given key prefix. +// The prefix should be unique per accessory to avoid conflicts when running +// multiple accessories on the same router (e.g. "hap_timer_", "hap_light_"). +func NewNvramStore(prefix string) *NvramStore { + return &NvramStore{prefix: prefix} } // nvram command wrappers - can be replaced in tests @@ -70,16 +71,16 @@ var ( // nvramKey converts a Store key to an NVRAM variable name. // Pairing keys are converted from hex-encoded UUIDs to readable UUID strings. -func nvramKey(key string) string { +func (s *NvramStore) nvramKey(key string) string { if strings.HasSuffix(key, ".pairing") { hexName := strings.TrimSuffix(key, ".pairing") name, err := hex.DecodeString(hexName) if err != nil { - return nvramPrefix + key + return s.prefix + key } - return nvramPrefix + "p_" + string(name) + return s.prefix + "p_" + string(name) } - return nvramPrefix + key + return s.prefix + key } // Set stores a key-value pair in NVRAM. @@ -89,7 +90,7 @@ func (s *NvramStore) Set(key string, value []byte) error { s.mu.Lock() defer s.mu.Unlock() - nkey := nvramKey(key) + nkey := s.nvramKey(key) var encoded string if key == "configHash" { @@ -116,7 +117,7 @@ func (s *NvramStore) Get(key string) ([]byte, error) { s.mu.RLock() defer s.mu.RUnlock() - nkey := nvramKey(key) + nkey := s.nvramKey(key) value, err := nvramGet(nkey) if err != nil { @@ -139,7 +140,7 @@ func (s *NvramStore) Delete(key string) error { s.mu.Lock() defer s.mu.Unlock() - nkey := nvramKey(key) + nkey := s.nvramKey(key) if err := nvramUnset(nkey); err != nil { return err @@ -174,17 +175,17 @@ func (s *NvramStore) KeysWithSuffix(suffix string) (keys []string, err error) { } nkey := parts[0] - if !strings.HasPrefix(nkey, nvramPrefix) { + if !strings.HasPrefix(nkey, s.prefix) { continue } - if suffix == ".pairing" && strings.HasPrefix(nkey, nvramPrefix+"p_") { + if suffix == ".pairing" && strings.HasPrefix(nkey, s.prefix+"p_") { // Extract UUID string and reconstruct original key - uuidStr := strings.TrimPrefix(nkey, nvramPrefix+"p_") + uuidStr := strings.TrimPrefix(nkey, s.prefix+"p_") originalKey := hex.EncodeToString([]byte(uuidStr)) + ".pairing" keys = append(keys, originalKey) } else { - key := strings.TrimPrefix(nkey, nvramPrefix) + key := strings.TrimPrefix(nkey, s.prefix) if strings.HasSuffix(key, suffix) { keys = append(keys, key) } diff --git a/nvram_store_test.go b/nvram_store_test.go index 93d0572..67c1473 100644 --- a/nvram_store_test.go +++ b/nvram_store_test.go @@ -45,7 +45,7 @@ func setupMockNvram() *mockNvram { func TestNvramStore_SetGet(t *testing.T) { setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") // Test setting and getting a simple value err := store.Set("uuid", []byte("AA:BB:CC:DD:EE:FF")) @@ -64,7 +64,7 @@ func TestNvramStore_SetGet(t *testing.T) { func TestNvramStore_CommitOnPairing(t *testing.T) { mock := setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") // Set non-pairing keys - should not commit store.Set("uuid", []byte("AA:BB:CC:DD:EE:FF")) @@ -87,7 +87,7 @@ func TestNvramStore_CommitOnPairing(t *testing.T) { func TestNvramStore_Delete(t *testing.T) { setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") store.Set("test", []byte("value")) @@ -109,7 +109,7 @@ func TestNvramStore_Delete(t *testing.T) { func TestNvramStore_DeletePairingCommits(t *testing.T) { mock := setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") pairingKey := "33313046433135382d423239452d344635322d423542322d413734324344464345383141.pairing" store.Set(pairingKey, []byte(`{"Name":"310FC158-B29E-4F52-B5B2-A742CDFCE81A"}`)) @@ -124,7 +124,7 @@ func TestNvramStore_DeletePairingCommits(t *testing.T) { func TestNvramStore_KeysWithSuffix(t *testing.T) { setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") // Set some regular keys store.Set("uuid", []byte("test")) @@ -158,7 +158,7 @@ func TestNvramStore_KeysWithSuffix(t *testing.T) { func TestNvramStore_BinaryData(t *testing.T) { mock := setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") // Test with binary data including null bytes and high bytes (like MD5 hash) binary := []byte{0x00, 0x01, 0xFF, 0xFE, 0x80, 0x7F} @@ -193,7 +193,7 @@ func TestNvramStore_BinaryData(t *testing.T) { func TestNvramStore_JSONData(t *testing.T) { mock := setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") // Test with JSON data similar to what hap stores keypair := `{"Public":"CCSWr6dPt0Nqo6OjmG21fQA0ysUED7/nXV9lTQ+4+Us=","Private":"ZmJmNjA4YmFmNzVmZGZkMTVjOWRhZWQ1NzU4NzY0MmMIJJavp0+3Q2qjo6OYbbV9ADTKxQQPv+ddX2VND7j5Sw=="}` @@ -221,7 +221,7 @@ func TestNvramStore_JSONData(t *testing.T) { func TestNvramStore_TextValuesStoredAsIs(t *testing.T) { mock := setupMockNvram() - store := NewNvramStore() + store := NewNvramStore("hap_") tests := []struct { key string @@ -253,6 +253,8 @@ func TestNvramStore_TextValuesStoredAsIs(t *testing.T) { } func TestNvramKey(t *testing.T) { + store := NewNvramStore("hap_") + tests := []struct { input string expected string @@ -265,7 +267,7 @@ func TestNvramKey(t *testing.T) { } for _, tt := range tests { - result := nvramKey(tt.input) + result := store.nvramKey(tt.input) if result != tt.expected { t.Errorf("nvramKey(%s) = %s, expected %s", tt.input, result, tt.expected) } @@ -273,7 +275,7 @@ func TestNvramKey(t *testing.T) { // Test pairing key - should be converted to readable UUID pairingKey := "33313046433135382d423239452d344635322d423542322d413734324344464345383141.pairing" - result := nvramKey(pairingKey) + result := store.nvramKey(pairingKey) expected := "hap_p_310FC158-B29E-4F52-B5B2-A742CDFCE81A" if result != expected { @@ -285,3 +287,34 @@ func TestNvramKey(t *testing.T) { t.Errorf("Pairing key exceeds 64 char limit: %d chars", len(result)) } } + +func TestNvramStore_DifferentPrefixes(t *testing.T) { + mock := setupMockNvram() + + // Create two stores with different prefixes + timerStore := NewNvramStore("timer_") + lightStore := NewNvramStore("light_") + + // Set the same key in both stores + timerStore.Set("uuid", []byte("AA:BB:CC:DD:EE:FF")) + lightStore.Set("uuid", []byte("11:22:33:44:55:66")) + + // Verify they're stored with different prefixes + if mock.data["timer_uuid"] != "AA:BB:CC:DD:EE:FF" { + t.Errorf("timer_uuid: expected AA:BB:CC:DD:EE:FF, got %s", mock.data["timer_uuid"]) + } + if mock.data["light_uuid"] != "11:22:33:44:55:66" { + t.Errorf("light_uuid: expected 11:22:33:44:55:66, got %s", mock.data["light_uuid"]) + } + + // Verify each store retrieves its own value + timerVal, _ := timerStore.Get("uuid") + lightVal, _ := lightStore.Get("uuid") + + if string(timerVal) != "AA:BB:CC:DD:EE:FF" { + t.Errorf("timerStore.Get: expected AA:BB:CC:DD:EE:FF, got %s", timerVal) + } + if string(lightVal) != "11:22:33:44:55:66" { + t.Errorf("lightStore.Get: expected 11:22:33:44:55:66, got %s", lightVal) + } +}