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 }