Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/driver-poll-return-interval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

Honour the interval a driver returns from `driver_poll`, as the driver contract says. Core ignored it before and polled every five seconds. The returned wait now sets the next poll, between one second and thirty seconds. A driver that calls `host.set_poll_interval` keeps that interval, as before, whatever it returns.

Bundled drivers that return one second now read once a second: ambibox_v2x, ferroamp, ferroamp_dc2_v2x, pixii_pv, sdm630, sonnen and zap. myuplink reads every thirty seconds instead of every five. easee_cloud, tesla_wall_connector and zaptec_cloud wait ten seconds after a failed read, and solaredge_legacy thirty seconds after a failed SunSpec probe.
63 changes: 59 additions & 4 deletions go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,13 @@ type HostEnv struct {
BatteryTelemetryOnly bool

mu sync.Mutex
// Desired poll interval — driver can set via host.set_poll_interval OR
// return it from driver_poll. We persist the last hint here.
PollIntervalMS int32
// Standing poll interval, 5 s until the driver calls
// host.set_poll_interval. Once it has, that interval wins over anything
// driver_poll returns.
PollIntervalMS int32
pollIntervalSet bool
// scheduledPoll is the wait the run loop armed last, standing or returned.
scheduledPoll time.Duration
// Identity set by driver / capability layer.
// Make + SN are reported via host.set_make / host.set_sn.
// Endpoint is the protocol+host+port string set by the registry when
Expand Down Expand Up @@ -546,6 +550,7 @@ func (h *HostEnv) log(level int32, msg string) {
func (h *HostEnv) setPollInterval(ms int32) {
h.mu.Lock()
h.PollIntervalMS = ms
h.pollIntervalSet = true
h.mu.Unlock()
}

Expand Down Expand Up @@ -798,16 +803,66 @@ func (h *HostEnv) stampIdentity(raw []byte) []byte {
return out
}

// PollInterval returns the driver's current requested poll cadence.
// PollInterval returns the driver's standing poll cadence.
func (h *HostEnv) PollInterval() time.Duration {
h.mu.Lock()
defer h.mu.Unlock()
return h.standingPollIntervalLocked()
}

func (h *HostEnv) standingPollIntervalLocked() time.Duration {
if h.PollIntervalMS <= 0 {
return 5 * time.Second
}
return time.Duration(h.PollIntervalMS) * time.Millisecond
}

// Bounds for an interval a driver returns from driver_poll. Lane 0 frames
// leave once a second and the control loop ticks every two seconds by
// default, so a faster read reaches nothing but the device's bus; some
// catalog drivers return 200 ms or 50 ms. The ceiling is half the default
// 60 s watchdog window, so a returned wait cannot make a driver stale by
// itself: myuplink returns 60 s after a good read. A driver that needs longer
// sets it with host.set_poll_interval, as the vehicle drivers do.
const (
minReturnedPollInterval = time.Second
maxReturnedPollInterval = 30 * time.Second

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap returned waits below the configured watchdog

When site.watchdog_timeout_s is configured below 30 seconds—config.go accepts any nonnegative value—a driver that emits successfully and then returns a longer interval is now left idle past its watchdog deadline. For example, a 10-second watchdog with a 30-second returned wait makes WatchdogScan repeatedly mark the driver offline and send its autonomous default between otherwise successful polls; the previous five-second cadence did not. Derive this cap from the effective site/per-driver watchdog or otherwise ensure the scheduled wait remains below it.

AGENTS.md reference: AGENTS.md:L66-L67

Useful? React with 👍 / 👎.

)

// returnedPollInterval converts milliseconds returned by driver_poll. It caps
// before converting: a Lua number can exceed the range of time.Duration.
func returnedPollInterval(ms float64) time.Duration {
ms = math.Min(ms, float64(maxReturnedPollInterval/time.Millisecond))
return time.Duration(ms) * time.Millisecond
}

// nextPollInterval picks the wait before the next driver_poll. An interval
// set with host.set_poll_interval wins, unbounded, as it always has. A driver
// that never set one gets the positive interval its last poll returned,
// within bounds, or the 5 s default.
func (h *HostEnv) nextPollInterval(returned time.Duration) time.Duration {
h.mu.Lock()
defer h.mu.Unlock()
next := h.standingPollIntervalLocked()
if !h.pollIntervalSet && returned > 0 {
next = min(max(returned, minReturnedPollInterval), maxReturnedPollInterval)
}
h.scheduledPoll = next
return next
}

// ScheduledPollInterval returns the wait the run loop armed last, or the
// standing interval before the first poll has run.
func (h *HostEnv) ScheduledPollInterval() time.Duration {
h.mu.Lock()
scheduled := h.scheduledPoll
h.mu.Unlock()
if scheduled > 0 {
return scheduled
}
return h.PollInterval()
}

// Identity returns (make, serial) set by the driver.
func (h *HostEnv) Identity() (make, sn string) {
h.mu.Lock()
Expand Down
2 changes: 1 addition & 1 deletion go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ func (d *LuaDriver) pollLocked(ctx context.Context) (time.Duration, error) {
}
// Driver may return an int number of milliseconds.
if n, ok := ret.(lua.LNumber); ok && n > 0 {
return time.Duration(n) * time.Millisecond, nil
return returnedPollInterval(float64(n)), nil
}
return 0, nil
}
Expand Down
142 changes: 142 additions & 0 deletions go/internal/drivers/poll_interval_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package drivers

import (
"context"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/telemetry"
)

func TestNextPollIntervalHonoursTheReturnUnlessSetPollIntervalWasCalled(t *testing.T) {
cases := []struct {
name string
standingMS int32 // 0: the driver never called host.set_poll_interval
returned time.Duration
want time.Duration
}{
{"nothing returned uses the default", 0, 0, 5 * time.Second},
{"a returned wait sets the next poll", 0, time.Second, time.Second},
{"a fast return is held to one second", 0, 200 * time.Millisecond, time.Second},
{"a long return is held to thirty seconds", 0, time.Minute, 30 * time.Second},
{"set_poll_interval wins over a shorter return", 2000, time.Second, 2 * time.Second},
{"set_poll_interval wins over a longer return", 2000, time.Minute, 2 * time.Second},
{"set_poll_interval is not bounded", 10, 0, 10 * time.Millisecond},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env := NewHostEnv("probe", nil)
if tc.standingMS > 0 {
env.setPollInterval(tc.standingMS)
}
if got := env.nextPollInterval(tc.returned); got != tc.want {
t.Fatalf("next wait = %v, want %v", got, tc.want)
}
if got := env.ScheduledPollInterval(); got != tc.want {
t.Fatalf("scheduled wait = %v, want %v", got, tc.want)
}
})
}
}

func TestReturnedPollIntervalCapsNumbersBeyondDuration(t *testing.T) {
for _, ms := range []float64{1e16, 1e300} {
if got := returnedPollInterval(ms); got != 30*time.Second {
t.Fatalf("returnedPollInterval(%g) = %v, want 30s", ms, got)
}
}
}

// returnedRuntime returns a fixed interval from every poll and reports when
// each poll ran.
type returnedRuntime struct {
env *HostEnv
returned time.Duration
polls chan time.Time
}

func (r *returnedRuntime) Init(context.Context, []byte) error { return nil }
func (r *returnedRuntime) Poll(context.Context) (time.Duration, error) {
r.polls <- time.Now()
return r.returned, nil
}
func (r *returnedRuntime) Command(context.Context, []byte) error { return nil }
func (r *returnedRuntime) DefaultMode(context.Context) error { return nil }
func (r *returnedRuntime) Cleanup(context.Context) error { return nil }
func (r *returnedRuntime) Discard() {}
func (r *returnedRuntime) Env() *HostEnv { return r.env }

// A driver that only returns its interval, as sungrow.lua does, is polled at
// that interval rather than the 5 s default.
func TestRunLoopWaitsTheIntervalThePollReturned(t *testing.T) {
tel := telemetry.NewStore()
r := NewRegistry(tel)
env := NewHostEnv("d1", tel)
// Bring the first poll forward without marking the interval as set by
// the driver; the default would otherwise delay it five seconds.
env.PollIntervalMS = 10
rt := &returnedRuntime{env: env, returned: time.Second, polls: make(chan time.Time, 8)}
rd := &runningDriver{
driver: rt,
env: env,
cfg: config.Driver{Name: "d1"},
cmdCh: make(chan driverCmd, 1),
defaultCh: make(chan driverCmd, 1),
stop: make(chan bool, 1),
done: make(chan struct{}),
}
r.rec["d1"] = rd
go r.runLoop(rd)
t.Cleanup(func() { r.remove("d1", true) })

next := func() time.Time {
t.Helper()
select {
case at := <-rt.polls:
return at
case <-time.After(3 * time.Second):
t.Fatal("no poll within 3 s")
return time.Time{}
}
}
first := next()
second := next()
if gap := second.Sub(first); gap < 950*time.Millisecond || gap > 1500*time.Millisecond {
t.Fatalf("second poll %v after the first, want the returned second", gap)
}
if got, _ := r.PollInterval("d1"); got != time.Second {
t.Fatalf("PollInterval = %v, want the returned 1s", got)
}
}

// A driver that set its own cadence keeps it, whatever driver_poll returns.
func TestRunLoopKeepsSetPollIntervalOverTheReturn(t *testing.T) {
path := writeTestDriver(t, `
local polls = 0
function driver_init(config) host.set_poll_interval(10) end
function driver_poll()
polls = polls + 1
host.emit_metric("polls", polls)
return 60000
end
function driver_command() end
function driver_default_mode() end
`)
tel := telemetry.NewStore()
reg := NewRegistry(tel)
if err := reg.Add(context.Background(), config.Driver{Name: "standing", Lua: path}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { reg.remove("standing", true) })

deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if value, _, _ := tel.LatestMetric("standing", "polls"); value >= 3 {
return
}
time.Sleep(5 * time.Millisecond)
}
value, _, _ := tel.LatestMetric("standing", "polls")
t.Fatalf("polled %v times in 2 s at a 10 ms set_poll_interval", value)
}
10 changes: 5 additions & 5 deletions go/internal/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,7 @@ func (r *Registry) runLoop(rd *runningDriver) {
// Register the poll as the active Lua call so SendDefault can
// cancel it the same way it cancels an in-flight command.
pollCtx, finishPoll := rd.beginCommand(ctx)
_, err := rd.driver.Poll(pollCtx)
returned, err := rd.driver.Poll(pollCtx)
finishPoll()
if err != nil {
slog.Warn("driver poll failed", "name", rd.cfg.Name, "err", err)
Expand All @@ -1073,8 +1073,8 @@ func (r *Registry) runLoop(rd *runningDriver) {
// re-stamps LastSuccess every tick from cached values.
r.tel.RecordDriverTick(rd.cfg.Name)
}
// Re-arm timer at driver's requested interval
interval = rd.env.PollInterval()
// Re-arm at the interval the poll returned, or the standing one.
interval = rd.env.nextPollInterval(returned)
timer.Reset(interval)
case <-recoveryC:
if err := attemptDefault(); err != nil {
Expand Down Expand Up @@ -1468,7 +1468,7 @@ func replacementContext(ctx context.Context) context.Context {
return context.WithoutCancel(ctx)
}

// PollInterval returns the currently requested cadence for a running driver.
// PollInterval returns the cadence a running driver is polled at now.
// Repository activation uses it to wait for two fresh cycles before declaring
// a new artifact healthy.
func (r *Registry) PollInterval(name string) (time.Duration, bool) {
Expand All @@ -1478,7 +1478,7 @@ func (r *Registry) PollInterval(name string) (time.Duration, bool) {
if !ok {
return 0, false
}
return rd.env.PollInterval(), true
return rd.env.ScheduledPollInterval(), true
}

// tcpAllowedHostsFor builds the effective allowlist for a TCP-capable
Expand Down
Loading