Files
felhom-controller/controller/internal/upwatch/qblimit.go
T
admin a9c1997fbc
gates / gates (push) Successful in 1m17s
v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s,
25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex,
app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second
for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs.
Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
2026-10-23 21:26:09 +02:00

78 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package upwatch
import (
"context"
"math"
"time"
)
// ── qBittorrent's default upload limit (operator ruling 2026-10-23 19:22; the size is decide row R-1245) ─────────
//
// qBittorrent seeds at whatever the line gives (measured on 9202: 131 Mbit/s until the receiver capped it), so a
// family's uploads — a video call, a photo sync — wait behind it. The box sets qBittorrent's GLOBAL upload limit to a
// share of the measured line, and leaves it alone the moment the household touches it:
//
// - never set by the box, qBittorrent unlimited (0) → the box sets it
// - never set by the box, qBittorrent already has a limit → the household's: never touched
// - set by the box, qBittorrent still holds exactly that value → the box's: re-set when the line moves > 20 %
// - set by the box, qBittorrent holds anything else (0 included) → the household changed it: never touched again
//
// 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.
//
// 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.
// QBLimitState is what the box remembers (settings.json `upwatch.qb_limit`).
type QBLimitState struct {
BoxSetBytes int64 `json:"box_set_bytes,omitempty"` // the value qBittorrent stored when the box last set it
LineMbps float64 `json:"line_mbps,omitempty"` // the line the box set it from
SetAt string `json:"set_at,omitempty"`
Household bool `json:"household,omitempty"` // the household owns the limit: never touched again
HouseholdAt string `json:"household_at,omitempty"`
}
// QBLimiter is the qBittorrent side the rule needs (the QBittorrent explainer satisfies it).
type QBLimiter interface {
UploadLimit(ctx context.Context, h map[string]string) (int64, error)
SetUploadLimit(ctx context.Context, bytesPerSec int64) (int64, error)
}
// QBLimitTarget is share × line, in bytes/s.
func QBLimitTarget(lineMbps, share float64) int64 {
return int64(math.Round(lineMbps * share * 1e6 / 8))
}
// ApplyQBLimit runs the rule once. It returns the new state and what happened ("set", "kept", "household", "no-line",
// "error"). It never lowers or raises a limit the household owns.
func ApplyQBLimit(ctx context.Context, q QBLimiter, st QBLimitState, lineMbps float64, lineKnown bool, share float64, now time.Time) (QBLimitState, string, error) {
if st.Household {
return st, "household", nil
}
cur, err := q.UploadLimit(ctx, nil)
if err != nil {
return st, "error", err
}
if st.BoxSetBytes == 0 {
if cur != 0 {
st.Household, st.HouseholdAt = true, now.UTC().Format(time.RFC3339)
return st, "household", nil
}
} else if cur != st.BoxSetBytes {
st.Household, st.HouseholdAt = true, now.UTC().Format(time.RFC3339)
return st, "household", nil
} else if lineKnown && st.LineMbps > 0 && math.Abs(lineMbps-st.LineMbps)/st.LineMbps <= 0.2 {
return st, "kept", nil
}
if !lineKnown {
return st, "no-line", nil
}
stored, err := q.SetUploadLimit(ctx, QBLimitTarget(lineMbps, share))
if err != nil {
return st, "error", err
}
st.BoxSetBytes, st.LineMbps, st.SetAt = stored, lineMbps, now.UTC().Format(time.RFC3339)
return st, "set", nil
}