- Added AddSched, ModSched, and DelSched to knownTD string set so schedule CRUD results do not emit unparsed diagnostic errors.
328 lines
9.2 KiB
Go
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
|
|
}
|