Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions nvram_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package hap

import (
"encoding/hex"
"fmt"
"os/exec"
"strings"
"sync"

"github.com/brutella/hap/log"
)

// 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
prefix string
}

// 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
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 (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 s.prefix + key
}
return s.prefix + "p_" + string(name)
}
return s.prefix + 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 := s.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 := s.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 := s.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, s.prefix) {
continue
}

if suffix == ".pairing" && strings.HasPrefix(nkey, s.prefix+"p_") {
// Extract UUID string and reconstruct original key
uuidStr := strings.TrimPrefix(nkey, s.prefix+"p_")
originalKey := hex.EncodeToString([]byte(uuidStr)) + ".pairing"
keys = append(keys, originalKey)
} else {
key := strings.TrimPrefix(nkey, s.prefix)
if strings.HasSuffix(key, suffix) {
keys = append(keys, key)
}
}
}
return
}
Loading