v0.342.0 (unreleased): the upload watch reads the host's weekly line test (GET /host/line-test, agent >= 0.164.0); qBittorrent's limit = 50 % of the TESTED line only (D41), the watch uses the higher of test and push floor, the system page names the source; red-proved
gates / gates (push) Successful in 1m15s

This commit is contained in:
2026-10-24 08:59:57 +02:00
parent 60e487db51
commit ed1011b088
19 changed files with 300 additions and 19 deletions
+2 -1
View File
@@ -19,7 +19,8 @@ import (
//
// The value compared is the one qBittorrent STORED (it keeps KiB/s: 2 500 000 reads back as 2 499 584, measured), so
// the box's own rounding is never mistaken for a household's change. The limit survives a qBittorrent restart
// (measured: `Session\GlobalUPSpeedLimit` in its config). No line measured yet → nothing is set.
// (measured: `Session\GlobalUPSpeedLimit` in its config). The line is the host's weekly line TEST (the agent's
// GET /host/line-test, R-1247), never the off-site push's floor; no test yet → nothing is set (Service.QBLimitNow).
//
// Edge: an app reinstalled from scratch starts unlimited while the record still says „set by the box"; that reads as
// the household removing the limit and is left alone. Recorded in the design (`08` §9.7), not guarded.
+60 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"log"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
@@ -39,12 +40,61 @@ type Service struct {
QBRunning func() bool
// QBShare is the share of the measured line qBittorrent may upload at (operator decide row; CC's pick 0.5).
QBShare float64
Log *log.Logger
// Measured reads the host's weekly line test (the agent's GET /host/line-test, R-1247): a 10-second iperf3 upload
// to ep0 inside the tunnel. nil or ok=false = not measured. Read every 15 minutes (RefreshMeasured), never per
// minute.
Measured func(ctx context.Context) (mbps float64, at time.Time, ok bool)
Log *log.Logger
mu sync.Mutex
measured float64
measuredAt time.Time
measuredOK bool
}
// Line is the measured upload line in Mbit/s (a lower bound); ok=false while no push counted.
// RefreshMeasured reads the agent's line test once. A failed read keeps the last value (the agent persists it; an
// agent restart must not drop qBittorrent's limit to „unknown").
func (s *Service) RefreshMeasured(ctx context.Context) {
if s.Measured == nil {
return
}
mbps, at, ok := s.Measured(ctx)
if !ok || mbps <= 0 {
return
}
s.mu.Lock()
changed := !s.measuredOK || s.measured != mbps
s.measured, s.measuredAt, s.measuredOK = mbps, at, true
s.mu.Unlock()
if changed {
logx.Infof(s.Log, "[upwatch] the host's weekly line test: %.1f Mbit/s (measured %s)", mbps, at.UTC().Format(time.RFC3339))
}
}
// MeasuredLine is the host's tested upload line (ok=false: never tested).
func (s *Service) MeasuredLine() (float64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.measured, s.measuredOK
}
// Line is the upload line the watch judges against, in Mbit/s: the higher of the weekly test and the off-site push's
// floor (both are lower bounds of the real line; the test is the close one). ok=false while neither exists.
func (s *Service) Line() (float64, bool) {
return LineFromRecords(decode(s.Store.GetUpwatch()).Line)
l, _, ok := s.line()
return l, ok
}
func (s *Service) line() (mbps float64, tested, ok bool) {
passive, pok := LineFromRecords(decode(s.Store.GetUpwatch()).Line)
m, mok := s.MeasuredLine()
switch {
case mok && m >= passive:
return m, true, true
case pok:
return passive, false, true
}
return 0, false, false
}
// RecordPush stores one off-site push's measurement.
@@ -82,7 +132,10 @@ func (s *Service) QBLimitNow(ctx context.Context) {
if s.QB == nil || (s.QBRunning != nil && !s.QBRunning()) {
return
}
line, known := s.Line()
// D41 (operator 2026-10-24): 50 % of the TESTED line. The push floor is not used here — on demo-hp it read 10 Mbit/s
// on a 90 Mbit/s line, and half of that would have held a seeding qBittorrent at 5. No test yet → no limit.
s.RefreshMeasured(ctx)
line, known := s.MeasuredLine()
cur := decode(s.Store.GetUpwatch())
var st QBLimitState
if cur.QBLimit != nil {
@@ -105,7 +158,7 @@ func (s *Service) QBLimitNow(ctx context.Context) {
}
switch act {
case "set":
logx.Infof(s.Log, "[upwatch] qBittorrent upload limit set to %d B/s (%.0f %% of the measured line, %.1f Mbit/s)", ns.BoxSetBytes, s.QBShare*100, line)
logx.Infof(s.Log, "[upwatch] qBittorrent upload limit set to %d B/s (%.0f %% of the tested line, %.1f Mbit/s)", ns.BoxSetBytes, s.QBShare*100, line)
case "household":
if !st.Household {
logx.Infof(s.Log, "[upwatch] qBittorrent's upload limit is the household's — the box leaves it alone from now on")
@@ -117,6 +170,7 @@ func (s *Service) QBLimitNow(ctx context.Context) {
type View struct {
LineMbps float64
LineKnown bool
LineTested bool // LineMbps is the weekly test's (else the off-site push's floor, „at least")
QBLimitBps int64 // the limit the box set and still owns; 0 = none shown
}
@@ -124,7 +178,7 @@ type View struct {
func (s *Service) View() View {
st := decode(s.Store.GetUpwatch())
v := View{}
v.LineMbps, v.LineKnown = LineFromRecords(st.Line)
v.LineMbps, v.LineTested, v.LineKnown = s.line()
if st.QBLimit != nil && !st.QBLimit.Household {
v.QBLimitBps = st.QBLimit.BoxSetBytes
}
@@ -0,0 +1,74 @@
package upwatch
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
)
type memStore struct {
mu sync.Mutex
raw json.RawMessage
}
func (m *memStore) GetUpwatch() json.RawMessage { m.mu.Lock(); defer m.mu.Unlock(); return m.raw }
func (m *memStore) UpdateUpwatch(fn func(json.RawMessage) (json.RawMessage, error)) error {
m.mu.Lock()
defer m.mu.Unlock()
n, err := fn(m.raw)
if err == nil {
m.raw = n
}
return err
}
// D41 (operator 2026-10-24): qBittorrent gets 50 % of the host's TESTED line — never of the push floor, which read
// 10 Mbit/s on demo-hp's 90 Mbit/s line. The watch judges against the higher of the two.
// RED-PROOF: let QBLimitNow use s.Line() → the push floor alone sets a limit → fails; let line() ignore the test →
// Line() stays at the floor → fails.
func TestService_QBLimitFromTheTestedLineOnly(t *testing.T) {
logins, expire, limit := 0, false, int64(0)
srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit)
defer srv.Close()
st := &memStore{}
floor := LineRecord{Peak10Mbps: 10, Counts: true}
if err := st.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{Line: []LineRecord{floor}}) }); err != nil {
t.Fatal(err)
}
var tested float64
at := time.Date(2026, 10, 24, 6, 31, 0, 0, time.UTC)
svc := &Service{Store: st, QB: &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL},
QBShare: 0.5, Measured: func(context.Context) (float64, time.Time, bool) { return tested, at, tested > 0 }}
svc.QBLimitNow(context.Background())
if limit != 0 {
t.Fatalf("no test yet, push floor 10 Mbit/s: qBittorrent limited to %d B/s — the floor must not set a limit", limit)
}
if l, ok := svc.Line(); !ok || l != 10 {
t.Fatalf("watch line before a test = %.1f %v, want the push floor 10", l, ok)
}
if v := svc.View(); !v.LineKnown || v.LineTested || v.LineMbps != 10 {
t.Fatalf("view before a test = %+v", v)
}
tested = 90.7
svc.QBLimitNow(context.Background())
if want := int64(5668750) / 1024 * 1024; limit != want {
t.Fatalf("tested 90.7 Mbit/s: limit %d B/s, want 50 %% = %d (stored in KiB/s)", limit, want)
}
if l, ok := svc.Line(); !ok || l != 90.7 {
t.Fatalf("watch line after the test = %.1f %v, want 90.7", l, ok)
}
if v := svc.View(); !v.LineTested || v.LineMbps != 90.7 || v.QBLimitBps != limit {
t.Fatalf("view after the test = %+v", v)
}
// The agent stops answering: the last tested value stays (an agent restart must not drop the limit).
tested = 0
svc.RefreshMeasured(context.Background())
if l, ok := svc.MeasuredLine(); !ok || l != 90.7 {
t.Fatalf("after a failed read: %.1f %v, want the last value kept", l, ok)
}
}