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 / hdhrtest / hdhrtest.go
hdhrtest.go 10.2K
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.5package hdhrtest67import (8 "encoding/json"9 "fmt"10 "net/http"11 "net/http/httptest"12 "slices"13 "strconv"14 "strings"15 "sync"16 "testing"17 "time"1819 "airwaves/internal/hdhr"20)2122// OtherFrequency is what a tuner taken by someone else (Busy) is tuned to23// in status.json: RF 2.24const OtherFrequency = 57_000_0002526// OtherTarget is where a tuner taken by someone else sends its stream.27const OtherTarget = "192.0.2.9"2829// Device describes the fake device.30type 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}5960// Server is a running fake device.61type Server struct {62 d Device63 srv *httptest.Server64 quit chan struct{}6566 mu sync.Mutex67 lineup []hdhr.Channel68 tuners []tuner69 scanning bool70 scanFrom time.Time71 scans int72}7374type 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}8081// New starts a fake device, closed when the test ends.82func 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}111112func or(s, def string) string {113 if s == "" {114 return def115 }116 return s117}118119// URL is the device's base URL, for its API and its streams alike.120func (s *Server) URL() string { return s.srv.URL }121122// Busy has someone else take tuner, as another app on the network would.123func (s *Server) Busy(tuner int) {124 s.mu.Lock()125 s.tuners[tuner].busy = true126 s.mu.Unlock()127}128129// Free gives back a tuner Busy took.130func (s *Server) Free(tuner int) {131 s.mu.Lock()132 s.tuners[tuner].busy = false133 s.mu.Unlock()134}135136// Streams counts the streams open now.137func (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}148149// Scans counts the channel scans started.150func (s *Server) Scans() int {151 s.mu.Lock()152 defer s.mu.Unlock()153 return s.scans154}155156// settle ends a scan whose time is up. s.mu is held.157func (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}165166func (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}173174func (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}198199func (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}211212func (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}226227func (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}236237func (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}262263// stream serves /auto/ch<freq> and /tuner<n>/ch<freq>: the whole RF264// channel, on any free tuner or the one asked for.265func (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}323324// take allocates a tuner for a stream; n is -1 when it can't, with the325// status and X-HDHomeRun-Error to refuse with.326func (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}358359func (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}365366func 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}372373func writeJSON(w http.ResponseWriter, v any) {374 w.Header().Set("Content-Type", "application/json")375 _ = json.NewEncoder(w).Encode(v)376}