623 lines
16 KiB
Go
623 lines
16 KiB
Go
package robot
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.i3omb.com/gronod/ha-n95-local-control/internal/ctl"
|
|
"git.i3omb.com/gronod/ha-n95-local-control/internal/session"
|
|
)
|
|
|
|
// Command is one vacuum command submitted to the actor. This phase accepts
|
|
// the §11 names start, stop, return_to_base, clean_spot, locate and
|
|
// set_fan_speed; every other name is rejected without writing XMPP.
|
|
type Command struct {
|
|
Name string
|
|
Args map[string]string
|
|
Payload []byte
|
|
Reject string
|
|
}
|
|
|
|
// Clock abstracts time so tests can advance the correlation deadline without
|
|
// sleeping.
|
|
type Clock interface {
|
|
Now() time.Time
|
|
After(d time.Duration) <-chan time.Time
|
|
}
|
|
|
|
type realClock struct{}
|
|
|
|
func (realClock) Now() time.Time { return time.Now() }
|
|
func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
|
|
|
|
var (
|
|
errOffline = errors.New("offline")
|
|
errConnectionLost = errors.New("connection-lost")
|
|
)
|
|
|
|
type evReady struct{ e session.ReadyEvent }
|
|
type evAnnounce struct{ e session.ReadyEvent }
|
|
type evDown struct{ e session.DownEvent }
|
|
type evStanza struct{ e session.StanzaEvent }
|
|
type evSubmit struct {
|
|
cmd Command
|
|
reply chan error
|
|
}
|
|
|
|
type evSetRepublish struct{ fn RepublishFunc }
|
|
type evSetSendCommand struct{ fn SendCommandFunc }
|
|
|
|
// evBarrier is a test synchronization point: handling it closes the channel
|
|
// once every event queued before it has been processed.
|
|
type evBarrier chan struct{}
|
|
|
|
// Actor serializes one robot's commands, ctl correlations and state mutation.
|
|
// It implements session.Observer; observer calls enqueue on a mailbox
|
|
// buffered to 64 and processed by a single goroutine. Stanza delivery may
|
|
// block: stanza order is the point of the actor (§14).
|
|
type Actor struct {
|
|
ctx context.Context
|
|
jid string
|
|
serial string
|
|
controllerJID string
|
|
republish RepublishFunc
|
|
trace ctl.TraceSink
|
|
diag session.DiagnosticSink
|
|
clock Clock
|
|
mailbox chan any
|
|
|
|
// Fields below are owned by the actor goroutine.
|
|
snap Snapshot
|
|
corr *ctl.Correlator
|
|
gen uint64
|
|
send func([]byte) error
|
|
requestedFan map[string]string
|
|
sendCommandFn SendCommandFunc
|
|
}
|
|
|
|
// NewActor starts the actor goroutine. A nil clock uses real time.
|
|
func NewActor(ctx context.Context, jid, serial, controllerJID string, republish RepublishFunc, trace ctl.TraceSink, diag session.DiagnosticSink, clock Clock) *Actor {
|
|
if clock == nil {
|
|
clock = realClock{}
|
|
}
|
|
a := &Actor{
|
|
ctx: ctx,
|
|
jid: jid,
|
|
serial: serial,
|
|
controllerJID: controllerJID,
|
|
republish: republish,
|
|
trace: trace,
|
|
diag: diag,
|
|
clock: clock,
|
|
mailbox: make(chan any, 64),
|
|
snap: NewSnapshot(),
|
|
requestedFan: map[string]string{},
|
|
}
|
|
a.corr = ctl.NewCorrelator(clock.Now)
|
|
go a.run()
|
|
return a
|
|
}
|
|
|
|
func (a *Actor) SessionReady(e session.ReadyEvent) {
|
|
if e.JID == a.jid {
|
|
a.enqueue(evReady{e})
|
|
}
|
|
}
|
|
|
|
func (a *Actor) AnnounceOK(e session.ReadyEvent) {
|
|
if e.JID == a.jid {
|
|
a.enqueue(evAnnounce{e})
|
|
}
|
|
}
|
|
|
|
func (a *Actor) SessionDown(e session.DownEvent) {
|
|
if e.JID == a.jid {
|
|
a.enqueue(evDown{e})
|
|
}
|
|
}
|
|
|
|
func (a *Actor) Stanza(e session.StanzaEvent) {
|
|
if e.JID == a.jid {
|
|
a.enqueue(evStanza{e})
|
|
}
|
|
}
|
|
|
|
func (a *Actor) enqueue(ev any) {
|
|
select {
|
|
case a.mailbox <- ev:
|
|
case <-a.ctx.Done():
|
|
}
|
|
}
|
|
|
|
// Submit only enqueues work; the actor goroutine remains the sole Snapshot
|
|
// mutator and the only RepublishFunc caller. Callers — including the phase 04
|
|
// MQTT callback — must never invoke RepublishFunc themselves, so a publish
|
|
// cannot deadlock the callback. Submit returns once the send attempt has run:
|
|
// nil on a successful write, or the exact string offline, connection-lost or
|
|
// rejected:<name>.
|
|
func (a *Actor) Submit(ctx context.Context, command Command) error {
|
|
reply := make(chan error, 1)
|
|
select {
|
|
case a.mailbox <- evSubmit{cmd: command, reply: reply}:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-a.ctx.Done():
|
|
return errConnectionLost
|
|
}
|
|
select {
|
|
case err := <-reply:
|
|
return err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-a.ctx.Done():
|
|
return errConnectionLost
|
|
}
|
|
}
|
|
|
|
func (a *Actor) run() {
|
|
var expire <-chan time.Time
|
|
arm := func() {
|
|
wait := ctl.CommandTimeout
|
|
if deadline, ok := a.corr.NextDeadline(); ok {
|
|
if wait = deadline.Sub(a.clock.Now()); wait < 0 {
|
|
wait = 0
|
|
}
|
|
}
|
|
expire = a.clock.After(wait)
|
|
}
|
|
arm()
|
|
for {
|
|
select {
|
|
case <-a.ctx.Done():
|
|
return
|
|
case ev := <-a.mailbox:
|
|
a.handle(ev)
|
|
arm()
|
|
case now := <-expire:
|
|
for _, tr := range a.corr.Expire(now) {
|
|
a.handleTrace(tr)
|
|
}
|
|
arm()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Actor) handle(ev any) {
|
|
switch e := ev.(type) {
|
|
case evReady:
|
|
a.gen = e.e.Generation
|
|
a.send = e.e.Send
|
|
a.serial = e.e.Serial
|
|
case evAnnounce:
|
|
a.onAnnounce(e.e)
|
|
case evDown:
|
|
a.onDown(e.e)
|
|
case evStanza:
|
|
a.onStanza(e.e)
|
|
case evSubmit:
|
|
a.onSubmit(e)
|
|
case evSetRepublish:
|
|
a.republish = e.fn
|
|
case evSetSendCommand:
|
|
a.sendCommandFn = e.fn
|
|
case evBarrier:
|
|
close(e)
|
|
}
|
|
}
|
|
|
|
// onAnnounce runs the §8 fan-out for the current generation only. Results are
|
|
// not awaited between sends; correlations complete as stanzas arrive.
|
|
func (a *Actor) onAnnounce(e session.ReadyEvent) {
|
|
if e.Generation != a.gen || a.send == nil {
|
|
return
|
|
}
|
|
for _, item := range readySequence(a.clock.Now()) {
|
|
_ = a.sendCommand(item.out, item.name, "")
|
|
}
|
|
}
|
|
|
|
// onDown ends every outstanding correlation for the current generation as
|
|
// connection-lost, clears the generation, drains pending command submissions
|
|
// the same way, and leaves the snapshot in place. A down event for an older
|
|
// generation is ignored.
|
|
func (a *Actor) onDown(e session.DownEvent) {
|
|
if e.Generation != a.gen {
|
|
return
|
|
}
|
|
traces := a.corr.FailGeneration("connection-lost")
|
|
a.gen = 0
|
|
a.send = nil
|
|
a.requestedFan = map[string]string{}
|
|
for _, tr := range traces {
|
|
a.handleTrace(tr)
|
|
}
|
|
for {
|
|
select {
|
|
case ev := <-a.mailbox:
|
|
if s, ok := ev.(evSubmit); ok {
|
|
a.failSubmit(s, errConnectionLost)
|
|
} else {
|
|
a.handle(ev)
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Actor) onStanza(e session.StanzaEvent) {
|
|
if e.Generation != a.gen {
|
|
return
|
|
}
|
|
in, err := ctl.Parse(e.Stanza)
|
|
if err != nil {
|
|
a.diagnose(e, "malformed stanza: "+err.Error())
|
|
return
|
|
}
|
|
switch in.Kind {
|
|
case ctl.KindAck:
|
|
tr, ok := a.corr.CompleteSID(in.SID)
|
|
if !ok {
|
|
a.diagnose(e, "iq result with no outstanding stanza id")
|
|
return
|
|
}
|
|
a.handleTrace(tr)
|
|
case ctl.KindResult:
|
|
tr, ok := a.corr.CompleteCID(in.CID)
|
|
if !ok {
|
|
// Unknown or already completed cid: ignored, no state change.
|
|
return
|
|
}
|
|
tr.Ret = in.Ret
|
|
tr.Errno = in.Errno
|
|
// Results carry no td; reuse the registered command name so Apply can
|
|
// route payloads such as GetLifeSpan and SetCleanSpeed.
|
|
in.TD = tr.Command
|
|
fan := a.requestedFan[in.CID]
|
|
delete(a.requestedFan, in.CID)
|
|
a.emitTrace(tr)
|
|
var applyErr error
|
|
if in.Ret == "ok" {
|
|
applyErr = Apply(&a.snap, in, fan)
|
|
}
|
|
a.snap.SetCommandError(commandErrorFor(tr))
|
|
if applyErr != nil {
|
|
a.diagnose(e, applyErr.Error())
|
|
}
|
|
a.publish()
|
|
case ctl.KindPush, ctl.KindBattery:
|
|
// Unsolicited pushes and bare battery are never acked (§6.3).
|
|
before := a.snap.Clone()
|
|
err := Apply(&a.snap, in, "")
|
|
if err != nil {
|
|
a.diagnose(e, err.Error())
|
|
}
|
|
if err == nil || !reflect.DeepEqual(before, a.snap) {
|
|
a.publish()
|
|
}
|
|
default:
|
|
a.diagnose(e, "unparsed stanza")
|
|
}
|
|
}
|
|
|
|
func (a *Actor) onSubmit(s evSubmit) {
|
|
if a.gen == 0 || a.send == nil {
|
|
a.failSubmit(s, errOffline)
|
|
return
|
|
}
|
|
if s.cmd.Reject != "" {
|
|
a.failSubmit(s, errors.New("rejected:"+s.cmd.Reject))
|
|
return
|
|
}
|
|
if s.cmd.Name == "send_command" {
|
|
if a.sendCommandFn == nil {
|
|
a.failSubmit(s, errors.New("rejected:unsupported"))
|
|
return
|
|
}
|
|
cmd, err := a.sendCommandFn(a.ctx, a.serial, s.cmd.Payload)
|
|
if err != nil {
|
|
a.failSubmit(s, err)
|
|
return
|
|
}
|
|
s.cmd = cmd
|
|
}
|
|
out, name, fan, ok := commandStanza(s.cmd, a.snap.Facts.Fan, a.clock.Now())
|
|
if !ok {
|
|
a.failSubmit(s, errors.New("rejected:"+s.cmd.Name))
|
|
return
|
|
}
|
|
if err := a.sendCommand(out, name, fan); err != nil {
|
|
s.reply <- errConnectionLost
|
|
return
|
|
}
|
|
s.reply <- nil
|
|
}
|
|
|
|
func (a *Actor) failSubmit(s evSubmit, err error) {
|
|
v := err.Error()
|
|
a.snap.SetCommandError(&v)
|
|
a.publish()
|
|
s.reply <- err
|
|
}
|
|
|
|
// sendCommand writes one ctl envelope and registers its correlation. A failed
|
|
// write fails the command locally as connection-lost and does not register a
|
|
// waiter.
|
|
func (a *Actor) sendCommand(out ctl.Outbound, name, fan string) error {
|
|
stanza, sid, cid, err := ctl.Envelope(a.controllerJID, a.jid, out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.send(stanza); err != nil {
|
|
phase := "result"
|
|
if out.OmitCtlID {
|
|
phase = "ack"
|
|
}
|
|
a.handleTrace(ctl.Trace{
|
|
SID: sid,
|
|
CID: cid,
|
|
Command: name,
|
|
Phase: phase,
|
|
Ret: "connection-lost",
|
|
At: a.clock.Now(),
|
|
})
|
|
return err
|
|
}
|
|
a.corr.Register(sid, cid, name, !out.OmitCtlID)
|
|
if fan != "" {
|
|
a.requestedFan[cid] = fan
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *Actor) handleTrace(tr ctl.Trace) {
|
|
a.emitTrace(tr)
|
|
if tr.CID != "" && (tr.Ret == "timeout" || tr.Ret == "connection-lost") {
|
|
delete(a.requestedFan, tr.CID)
|
|
}
|
|
a.snap.SetCommandError(commandErrorFor(tr))
|
|
a.publish()
|
|
}
|
|
|
|
func (a *Actor) emitTrace(tr ctl.Trace) {
|
|
if a.trace != nil {
|
|
a.trace(a.serial, tr)
|
|
}
|
|
}
|
|
|
|
func (a *Actor) publish() {
|
|
if a.republish != nil {
|
|
a.republish(a.ctx, a.snap.Clone())
|
|
}
|
|
}
|
|
|
|
func (a *Actor) diagnose(e session.StanzaEvent, reason string) {
|
|
if a.diag == nil {
|
|
return
|
|
}
|
|
a.diag(session.Diagnostic{
|
|
Serial: a.serial,
|
|
Direction: session.DirectionIn,
|
|
Kind: "unparsed",
|
|
Generation: e.Generation,
|
|
XML: e.Stanza,
|
|
Reason: reason,
|
|
})
|
|
}
|
|
|
|
// commandErrorFor maps a trace to last_command_error: an ack or ret ok
|
|
// clears, ret fail stores fail[:errno], and failure reasons such as timeout
|
|
// and connection-lost are stored verbatim.
|
|
func commandErrorFor(tr ctl.Trace) *string {
|
|
switch tr.Ret {
|
|
case "", "ok":
|
|
return nil
|
|
case "fail":
|
|
if tr.Errno != nil && *tr.Errno != "" {
|
|
v := "fail:" + *tr.Errno
|
|
return &v
|
|
}
|
|
v := "fail"
|
|
return &v
|
|
default:
|
|
v := tr.Ret
|
|
return &v
|
|
}
|
|
}
|
|
|
|
// commandStanza maps a submitted command name to its §11 ctl body. It returns
|
|
// the correlator name — the ctl td — and, for SetCleanSpeed, the requested fan
|
|
// stored against the cid.
|
|
func commandStanza(cmd Command, fan string, now time.Time) (out ctl.Outbound, name, requestedFan string, ok bool) {
|
|
switch cmd.Name {
|
|
case "start":
|
|
return cleanOutbound("auto", "s", fan), "Clean", "", true
|
|
case "stop":
|
|
return cleanOutbound("stop", "h", fan), "Clean", "", true
|
|
case "clean_spot":
|
|
return cleanOutbound("spot", "s", fan), "Clean", "", true
|
|
case "return_to_base":
|
|
return chargeOutbound("go"), "Charge", "", true
|
|
case "locate":
|
|
return playSoundOutbound(), "PlaySound", "", true
|
|
case "set_fan_speed":
|
|
speed := cmd.Args["speed"]
|
|
if !validFan(speed) {
|
|
return ctl.Outbound{}, "", "", false
|
|
}
|
|
return setCleanSpeedOutbound(speed), "SetCleanSpeed", speed, true
|
|
case "clean":
|
|
return cleanOutbound(cmd.Args["type"], "s", fan), "Clean", "", true
|
|
case "move":
|
|
return moveOutbound(cmd.Args["action"]), "Move", "", true
|
|
case "cancel_return":
|
|
return chargeOutbound("stopGo"), "Charge", "", true
|
|
case "set_time":
|
|
return setTimeOutbound(now), "SetTime", "", true
|
|
case "get_status":
|
|
return ctl.Outbound{}, "GetStatus", "", true
|
|
case "get_lifespan":
|
|
return ctl.Outbound{}, "GetLifeSpan", "", true
|
|
case "add_sched":
|
|
return ctl.AddSchedOutbound(cmd.Args["name"], cmd.Args["on"], cmd.Args["time"], cmd.Args["repeat"]), "AddSched", "", true
|
|
case "mod_sched":
|
|
return ctl.ModSchedOutbound(cmd.Args["name"], cmd.Args["on"], cmd.Args["time"], cmd.Args["repeat"]), "ModSched", "", true
|
|
case "del_sched":
|
|
return ctl.DelSchedOutbound(cmd.Args["name"]), "DelSched", "", true
|
|
case "get_sched":
|
|
return ctl.GetSchedOutbound(), "GetSched", "", true
|
|
case "raw":
|
|
out := ctl.Outbound{
|
|
TD: cmd.Args["td"],
|
|
Inner: cmd.Payload,
|
|
OmitCtlID: cmd.Args["id"] == "",
|
|
}
|
|
return out, cmd.Args["td"], "", true
|
|
}
|
|
return ctl.Outbound{}, "", "", false
|
|
}
|
|
|
|
func setTimeOutbound(now time.Time) ctl.Outbound {
|
|
_, offset := now.Zone()
|
|
hours := offset / 3600
|
|
minutes := (offset % 3600) / 60
|
|
return ctl.Outbound{
|
|
TD: "SetTime",
|
|
Inner: []byte(fmt.Sprintf(`<time t="%d" tz="%d" tzm="%d"/>`, now.Unix(), hours, minutes)),
|
|
}
|
|
}
|
|
|
|
// RepublishFactory builds a per-serial republish hook; the fleet invokes it
|
|
// when creating an actor on first SessionReady.
|
|
type RepublishFactory func(serial string) RepublishFunc
|
|
|
|
// SendCommandFunc executes a send_command payload; an error fails
|
|
// the command.
|
|
type SendCommandFunc func(ctx context.Context, serial string, payload []byte) (Command, error)
|
|
|
|
// Fleet is the session.Observer that owns one Actor per full bot JID. It
|
|
// creates the actor on the first SessionReady and forwards every later event
|
|
// for that JID to it.
|
|
type Fleet struct {
|
|
ctx context.Context
|
|
controllerJID string
|
|
republish RepublishFunc
|
|
trace ctl.TraceSink
|
|
diag session.DiagnosticSink
|
|
clock Clock
|
|
|
|
mu sync.Mutex
|
|
actors map[string]*Actor
|
|
actorsBySerial map[string]*Actor
|
|
republishFactory RepublishFactory
|
|
sendCommandFn SendCommandFunc
|
|
}
|
|
|
|
func NewFleet(ctx context.Context, controllerJID string, republish RepublishFunc, trace ctl.TraceSink, diag session.DiagnosticSink, clock Clock) *Fleet {
|
|
return &Fleet{
|
|
ctx: ctx,
|
|
controllerJID: controllerJID,
|
|
republish: republish,
|
|
trace: trace,
|
|
diag: diag,
|
|
clock: clock,
|
|
actors: map[string]*Actor{},
|
|
actorsBySerial: map[string]*Actor{},
|
|
}
|
|
}
|
|
|
|
// Actor returns the actor for jid, if one has been created.
|
|
func (f *Fleet) Actor(jid string) (*Actor, bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
a, ok := f.actors[jid]
|
|
return a, ok
|
|
}
|
|
|
|
// SetRepublishFactory installs the per-serial republish factory before or
|
|
// after events; existing actors receive it through their mailbox.
|
|
func (f *Fleet) SetRepublishFactory(factory RepublishFactory) {
|
|
f.mu.Lock()
|
|
f.republishFactory = factory
|
|
list := make([]*Actor, 0, len(f.actorsBySerial))
|
|
serials := make([]string, 0, len(f.actorsBySerial))
|
|
for serial, a := range f.actorsBySerial {
|
|
list = append(list, a)
|
|
serials = append(serials, serial)
|
|
}
|
|
f.mu.Unlock()
|
|
for i, a := range list {
|
|
var rf RepublishFunc
|
|
if factory != nil {
|
|
rf = factory(serials[i])
|
|
}
|
|
a.enqueue(evSetRepublish{rf})
|
|
}
|
|
}
|
|
|
|
// SetSendCommandFunc installs the send_command hook on the fleet and every
|
|
// existing actor.
|
|
func (f *Fleet) SetSendCommandFunc(fn SendCommandFunc) {
|
|
f.mu.Lock()
|
|
f.sendCommandFn = fn
|
|
list := make([]*Actor, 0, len(f.actorsBySerial))
|
|
for _, a := range f.actorsBySerial {
|
|
list = append(list, a)
|
|
}
|
|
f.mu.Unlock()
|
|
for _, a := range list {
|
|
a.enqueue(evSetSendCommand{fn})
|
|
}
|
|
}
|
|
|
|
// Submit routes a command to the actor for serial; an unknown serial is
|
|
// offline.
|
|
func (f *Fleet) Submit(ctx context.Context, serial string, command Command) error {
|
|
f.mu.Lock()
|
|
a := f.actorsBySerial[serial]
|
|
f.mu.Unlock()
|
|
if a == nil {
|
|
return errOffline
|
|
}
|
|
return a.Submit(ctx, command)
|
|
}
|
|
|
|
func (f *Fleet) SessionReady(e session.ReadyEvent) {
|
|
f.mu.Lock()
|
|
a := f.actors[e.JID]
|
|
if a == nil {
|
|
republish := f.republish
|
|
if f.republishFactory != nil {
|
|
republish = f.republishFactory(e.Serial)
|
|
}
|
|
a = NewActor(f.ctx, e.JID, e.Serial, f.controllerJID, republish, f.trace, f.diag, f.clock)
|
|
f.actors[e.JID] = a
|
|
f.actorsBySerial[e.Serial] = a
|
|
a.enqueue(evSetSendCommand{f.sendCommandFn})
|
|
}
|
|
f.mu.Unlock()
|
|
a.SessionReady(e)
|
|
}
|
|
|
|
func (f *Fleet) AnnounceOK(e session.ReadyEvent) {
|
|
if a, ok := f.Actor(e.JID); ok {
|
|
a.AnnounceOK(e)
|
|
}
|
|
}
|
|
|
|
func (f *Fleet) SessionDown(e session.DownEvent) {
|
|
if a, ok := f.Actor(e.JID); ok {
|
|
a.SessionDown(e)
|
|
}
|
|
}
|
|
|
|
func (f *Fleet) Stanza(e session.StanzaEvent) {
|
|
if a, ok := f.Actor(e.JID); ok {
|
|
a.Stanza(e)
|
|
}
|
|
}
|