Files
gronod 512b0a11e6 Fix schedule command result parsing
- Added AddSched, ModSched, and DelSched to knownTD string set so schedule CRUD results do not emit unparsed diagnostic errors.
2026-09-24 14:01:02 +01:00

328 lines
9.2 KiB
Go

// Package robot owns the per-robot actor, the READY ctl fan-out, and the
// retained state and attribute documents from N95-FULL-SPECIFICATION.md §10.
package robot
import (
"context"
"fmt"
"strconv"
"strings"
"git.i3omb.com/gronod/ha-n95-local-control/internal/ctl"
)
// Facts are the retained inputs the Derive precedence table reads.
type Facts struct {
ErrorLatched bool
LastError *string
Charge string
CleanType string
Paused bool
Fan string
}
// StateDocument is the HA state payload, field order state then fan_speed.
type StateDocument struct {
State string `json:"state"`
FanSpeed string `json:"fan_speed"`
}
// LifespanTotal preserves the reported totals; their unit is unknown.
type LifespanTotal struct {
SideBrush *int `json:"side_brush"`
MainBrush *int `json:"main_brush"`
Filter *int `json:"filter"`
}
// ScheduleAction is the inner ctl of a schedule entry.
type ScheduleAction struct {
TD string `json:"td"`
Type string `json:"type"`
}
// Schedule is one §10.3 schedule entry. Phase 05 is its only writer.
type Schedule struct {
Name string `json:"name"`
On bool `json:"on"`
Time string `json:"time"`
Repeat string `json:"repeat"`
Flag string `json:"flag"`
Action ScheduleAction `json:"action"`
}
// AttributeDocument is the full §10.3 json_attributes object. Pointer fields
// publish JSON null until a report fills them.
type AttributeDocument struct {
BatteryLevel *int `json:"battery_level"`
SideBrush *int `json:"side_brush"`
MainBrush *int `json:"main_brush"`
Filter *int `json:"filter"`
LifespanTotal LifespanTotal `json:"lifespan_total"`
CleanType *string `json:"clean_type"`
ChargeState *string `json:"charge_state"`
LastError *string `json:"last_error"`
LastCommandError *string `json:"last_command_error"`
Schedules []Schedule `json:"schedules"`
}
// Snapshot is the only retained view of a robot. The actor is its sole
// mutator; RepublishFunc receives a Clone so slices and pointers cannot race.
type Snapshot struct {
Facts Facts `json:"-"`
State StateDocument `json:"-"`
Attributes AttributeDocument `json:"-"`
}
// RepublishFunc is invoked after every successful Apply with both documents
// filled. Phase 04 assigns the publishing function; nil skips IO.
type RepublishFunc func(ctx context.Context, snap Snapshot)
// ScheduleParser parses the ctl inner XML of a GetSched result or a Sched2
// push into the complete schedules array. Phase 05 assigns the hook.
type ScheduleParser func(inner []byte) ([]Schedule, error)
// LifespanData holds the parsed result of a GetLifeSpan query.
type LifespanData struct {
Target string // e.g. "side_brush", "main_brush", "filter"
Val int
Total int
}
// LifespanParser consumes the ctl attributes of a GetLifeSpan result.
// Phase 05 assigns the hook.
type LifespanParser func(td string, attrs map[string]string) (LifespanData, error)
// ParseSchedules and ParseLifespan are the phase 05 hooks. Nil means the
// corresponding result still completes its cid and leaves the fields empty.
var (
ParseSchedules ScheduleParser
ParseLifespan LifespanParser
)
// ValidationError marks a reported value outside its allowed range. Apply
// leaves the offending field untouched and returns this error so the actor
// can emit an unparsed diagnostic.
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return "invalid " + e.Field + " " + strconv.Quote(e.Value)
}
// NewSnapshot returns the initial snapshot: fan standard, HA state idle,
// schedules an empty array.
func NewSnapshot() Snapshot {
s := Snapshot{
Facts: Facts{Fan: "standard"},
Attributes: AttributeDocument{
Schedules: []Schedule{},
},
}
s.rebuild()
return s
}
// rebuild rewrites the derived fields of both documents. Stored fields such
// as battery_level, consumables, last_command_error and schedules persist.
func (s *Snapshot) rebuild() {
s.State.State = Derive(s.Facts)
s.State.FanSpeed = s.Facts.Fan
s.Attributes.CleanType = stringOrNil(s.Facts.CleanType)
s.Attributes.ChargeState = stringOrNil(s.Facts.Charge)
s.Attributes.LastError = s.Facts.LastError
}
// Apply folds one inbound payload into the snapshot and rebuilds both
// documents. An out-of-range battery or an unknown fan speed leaves that
// value untouched and returns a ValidationError. Result stanzas carry their
// command name in in.TD so a SetCleanSpeed result without a speed echo can
// still store requestedFan.
func Apply(snap *Snapshot, in ctl.Inbound, requestedFan string) error {
var firstErr error
fail := func(err error) {
if firstErr == nil {
firstErr = err
}
}
if in.Kind != ctl.KindBattery && !knownTD(in.TD) {
return &ValidationError{Field: "td", Value: in.TD}
}
if in.TD == "error" {
if in.Errno != nil && *in.Errno == "100" {
snap.Facts.ErrorLatched = false
snap.Facts.LastError = nil
} else {
snap.Facts.ErrorLatched = true
snap.Facts.LastError = in.Errno
}
}
if in.TD == "Idle" {
snap.Facts.Charge = "Idle"
}
if in.CleanAttrs != nil {
if v := in.CleanAttrs["type"]; v != "" {
snap.Facts.CleanType = v
}
if v := in.CleanAttrs["speed"]; v != "" {
if validFan(v) {
snap.Facts.Fan = v
} else {
fail(&ValidationError{Field: "clean speed", Value: v})
}
}
var keys []string
switch in.TD {
case "CleanReport":
keys = []string{"st", "rsn"}
case "GetCleanState":
keys = []string{"t", "a"}
}
var nonblank []string
for _, k := range keys {
if v := in.CleanAttrs[k]; strings.TrimSpace(v) != "" {
nonblank = append(nonblank, fmt.Sprintf("%s=%q", k, v))
}
}
if len(nonblank) > 0 {
fail(fmt.Errorf("nonblank clean %s", strings.Join(nonblank, " ")))
}
}
if in.ChargeAttrs != nil {
if v := in.ChargeAttrs["type"]; v != "" {
snap.Facts.Charge = v
}
}
if in.BatteryPower != "" {
if n, err := strconv.Atoi(in.BatteryPower); err == nil && n >= 0 && n <= 100 {
snap.Attributes.BatteryLevel = &n
} else {
fail(&ValidationError{Field: "battery power", Value: in.BatteryPower})
}
}
if in.TD == "GetCleanSpeed" {
if v := in.Attrs["speed"]; v != "" {
if validFan(v) {
snap.Facts.Fan = v
} else {
fail(&ValidationError{Field: "clean speed", Value: v})
}
}
}
if in.TD == "SetCleanSpeed" && requestedFan != "" {
if validFan(requestedFan) {
snap.Facts.Fan = requestedFan
} else {
fail(&ValidationError{Field: "clean speed", Value: requestedFan})
}
}
if (in.TD == "GetSched" || in.TD == "Sched2") && ParseSchedules != nil {
schedules, err := ParseSchedules(in.Inner)
if schedules == nil {
snap.Attributes.Schedules = []Schedule{}
} else {
snap.Attributes.Schedules = schedules
}
if err != nil {
fail(err)
}
}
if in.TD == "GetLifeSpan" && ParseLifespan != nil {
data, err := ParseLifespan(in.TD, in.Attrs)
if err != nil {
fail(err)
} else {
v := data.Val
t := data.Total
switch data.Target {
case "side_brush":
snap.Attributes.SideBrush = &v
snap.Attributes.LifespanTotal.SideBrush = &t
case "main_brush":
snap.Attributes.MainBrush = &v
snap.Attributes.LifespanTotal.MainBrush = &t
case "filter":
snap.Attributes.Filter = &v
snap.Attributes.LifespanTotal.Filter = &t
}
}
}
snap.rebuild()
return firstErr
}
// SetCommandError sets last_command_error and rebuilds both documents. It
// never touches last_error.
func (s *Snapshot) SetCommandError(value *string) {
s.Attributes.LastCommandError = value
s.rebuild()
}
// Clone returns a deep-enough copy that no slice or pointer is shared with
// the original, so a published snapshot cannot race later mutation.
func (s Snapshot) Clone() Snapshot {
c := s
c.Facts.LastError = cloneStr(s.Facts.LastError)
a := &c.Attributes
a.BatteryLevel = cloneInt(a.BatteryLevel)
a.SideBrush = cloneInt(a.SideBrush)
a.MainBrush = cloneInt(a.MainBrush)
a.Filter = cloneInt(a.Filter)
a.LifespanTotal.SideBrush = cloneInt(a.LifespanTotal.SideBrush)
a.LifespanTotal.MainBrush = cloneInt(a.LifespanTotal.MainBrush)
a.LifespanTotal.Filter = cloneInt(a.LifespanTotal.Filter)
a.CleanType = cloneStr(a.CleanType)
a.ChargeState = cloneStr(a.ChargeState)
a.LastError = cloneStr(a.LastError)
a.LastCommandError = cloneStr(a.LastCommandError)
if a.Schedules != nil {
a.Schedules = append([]Schedule{}, a.Schedules...)
}
return c
}
func validFan(v string) bool { return v == "standard" || v == "strong" }
// knownTD reports whether td is a value this phase understands: every
// outgoing command name and every supported push.
func knownTD(td string) bool {
switch td {
case "SetTime", "GetBatteryInfo", "GetCleanState", "GetChargeState",
"GetCleanSpeed", "GetSched", "GetLifeSpan",
"Clean", "Charge", "PlaySound", "SetCleanSpeed", "Move",
"Sched2", "CleanReport", "ChargeState", "BatteryInfo", "error", "Idle",
"AddSched", "ModSched", "DelSched":
return true
}
return false
}
func stringOrNil(v string) *string {
if v == "" {
return nil
}
vv := v
return &vv
}
func cloneStr(p *string) *string {
if p == nil {
return nil
}
v := *p
return &v
}
func cloneInt(p *int) *int {
if p == nil {
return nil
}
v := *p
return &v
}