internal/hdhr also talks to real HDHomeRuns now: discovery by broadcast on every interface (a tuner on a link-local port included), discover.json, the lineup with each channel's frequency and program (lineup.json?tuning), the channel scan, per-tuner status (strength, quality, symbol quality), and whole RF channels streamed from a chosen tuner, with the device's refusals as errors. internal/hdhr/hdhrtest is a fake device for tests, streaming at a broadcast's rate.
- commit
- e3bf70d34ac5793b1e1c7a20632b70f6203d73d5
- parent
- 78940b98aa2a2463b0db4cd6d1a3553a74a5d008
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 1.5 of 3, Jev's estimate
| A | internal/ | 432 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 256 | ++++++++++++++++++ |
| M | internal/ | 69 | +++- |
| A | internal/ | 223 | +++++++++++++++ |
| A | internal/ | 135 | +++++++++ |
| M | internal/ | 18 | +- |
| A | internal/ | 376 | ++++++++++++++++++++++++++ |
@@ -0,0 +1,432 @@
1+package hdhr2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io"9+ "math"10+ "net"11+ "net/http"12+ "net/url"13+ "strconv"14+ "strings"15+ "time"16+)17+18+// Device is a real HDHomeRun on the network, reached over its HTTP API.19+type Device struct {20+ ID string // "1099B035"21+ FriendlyName string // "HDHomeRun FLEX DUO"22+ Model string // its ModelNumber, "HDFX-2US"23+ Firmware string // its FirmwareName, "hdhomerun_dvr_atsc"24+ Version string // its FirmwareVersion, "20250623"25+ Tuners int26+ // BaseURL is the API, "http://192.168.1.30", and StreamURL where the27+ // streams are, on port 5004 ("http://192.168.1.30:5004"); a BaseURL28+ // with a port of its own (a test server) serves both.29+ BaseURL string30+ StreamURL string31+ // HTTP makes the API calls, with a timeout; streams use a copy32+ // without one.33+ HTTP *http.Client34+}35+36+// Open reads discover.json at base: "192.168.1.30", "hdhomerun.local" or37+// "http://...". A nil client gets one with a 10 s timeout.38+func Open(ctx context.Context, base string, c *http.Client) (*Device, error) {39+ u, err := apiURL(base)40+ if err != nil {41+ return nil, err42+ }43+ if c == nil {44+ c = &http.Client{Timeout: 10 * time.Second}45+ }46+ d := &Device{BaseURL: u, StreamURL: streamURL(u), HTTP: c}47+ if err := d.Refresh(ctx); err != nil {48+ return nil, err49+ }50+ return d, nil51+}52+53+// apiURL is a device's base URL from an address as someone wrote it.54+func apiURL(s string) (string, error) {55+ s = strings.TrimSpace(s)56+ if s == "" {57+ return "", errors.New("no HDHomeRun address")58+ }59+ if !strings.Contains(s, "://") {60+ s = "http://" + s61+ }62+ u, err := url.Parse(s)63+ if err != nil || u.Host == "" {64+ return "", fmt.Errorf("HDHomeRun address %q isn't a host", s)65+ }66+ if u.Port() == "80" {67+ return u.Scheme + "://" + u.Hostname(), nil68+ }69+ return u.Scheme + "://" + u.Host, nil70+}71+72+// streamURL is where a device at base serves streams: port 5004, unless73+// base names a port of its own other than HTTP's (a discovery reply gives74+// "http://192.168.1.30:80").75+func streamURL(base string) string {76+ u, err := url.Parse(base)77+ if err != nil || u.Port() != "" && u.Port() != "80" {78+ return base79+ }80+ return u.Scheme + "://" + net.JoinHostPort(u.Hostname(), "5004")81+}82+83+// Refresh reads discover.json again, updating the device's fields in84+// place; it isn't safe while other goroutines read them.85+func (d *Device) Refresh(ctx context.Context) error {86+ var body struct {87+ FriendlyName flexString88+ ModelNumber flexString89+ FirmwareName flexString90+ FirmwareVersion flexString91+ DeviceID flexString92+ TunerCount flexInt93+ }94+ if err := d.get(ctx, "/discover.json", &body); err != nil {95+ return err96+ }97+ d.ID, d.FriendlyName, d.Model = string(body.DeviceID), string(body.FriendlyName), string(body.ModelNumber)98+ d.Firmware, d.Version, d.Tuners = string(body.FirmwareName), string(body.FirmwareVersion), int(body.TunerCount)99+ return nil100+}101+102+// ATSC3 reports whether the device's firmware receives ATSC 3.0103+// ("hdhomerun_dvr_atsc3" on the 4K models).104+func (d *Device) ATSC3() bool { return strings.Contains(d.Firmware, "atsc3") }105+106+// Channel is one entry of the device's lineup, from its last channel scan.107+type Channel struct {108+ Number string // "2.1"109+ Name string // "KWGN-DT"110+ FrequencyHz int64111+ Program int // the MPEG program number in the RF channel's multiplex112+ TSID int113+ Modulation string // "8vsb"114+ VideoCodec string // "MPEG2", "H264", "HEVC"115+ AudioCodec string // "AC3"116+ HD bool117+ Favorite bool118+ DRM bool119+ ATSC3 bool120+ // SignalStrength and SignalQuality are what the scan measured, in121+ // percent.122+ SignalStrength int123+ SignalQuality int124+}125+126+// Lineup lists the channels the device's last scan found, with how to tune127+// each (lineup.json?tuning).128+func (d *Device) Lineup(ctx context.Context) ([]Channel, error) {129+ var body []struct {130+ GuideNumber flexString131+ GuideName flexString132+ Frequency flexInt133+ ProgramNumber flexInt134+ TransportStreamID flexInt135+ Modulation flexString136+ VideoCodec flexString137+ AudioCodec flexString138+ HD flexInt139+ Favorite flexInt140+ DRM flexInt141+ ATSC3 flexInt142+ SignalStrength flexInt143+ SignalQuality flexInt144+ }145+ if err := d.get(ctx, "/lineup.json?tuning", &body); err != nil {146+ return nil, err147+ }148+ out := make([]Channel, 0, len(body))149+ for _, e := range body {150+ out = append(out, Channel{151+ Number: string(e.GuideNumber), Name: strings.TrimSpace(string(e.GuideName)),152+ FrequencyHz: int64(e.Frequency), Program: int(e.ProgramNumber), TSID: int(e.TransportStreamID),153+ Modulation: string(e.Modulation), VideoCodec: string(e.VideoCodec), AudioCodec: string(e.AudioCodec),154+ HD: e.HD != 0, Favorite: e.Favorite != 0, DRM: e.DRM != 0, ATSC3: e.ATSC3 != 0,155+ SignalStrength: int(e.SignalStrength), SignalQuality: int(e.SignalQuality),156+ })157+ }158+ return out, nil159+}160+161+// ScanStatus is how the device's channel scan is going.162+type ScanStatus struct {163+ InProgress bool164+ // Possible is whether a scan can start now.165+ Possible bool166+ // Progress is in percent, and Found the channels found so far, while167+ // a scan runs.168+ Progress int169+ Found int170+ Source string // "Antenna"171+}172+173+// ScanStatus reads lineup_status.json.174+func (d *Device) ScanStatus(ctx context.Context) (ScanStatus, error) {175+ var body struct {176+ ScanInProgress flexInt177+ ScanPossible flexInt178+ Progress flexInt179+ Found flexInt180+ Source flexString181+ }182+ if err := d.get(ctx, "/lineup_status.json", &body); err != nil {183+ return ScanStatus{}, err184+ }185+ return ScanStatus{186+ InProgress: body.ScanInProgress != 0, Possible: body.ScanPossible != 0,187+ Progress: int(body.Progress), Found: int(body.Found), Source: string(body.Source),188+ }, nil189+}190+191+// StartScan starts the device's channel scan of its antenna input. While192+// it runs, the device takes its tuners and refuses streams (803 System193+// Busy); afterwards Lineup lists what it found.194+func (d *Device) StartScan(ctx context.Context) error {195+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL+"/lineup.post?scan=start&source=Antenna", nil)196+ if err != nil {197+ return err198+ }199+ resp, err := d.client().Do(req)200+ if err != nil {201+ return fmt.Errorf("HDHomeRun: %w", err)202+ }203+ defer resp.Body.Close()204+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))205+ if resp.StatusCode != http.StatusOK {206+ return deviceError(resp)207+ }208+ return nil209+}210+211+// TunerStatus is one tuner as status.json reports it.212+type TunerStatus struct {213+ Tuner int // from its resource name, "tuner1"; -1 if it has another214+ // Channel and Name are the virtual channel, when it was tuned to one.215+ Channel string216+ Name string217+ // FrequencyHz is what it's tuned to, 0 while idle.218+ FrequencyHz int64219+ // StrengthPct is signal strength, QualityPct signal to noise quality220+ // (SNQ) and SymbolPct symbol quality (SEQ, the share of data that221+ // arrived intact or corrected), in percent; nil while idle.222+ StrengthPct *int223+ QualityPct *int224+ SymbolPct *int225+ TargetIP string226+ // NetworkRate is what the tuner sends, in bits a second.227+ NetworkRate int64228+}229+230+// Tuned reports whether the tuner is in use.231+func (t TunerStatus) Tuned() bool { return t.FrequencyHz > 0 || t.Channel != "" }232+233+// Status reads every tuner's status (status.json). The device measures a234+// tuner's signal only while something has it tuned.235+func (d *Device) Status(ctx context.Context) ([]TunerStatus, error) {236+ var body []struct {237+ Resource flexString238+ VctNumber flexString239+ VctName flexString240+ Frequency flexInt241+ SignalStrengthPercent *flexInt242+ SignalQualityPercent *flexInt243+ SymbolQualityPercent *flexInt244+ TargetIP flexString245+ NetworkRate flexInt246+ }247+ if err := d.get(ctx, "/status.json", &body); err != nil {248+ return nil, err249+ }250+ out := make([]TunerStatus, 0, len(body))251+ for _, e := range body {252+ n, ok := tunerNumber(string(e.Resource))253+ if !ok {254+ n = -1255+ }256+ out = append(out, TunerStatus{257+ Tuner: n, Channel: string(e.VctNumber), Name: string(e.VctName), FrequencyHz: int64(e.Frequency),258+ StrengthPct: e.SignalStrengthPercent.int(), QualityPct: e.SignalQualityPercent.int(), SymbolPct: e.SymbolQualityPercent.int(),259+ TargetIP: string(e.TargetIP), NetworkRate: int64(e.NetworkRate),260+ })261+ }262+ return out, nil263+}264+265+// Stream is a stream from one of the device's tuners. Closing it frees the266+// tuner.267+type Stream struct {268+ io.ReadCloser269+ // Tuner is the tuner the device used (from X-HDHomeRun-Resource).270+ Tuner int271+}272+273+// OpenRF streams the whole RF channel at freqHz, every program in its274+// multiplex, from tuner (-1 for any free one). It returns once the device275+// answers, which for a channel it can't lock is never: ctx bounds the wait,276+// and ends the stream.277+func (d *Device) OpenRF(ctx context.Context, tuner int, freqHz int64) (*Stream, error) {278+ where := "auto"279+ if tuner >= 0 {280+ where = "tuner" + strconv.Itoa(tuner)281+ }282+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s/ch%d", d.StreamURL, where, freqHz), nil)283+ if err != nil {284+ return nil, err285+ }286+ // The client's timeout would end the stream; ctx ends it instead.287+ hc := *d.client()288+ hc.Timeout = 0289+ resp, err := hc.Do(req)290+ if err != nil {291+ return nil, fmt.Errorf("HDHomeRun: %w", err)292+ }293+ if resp.StatusCode != http.StatusOK {294+ resp.Body.Close()295+ return nil, deviceError(resp)296+ }297+ s := &Stream{ReadCloser: resp.Body, Tuner: tuner}298+ if n, ok := tunerNumber(resp.Header.Get("X-HDHomeRun-Resource")); ok {299+ s.Tuner = n300+ }301+ return s, nil302+}303+304+// Error is a request the device refused.305+type Error struct {306+ Status int // the HTTP status307+ // Code and Reason are from its X-HDHomeRun-Error header ("805 All308+ // Tuners In Use"), when it gave one.309+ Code int310+ Reason string311+}312+313+func (e *Error) Error() string {314+ if e.Code != 0 {315+ return strings.TrimSpace(fmt.Sprintf("HDHomeRun: %d %s", e.Code, e.Reason))316+ }317+ if e.Reason != "" {318+ return "HDHomeRun: " + e.Reason319+ }320+ return fmt.Sprintf("HDHomeRun: HTTP %d", e.Status)321+}322+323+// Busy reports whether the device refused for want of a tuner: all in use324+// (805), the one asked for in use (804), or busy scanning (803).325+func (e *Error) Busy() bool {326+ switch e.Code {327+ case 803, 804, 805:328+ return true329+ case 0:330+ return e.Status == http.StatusServiceUnavailable331+ }332+ return false333+}334+335+func deviceError(resp *http.Response) *Error {336+ e := &Error{Status: resp.StatusCode}337+ if h := strings.TrimSpace(resp.Header.Get("X-HDHomeRun-Error")); h != "" {338+ code, reason, _ := strings.Cut(h, " ")339+ if n, err := strconv.Atoi(code); err == nil {340+ e.Code, e.Reason = n, strings.TrimSpace(reason)341+ } else {342+ e.Reason = h343+ }344+ }345+ return e346+}347+348+// tunerNumber reads a tuner's resource name: "tuner1" is 1.349+func tunerNumber(s string) (int, bool) {350+ n, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(s), "tuner"))351+ if err != nil || !strings.HasPrefix(strings.TrimSpace(s), "tuner") || n < 0 {352+ return 0, false353+ }354+ return n, true355+}356+357+func (d *Device) client() *http.Client {358+ if d.HTTP != nil {359+ return d.HTTP360+ }361+ return http.DefaultClient362+}363+364+func (d *Device) get(ctx context.Context, path string, out any) error {365+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.BaseURL+path, nil)366+ if err != nil {367+ return err368+ }369+ resp, err := d.client().Do(req)370+ if err != nil {371+ return fmt.Errorf("HDHomeRun: %w", err)372+ }373+ defer resp.Body.Close()374+ if resp.StatusCode != http.StatusOK {375+ return deviceError(resp)376+ }377+ if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out); err != nil {378+ return fmt.Errorf("HDHomeRun %s: %w", strings.SplitN(path, "?", 2)[0], err)379+ }380+ return nil381+}382+383+// flexInt is a number as devices write it: a number (rounded), a numeric384+// string, or a boolean flag (1 or 0).385+type flexInt int64386+387+func (n *flexInt) UnmarshalJSON(raw []byte) error {388+ s := strings.Trim(strings.TrimSpace(string(raw)), `"`)389+ switch s {390+ case "", "null", "false":391+ *n = 0392+ return nil393+ case "true":394+ *n = 1395+ return nil396+ }397+ f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)398+ if err != nil {399+ return fmt.Errorf("%s is not a number", raw)400+ }401+ *n = flexInt(math.Round(f))402+ return nil403+}404+405+// int is the number, nil when it was absent.406+func (n *flexInt) int() *int {407+ if n == nil {408+ return nil409+ }410+ v := int(*n)411+ return &v412+}413+414+// flexString is a string, or a number or flag written without quotes.415+type flexString string416+417+func (s *flexString) UnmarshalJSON(raw []byte) error {418+ var v string419+ if err := json.Unmarshal(raw, &v); err == nil {420+ *s = flexString(v)421+ return nil422+ }423+ switch t := strings.TrimSpace(string(raw)); {424+ case t == "null":425+ *s = ""426+ case strings.HasPrefix(t, "{") || strings.HasPrefix(t, "["):427+ return fmt.Errorf("%s is not a string", raw)428+ default:429+ *s = flexString(t)430+ }431+ return nil432+}@@ -0,0 +1,256 @@
1+package hdhr_test2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "io"8+ "net/http"9+ "net/http/httptest"10+ "reflect"11+ "testing"12+ "time"13+14+ "airwaves/internal/hdhr"15+ "airwaves/internal/hdhr/hdhrtest"16+)17+18+var lineup = []hdhr.Channel{19+ {Number: "2.1", Name: "KWGN-DT", FrequencyHz: 605_000_000, Program: 6, TSID: 467, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3", HD: true, SignalStrength: 96, SignalQuality: 100},20+ {Number: "31.1", Name: "KDVR-HD", FrequencyHz: 605_000_000, Program: 3, TSID: 467, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3", HD: true, SignalStrength: 96, SignalQuality: 100},21+ {Number: "14.2", Name: "KCEC-2", FrequencyHz: 563_000_000, Program: 4, TSID: 1, Modulation: "8vsb", VideoCodec: "H264", AudioCodec: "AC3", DRM: true, SignalStrength: 82, SignalQuality: 87},22+}23+24+// mux is a stand-in multiplex: a few null packets.25+var mux = bytes.Repeat(append([]byte{0x47, 0x1f, 0xff, 0x10}, make([]byte, 184)...), 7)26+27+func fake(t *testing.T, d hdhrtest.Device) (*hdhrtest.Server, *hdhr.Device) {28+ t.Helper()29+ if d.Lineup == nil {30+ d.Lineup = lineup31+ }32+ if d.Mux == nil {33+ d.Mux = func(freq int64) []byte {34+ if freq == 509_000_000 {35+ return nil // nothing there36+ }37+ return mux38+ }39+ }40+ s := hdhrtest.New(t, d)41+ dev, err := hdhr.Open(context.Background(), s.URL(), nil)42+ if err != nil {43+ t.Fatal(err)44+ }45+ return s, dev46+}47+48+func TestOpen(t *testing.T) {49+ s, d := fake(t, hdhrtest.Device{})50+ if d.Model != "HDFX-2US" || d.FriendlyName != "HDHomeRun FLEX DUO" || d.Tuners != 2 || d.Version != "20250623" || d.ID == "" {51+ t.Errorf("device %+v", d)52+ }53+ if d.BaseURL != s.URL() || d.StreamURL != s.URL() || d.ATSC3() {54+ t.Errorf("URLs %q %q, ATSC 3.0 %v", d.BaseURL, d.StreamURL, d.ATSC3())55+ }56+ _, d4k := fake(t, hdhrtest.Device{Model: "HDFX-4K", Firmware: "hdhomerun_dvr_atsc3", Tuners: 4})57+ if !d4k.ATSC3() || d4k.Tuners != 4 {58+ t.Errorf("4K: %+v", d4k)59+ }60+ if _, err := hdhr.Open(context.Background(), "", nil); err == nil {61+ t.Error("opened no address")62+ }63+}64+65+func TestLineup(t *testing.T) {66+ _, d := fake(t, hdhrtest.Device{})67+ got, err := d.Lineup(context.Background())68+ if err != nil {69+ t.Fatal(err)70+ }71+ if !reflect.DeepEqual(got, lineup) {72+ t.Errorf("lineup\n%+v\nwant\n%+v", got, lineup)73+ }74+}75+76+// TestLenient reads what devices write in other ways: numbers as strings,77+// flags as booleans, a decimal place, and fields it doesn't know.78+func TestLenient(t *testing.T) {79+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {80+ switch r.URL.Path {81+ case "/discover.json":82+ io.WriteString(w, `{"DeviceID":"1099B035","ModelNumber":"HDFX-2US","FirmwareVersion":20250623,"TunerCount":"2","Legacy":0}`)83+ case "/lineup.json":84+ io.WriteString(w, `[{"GuideNumber":7.1,"GuideName":" KMGH-HD ","Frequency":"177000000","ProgramNumber":3,"HD":true,"DRM":false,"Tags":"favorite"}]`)85+ case "/status.json":86+ io.WriteString(w, `[{"Resource":"tuner0","Frequency":177000000,"SignalStrengthPercent":"83.6","SignalQualityPercent":90.2,"SymbolQualityPercent":null},{"Resource":"tuner1"}]`)87+ }88+ }))89+ defer srv.Close()90+ d, err := hdhr.Open(context.Background(), srv.URL, nil)91+ if err != nil {92+ t.Fatal(err)93+ }94+ if d.Version != "20250623" || d.Tuners != 2 {95+ t.Errorf("device %+v", d)96+ }97+ l, err := d.Lineup(context.Background())98+ if err != nil || len(l) != 1 || l[0].Number != "7.1" || l[0].Name != "KMGH-HD" || l[0].FrequencyHz != 177_000_000 || !l[0].HD || l[0].DRM {99+ t.Errorf("lineup %+v %v", l, err)100+ }101+ st, err := d.Status(context.Background())102+ if err != nil || len(st) != 2 {103+ t.Fatalf("status %+v %v", st, err)104+ }105+ if st[0].StrengthPct == nil || *st[0].StrengthPct != 84 || *st[0].QualityPct != 90 || st[0].SymbolPct != nil || !st[0].Tuned() {106+ t.Errorf("tuner 0 %+v", st[0])107+ }108+ if st[1].Tuner != 1 || st[1].Tuned() || st[1].StrengthPct != nil {109+ t.Errorf("tuner 1 %+v", st[1])110+ }111+}112+113+func TestOpenRF(t *testing.T) {114+ s, d := fake(t, hdhrtest.Device{})115+ ctx, cancel := context.WithCancel(context.Background())116+ defer cancel()117+ st, err := d.OpenRF(ctx, -1, 605_000_000)118+ if err != nil {119+ t.Fatal(err)120+ }121+ if st.Tuner != 0 {122+ t.Errorf("tuner %d", st.Tuner)123+ }124+ buf := make([]byte, 4*len(mux))125+ if _, err := io.ReadFull(st, buf); err != nil || buf[0] != 0x47 {126+ t.Fatalf("read: %v", err)127+ }128+ status, err := d.Status(ctx)129+ if err != nil {130+ t.Fatal(err)131+ }132+ if !status[0].Tuned() || status[0].FrequencyHz != 605_000_000 || status[0].QualityPct == nil || *status[0].QualityPct != 95 || status[0].NetworkRate == 0 {133+ t.Errorf("tuner 0 %+v", status[0])134+ }135+ if status[1].Tuned() {136+ t.Errorf("tuner 1 %+v", status[1])137+ }138+139+ // A tuner asked for by number.140+ st1, err := d.OpenRF(ctx, 1, 563_000_000)141+ if err != nil || st1.Tuner != 1 {142+ t.Fatalf("tuner 1: %+v %v", st1, err)143+ }144+ st1.Close()145+146+ // Closing frees the tuner.147+ st.Close()148+ waitFor(t, func() bool { return s.Streams() == 0 })149+ status, _ = d.Status(ctx)150+ if status[0].Tuned() || status[0].StrengthPct != nil {151+ t.Errorf("tuner 0 after closing %+v", status[0])152+ }153+}154+155+func TestBusy(t *testing.T) {156+ s, d := fake(t, hdhrtest.Device{})157+ ctx := context.Background()158+ s.Busy(0)159+ s.Busy(1)160+ _, err := d.OpenRF(ctx, -1, 605_000_000)161+ var he *hdhr.Error162+ if !errors.As(err, &he) || he.Code != 805 || he.Reason != "All Tuners In Use" || !he.Busy() {163+ t.Errorf("all busy: %v", err)164+ }165+ _, err = d.OpenRF(ctx, 1, 605_000_000)166+ if !errors.As(err, &he) || he.Code != 804 || !he.Busy() {167+ t.Errorf("tuner 1 busy: %v", err)168+ }169+ status, _ := d.Status(ctx)170+ if status[0].FrequencyHz != hdhrtest.OtherFrequency || status[0].TargetIP != hdhrtest.OtherTarget {171+ t.Errorf("busy tuner %+v", status[0])172+ }173+ s.Free(1)174+ st, err := d.OpenRF(ctx, -1, 605_000_000)175+ if err != nil || st.Tuner != 1 {176+ t.Fatalf("after freeing tuner 1: %v", err)177+ }178+ st.Close()179+180+ if _, err := d.OpenRF(ctx, 7, 605_000_000); !errors.As(err, &he) || he.Busy() || he.Status != http.StatusNotFound {181+ t.Errorf("no tuner 7: %v", err)182+ }183+ if e := (&hdhr.Error{Status: 503}); !e.Busy() || e.Error() != "HDHomeRun: HTTP 503" {184+ t.Errorf("bare 503: %v busy %v", e, e.Busy())185+ }186+ if e := (&hdhr.Error{Status: 503, Code: 807, Reason: "No Video Data"}); e.Busy() || e.Error() != "HDHomeRun: 807 No Video Data" {187+ t.Errorf("807: %v busy %v", e, e.Busy())188+ }189+}190+191+// TestNoLock: an RF channel the tuner can't lock gets no answer, while192+// status.json shows the tuner on it with what it measures.193+func TestNoLock(t *testing.T) {194+ s, d := fake(t, hdhrtest.Device{})195+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)196+ defer cancel()197+ done := make(chan error, 1)198+ go func() {199+ _, err := d.OpenRF(ctx, -1, 509_000_000)200+ done <- err201+ }()202+ waitFor(t, func() bool {203+ st, err := d.Status(context.Background())204+ return err == nil && st[0].FrequencyHz == 509_000_000205+ })206+ st, _ := d.Status(context.Background())207+ if st[0].StrengthPct == nil || *st[0].StrengthPct != 40 || *st[0].QualityPct != 0 || st[0].NetworkRate != 0 {208+ t.Errorf("tuner 0 %+v", st[0])209+ }210+ if err := <-done; !errors.Is(err, context.DeadlineExceeded) {211+ t.Errorf("open: %v, want the deadline", err)212+ }213+ waitFor(t, func() bool { return s.Streams() == 0 })214+}215+216+func TestScan(t *testing.T) {217+ rescan := append([]hdhr.Channel{{Number: "9.1", Name: "KUSA-HD", FrequencyHz: 189_000_000, Program: 3, Modulation: "8vsb", HD: true}}, lineup...)218+ s, d := fake(t, hdhrtest.Device{Rescan: rescan, ScanTime: 100 * time.Millisecond})219+ ctx := context.Background()220+ st, err := d.ScanStatus(ctx)221+ if err != nil || st.InProgress || !st.Possible || st.Source != "Antenna" {222+ t.Fatalf("before: %+v %v", st, err)223+ }224+ if err := d.StartScan(ctx); err != nil {225+ t.Fatal(err)226+ }227+ if st, _ := d.ScanStatus(ctx); !st.InProgress {228+ t.Errorf("during: %+v", st)229+ }230+ var he *hdhr.Error231+ if _, err := d.OpenRF(ctx, -1, 605_000_000); !errors.As(err, &he) || he.Code != 803 || !he.Busy() {232+ t.Errorf("stream during a scan: %v", err)233+ }234+ waitFor(t, func() bool {235+ st, err := d.ScanStatus(ctx)236+ return err == nil && !st.InProgress237+ })238+ got, err := d.Lineup(ctx)239+ if err != nil || len(got) != len(rescan) || got[0].Number != "9.1" {240+ t.Errorf("after: %+v %v", got, err)241+ }242+ if s.Scans() != 1 {243+ t.Errorf("%d scans", s.Scans())244+ }245+}246+247+func waitFor(t *testing.T, ok func() bool) {248+ t.Helper()249+ deadline := time.Now().Add(3 * time.Second)250+ for !ok() {251+ if time.Now().After(deadline) {252+ t.Fatal("timed out")253+ }254+ time.Sleep(10 * time.Millisecond)255+ }256+}@@ -79,37 +79,66 @@ func u32(v uint32) []byte {
7979 return b8080 }8181 82-// parseRequest reports whether pkt is a discover request this device should83-// answer (a tuner or wildcard search, for our ID or any ID).84-func parseRequest(pkt []byte, id uint32) bool {85- if len(pkt) < 8 || binary.BigEndian.Uint16(pkt) != typeDiscoverRq {86- return false82+// parsePacket checks a packet's length and CRC and returns its type and83+// payload.84+func parsePacket(pkt []byte) (typ uint16, payload []byte, ok bool) {85+ if len(pkt) < 8 {86+ return 0, nil, false8787 }8888 n := int(binary.BigEndian.Uint16(pkt[2:]))8989 if 4+n+4 > len(pkt) || crc32.ChecksumIEEE(pkt[:4+n]) != binary.LittleEndian.Uint32(pkt[4+n:]) {90- return false90+ return 0, nil, false9191 }92- p := pkt[4 : 4+n]93- for len(p) >= 2 {94- tag, l := p[0], int(p[1])95- if len(p) < 2+l {92+ return binary.BigEndian.Uint16(pkt), pkt[4 : 4+n], true93+}94+95+// eachTag calls f with each tag of a payload and its value, whose length96+// takes one byte, or two from 128 on. It reports whether the payload was97+// well formed.98+func eachTag(p []byte, f func(tag byte, v []byte)) bool {99+ for len(p) > 0 {100+ if len(p) < 2 {96101 return false97102 }98- v := p[2 : 2+l]99- if l == 4 {100- x := binary.BigEndian.Uint32(v)101- if tag == tagDeviceType && x != wildcard && x != deviceTuner {102- return false103- }104- if tag == tagDeviceID && x != wildcard && x != id {103+ tag, l, hdr := p[0], int(p[1]), 2104+ if l&0x80 != 0 {105+ if len(p) < 3 {105106 return false106107 }108+ l, hdr = l&0x7F|int(p[2])<<7, 3109+ }110+ if len(p) < hdr+l {111+ return false107112 }108- p = p[2+l:]113+ f(tag, p[hdr:hdr+l])114+ p = p[hdr+l:]109115 }110116 return true111117 }112118 119+// parseRequest reports whether pkt is a discover request this device should120+// answer (a tuner or wildcard search, for our ID or any ID).121+func parseRequest(pkt []byte, id uint32) bool {122+ typ, p, ok := parsePacket(pkt)123+ if !ok || typ != typeDiscoverRq {124+ return false125+ }126+ answer := true127+ ok = eachTag(p, func(tag byte, v []byte) {128+ if len(v) != 4 {129+ return130+ }131+ x := binary.BigEndian.Uint32(v)132+ if tag == tagDeviceType && x != wildcard && x != deviceTuner {133+ answer = false134+ }135+ if tag == tagDeviceID && x != wildcard && x != id {136+ answer = false137+ }138+ })139+ return ok && answer140+}141+113142 func reply(id uint32, tuners int, base string) []byte {114143 var b bytes.Buffer115144 tlv(&b, tagDeviceType, u32(deviceTuner))@@ -122,8 +151,8 @@ func reply(id uint32, tuners int, base string) []byte {
122151 123152 // Discover answers HDHomeRun discovery broadcasts until ctx ends. port is124153 // the HTTP port advertised in the base URL. Requests from this host's own125-// addresses are ignored, so Tvheadend on the same machine never mistakes126-// Airwaves for a tuner it should use.154+// addresses are ignored, so looking for the real tuner from this machine155+// (Find) never turns up Airwaves itself.127156 func (s *Server) Discover(ctx context.Context, port int) error {128157 conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: discoverPort})129158 if err != nil {@@ -0,0 +1,223 @@
1+package hdhr2+3+import (4+ "bytes"5+ "cmp"6+ "context"7+ "encoding/binary"8+ "errors"9+ "fmt"10+ "net"11+ "net/http"12+ "net/url"13+ "slices"14+ "strconv"15+ "strings"16+ "time"17+)18+19+// ErrNotFound is returned by Find when no HDHomeRun answers discovery.20+var ErrNotFound = errors.New("no HDHomeRun answered discovery")21+22+// Found is a tuner that answered discovery.23+type Found struct {24+ ID uint3225+ Tuners int26+ // BaseURL is where its HTTP API is: the address it answered from,27+ // with the port it gave, if any.28+ BaseURL string29+}30+31+// Find locates a device: at addr when that's an address ("192.168.1.30",32+// "hdhomerun.local", "http://..."), by discovery when it's a device ID33+// ("1099B035"), and otherwise the first device discovery finds, never the34+// one with ID skip (the emulated device of this server).35+func Find(ctx context.Context, addr string, skip uint32, c *http.Client) (*Device, error) {36+ want, byID := deviceID(addr)37+ if addr != "" && !byID {38+ return Open(ctx, addr, c)39+ }40+ found, err := Discover(ctx, 2*time.Second)41+ if err != nil {42+ return nil, err43+ }44+ for _, f := range found {45+ if f.ID == skip || byID && f.ID != want {46+ continue47+ }48+ return Open(ctx, f.BaseURL, c)49+ }50+ if byID {51+ return nil, fmt.Errorf("no HDHomeRun %08X answered discovery", want)52+ }53+ return nil, ErrNotFound54+}55+56+// deviceID reports whether s is a device ID: eight hex digits with a57+// correct check digit.58+func deviceID(s string) (uint32, bool) {59+ if len(s) != 8 {60+ return 0, false61+ }62+ id, err := strconv.ParseUint(s, 16, 32)63+ if err != nil || !ValidDeviceID(uint32(id)) {64+ return 0, false65+ }66+ return uint32(id), true67+}68+69+// Discover broadcasts a discovery request on every IPv4 interface and70+// lists the tuners that answer within wait, by ID. The request goes to71+// each interface's own broadcast address as well as 255.255.255.255, so a72+// tuner on a link-local port of its own is found too.73+func Discover(ctx context.Context, wait time.Duration) ([]Found, error) {74+ conn, err := net.ListenUDP("udp4", &net.UDPAddr{})75+ if err != nil {76+ return nil, err77+ }78+ defer conn.Close()79+ deadline := time.Now().Add(wait)80+ if d, ok := ctx.Deadline(); ok && d.Before(deadline) {81+ deadline = d82+ }83+ _ = conn.SetReadDeadline(deadline)84+ stop := context.AfterFunc(ctx, func() { _ = conn.SetReadDeadline(time.Now()) })85+ defer stop()86+87+ req := searchPacket()88+ send := func() {89+ for _, ip := range broadcastTargets() {90+ _, _ = conn.WriteToUDP(req, &net.UDPAddr{IP: ip, Port: discoverPort})91+ }92+ }93+ send()94+ // Once more halfway, in case the first was lost.95+ resend := time.AfterFunc(wait/2, send)96+ defer resend.Stop()97+98+ var out []Found99+ buf := make([]byte, 1500)100+ for {101+ n, from, err := conn.ReadFromUDP(buf)102+ if err != nil {103+ break // the deadline, or ctx ended104+ }105+ f, ok := parseReply(buf[:n], from.IP)106+ if ok && !slices.ContainsFunc(out, func(x Found) bool { return x.ID == f.ID }) {107+ out = append(out, f)108+ }109+ }110+ if len(out) == 0 && ctx.Err() != nil {111+ return nil, ctx.Err()112+ }113+ slices.SortFunc(out, func(a, b Found) int { return cmp.Compare(a.ID, b.ID) })114+ return out, nil115+}116+117+// searchPacket asks every tuner to answer.118+func searchPacket() []byte {119+ var b bytes.Buffer120+ tlv(&b, tagDeviceType, u32(deviceTuner))121+ tlv(&b, tagDeviceID, u32(wildcard))122+ return packet(typeDiscoverRq, b.Bytes())123+}124+125+// parseReply reads a tuner's discovery reply, received from the address126+// from; ok is false for anything else.127+func parseReply(pkt []byte, from net.IP) (Found, bool) {128+ typ, p, ok := parsePacket(pkt)129+ if !ok || typ != typeDiscoverRp {130+ return Found{}, false131+ }132+ var f Found133+ tuner, hasID := true, false134+ ok = eachTag(p, func(tag byte, v []byte) {135+ switch tag {136+ case tagDeviceType:137+ tuner = len(v) == 4 && binary.BigEndian.Uint32(v) == deviceTuner138+ case tagDeviceID:139+ if len(v) == 4 {140+ f.ID, hasID = binary.BigEndian.Uint32(v), true141+ }142+ case tagTunerCount:143+ if len(v) == 1 {144+ f.Tuners = int(v[0])145+ }146+ case tagBaseURL:147+ f.BaseURL = strings.TrimRight(string(v), "\x00")148+ }149+ })150+ if !ok || !tuner || !hasID {151+ return Found{}, false152+ }153+ f.BaseURL = reachable(f.BaseURL, from)154+ return f, true155+}156+157+// reachable is a device's base URL with its host the address it answered158+// from, when it gave a name (newer firmware gives "hdhr-1099b035.local",159+// which a container may not resolve) or nothing.160+func reachable(base string, from net.IP) string {161+ if from == nil {162+ return base163+ }164+ u, err := url.Parse(base)165+ if err != nil || u.Host == "" {166+ return "http://" + from.String()167+ }168+ if net.ParseIP(u.Hostname()) != nil {169+ return base170+ }171+ host := from.String()172+ if p := u.Port(); p != "" {173+ host = net.JoinHostPort(host, p)174+ }175+ return u.Scheme + "://" + host176+}177+178+// broadcastTargets lists where a discovery request goes: 255.255.255.255,179+// and the broadcast address of each IPv4 network this host is on.180+func broadcastTargets() []net.IP {181+ out := []net.IP{net.IPv4bcast}182+ ifs, _ := net.Interfaces()183+ for _, ifc := range ifs {184+ if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagBroadcast == 0 || ifc.Flags&net.FlagLoopback != 0 {185+ continue186+ }187+ addrs, _ := ifc.Addrs()188+ for _, a := range addrs {189+ n, ok := a.(*net.IPNet)190+ if !ok {191+ continue192+ }193+ if b, ok := broadcastAddr(n); ok && !slices.ContainsFunc(out, b.Equal) {194+ out = append(out, b)195+ }196+ }197+ }198+ return out199+}200+201+// broadcastAddr is an IPv4 network's broadcast address; ok is false for202+// IPv6, loopback, and /31 and /32 networks, which have none.203+func broadcastAddr(n *net.IPNet) (net.IP, bool) {204+ ip := n.IP.To4()205+ if ip == nil || ip.IsLoopback() {206+ return nil, false207+ }208+ mask := n.Mask209+ if len(mask) == net.IPv6len {210+ mask = mask[12:]211+ }212+ if len(mask) != net.IPv4len {213+ return nil, false214+ }215+ if ones, _ := mask.Size(); ones >= 31 {216+ return nil, false217+ }218+ b := make(net.IP, net.IPv4len)219+ for i := range b {220+ b[i] = ip[i] | ^mask[i]221+ }222+ return b, true223+}@@ -0,0 +1,135 @@
1+package hdhr2+3+import (4+ "bytes"5+ "fmt"6+ "net"7+ "testing"8+)9+10+func TestBroadcastAddr(t *testing.T) {11+ for _, tc := range []struct {12+ cidr string13+ want string // "" for none14+ }{15+ {"169.254.93.183/16", "169.254.255.255"}, // a tuner's link-local port16+ {"192.168.1.20/24", "192.168.1.255"},17+ {"10.1.2.3/8", "10.255.255.255"},18+ {"100.64.1.2/32", ""}, // a tailnet address19+ {"10.0.0.1/31", ""},20+ {"127.0.0.1/8", ""},21+ {"fe80::1/64", ""},22+ } {23+ ip, n, err := net.ParseCIDR(tc.cidr)24+ if err != nil {25+ t.Fatal(err)26+ }27+ n.IP = ip28+ got, ok := broadcastAddr(n)29+ if tc.want == "" {30+ if ok {31+ t.Errorf("%s: broadcast %s, want none", tc.cidr, got)32+ }33+ continue34+ }35+ if !ok || got.String() != tc.want {36+ t.Errorf("%s: broadcast %v %v, want %s", tc.cidr, got, ok, tc.want)37+ }38+ }39+ // Interface addresses can come as 16-byte IPs with 16-byte masks.40+ n := &net.IPNet{IP: net.ParseIP("192.168.1.20"), Mask: net.CIDRMask(120, 128)}41+ if got, ok := broadcastAddr(n); !ok || got.String() != "192.168.1.255" {42+ t.Errorf("16-byte form: %v %v", got, ok)43+ }44+ if targets := broadcastTargets(); len(targets) == 0 || !targets[0].Equal(net.IPv4bcast) {45+ t.Errorf("targets %v, want 255.255.255.255 first", targets)46+ }47+}48+49+func TestDiscoveryClient(t *testing.T) {50+ // The emulated device answers the search the client sends.51+ if !parseRequest(searchPacket(), DeviceIDFor("nas")) {52+ t.Error("the search isn't one a device answers")53+ }54+55+ id := DeviceIDFor("tuner")56+ from := net.ParseIP("169.254.88.116")57+ f, ok := parseReply(reply(id, 2, "http://169.254.88.116"), from)58+ if !ok || f.ID != id || f.Tuners != 2 || f.BaseURL != "http://169.254.88.116" {59+ t.Errorf("reply: %+v %v", f, ok)60+ }61+ // A name the network may not resolve gives way to the address it62+ // answered from; a port stays.63+ f, _ = parseReply(reply(id, 4, "http://hdhr-10afffff.local"), from)64+ if f.BaseURL != "http://169.254.88.116" {65+ t.Errorf("named base URL: %q", f.BaseURL)66+ }67+ f, _ = parseReply(reply(id, 2, "http://airwaves.local:5004"), from)68+ if f.BaseURL != "http://169.254.88.116:5004" {69+ t.Errorf("named base URL with a port: %q", f.BaseURL)70+ }71+72+ bad := reply(id, 2, "http://169.254.88.116")73+ bad[len(bad)-1] ^= 0xFF74+ if _, ok := parseReply(bad, from); ok {75+ t.Error("a reply with a bad CRC was read")76+ }77+ if _, ok := parseReply(searchPacket(), from); ok {78+ t.Error("a request was read as a reply")79+ }80+ // Other kinds of device (a record engine) aren't tuners.81+ var b bytes.Buffer82+ tlv(&b, tagDeviceType, u32(5))83+ tlv(&b, tagDeviceID, u32(id))84+ if _, ok := parseReply(packet(typeDiscoverRp, b.Bytes()), from); ok {85+ t.Error("a storage device was taken for a tuner")86+ }87+}88+89+func TestLongTag(t *testing.T) {90+ long := bytes.Repeat([]byte("x"), 300)91+ var b bytes.Buffer92+ tlv(&b, tagBaseURL, long)93+ tlv(&b, tagTunerCount, []byte{4})94+ var got [][]byte95+ if !eachTag(b.Bytes(), func(_ byte, v []byte) { got = append(got, v) }) {96+ t.Fatal("not well formed")97+ }98+ if len(got) != 2 || !bytes.Equal(got[0], long) || !bytes.Equal(got[1], []byte{4}) {99+ t.Errorf("values %q", got)100+ }101+ if eachTag(b.Bytes()[:b.Len()-1], func(byte, []byte) {}) {102+ t.Error("a truncated payload was well formed")103+ }104+}105+106+func TestDeviceIDOrAddress(t *testing.T) {107+ id := DeviceIDFor("tuner")108+ if got, ok := deviceID(fmt.Sprintf("%08X", id)); !ok || got != id {109+ t.Errorf("device ID: %08X %v", got, ok)110+ }111+ for _, s := range []string{"", "nas.local", "192.168.1.30", fmt.Sprintf("%08X", id^1)} {112+ if _, ok := deviceID(s); ok {113+ t.Errorf("%q taken for a device ID", s)114+ }115+ }116+}117+118+func TestURLs(t *testing.T) {119+ for _, tc := range []struct{ in, api, stream string }{120+ {"192.168.1.30", "http://192.168.1.30", "http://192.168.1.30:5004"},121+ {"http://hdhomerun.local/", "http://hdhomerun.local", "http://hdhomerun.local:5004"},122+ {" http://127.0.0.1:8080/discover.json", "http://127.0.0.1:8080", "http://127.0.0.1:8080"},123+ {"[fe80::1]", "http://[fe80::1]", "http://[fe80::1]:5004"},124+ // As a discovery reply gives it.125+ {"http://169.254.1.2:80", "http://169.254.1.2", "http://169.254.1.2:5004"},126+ } {127+ api, err := apiURL(tc.in)128+ if err != nil || api != tc.api || streamURL(api) != tc.stream {129+ t.Errorf("%q: %q %q %v, want %q %q", tc.in, api, streamURL(api), err, tc.api, tc.stream)130+ }131+ }132+ if _, err := apiURL(" "); err == nil {133+ t.Error("an empty address was accepted")134+ }135+}@@ -1,11 +1,15 @@
1-// Package hdhr makes Airwaves look like a SiliconDust HDHomeRun network2-// tuner, so apps such as Channels DVR, Jellyfin, Emby, VLC and the3-// HDHomeRun app can use its channels, including custom ones that have no4-// tuner behind them.1+// Package hdhr speaks SiliconDust's HDHomeRun protocols both ways.52 //6-// It serves the HDHomeRun HTTP API (discover.json, lineup.json, /auto/v3+// Server makes Airwaves look like an HDHomeRun network tuner, so apps such4+// as Channels DVR, Jellyfin, Emby, VLC and the HDHomeRun app can use its5+// channels, including custom ones that have no tuner behind them. It6+// serves the HDHomeRun HTTP API (discover.json, lineup.json, /auto/v77 // streams) plus an M3U playlist and XMLTV guide, and answers the UDP88 // discovery broadcast on port 65001.9+//10+// Device is a real HDHomeRun, found by the same discovery broadcast and11+// reached over its HTTP API: its lineup and channel scan, its tuners'12+// status and signal, and streams of whole RF channels.913 package hdhr1014 1115 import (@@ -91,8 +95,8 @@ func (s *Server) Handler() http.Handler {
9195 mux.HandleFunc("GET /lineup_status.json", func(w http.ResponseWriter, r *http.Request) {9296 writeJSON(w, map[string]any{"ScanInProgress": 0, "ScanPossible": 1, "Source": "Antenna", "SourceList": []string{"Antenna"}})9397 })94- // Apps ask the tuner to rescan; Tvheadend owns scanning, so accept and95- // do nothing.98+ // Apps ask the tuner to rescan. A scan takes every real tuner, so99+ // that's left to Reception's Scan for channels: accept and do nothing.96100 mux.HandleFunc("POST /lineup.post", func(w http.ResponseWriter, r *http.Request) {})97101 mux.HandleFunc("GET /lineup.json", func(w http.ResponseWriter, r *http.Request) {98102 lineup, err := s.Backend.Lineup(r.Context())@@ -0,0 +1,376 @@
1+// Package hdhrtest is a fake HDHomeRun for tests: its HTTP API (discover,2+// lineup, scan and status) and its streams of whole RF channels, with3+// tuners that can be taken by someone else and RF channels that carry a4+// multiplex or can't be locked.5+package hdhrtest6+7+import (8+ "encoding/json"9+ "fmt"10+ "net/http"11+ "net/http/httptest"12+ "slices"13+ "strconv"14+ "strings"15+ "sync"16+ "testing"17+ "time"18+19+ "airwaves/internal/hdhr"20+)21+22+// OtherFrequency is what a tuner taken by someone else (Busy) is tuned to23+// in status.json: RF 2.24+const OtherFrequency = 57_000_00025+26+// OtherTarget is where a tuner taken by someone else sends its stream.27+const OtherTarget = "192.0.2.9"28+29+// Device describes the fake device.30+type Device struct {31+ // ID, FriendlyName, Model, Firmware and Version default to a FLEX32+ // DUO's.33+ ID string34+ FriendlyName string35+ Model string36+ Firmware string37+ Version string38+ // Tuners defaults to 2.39+ Tuners int40+ // Lineup is what lineup.json lists, and Rescan what it lists after a41+ // channel scan (nil keeps Lineup).42+ Lineup []hdhr.Channel43+ Rescan []hdhr.Channel44+ // ScanTime is how long a scan runs; 50 ms by default.45+ ScanTime time.Duration46+ // Mux is what the RF channel at freqHz carries, sent over and over47+ // while a client reads. Nil, or an empty multiplex, can't be locked:48+ // the request then waits without an answer until the client goes49+ // away, while status.json shows the tuner on that frequency.50+ Mux func(freqHz int64) []byte51+ // Rate is how fast an RF channel streams, in bytes a second: about a52+ // broadcast's 19.4 Mb/s by default.53+ Rate int54+ // Signal is what status.json reports for a tuner on freqHz: strength,55+ // signal to noise quality and symbol quality, in percent. By default56+ // 90, 95 and 100 where Mux has something, else 40, 0 and 0.57+ Signal func(freqHz int64) (strength, quality, symbol int)58+}59+60+// Server is a running fake device.61+type Server struct {62+ d Device63+ srv *httptest.Server64+ quit chan struct{}65+66+ mu sync.Mutex67+ lineup []hdhr.Channel68+ tuners []tuner69+ scanning bool70+ scanFrom time.Time71+ scans int72+}73+74+type tuner struct {75+ freq int64 // what it's tuned to; 0 while idle76+ open bool // a client of ours streams from it77+ locked bool // its RF channel has a multiplex78+ busy bool // someone else has it79+}80+81+// New starts a fake device, closed when the test ends.82+func New(t testing.TB, d Device) *Server {83+ t.Helper()84+ d.ID = or(d.ID, fmt.Sprintf("%08X", hdhr.DeviceIDFor("hdhrtest")))85+ d.FriendlyName = or(d.FriendlyName, "HDHomeRun FLEX DUO")86+ d.Model = or(d.Model, "HDFX-2US")87+ d.Firmware = or(d.Firmware, "hdhomerun_dvr_atsc")88+ d.Version = or(d.Version, "20250623")89+ if d.Tuners <= 0 {90+ d.Tuners = 291+ }92+ if d.ScanTime <= 0 {93+ d.ScanTime = 50 * time.Millisecond94+ }95+ s := &Server{d: d, quit: make(chan struct{}), lineup: slices.Clone(d.Lineup), tuners: make([]tuner, d.Tuners)}96+ mux := http.NewServeMux()97+ mux.HandleFunc("GET /discover.json", s.discover)98+ mux.HandleFunc("GET /lineup.json", s.lineupJSON)99+ mux.HandleFunc("GET /lineup_status.json", s.lineupStatus)100+ mux.HandleFunc("POST /lineup.post", s.lineupPost)101+ mux.HandleFunc("GET /status.json", s.status)102+ mux.HandleFunc("GET /{tuner}/{ch}", s.stream)103+ s.srv = httptest.NewServer(mux)104+ t.Cleanup(func() {105+ close(s.quit)106+ s.srv.CloseClientConnections()107+ s.srv.Close()108+ })109+ return s110+}111+112+func or(s, def string) string {113+ if s == "" {114+ return def115+ }116+ return s117+}118+119+// URL is the device's base URL, for its API and its streams alike.120+func (s *Server) URL() string { return s.srv.URL }121+122+// Busy has someone else take tuner, as another app on the network would.123+func (s *Server) Busy(tuner int) {124+ s.mu.Lock()125+ s.tuners[tuner].busy = true126+ s.mu.Unlock()127+}128+129+// Free gives back a tuner Busy took.130+func (s *Server) Free(tuner int) {131+ s.mu.Lock()132+ s.tuners[tuner].busy = false133+ s.mu.Unlock()134+}135+136+// Streams counts the streams open now.137+func (s *Server) Streams() int {138+ s.mu.Lock()139+ defer s.mu.Unlock()140+ n := 0141+ for _, t := range s.tuners {142+ if t.open {143+ n++144+ }145+ }146+ return n147+}148+149+// Scans counts the channel scans started.150+func (s *Server) Scans() int {151+ s.mu.Lock()152+ defer s.mu.Unlock()153+ return s.scans154+}155+156+// settle ends a scan whose time is up. s.mu is held.157+func (s *Server) settle() {158+ if s.scanning && time.Since(s.scanFrom) >= s.d.ScanTime {159+ s.scanning = false160+ if s.d.Rescan != nil {161+ s.lineup = slices.Clone(s.d.Rescan)162+ }163+ }164+}165+166+func (s *Server) discover(w http.ResponseWriter, r *http.Request) {167+ writeJSON(w, map[string]any{168+ "FriendlyName": s.d.FriendlyName, "ModelNumber": s.d.Model,169+ "FirmwareName": s.d.Firmware, "FirmwareVersion": s.d.Version, "DeviceID": s.d.ID,170+ "BaseURL": s.srv.URL, "LineupURL": s.srv.URL + "/lineup.json", "TunerCount": s.d.Tuners,171+ })172+}173+174+func (s *Server) lineupJSON(w http.ResponseWriter, r *http.Request) {175+ s.mu.Lock()176+ s.settle()177+ lineup := slices.Clone(s.lineup)178+ s.mu.Unlock()179+ tuning := r.URL.Query().Has("tuning")180+ out := make([]map[string]any, 0, len(lineup))181+ for _, c := range lineup {182+ e := map[string]any{"GuideNumber": c.Number, "GuideName": c.Name}183+ if tuning {184+ e["TransportStreamID"], e["Modulation"], e["Frequency"], e["ProgramNumber"] = c.TSID, or(c.Modulation, "8vsb"), c.FrequencyHz, c.Program185+ }186+ e["VideoCodec"], e["AudioCodec"] = or(c.VideoCodec, "MPEG2"), or(c.AudioCodec, "AC3")187+ for name, on := range map[string]bool{"HD": c.HD, "Favorite": c.Favorite, "DRM": c.DRM, "ATSC3": c.ATSC3} {188+ if on {189+ e[name] = 1190+ }191+ }192+ e["SignalStrength"], e["SignalQuality"] = c.SignalStrength, c.SignalQuality193+ e["URL"] = s.srv.URL + "/auto/v" + c.Number194+ out = append(out, e)195+ }196+ writeJSON(w, out)197+}198+199+func (s *Server) lineupStatus(w http.ResponseWriter, r *http.Request) {200+ s.mu.Lock()201+ s.settle()202+ scanning, from, found := s.scanning, s.scanFrom, len(s.lineup)203+ s.mu.Unlock()204+ if scanning {205+ progress := min(99, int(100*time.Since(from)/s.d.ScanTime))206+ writeJSON(w, map[string]any{"ScanInProgress": 1, "Progress": progress, "Found": found})207+ return208+ }209+ writeJSON(w, map[string]any{"ScanInProgress": 0, "ScanPossible": 1, "Source": "Antenna", "SourceList": []string{"Antenna", "Cable"}})210+}211+212+func (s *Server) lineupPost(w http.ResponseWriter, r *http.Request) {213+ s.mu.Lock()214+ defer s.mu.Unlock()215+ s.settle()216+ switch r.URL.Query().Get("scan") {217+ case "start":218+ s.scans++219+ s.scanning, s.scanFrom = true, time.Now()220+ case "abort":221+ s.scanning = false222+ default:223+ http.Error(w, "bad request", http.StatusBadRequest)224+ }225+}226+227+func (s *Server) signal(freq int64, locked bool) (int, int, int) {228+ if s.d.Signal != nil {229+ return s.d.Signal(freq)230+ }231+ if locked {232+ return 90, 95, 100233+ }234+ return 40, 0, 0235+}236+237+func (s *Server) status(w http.ResponseWriter, r *http.Request) {238+ s.mu.Lock()239+ tuners := slices.Clone(s.tuners)240+ s.mu.Unlock()241+ out := make([]map[string]any, 0, len(tuners))242+ for i, t := range tuners {243+ e := map[string]any{"Resource": fmt.Sprintf("tuner%d", i)}244+ switch {245+ case t.busy:246+ e["Frequency"], e["TargetIP"], e["NetworkRate"] = OtherFrequency, OtherTarget, 19392658247+ e["SignalStrengthPercent"], e["SignalQualityPercent"], e["SymbolQualityPercent"] = 90, 95, 100248+ case t.open:249+ ss, snq, seq := s.signal(t.freq, t.locked)250+ e["Frequency"], e["TargetIP"] = t.freq, "127.0.0.1"251+ e["SignalStrengthPercent"], e["SignalQualityPercent"], e["SymbolQualityPercent"] = ss, snq, seq252+ if t.locked {253+ e["NetworkRate"] = 19392658254+ } else {255+ e["NetworkRate"] = 0256+ }257+ }258+ out = append(out, e)259+ }260+ writeJSON(w, out)261+}262+263+// stream serves /auto/ch<freq> and /tuner<n>/ch<freq>: the whole RF264+// channel, on any free tuner or the one asked for.265+func (s *Server) stream(w http.ResponseWriter, r *http.Request) {266+ where, ch := r.PathValue("tuner"), r.PathValue("ch")267+ freq, err := strconv.ParseInt(strings.TrimPrefix(ch, "ch"), 10, 64)268+ if !strings.HasPrefix(ch, "ch") || err != nil || freq < 1_000_000 {269+ refuse(w, http.StatusNotFound, "801 Unknown Channel")270+ return271+ }272+ var mux []byte273+ if s.d.Mux != nil {274+ mux = s.d.Mux(freq)275+ }276+ n, status, reason := s.take(where, freq, len(mux) > 0)277+ if n < 0 {278+ refuse(w, status, reason)279+ return280+ }281+ defer s.release(n)282+ if len(mux) == 0 {283+ // No lock: no answer at all, as the device does.284+ select {285+ case <-r.Context().Done():286+ case <-s.quit:287+ }288+ return289+ }290+ w.Header().Set("Content-Type", "video/mpeg")291+ w.Header().Set("X-HDHomeRun-Resource", fmt.Sprintf("tuner%d", n))292+ w.WriteHeader(http.StatusOK)293+ flusher, _ := w.(http.Flusher)294+ // Whole packets every 20 ms, at Rate, round and round the multiplex.295+ rate := s.d.Rate296+ if rate <= 0 {297+ rate = 2_425_000298+ }299+ chunk := max(rate/50/188, 1) * 188300+ tick := time.NewTicker(20 * time.Millisecond)301+ defer tick.Stop()302+ for at := 0; ; {303+ for n := 0; n < chunk; {304+ part := mux[at:min(len(mux), at+chunk-n)]305+ if _, err := w.Write(part); err != nil {306+ return307+ }308+ n += len(part)309+ at = (at + len(part)) % len(mux)310+ }311+ if flusher != nil {312+ flusher.Flush()313+ }314+ select {315+ case <-r.Context().Done():316+ return317+ case <-s.quit:318+ return319+ case <-tick.C:320+ }321+ }322+}323+324+// take allocates a tuner for a stream; n is -1 when it can't, with the325+// status and X-HDHomeRun-Error to refuse with.326+func (s *Server) take(where string, freq int64, locked bool) (n, status int, reason string) {327+ s.mu.Lock()328+ defer s.mu.Unlock()329+ s.settle()330+ if s.scanning {331+ return -1, http.StatusServiceUnavailable, "803 System Busy"332+ }333+ free := func(i int) bool { return !s.tuners[i].open && !s.tuners[i].busy }334+ n = -1335+ if where == "auto" {336+ for i := range s.tuners {337+ if free(i) {338+ n = i339+ break340+ }341+ }342+ if n < 0 {343+ return -1, http.StatusServiceUnavailable, "805 All Tuners In Use"344+ }345+ } else {346+ i, err := strconv.Atoi(strings.TrimPrefix(where, "tuner"))347+ if !strings.HasPrefix(where, "tuner") || err != nil || i < 0 || i >= len(s.tuners) {348+ return -1, http.StatusNotFound, ""349+ }350+ if !free(i) {351+ return -1, http.StatusServiceUnavailable, "804 Tuner In Use"352+ }353+ n = i354+ }355+ s.tuners[n] = tuner{freq: freq, open: true, locked: locked}356+ return n, 0, ""357+}358+359+func (s *Server) release(n int) {360+ s.mu.Lock()361+ busy := s.tuners[n].busy362+ s.tuners[n] = tuner{busy: busy}363+ s.mu.Unlock()364+}365+366+func refuse(w http.ResponseWriter, status int, reason string) {367+ if reason != "" {368+ w.Header().Set("X-HDHomeRun-Error", reason)369+ }370+ http.Error(w, http.StatusText(status), status)371+}372+373+func writeJSON(w http.ResponseWriter, v any) {374+ w.Header().Set("Content-Type", "application/json")375+ _ = json.NewEncoder(w).Encode(v)376+}