clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / hdhr / device_test.go
device_test.go 8.2K
1package hdhr_test23import (4 "bytes"5 "context"6 "errors"7 "io"8 "net/http"9 "net/http/httptest"10 "reflect"11 "testing"12 "time"1314 "airwaves/internal/hdhr"15 "airwaves/internal/hdhr/hdhrtest"16)1718var 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}2324// mux is a stand-in multiplex: a few null packets.25var mux = bytes.Repeat(append([]byte{0x47, 0x1f, 0xff, 0x10}, make([]byte, 184)...), 7)2627func 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}4748func 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}6465func 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}7576// TestLenient reads what devices write in other ways: numbers as strings,77// flags as booleans, a decimal place, and fields it doesn't know.78func 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}112113func 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 }138139 // 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()145146 // 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}154155func 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()179180 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}190191// TestNoLock: an RF channel the tuner can't lock gets no answer, while192// status.json shows the tuner on it with what it measures.193func 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}215216func 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}246247func 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}