implement some features of Venus OS
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-19 15:37:41 +11:00
parent d72e88ab7b
commit e8153e2953
21 changed files with 4143 additions and 90 deletions

194
mk2driver/managed_writer.go Normal file
View File

@@ -0,0 +1,194 @@
package mk2driver
import (
"errors"
"fmt"
"sync"
"time"
)
type WriterProfile string
const (
WriterProfileNormal WriterProfile = "normal"
WriterProfileMaintenance WriterProfile = "maintenance"
WriterProfileReadOnly WriterProfile = "read_only"
)
type WriterPolicy struct {
Profile WriterProfile
MaxCurrentLimitA *float64
ModeChangeMinInterval time.Duration
LockoutWindow time.Duration
}
type CommandEvent struct {
Timestamp time.Time `json:"timestamp"`
Source CommandSource `json:"source"`
Kind string `json:"kind"`
Allowed bool `json:"allowed"`
Error string `json:"error,omitempty"`
}
type ManagedWriter struct {
writer SettingsWriter
policy WriterPolicy
mu sync.Mutex
lastModeChange time.Time
lockoutUntil time.Time
events []CommandEvent
}
var _ SettingsWriter = (*ManagedWriter)(nil)
var _ SourceAwareSettingsWriter = (*ManagedWriter)(nil)
func NewManagedWriter(writer SettingsWriter, policy WriterPolicy) *ManagedWriter {
if policy.Profile == "" {
policy.Profile = WriterProfileNormal
}
return &ManagedWriter{
writer: writer,
policy: policy,
events: make([]CommandEvent, 0, 100),
}
}
func (m *ManagedWriter) WriteRAMVar(id uint16, value int16) error {
return m.WriteRAMVarWithSource(CommandSourceUnknown, id, value)
}
func (m *ManagedWriter) WriteSetting(id uint16, value int16) error {
return m.WriteSettingWithSource(CommandSourceUnknown, id, value)
}
func (m *ManagedWriter) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
return m.SetPanelStateWithSource(CommandSourceUnknown, switchState, currentLimitA)
}
func (m *ManagedWriter) SetStandby(enabled bool) error {
return m.SetStandbyWithSource(CommandSourceUnknown, enabled)
}
func (m *ManagedWriter) WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error {
return m.apply(source, "write_ram_var", func() error {
if err := m.ensureProfileAllows("write_ram_var"); err != nil {
return err
}
return m.baseWriter().WriteRAMVar(id, value)
})
}
func (m *ManagedWriter) WriteSettingWithSource(source CommandSource, id uint16, value int16) error {
return m.apply(source, "write_setting", func() error {
if err := m.ensureProfileAllows("write_setting"); err != nil {
return err
}
return m.baseWriter().WriteSetting(id, value)
})
}
func (m *ManagedWriter) SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error {
return m.apply(source, "set_panel_state", func() error {
if err := m.ensureProfileAllows("set_panel_state"); err != nil {
return err
}
if m.policy.MaxCurrentLimitA != nil && currentLimitA != nil && *currentLimitA > *m.policy.MaxCurrentLimitA {
return fmt.Errorf("current limit %.2fA exceeds configured policy max %.2fA", *currentLimitA, *m.policy.MaxCurrentLimitA)
}
if m.policy.Profile == WriterProfileMaintenance && switchState != PanelSwitchOff {
return errors.New("maintenance profile only allows panel switch off")
}
if m.policy.ModeChangeMinInterval > 0 && !m.lastModeChange.IsZero() && time.Since(m.lastModeChange) < m.policy.ModeChangeMinInterval {
return fmt.Errorf("mode change denied due to rate limit; wait %s", m.policy.ModeChangeMinInterval-time.Since(m.lastModeChange))
}
if err := m.baseWriter().SetPanelState(switchState, currentLimitA); err != nil {
return err
}
m.lastModeChange = time.Now().UTC()
return nil
})
}
func (m *ManagedWriter) SetStandbyWithSource(source CommandSource, enabled bool) error {
return m.apply(source, "set_standby", func() error {
if err := m.ensureProfileAllows("set_standby"); err != nil {
return err
}
return m.baseWriter().SetStandby(enabled)
})
}
func (m *ManagedWriter) History(limit int) []CommandEvent {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 || limit > len(m.events) {
limit = len(m.events)
}
out := make([]CommandEvent, limit)
if limit > 0 {
copy(out, m.events[len(m.events)-limit:])
}
return out
}
func (m *ManagedWriter) apply(source CommandSource, kind string, fn func() error) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.writer == nil {
err := errors.New("settings writer is not available")
m.recordLocked(source, kind, false, err)
return err
}
if m.policy.LockoutWindow > 0 && time.Now().UTC().Before(m.lockoutUntil) {
err := fmt.Errorf("command denied during lockout window until %s", m.lockoutUntil.Format(time.RFC3339))
m.recordLocked(source, kind, false, err)
return err
}
if err := fn(); err != nil {
m.recordLocked(source, kind, false, err)
return err
}
if m.policy.LockoutWindow > 0 {
m.lockoutUntil = time.Now().UTC().Add(m.policy.LockoutWindow)
}
m.recordLocked(source, kind, true, nil)
return nil
}
func (m *ManagedWriter) ensureProfileAllows(kind string) error {
switch m.policy.Profile {
case WriterProfileReadOnly:
return errors.New("write denied by read-only profile")
case WriterProfileMaintenance:
if kind == "set_standby" || kind == "set_panel_state" {
return nil
}
return fmt.Errorf("maintenance profile blocks %s", kind)
default:
return nil
}
}
func (m *ManagedWriter) recordLocked(source CommandSource, kind string, allowed bool, err error) {
event := CommandEvent{
Timestamp: time.Now().UTC(),
Source: source,
Kind: kind,
Allowed: allowed,
}
if err != nil {
event.Error = err.Error()
}
m.events = append(m.events, event)
if len(m.events) > 100 {
m.events = m.events[len(m.events)-100:]
}
}
func (m *ManagedWriter) baseWriter() SettingsWriter {
return m.writer
}

View File

@@ -0,0 +1,79 @@
package mk2driver
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
type writerStub struct {
settingWrites int
ramWrites int
panelWrites int
standbyWrites int
}
func (w *writerStub) WriteRAMVar(id uint16, value int16) error {
w.ramWrites++
return nil
}
func (w *writerStub) WriteSetting(id uint16, value int16) error {
w.settingWrites++
return nil
}
func (w *writerStub) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
w.panelWrites++
return nil
}
func (w *writerStub) SetStandby(enabled bool) error {
w.standbyWrites++
return nil
}
func TestManagedWriterReadOnlyProfile(t *testing.T) {
base := &writerStub{}
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileReadOnly,
})
err := managed.WriteSettingWithSource(CommandSourceMQTT, 1, 1)
assert.Error(t, err)
assert.Equal(t, 0, base.settingWrites)
history := managed.History(10)
if assert.Len(t, history, 1) {
assert.False(t, history[0].Allowed)
assert.Equal(t, CommandSourceMQTT, history[0].Source)
}
}
func TestManagedWriterCurrentLimitGuard(t *testing.T) {
base := &writerStub{}
max := 16.0
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileNormal,
MaxCurrentLimitA: &max,
})
limit := 20.0
err := managed.SetPanelStateWithSource(CommandSourceUI, PanelSwitchOn, &limit)
assert.Error(t, err)
assert.Equal(t, 0, base.panelWrites)
}
func TestManagedWriterModeRateLimit(t *testing.T) {
base := &writerStub{}
managed := NewManagedWriter(base, WriterPolicy{
Profile: WriterProfileNormal,
ModeChangeMinInterval: 10 * time.Second,
})
err := managed.SetPanelStateWithSource(CommandSourceAutomation, PanelSwitchOn, nil)
assert.NoError(t, err)
err = managed.SetPanelStateWithSource(CommandSourceAutomation, PanelSwitchOff, nil)
assert.Error(t, err)
assert.Equal(t, 1, base.panelWrites)
}

332
mk2driver/metadata.go Normal file
View File

@@ -0,0 +1,332 @@
package mk2driver
import (
"fmt"
"math"
"sort"
"time"
)
const (
defaultTransactionRetries = 2
defaultTransactionRetryDelay = 200 * time.Millisecond
defaultTransactionBackoff = 1.5
fastCommandTimeout = 1500 * time.Millisecond
standardCommandTimeout = 3 * time.Second
slowCommandTimeout = 6 * time.Second
)
type registerKey struct {
kind RegisterKind
id uint16
}
func int16Ptr(v int16) *int16 {
return &v
}
var knownRegisterMetadata = map[registerKey]RegisterMetadata{
{kind: RegisterKindRAMVar, id: ramVarVMains}: {
Kind: RegisterKindRAMVar,
ID: ramVarVMains,
Name: "mains_voltage",
Description: "AC input mains voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIMains}: {
Kind: RegisterKindRAMVar,
ID: ramVarIMains,
Name: "mains_current",
Description: "AC input mains current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVInverter}: {
Kind: RegisterKindRAMVar,
ID: ramVarVInverter,
Name: "inverter_voltage",
Description: "AC output inverter voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIInverter}: {
Kind: RegisterKindRAMVar,
ID: ramVarIInverter,
Name: "inverter_current",
Description: "AC output inverter current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVBat}: {
Kind: RegisterKindRAMVar,
ID: ramVarVBat,
Name: "battery_voltage",
Description: "Battery voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIBat}: {
Kind: RegisterKindRAMVar,
ID: ramVarIBat,
Name: "battery_current",
Description: "Battery current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVBatRipple}: {
Kind: RegisterKindRAMVar,
ID: ramVarVBatRipple,
Name: "battery_voltage_ripple",
Description: "Battery ripple voltage (scaled)",
Unit: "V",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarInverterPeriod}: {
Kind: RegisterKindRAMVar,
ID: ramVarInverterPeriod,
Name: "inverter_period",
Description: "Inverter period source value",
Unit: "ticks",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarMainPeriod}: {
Kind: RegisterKindRAMVar,
ID: ramVarMainPeriod,
Name: "mains_period",
Description: "Mains period source value",
Unit: "ticks",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarIACLoad}: {
Kind: RegisterKindRAMVar,
ID: ramVarIACLoad,
Name: "ac_load_current",
Description: "AC load current (scaled)",
Unit: "A",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarVirSwitchPos}: {
Kind: RegisterKindRAMVar,
ID: ramVarVirSwitchPos,
Name: "virtual_switch_position",
Description: "Virtual switch position",
Unit: "state",
Scale: 1,
Writable: true,
Signed: false,
MinValue: int16Ptr(0),
MaxValue: int16Ptr(255),
SafetyClass: RegisterSafetyGuarded,
},
{kind: RegisterKindRAMVar, id: ramVarIgnACInState}: {
Kind: RegisterKindRAMVar,
ID: ramVarIgnACInState,
Name: "ignored_ac_input_state",
Description: "AC input state as seen by firmware",
Unit: "state",
Scale: 1,
Writable: false,
Signed: false,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarMultiFuncRelay}: {
Kind: RegisterKindRAMVar,
ID: ramVarMultiFuncRelay,
Name: "multifunction_relay",
Description: "Multifunction relay state",
Unit: "state",
Scale: 1,
Writable: true,
Signed: false,
MinValue: int16Ptr(0),
MaxValue: int16Ptr(1),
SafetyClass: RegisterSafetyOperational,
},
{kind: RegisterKindRAMVar, id: ramVarChargeState}: {
Kind: RegisterKindRAMVar,
ID: ramVarChargeState,
Name: "battery_charge_state",
Description: "Battery charge state fraction",
Unit: "fraction",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarInverterPower1}: {
Kind: RegisterKindRAMVar,
ID: ramVarInverterPower1,
Name: "inverter_power_1",
Description: "Inverter power source register 1",
Unit: "W",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarInverterPower2}: {
Kind: RegisterKindRAMVar,
ID: ramVarInverterPower2,
Name: "inverter_power_2",
Description: "Inverter power source register 2",
Unit: "W",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
{kind: RegisterKindRAMVar, id: ramVarOutPower}: {
Kind: RegisterKindRAMVar,
ID: ramVarOutPower,
Name: "output_power",
Description: "Output power source register",
Unit: "W",
Scale: 1,
Writable: false,
Signed: true,
SafetyClass: RegisterSafetyReadOnly,
},
}
func normalizeTransactionOptions(opts TransactionOptions) TransactionOptions {
if opts.Retries < 0 {
opts.Retries = 0
}
if opts.RetryDelay < 0 {
opts.RetryDelay = 0
}
if opts.BackoffFactor <= 0 {
opts.BackoffFactor = defaultTransactionBackoff
}
if opts.Retries == 0 && opts.RetryDelay == 0 && !opts.ReadBeforeWrite && !opts.VerifyAfterWrite {
opts.Retries = defaultTransactionRetries
opts.RetryDelay = defaultTransactionRetryDelay
opts.BackoffFactor = defaultTransactionBackoff
opts.ReadBeforeWrite = true
opts.VerifyAfterWrite = true
opts.TimeoutClass = TimeoutClassStandard
return opts
}
if opts.RetryDelay == 0 {
opts.RetryDelay = defaultTransactionRetryDelay
}
if opts.TimeoutClass == "" {
opts.TimeoutClass = TimeoutClassStandard
}
return opts
}
func lookupRegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool) {
meta, ok := knownRegisterMetadata[registerKey{kind: kind, id: id}]
if ok {
meta = withMetadataDefaults(meta)
}
return meta, ok
}
func listRegisterMetadata() []RegisterMetadata {
out := make([]RegisterMetadata, 0, len(knownRegisterMetadata))
for _, meta := range knownRegisterMetadata {
out = append(out, withMetadataDefaults(meta))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
return out[i].ID < out[j].ID
})
return out
}
func validateValueAgainstMetadata(meta RegisterMetadata, value int16) error {
if meta.MinValue != nil && value < *meta.MinValue {
return fmt.Errorf("value %d is below minimum %d for %s:%d", value, *meta.MinValue, meta.Kind, meta.ID)
}
if meta.MaxValue != nil && value > *meta.MaxValue {
return fmt.Errorf("value %d is above maximum %d for %s:%d", value, *meta.MaxValue, meta.Kind, meta.ID)
}
return nil
}
func withMetadataDefaults(meta RegisterMetadata) RegisterMetadata {
if meta.Scale == 0 {
meta.Scale = 1
}
if meta.SafetyClass == "" {
if meta.Writable {
meta.SafetyClass = RegisterSafetyGuarded
} else {
meta.SafetyClass = RegisterSafetyReadOnly
}
}
return meta
}
func resolveCommandTimeout(opts TransactionOptions) time.Duration {
if opts.CommandTimeout > 0 {
return opts.CommandTimeout
}
switch opts.TimeoutClass {
case TimeoutClassFast:
return fastCommandTimeout
case TimeoutClassSlow:
return slowCommandTimeout
default:
return standardCommandTimeout
}
}
func retryDelayForAttempt(opts TransactionOptions, attempt int) time.Duration {
if opts.RetryDelay <= 0 || attempt <= 1 {
return opts.RetryDelay
}
factor := math.Pow(opts.BackoffFactor, float64(attempt-1))
delay := float64(opts.RetryDelay) * factor
return time.Duration(delay)
}
func defaultWritableRegisterAddresses() []RegisterAddress {
metas := listRegisterMetadata()
out := make([]RegisterAddress, 0, len(metas))
for _, meta := range metas {
if !meta.Writable {
continue
}
out = append(out, RegisterAddress{
Kind: meta.Kind,
ID: meta.ID,
})
}
return out
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"math"
"strings"
"sync"
"sync/atomic"
"time"
@@ -133,9 +134,22 @@ type mk2Ser struct {
stateAck chan struct{}
ifaceAck chan byte
wg sync.WaitGroup
diagMu sync.Mutex
traceLimit int
traces []ProtocolTrace
lastFrameAt time.Time
recentErrors []string
commandTimeouts atomic.Uint64
commandFailures atomic.Uint64
checksumErrors atomic.Uint64
}
var _ ProtocolControl = (*mk2Ser)(nil)
var _ MetadataControl = (*mk2Ser)(nil)
var _ SnapshotControl = (*mk2Ser)(nil)
var _ DiagnosticsControl = (*mk2Ser)(nil)
func parseFrameLength(raw byte) (payloadLength byte, hasLEDStatus bool) {
if raw&frameLengthLEDBit != 0 {
@@ -175,6 +189,9 @@ func NewMk2Connection(dev io.ReadWriter) (Mk2, error) {
mk2.winmonAck = make(chan winmonResponse, 32)
mk2.stateAck = make(chan struct{}, 1)
mk2.ifaceAck = make(chan byte, 1)
mk2.traceLimit = 200
mk2.traces = make([]ProtocolTrace, 0, mk2.traceLimit)
mk2.recentErrors = make([]string, 0, 20)
mk2.setTarget()
mk2.run = make(chan struct{})
mk2.infochan = make(chan *Mk2Info)
@@ -270,6 +287,298 @@ func (m *mk2Ser) C() chan *Mk2Info {
return m.infochan
}
func (m *mk2Ser) RegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool) {
return lookupRegisterMetadata(kind, id)
}
func (m *mk2Ser) ListRegisterMetadata() []RegisterMetadata {
return listRegisterMetadata()
}
func (m *mk2Ser) ReadRegister(kind RegisterKind, id uint16) (int16, error) {
m.beginCommand()
defer m.endCommand()
return m.readRegisterLocked(kind, id)
}
func (m *mk2Ser) WriteRegister(kind RegisterKind, id uint16, value int16, opts TransactionOptions) (RegisterTransactionResult, error) {
options := normalizeTransactionOptions(opts)
commandTimeout := resolveCommandTimeout(options)
start := time.Now()
result := RegisterTransactionResult{
Kind: kind,
ID: id,
TargetValue: value,
Timeout: commandTimeout,
}
if meta, ok := lookupRegisterMetadata(kind, id); ok {
if !meta.Writable {
return result, fmt.Errorf("register %s:%d (%s) is marked read-only", kind, id, meta.Name)
}
if err := validateValueAgainstMetadata(meta, value); err != nil {
return result, err
}
}
m.beginCommand()
defer m.endCommand()
if options.ReadBeforeWrite {
prev, err := m.readRegisterLockedWithTimeout(kind, id, commandTimeout)
if err != nil {
result.Duration = time.Since(start)
return result, fmt.Errorf("could not read current value for %s:%d: %w", kind, id, err)
}
result.PreviousValue = int16Ptr(prev)
}
var lastErr error
maxAttempts := options.Retries + 1
for attempt := 1; attempt <= maxAttempts; attempt++ {
result.Attempts = attempt
err := m.writeRegisterLockedWithTimeout(kind, id, value, commandTimeout)
if err != nil {
lastErr = err
if attempt < maxAttempts {
delay := retryDelayForAttempt(options, attempt)
if delay > 0 {
time.Sleep(delay)
}
continue
}
break
}
if !options.VerifyAfterWrite {
result.Duration = time.Since(start)
return result, nil
}
verified, err := m.readRegisterLockedWithTimeout(kind, id, commandTimeout)
if err != nil {
lastErr = fmt.Errorf("verification read failed for %s:%d: %w", kind, id, err)
if attempt < maxAttempts {
delay := retryDelayForAttempt(options, attempt)
if delay > 0 {
time.Sleep(delay)
}
continue
}
break
}
result.VerifiedValue = int16Ptr(verified)
if verified != value {
lastErr = fmt.Errorf("verification mismatch for %s:%d expected %d got %d", kind, id, value, verified)
if attempt < maxAttempts {
delay := retryDelayForAttempt(options, attempt)
if delay > 0 {
time.Sleep(delay)
}
continue
}
break
}
result.Duration = time.Since(start)
return result, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("transaction failed for %s:%d", kind, id)
}
result.Duration = time.Since(start)
return result, lastErr
}
func (m *mk2Ser) CaptureSnapshot(addresses []RegisterAddress) (RegisterSnapshot, error) {
if len(addresses) == 0 {
addresses = defaultWritableRegisterAddresses()
}
snapshotTime := time.Now().UTC()
snapshot := RegisterSnapshot{
CapturedAt: snapshotTime,
Entries: make([]RegisterSnapshotEntry, 0, len(addresses)),
}
for _, address := range addresses {
value, err := m.ReadRegister(address.Kind, address.ID)
if err != nil {
return snapshot, fmt.Errorf("capture snapshot read failed for %s:%d: %w", address.Kind, address.ID, err)
}
meta, ok := m.RegisterMetadata(address.Kind, address.ID)
entry := RegisterSnapshotEntry{
Kind: address.Kind,
ID: address.ID,
Value: value,
CapturedAt: snapshotTime,
}
if ok {
entry.Name = meta.Name
entry.Writable = meta.Writable
entry.Safety = meta.SafetyClass
}
snapshot.Entries = append(snapshot.Entries, entry)
}
return snapshot, nil
}
func (m *mk2Ser) DiffSnapshot(snapshot RegisterSnapshot) ([]SnapshotDiff, error) {
diffs := make([]SnapshotDiff, 0, len(snapshot.Entries))
for _, entry := range snapshot.Entries {
current, err := m.ReadRegister(entry.Kind, entry.ID)
if err != nil {
return diffs, fmt.Errorf("snapshot diff read failed for %s:%d: %w", entry.Kind, entry.ID, err)
}
meta, ok := m.RegisterMetadata(entry.Kind, entry.ID)
name := entry.Name
writable := entry.Writable
safety := entry.Safety
if ok {
name = meta.Name
writable = meta.Writable
safety = meta.SafetyClass
}
diff := SnapshotDiff{
Kind: entry.Kind,
ID: entry.ID,
Name: name,
Current: current,
Target: entry.Value,
Changed: current != entry.Value,
Writable: writable,
Safety: safety,
DiffValue: int32(entry.Value) - int32(current),
}
diffs = append(diffs, diff)
}
return diffs, nil
}
func (m *mk2Ser) RestoreSnapshot(snapshot RegisterSnapshot, opts TransactionOptions) (SnapshotRestoreResult, error) {
restoreResult := SnapshotRestoreResult{
Applied: make([]RegisterTransactionResult, 0, len(snapshot.Entries)),
}
diffs, err := m.DiffSnapshot(snapshot)
if err != nil {
return restoreResult, err
}
for _, diff := range diffs {
if !diff.Changed || !diff.Writable {
continue
}
txResult, txErr := m.WriteRegister(diff.Kind, diff.ID, diff.Target, opts)
if txErr == nil {
restoreResult.Applied = append(restoreResult.Applied, txResult)
continue
}
restoreResult.RolledBack = true
rollbackErrs := m.rollbackAppliedTransactions(restoreResult.Applied, opts)
restoreResult.RollbackErrors = append(restoreResult.RollbackErrors, rollbackErrs...)
return restoreResult, fmt.Errorf("restore failed for %s:%d: %w", diff.Kind, diff.ID, txErr)
}
return restoreResult, nil
}
func (m *mk2Ser) rollbackAppliedTransactions(applied []RegisterTransactionResult, opts TransactionOptions) []string {
errs := make([]string, 0)
rollbackOpts := normalizeTransactionOptions(TransactionOptions{
Retries: 1,
RetryDelay: opts.RetryDelay,
BackoffFactor: opts.BackoffFactor,
VerifyAfterWrite: true,
TimeoutClass: opts.TimeoutClass,
CommandTimeout: opts.CommandTimeout,
})
for i := len(applied) - 1; i >= 0; i-- {
tx := applied[i]
if tx.PreviousValue == nil {
continue
}
if _, err := m.WriteRegister(tx.Kind, tx.ID, *tx.PreviousValue, rollbackOpts); err != nil {
errs = append(errs, fmt.Sprintf("rollback failed for %s:%d: %v", tx.Kind, tx.ID, err))
}
}
return errs
}
func (m *mk2Ser) DriverDiagnostics(limit int) DriverDiagnostics {
m.diagMu.Lock()
defer m.diagMu.Unlock()
traceLimit := limit
if traceLimit <= 0 || traceLimit > len(m.traces) {
traceLimit = len(m.traces)
}
traces := make([]ProtocolTrace, traceLimit)
if traceLimit > 0 {
copy(traces, m.traces[len(m.traces)-traceLimit:])
}
recentErrors := append([]string(nil), m.recentErrors...)
diag := DriverDiagnostics{
GeneratedAt: time.Now().UTC(),
CommandTimeouts: m.commandTimeouts.Load(),
CommandFailures: m.commandFailures.Load(),
ChecksumFailures: m.checksumErrors.Load(),
RecentErrors: recentErrors,
Traces: traces,
}
if !m.lastFrameAt.IsZero() {
last := m.lastFrameAt
diag.LastFrameAt = &last
}
diag.HealthScore = calculateDriverHealthScore(diag)
return diag
}
func (m *mk2Ser) readRegisterLocked(kind RegisterKind, id uint16) (int16, error) {
return m.readRegisterLockedWithTimeout(kind, id, writeResponseTimeout)
}
func (m *mk2Ser) readRegisterLockedWithTimeout(kind RegisterKind, id uint16, timeout time.Duration) (int16, error) {
switch kind {
case RegisterKindRAMVar:
return m.readValueByIDWithTimeout(commandReadRAMVar, commandReadRAMResponse, id, timeout)
case RegisterKindSetting:
return m.readValueByIDWithTimeout(commandReadSetting, commandReadSettingResponse, id, timeout)
default:
return 0, fmt.Errorf("unsupported register kind %q", kind)
}
}
func (m *mk2Ser) writeRegisterLocked(kind RegisterKind, id uint16, value int16) error {
return m.writeRegisterLockedWithTimeout(kind, id, value, writeResponseTimeout)
}
func (m *mk2Ser) writeRegisterLockedWithTimeout(kind RegisterKind, id uint16, value int16, timeout time.Duration) error {
switch kind {
case RegisterKindRAMVar:
err := m.writeByIDOnlyWithTimeout(commandWriteRAMViaID, commandWriteRAMViaIDResponse, id, value, timeout)
if err != nil && !errors.Is(err, errWriteRejected) {
mk2log.WithFields(logrus.Fields{
"id": id,
"value": value,
}).WithError(err).Warn("WriteRegister RAM by-id failed, trying legacy write path")
err = m.writeBySelectionWithTimeout(commandWriteRAMVar, commandWriteRAMResponse, id, value, timeout)
}
return err
case RegisterKindSetting:
err := m.writeByIDOnlyWithTimeout(commandWriteViaID, commandWriteViaIDResponse, id, value, timeout)
if err != nil && !errors.Is(err, errWriteRejected) {
mk2log.WithFields(logrus.Fields{
"id": id,
"value": value,
}).WithError(err).Warn("WriteRegister setting by-id failed, trying legacy write path")
err = m.writeBySelectionWithTimeout(commandWriteSetting, commandWriteSettingResponse, id, value, timeout)
}
return err
default:
return fmt.Errorf("unsupported register kind %q", kind)
}
}
func (m *mk2Ser) WriteRAMVar(id uint16, value int16) error {
mk2log.WithFields(logrus.Fields{
"id": id,
@@ -340,6 +649,39 @@ func (m *mk2Ser) WriteRAMVarByID(id uint16, value int16) error {
return m.writeByIDOnly(commandWriteRAMViaID, commandWriteRAMViaIDResponse, id, value)
}
func (m *mk2Ser) WriteSettingBySelection(id uint16, value int16) error {
m.beginCommand()
defer m.endCommand()
return m.writeBySelection(commandWriteSetting, commandWriteSettingResponse, id, value)
}
func (m *mk2Ser) WriteRAMVarBySelection(id uint16, value int16) error {
m.beginCommand()
defer m.endCommand()
return m.writeBySelection(commandWriteRAMVar, commandWriteRAMResponse, id, value)
}
func (m *mk2Ser) WriteSelectedData(value int16) error {
m.beginCommand()
defer m.endCommand()
m.clearWriteResponses()
raw := uint16(value)
m.sendCommandLocked([]byte{
winmonFrame,
commandWriteData,
byte(raw),
byte(raw >> 8),
})
_, err := m.waitForAnyWriteResponseWithTimeout([]byte{
commandWriteRAMResponse,
commandWriteSettingResponse,
commandWriteViaIDResponse,
commandWriteRAMViaIDResponse,
}, writeResponseTimeout)
return err
}
func (m *mk2Ser) GetDeviceState() (DeviceState, error) {
m.beginCommand()
defer m.endCommand()
@@ -552,6 +894,10 @@ func encodePanelCurrentLimit(currentLimitA *float64) (uint16, error) {
}
func (m *mk2Ser) writeByIDOnly(writeCommand, expectedResponse byte, id uint16, value int16) error {
return m.writeByIDOnlyWithTimeout(writeCommand, expectedResponse, id, value, writeResponseTimeout)
}
func (m *mk2Ser) writeByIDOnlyWithTimeout(writeCommand, expectedResponse byte, id uint16, value int16, timeout time.Duration) error {
m.clearWriteResponses()
rawValue := uint16(value)
m.sendCommandLocked([]byte{
@@ -562,10 +908,14 @@ func (m *mk2Ser) writeByIDOnly(writeCommand, expectedResponse byte, id uint16, v
byte(rawValue),
byte(rawValue >> 8),
})
return m.waitForWriteResponse(expectedResponse)
return m.waitForWriteResponseWithTimeout(expectedResponse, timeout)
}
func (m *mk2Ser) writeBySelection(selectCommand, expectedResponse byte, id uint16, value int16) error {
return m.writeBySelectionWithTimeout(selectCommand, expectedResponse, id, value, writeResponseTimeout)
}
func (m *mk2Ser) writeBySelectionWithTimeout(selectCommand, expectedResponse byte, id uint16, value int16, timeout time.Duration) error {
m.clearWriteResponses()
rawValue := uint16(value)
m.sendCommandLocked([]byte{
@@ -581,10 +931,14 @@ func (m *mk2Ser) writeBySelection(selectCommand, expectedResponse byte, id uint1
byte(rawValue >> 8),
})
return m.waitForWriteResponse(expectedResponse)
return m.waitForWriteResponseWithTimeout(expectedResponse, timeout)
}
func (m *mk2Ser) readValueByID(readCommand, expectedResponse byte, id uint16) (int16, error) {
return m.readValueByIDWithTimeout(readCommand, expectedResponse, id, writeResponseTimeout)
}
func (m *mk2Ser) readValueByIDWithTimeout(readCommand, expectedResponse byte, id uint16, timeout time.Duration) (int16, error) {
m.clearWinmonResponses()
m.sendCommandLocked([]byte{
winmonFrame,
@@ -593,7 +947,7 @@ func (m *mk2Ser) readValueByID(readCommand, expectedResponse byte, id uint16) (i
byte(id >> 8),
})
resp, err := m.waitForWinmonResponse(expectedResponse)
resp, err := m.waitForWinmonResponseWithTimeout(expectedResponse, timeout)
if err != nil {
return 0, err
}
@@ -670,6 +1024,10 @@ func (m *mk2Ser) clearWriteResponses() {
}
func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error {
return m.waitForWriteResponseWithTimeout(expectedResponse, writeResponseTimeout)
}
func (m *mk2Ser) waitForWriteResponseWithTimeout(expectedResponse byte, timeout time.Duration) error {
if m.writeAck == nil {
return errors.New("write response channel is not initialized")
}
@@ -684,15 +1042,56 @@ func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error {
case expectedResponse:
return nil
case commandUnsupportedResponse:
m.noteCommandFailure(fmt.Errorf("received unsupported response 0x%02x", response))
return errCommandUnsupported
case commandWriteNotAllowedResponse:
m.noteCommandFailure(fmt.Errorf("received write rejected response 0x%02x", response))
return errWriteRejected
default:
return fmt.Errorf("unexpected write response 0x%02x", response)
err := fmt.Errorf("unexpected write response 0x%02x", response)
m.noteCommandFailure(err)
return err
}
case <-time.After(writeResponseTimeout):
mk2log.WithField("expected_response", fmt.Sprintf("0x%02x", expectedResponse)).Error("Timed out waiting for write acknowledgement")
return fmt.Errorf("timed out waiting for write response after %s", writeResponseTimeout)
case <-time.After(timeout):
err := fmt.Errorf("timed out waiting for write response after %s", timeout)
mk2log.WithField("expected_response", fmt.Sprintf("0x%02x", expectedResponse)).WithError(err).Error("Timed out waiting for write acknowledgement")
m.noteCommandTimeout(err)
return err
}
}
func (m *mk2Ser) waitForAnyWriteResponseWithTimeout(expectedResponses []byte, timeout time.Duration) (byte, error) {
if m.writeAck == nil {
return 0, errors.New("write response channel is not initialized")
}
expected := make(map[byte]struct{}, len(expectedResponses))
for _, response := range expectedResponses {
expected[response] = struct{}{}
}
select {
case response := <-m.writeAck:
if _, ok := expected[response]; ok {
return response, nil
}
switch response {
case commandUnsupportedResponse:
err := fmt.Errorf("received unsupported write response 0x%02x", response)
m.noteCommandFailure(err)
return response, errCommandUnsupported
case commandWriteNotAllowedResponse:
err := fmt.Errorf("received write rejected response 0x%02x", response)
m.noteCommandFailure(err)
return response, errWriteRejected
default:
err := fmt.Errorf("unexpected write response 0x%02x", response)
m.noteCommandFailure(err)
return response, err
}
case <-time.After(timeout):
err := fmt.Errorf("timed out waiting for write response after %s", timeout)
m.noteCommandTimeout(err)
return 0, err
}
}
@@ -722,11 +1121,15 @@ func (m *mk2Ser) clearWinmonResponses() {
}
func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, error) {
return m.waitForWinmonResponseWithTimeout(expectedResponse, writeResponseTimeout)
}
func (m *mk2Ser) waitForWinmonResponseWithTimeout(expectedResponse byte, timeout time.Duration) (winmonResponse, error) {
if m.winmonAck == nil {
return winmonResponse{}, errors.New("winmon response channel is not initialized")
}
timeout := time.After(writeResponseTimeout)
timeoutChan := time.After(timeout)
for {
select {
case response := <-m.winmonAck:
@@ -740,8 +1143,10 @@ func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, e
case expectedResponse:
return response, nil
case commandUnsupportedResponse:
m.noteCommandFailure(fmt.Errorf("received unsupported winmon response 0x%02x", response.command))
return winmonResponse{}, errCommandUnsupported
case commandWriteNotAllowedResponse:
m.noteCommandFailure(fmt.Errorf("received write rejected winmon response 0x%02x", response.command))
return winmonResponse{}, errWriteRejected
default:
mk2log.WithFields(logrus.Fields{
@@ -749,8 +1154,10 @@ func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, e
"received_response": fmt.Sprintf("0x%02x", response.command),
}).Debug("Ignoring unrelated winmon response while waiting")
}
case <-timeout:
return winmonResponse{}, fmt.Errorf("timed out waiting for winmon response 0x%02x after %s", expectedResponse, writeResponseTimeout)
case <-timeoutChan:
err := fmt.Errorf("timed out waiting for winmon response 0x%02x after %s", expectedResponse, timeout)
m.noteCommandTimeout(err)
return winmonResponse{}, err
}
}
}
@@ -783,6 +1190,10 @@ func (m *mk2Ser) clearStateResponses() {
}
func (m *mk2Ser) waitForStateResponse() error {
return m.waitForStateResponseWithTimeout(writeResponseTimeout)
}
func (m *mk2Ser) waitForStateResponseWithTimeout(timeout time.Duration) error {
if m.stateAck == nil {
return errors.New("panel state response channel is not initialized")
}
@@ -791,9 +1202,11 @@ func (m *mk2Ser) waitForStateResponse() error {
case <-m.stateAck:
mk2log.Debug("Received panel state acknowledgement")
return nil
case <-time.After(writeResponseTimeout):
mk2log.Error("Timed out waiting for panel state acknowledgement")
return fmt.Errorf("timed out waiting for panel state response after %s", writeResponseTimeout)
case <-time.After(timeout):
err := fmt.Errorf("timed out waiting for panel state response after %s", timeout)
mk2log.WithError(err).Error("Timed out waiting for panel state acknowledgement")
m.noteCommandTimeout(err)
return err
}
}
@@ -823,6 +1236,10 @@ func (m *mk2Ser) clearInterfaceResponses() {
}
func (m *mk2Ser) waitForInterfaceResponse(expectedStandby bool) error {
return m.waitForInterfaceResponseWithTimeout(expectedStandby, writeResponseTimeout)
}
func (m *mk2Ser) waitForInterfaceResponseWithTimeout(expectedStandby bool, timeout time.Duration) error {
if m.ifaceAck == nil {
return errors.New("interface response channel is not initialized")
}
@@ -836,12 +1253,16 @@ func (m *mk2Ser) waitForInterfaceResponse(expectedStandby bool) error {
"actual_standby": standbyEnabled,
}).Debug("Received standby interface acknowledgement")
if standbyEnabled != expectedStandby {
return fmt.Errorf("unexpected standby line state 0x%02x", lineState)
err := fmt.Errorf("unexpected standby line state 0x%02x", lineState)
m.noteCommandFailure(err)
return err
}
return nil
case <-time.After(writeResponseTimeout):
mk2log.WithField("expected_standby", expectedStandby).Error("Timed out waiting for standby acknowledgement")
return fmt.Errorf("timed out waiting for standby response after %s", writeResponseTimeout)
case <-time.After(timeout):
err := fmt.Errorf("timed out waiting for standby response after %s", timeout)
mk2log.WithField("expected_standby", expectedStandby).WithError(err).Error("Timed out waiting for standby acknowledgement")
m.noteCommandTimeout(err)
return err
}
}
@@ -874,6 +1295,18 @@ func (m *mk2Ser) addError(err error) {
}
m.info.Errors = append(m.info.Errors, err)
m.info.Valid = false
m.recordError(err)
}
func (m *mk2Ser) noteCommandTimeout(err error) {
m.commandTimeouts.Add(1)
m.commandFailures.Add(1)
m.recordError(err)
}
func (m *mk2Ser) noteCommandFailure(err error) {
m.commandFailures.Add(1)
m.recordError(err)
}
// Updates report.
@@ -905,6 +1338,8 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte, appendedLED []byte) {
return
}
if checkChecksum(l, frame[0], frame[1:]) {
m.markFrameSeen()
m.recordRXTrace(frame)
switch frame[0] {
case bootupFrameHeader:
if m.pollPaused.Load() {
@@ -988,8 +1423,10 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte, appendedLED []byte) {
mk2log.Warnf("[handleFrame] Invalid frame %v", frame[0])
}
} else {
m.checksumErrors.Add(1)
mk2log.Errorf("[handleFrame] Invalid incoming frame checksum: %x", frame)
m.frameLock = false
m.recordError(fmt.Errorf("invalid incoming frame checksum"))
}
}
@@ -1220,13 +1657,127 @@ func (m *mk2Ser) sendCommandLocked(data []byte) {
dataOut[l+2] = cr
mk2log.Debugf("sendCommand %#v", dataOut)
m.recordTXTrace(dataOut, data)
_, err := m.p.Write(dataOut)
if err != nil {
mk2log.WithError(err).Error("Failed to send MK2 command")
m.addError(fmt.Errorf("Write error: %v", err))
m.noteCommandFailure(err)
}
}
func (m *mk2Ser) markFrameSeen() {
m.diagMu.Lock()
m.lastFrameAt = time.Now().UTC()
m.diagMu.Unlock()
}
func (m *mk2Ser) recordError(err error) {
if err == nil {
return
}
m.diagMu.Lock()
defer m.diagMu.Unlock()
m.recentErrors = append(m.recentErrors, err.Error())
if len(m.recentErrors) > 20 {
m.recentErrors = m.recentErrors[len(m.recentErrors)-20:]
}
}
func (m *mk2Ser) recordTXTrace(fullFrame []byte, payload []byte) {
command := ""
frame := ""
if len(payload) > 0 {
frame = fmt.Sprintf("0x%02x", payload[0])
command = decodeTraceCommandName(payload)
}
m.appendTrace(ProtocolTrace{
Timestamp: time.Now().UTC(),
Direction: TraceDirectionTX,
Frame: frame,
Command: command,
BytesHex: strings.ToUpper(fmt.Sprintf("%X", fullFrame)),
})
}
func (m *mk2Ser) recordRXTrace(frame []byte) {
command := ""
frameName := ""
if len(frame) > 0 {
frameName = fmt.Sprintf("0x%02x", frame[0])
command = decodeTraceCommandName(frame)
}
m.appendTrace(ProtocolTrace{
Timestamp: time.Now().UTC(),
Direction: TraceDirectionRX,
Frame: frameName,
Command: command,
BytesHex: strings.ToUpper(fmt.Sprintf("%X", frame)),
})
}
func decodeTraceCommandName(frame []byte) string {
if len(frame) == 0 {
return ""
}
switch frame[0] {
case winmonFrame:
if len(frame) < 2 {
return "winmon"
}
return fmt.Sprintf("winmon:0x%02x", frame[1])
case stateFrame:
return "panel_state"
case interfaceFrame:
return "interface"
case infoReqFrame:
return "info_request"
case ledFrame:
return "led_status"
case vFrame:
return "version"
case setTargetFrame:
return "set_target"
default:
if frame[0] == frameHeader && len(frame) > 1 && frame[1] == winmonFrame && len(frame) > 2 {
return fmt.Sprintf("winmon:0x%02x", frame[2])
}
return ""
}
}
func (m *mk2Ser) appendTrace(trace ProtocolTrace) {
m.diagMu.Lock()
defer m.diagMu.Unlock()
m.traces = append(m.traces, trace)
limit := m.traceLimit
if limit <= 0 {
limit = 200
}
if len(m.traces) > limit {
m.traces = m.traces[len(m.traces)-limit:]
}
}
func calculateDriverHealthScore(diag DriverDiagnostics) int {
score := 100
score -= int(diag.CommandTimeouts) * 5
score -= int(diag.CommandFailures) * 2
score -= int(diag.ChecksumFailures) * 3
if diag.LastFrameAt == nil {
score -= 10
} else if time.Since(diag.LastFrameAt.UTC()) > 30*time.Second {
score -= 10
}
if score < 0 {
return 0
}
if score > 100 {
return 100
}
return score
}
// Checks the frame crc.
func checkChecksum(l, t byte, d []byte) bool {
cr := (uint16(l) + uint16(t)) % 256

View File

@@ -463,6 +463,110 @@ func Test_mk2Ser_WriteRAMVarByID(t *testing.T) {
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_RegisterMetadata(t *testing.T) {
m := &mk2Ser{}
meta, ok := m.RegisterMetadata(RegisterKindRAMVar, ramVarVBat)
assert.True(t, ok)
assert.Equal(t, RegisterKindRAMVar, meta.Kind)
assert.Equal(t, uint16(ramVarVBat), meta.ID)
assert.Equal(t, "battery_voltage", meta.Name)
assert.False(t, meta.Writable)
_, ok = m.RegisterMetadata(RegisterKindSetting, 9999)
assert.False(t, ok)
all := m.ListRegisterMetadata()
assert.NotEmpty(t, all)
}
func Test_mk2Ser_WriteRegister_Verified(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 2),
winmonAck: make(chan winmonResponse, 4),
}
go func() {
time.Sleep(10 * time.Millisecond)
// Read-before-write response.
m.pushWinmonResponse(commandReadSettingResponse, []byte{0x01, 0x00})
time.Sleep(10 * time.Millisecond)
// Write acknowledgement.
m.pushWriteResponse(commandWriteViaIDResponse)
time.Sleep(10 * time.Millisecond)
// Verify-after-write response.
m.pushWinmonResponse(commandReadSettingResponse, []byte{0x34, 0x12})
}()
result, err := m.WriteRegister(RegisterKindSetting, 0x0042, 0x1234, TransactionOptions{
ReadBeforeWrite: true,
VerifyAfterWrite: true,
})
assert.NoError(t, err)
assert.Equal(t, 1, result.Attempts)
if assert.NotNil(t, result.PreviousValue) {
assert.Equal(t, int16(1), *result.PreviousValue)
}
if assert.NotNil(t, result.VerifiedValue) {
assert.Equal(t, int16(0x1234), *result.VerifiedValue)
}
expected := append([]byte{}, buildSentCommand(winmonFrame, commandReadSetting, 0x42, 0x00)...)
expected = append(expected, buildSentCommand(winmonFrame, commandWriteViaID, 0x42, 0x00, 0x34, 0x12)...)
expected = append(expected, buildSentCommand(winmonFrame, commandReadSetting, 0x42, 0x00)...)
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_WriteRegister_VerifyRetry(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 4),
winmonAck: make(chan winmonResponse, 8),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushWriteResponse(commandWriteViaIDResponse)
time.Sleep(10 * time.Millisecond)
// First verify mismatch.
m.pushWinmonResponse(commandReadSettingResponse, []byte{0x00, 0x00})
time.Sleep(10 * time.Millisecond)
m.pushWriteResponse(commandWriteViaIDResponse)
time.Sleep(10 * time.Millisecond)
// Second verify matches expected value.
m.pushWinmonResponse(commandReadSettingResponse, []byte{0x78, 0x56})
}()
result, err := m.WriteRegister(RegisterKindSetting, 0x0042, 0x5678, TransactionOptions{
Retries: 1,
RetryDelay: 1 * time.Millisecond,
VerifyAfterWrite: true,
})
assert.NoError(t, err)
assert.Equal(t, 2, result.Attempts)
if assert.NotNil(t, result.VerifiedValue) {
assert.Equal(t, int16(0x5678), *result.VerifiedValue)
}
}
func Test_mk2Ser_WriteRegister_ReadOnlyMetadata(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 1),
}
_, err := m.WriteRegister(RegisterKindRAMVar, ramVarVBat, 1, TransactionOptions{
VerifyAfterWrite: true,
})
assert.Error(t, err)
assert.ErrorContains(t, err, "read-only")
assert.Empty(t, writeBuffer.Bytes())
}
func Test_mk2Ser_GetDeviceState(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
@@ -726,6 +830,149 @@ func Test_mk2Ser_SetStandby_Disabled(t *testing.T) {
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_WriteSettingBySelection(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushWriteResponse(commandWriteSettingResponse)
}()
err := m.WriteSettingBySelection(0x0020, 0x0011)
assert.NoError(t, err)
expected := append([]byte{}, buildSentCommand(winmonFrame, commandWriteSetting, 0x20, 0x00)...)
expected = append(expected, buildSentCommand(winmonFrame, commandWriteData, 0x11, 0x00)...)
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_WriteSelectedData(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushWriteResponse(commandWriteRAMResponse)
}()
err := m.WriteSelectedData(0x0022)
assert.NoError(t, err)
expected := buildSentCommand(winmonFrame, commandWriteData, 0x22, 0x00)
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_CaptureAndDiffSnapshot(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
winmonAck: make(chan winmonResponse, 2),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushWinmonResponse(commandReadRAMResponse, []byte{0x03, 0x00})
time.Sleep(10 * time.Millisecond)
m.pushWinmonResponse(commandReadRAMResponse, []byte{0x04, 0x00})
}()
addresses := []RegisterAddress{
{Kind: RegisterKindRAMVar, ID: ramVarVirSwitchPos},
}
snapshot, err := m.CaptureSnapshot(addresses)
assert.NoError(t, err)
if assert.Len(t, snapshot.Entries, 1) {
assert.Equal(t, RegisterSafetyGuarded, snapshot.Entries[0].Safety)
assert.Equal(t, int16(3), snapshot.Entries[0].Value)
}
diffs, err := m.DiffSnapshot(snapshot)
assert.NoError(t, err)
if assert.Len(t, diffs, 1) {
assert.True(t, diffs[0].Changed)
assert.Equal(t, int16(4), diffs[0].Current)
assert.Equal(t, int16(3), diffs[0].Target)
}
}
func Test_mk2Ser_RestoreSnapshot(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
winmonAck: make(chan winmonResponse, 4),
writeAck: make(chan byte, 2),
}
go func() {
time.Sleep(10 * time.Millisecond)
// DiffSnapshot current value.
m.pushWinmonResponse(commandReadRAMResponse, []byte{0x04, 0x00})
time.Sleep(10 * time.Millisecond)
// WriteRegister read-before-write.
m.pushWinmonResponse(commandReadRAMResponse, []byte{0x04, 0x00})
time.Sleep(10 * time.Millisecond)
// Write ack.
m.pushWriteResponse(commandWriteRAMViaIDResponse)
time.Sleep(10 * time.Millisecond)
// Verify-after-write.
m.pushWinmonResponse(commandReadRAMResponse, []byte{0x03, 0x00})
}()
result, err := m.RestoreSnapshot(RegisterSnapshot{
Entries: []RegisterSnapshotEntry{
{
Kind: RegisterKindRAMVar,
ID: ramVarVirSwitchPos,
Value: 3,
Writable: true,
},
},
}, TransactionOptions{
ReadBeforeWrite: true,
VerifyAfterWrite: true,
RetryDelay: 1 * time.Millisecond,
})
assert.NoError(t, err)
assert.False(t, result.RolledBack)
if assert.Len(t, result.Applied, 1) {
if assert.NotNil(t, result.Applied[0].PreviousValue) {
assert.Equal(t, int16(4), *result.Applied[0].PreviousValue)
}
}
}
func Test_mk2Ser_DriverDiagnostics(t *testing.T) {
m := &mk2Ser{
traceLimit: 10,
traces: make([]ProtocolTrace, 0, 10),
}
m.appendTrace(ProtocolTrace{
Timestamp: time.Now().UTC(),
Direction: TraceDirectionTX,
Frame: "0x57",
Command: "winmon:0x30",
BytesHex: "AA",
})
m.noteCommandFailure(assert.AnError)
m.noteCommandTimeout(assert.AnError)
m.checksumErrors.Add(1)
m.markFrameSeen()
diag := m.DriverDiagnostics(10)
assert.NotEmpty(t, diag.Traces)
assert.Greater(t, diag.CommandFailures, uint64(0))
assert.Greater(t, diag.CommandTimeouts, uint64(0))
assert.Greater(t, diag.ChecksumFailures, uint64(0))
assert.GreaterOrEqual(t, diag.HealthScore, 0)
}
func Test_parseFrameLength(t *testing.T) {
tests := []struct {
name string

View File

@@ -151,8 +151,202 @@ type ProtocolControl interface {
ReadSelected() (int16, error)
// ReadRAMVarInfo reads RAM variable metadata via command 0x36.
ReadRAMVarInfo(id uint16) (RAMVarInfo, error)
// WriteSelectedData writes to the currently selected register via command 0x34.
WriteSelectedData(value int16) error
// WriteSettingBySelection performs 0x33 (select setting) followed by 0x34 (write data).
WriteSettingBySelection(id uint16, value int16) error
// WriteRAMVarBySelection performs 0x32 (select RAM var) followed by 0x34 (write data).
WriteRAMVarBySelection(id uint16, value int16) error
// WriteSettingByID writes a setting via command 0x37.
WriteSettingByID(id uint16, value int16) error
// WriteRAMVarByID writes a RAM variable via command 0x38.
WriteRAMVarByID(id uint16, value int16) error
}
type RegisterKind string
const (
RegisterKindSetting RegisterKind = "setting"
RegisterKindRAMVar RegisterKind = "ram_var"
)
type RegisterSafetyClass string
const (
// RegisterSafetyReadOnly indicates no write path should be exposed.
RegisterSafetyReadOnly RegisterSafetyClass = "read_only"
// RegisterSafetyOperational indicates normal runtime write usage is expected.
RegisterSafetyOperational RegisterSafetyClass = "operational"
// RegisterSafetyGuarded indicates writes should be policy-guarded.
RegisterSafetyGuarded RegisterSafetyClass = "guarded"
// RegisterSafetyCritical indicates high-impact settings that need stricter controls.
RegisterSafetyCritical RegisterSafetyClass = "critical"
)
type TimeoutClass string
const (
TimeoutClassFast TimeoutClass = "fast"
TimeoutClassStandard TimeoutClass = "standard"
TimeoutClassSlow TimeoutClass = "slow"
)
// RegisterMetadata documents known MK2 register IDs and expected value behavior.
type RegisterMetadata struct {
Kind RegisterKind
ID uint16
Name string
Description string
Unit string
Scale float64
Writable bool
Signed bool
MinValue *int16
MaxValue *int16
SafetyClass RegisterSafetyClass
}
// TransactionOptions controls retry and verification semantics for safe writes.
type TransactionOptions struct {
// Retries is the number of additional write attempts after the first try.
Retries int
// RetryDelay is slept between retries. Zero uses a sensible default.
RetryDelay time.Duration
// BackoffFactor multiplies retry delay for each additional attempt (1 disables backoff).
BackoffFactor float64
// ReadBeforeWrite captures previous value before writing when possible.
ReadBeforeWrite bool
// VerifyAfterWrite reads the register back and compares with written value.
VerifyAfterWrite bool
// TimeoutClass applies standard timeout buckets when CommandTimeout is not set.
TimeoutClass TimeoutClass
// CommandTimeout overrides timeout class for each protocol command inside the transaction.
CommandTimeout time.Duration
}
// RegisterTransactionResult reports details about a transactional register write.
type RegisterTransactionResult struct {
Kind RegisterKind
ID uint16
TargetValue int16
PreviousValue *int16
VerifiedValue *int16
Attempts int
Timeout time.Duration
Duration time.Duration
}
// MetadataControl adds register metadata and transactional safety helpers.
type MetadataControl interface {
ProtocolControl
// RegisterMetadata returns metadata for a known register.
RegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool)
// ListRegisterMetadata returns all known register metadata.
ListRegisterMetadata() []RegisterMetadata
// ReadRegister reads a setting or RAM var by kind and id.
ReadRegister(kind RegisterKind, id uint16) (int16, error)
// WriteRegister performs a safe transactional write with optional retry/verify.
WriteRegister(kind RegisterKind, id uint16, value int16, opts TransactionOptions) (RegisterTransactionResult, error)
}
type RegisterAddress struct {
Kind RegisterKind `json:"kind"`
ID uint16 `json:"id"`
}
type RegisterSnapshotEntry struct {
Kind RegisterKind `json:"kind"`
ID uint16 `json:"id"`
Name string `json:"name,omitempty"`
Value int16 `json:"value"`
Writable bool `json:"writable"`
Safety RegisterSafetyClass `json:"safety_class,omitempty"`
CapturedAt time.Time `json:"captured_at"`
}
type RegisterSnapshot struct {
CapturedAt time.Time `json:"captured_at"`
Entries []RegisterSnapshotEntry `json:"entries"`
}
type SnapshotDiff struct {
Kind RegisterKind `json:"kind"`
ID uint16 `json:"id"`
Name string `json:"name,omitempty"`
Current int16 `json:"current"`
Target int16 `json:"target"`
Changed bool `json:"changed"`
Writable bool `json:"writable"`
Safety RegisterSafetyClass `json:"safety_class,omitempty"`
DiffValue int32 `json:"diff_value"`
}
type SnapshotRestoreResult struct {
Applied []RegisterTransactionResult `json:"applied"`
RolledBack bool `json:"rolled_back"`
RollbackErrors []string `json:"rollback_errors,omitempty"`
}
// SnapshotControl provides register snapshot, diff preview, and rollback-aware restore.
type SnapshotControl interface {
MetadataControl
// CaptureSnapshot reads the provided register list. Empty addresses captures known writable registers.
CaptureSnapshot(addresses []RegisterAddress) (RegisterSnapshot, error)
// DiffSnapshot compares current values against a snapshot.
DiffSnapshot(snapshot RegisterSnapshot) ([]SnapshotDiff, error)
// RestoreSnapshot applies snapshot target values; if restore fails mid-way it attempts rollback.
RestoreSnapshot(snapshot RegisterSnapshot, opts TransactionOptions) (SnapshotRestoreResult, error)
}
type TraceDirection string
const (
TraceDirectionTX TraceDirection = "tx"
TraceDirectionRX TraceDirection = "rx"
)
type ProtocolTrace struct {
Timestamp time.Time `json:"timestamp"`
Direction TraceDirection `json:"direction"`
Frame string `json:"frame"`
Command string `json:"command,omitempty"`
BytesHex string `json:"bytes_hex"`
}
type DriverDiagnostics struct {
GeneratedAt time.Time `json:"generated_at"`
HealthScore int `json:"health_score"`
LastFrameAt *time.Time `json:"last_frame_at,omitempty"`
CommandTimeouts uint64 `json:"command_timeouts"`
CommandFailures uint64 `json:"command_failures"`
ChecksumFailures uint64 `json:"checksum_failures"`
RecentErrors []string `json:"recent_errors,omitempty"`
Traces []ProtocolTrace `json:"traces"`
}
// DiagnosticsControl exposes recent protocol traces and health information for troubleshooting bundles.
type DiagnosticsControl interface {
DriverDiagnostics(limit int) DriverDiagnostics
}
type CommandSource string
const (
CommandSourceUnknown CommandSource = "unknown"
CommandSourceUI CommandSource = "ui"
CommandSourceMQTT CommandSource = "mqtt"
CommandSourceAutomation CommandSource = "automation"
)
// SourceAwareSettingsWriter accepts source tags for arbitration and diagnostics.
type SourceAwareSettingsWriter interface {
SettingsWriter
WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error
WriteSettingWithSource(source CommandSource, id uint16, value int16) error
SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error
SetStandbyWithSource(source CommandSource, enabled bool) error
}
type CommandHistoryProvider interface {
History(limit int) []CommandEvent
}

View File

@@ -98,6 +98,18 @@ func (m *mock) WriteRAMVarByID(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteSelectedData(_ int16) error {
return nil
}
func (m *mock) WriteSettingBySelection(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteRAMVarBySelection(_ uint16, _ int16) error {
return nil
}
func (m *mock) genMockValues() {
mult := 1.0
ledState := LedOff