The antenna lineup is now exactly the channels Tvheadend found and mapped, with FCC records and listings only adding detail to them; channels that were predicted but never received are gone, and without a tuner there are none. Signal is measured, never predicted: Tvheadend's scan results, readings of any tuner in use every 10 s, and a "measure now" sweep that borrows only idle tuners at the lowest weight, kept in signal.json and served as /api/signal. A tune with no lock fails with what the tuner saw. Demo channels are off by default and removed once a real tuner exists. Reception estimates no longer reach the app: /api/preview and /api/profile answer 410, and otascan keeps the predictions.
- commit
- cae5de7e3791442145b7fb1cb4f3a671a4292084
- parent
- 4ba3c2ac0d1574b9d4f1ce5a458537faeaf9ca8a
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
@@ -71,14 +71,30 @@ each browser.
7171 **The tuner** is an HDHomeRun (for example a FLEX DUO) with the antenna on its coax7272 input. Both containers use host networking so Tvheadend can find it by broadcast, on7373 the LAN or on a spare Ethernet port with link-local addressing that the firewall trusts.74-Until a tuner is found, `airwavesd` gives Tvheadend generated test-pattern channels so75-live TV and recording can be exercised.74+For development without one, `AIRWAVES_DEMO=true` gives Tvheadend generated75+test-pattern channels until a tuner is found.7676 7777 When the HDHomeRun appears, `airwavesd` creates an "Airwaves antenna" network from the7878 US ATSC channel list, attaches its tuners, and lets Tvheadend scan. Channels are mapped as79-they are found and the demo channels are removed once real ones exist. The scan takes a80-few minutes. This path has not run against real hardware yet: if channels show up under81-the wrong numbers, check them in Tvheadend's Configuration > Channel / EPG.79+they are found, and any demo channels are removed for good. The scan takes a few minutes;80+if channels show up under the wrong numbers, check them in Tvheadend's Configuration >81+Channel / EPG.82+83+**The lineup is the tuner's.** The app's antenna channels are exactly the channels84+Tvheadend's scan found; the FCC's records, the Gracenote listings and the ATSC 3.0 list85+only describe them (call sign, network, transmitter, listings), never add one. Without a86+tuner there are no antenna channels. A channel that won't lock right now stays listed,87+and tuning it says so: "No signal on RF 31 (KTVD 20.x) right now".88+89+**Signal is measured, never estimated.** `airwavesd` records, per RF channel, Tvheadend's90+scan result and readings of the tuner (strength and quality as the HDHomeRun reports them,91+in percent; SNR in dB, BER and uncorrected blocks from tuners that report them): every92+10 seconds while a tuner is in use, and on request (`POST /api/signal/measure`, Measure93+now), which briefly tunes each RF channel worth measuring on an idle tuner, at the lowest94+weight a viewer's can have, so any viewer or recording takes the tuner over and the95+sweep stops. Readings are kept in `<data>/signal.json` and served at `GET /api/signal`96+and in the snapshot's `antenna` section. Terrain-based estimates are for planning an97+antenna, in `otascan`, and aren't served to the app.8298 8399 Recording rules are Airwaves': one airing, or a series by Gracenote series ID on one84100 channel, optionally new episodes only. Series rules skip episodes already recorded or@@ -571,14 +587,15 @@ cmd/otascan terminal report
571587 internal/service the engine airwavesd runs572588 internal/api HTTP API, and a client with the same interface573589 internal/dvr recording rules and reconciliation with Tvheadend574-internal/tvh Tvheadend API: channels, DVR entries, network setup590+internal/tvh Tvheadend API: channels, DVR entries, network setup, input status; tvhtest fakes it575591 internal/fcc FCC TV Query client and parser576592 internal/terrain Terrarium elevation tiles and path profiles577593 internal/reception propagation model and antenna presets578594 internal/atsc3 RabbitEars ATSC 3.0 list parser579595 internal/guide Gracenote lineup and listings580-internal/lineup merges all of the above into a Report581-internal/tuner Tvheadend input, demo source596+internal/lineup merges all of the above into a Report, and describes the tuner's channels from it597+internal/signal what the tuners measured per RF channel, kept across restarts598+internal/tuner Tvheadend input, demo test patterns582599 internal/stream ffmpeg to HLS, one session per client583600 internal/hdhr emulated HDHomeRun: discovery, lineup, streams, XMLTV584601 internal/vchan custom channels: Airwaves Weather, folder, Jellyfin and YouTube channels@@ -588,8 +605,9 @@ deploy/standalone Docker Compose for custom channels only, built from so
588605 frontend/dist the UI: plain HTML, CSS and JS, no build step; also served at /tv/589606 ```590607 591-`go test ./...` covers the parsers, the propagation model, recording rules against a592-fake Tvheadend, the API client and server, the emulated HDHomeRun's discovery and HTTP608+`go test ./...` covers the parsers, the propagation model, recording rules, the tuner609+lineup, signal measuring and demo cleanup against a fake Tvheadend (answering with a real610+one's JSON), the API client and server, the emulated HDHomeRun's discovery and HTTP593611 API, and an end-to-end ffmpeg HLS stream.594612 595613 ## License@@ -189,25 +189,32 @@ func (a *App) Scan(refresh bool) (*service.Snapshot, error) {
189189 return c.Snapshot(a.ctx, refresh)190190 }191191 192-// Profile returns the terrain between the viewer (or a previewed ZIP) and193-// a facility.194-func (a *App) Profile(facilityID, rf int, zip string) (*service.PathProfile, error) {192+// Signal returns what the server's tuners measured, by RF channel and by193+// channel, and how Measure now is going.194+func (a *App) Signal() (*service.SignalReport, error) {195195 c, _, err := a.server()196196 if err != nil {197197 return nil, err198198 }199- return c.Profile(a.ctx, facilityID, rf, zip)199+ return c.Signal(a.ctx)200200 }201201 202-// Preview returns a reception report for another ZIP code.203-func (a *App) Preview(zip string) (*service.Snapshot, error) {202+// Measure starts measuring every RF channel on an idle tuner.203+func (a *App) Measure() (*service.SweepStatus, error) {204204 c, _, err := a.server()205205 if err != nil {206206 return nil, err207207 }208- return c.Preview(a.ctx, zip)208+ return c.Measure(a.ctx)209209 }210210 211+// Profile and Preview answered with reception estimates, which the app212+// no longer shows; they stay until its interface stops calling them.213+func (a *App) Profile(int, int, string) (any, error) { return nil, api.ErrEstimates }214+215+// Preview: see Profile.216+func (a *App) Preview(string) (any, error) { return nil, api.ErrEstimates }217+211218 // Weather returns the local weather report from the server.212219 func (a *App) Weather() (*weather.Report, error) {213220 c, _, err := a.server()@@ -58,7 +58,7 @@ func run() error {
5858 lon := flag.Float64("lon", envFloat("AIRWAVES_LON"), "antenna longitude")5959 name := flag.String("name", env("AIRWAVES_NAME", host), "server name shown in the app")6060 token := flag.String("token", env("AIRWAVES_TOKEN", ""), "optional bearer token for the API")61- demo := flag.Bool("demo", env("AIRWAVES_DEMO", "true") == "true", "give Tvheadend generated channels until a tuner is attached")61+ demo := flag.Bool("demo", env("AIRWAVES_DEMO", "false") == "true", "for development: give Tvheadend generated test-pattern channels until a tuner is attached (never once one has been)")6262 demoFFmpeg := flag.String("demo-ffmpeg", env("AIRWAVES_DEMO_FFMPEG", "/usr/bin/ffmpeg"), "ffmpeg path inside the Tvheadend container")6363 weatherStar := flag.Int("weatherstar-port", int(envFloat("AIRWAVES_WEATHERSTAR_PORT")), "host port of a WeatherStar 4000+ container; 0 disables the WX channel")6464 weatherStarURL := flag.String("weatherstar-url", os.Getenv("AIRWAVES_WEATHERSTAR_URL"), "where airwavesd reaches the WeatherStar display, when not at 127.0.0.1 on -weatherstar-port (http://weatherstar:8080 for a container of its own)")@@ -115,6 +115,7 @@ func run() error {
115115 ConfigPath: filepath.Join(*data, "config.json"),116116 Config: service.Config{ZIP: *zip, GuideHours: 48},117117 DVRPath: filepath.Join(*data, "dvr.json"),118+ SignalPath: filepath.Join(*data, "signal.json"),118119 Demo: *demo,119120 DemoFFmpeg: *demoFFmpeg,120121 NoAntenna: !feat.antenna,@@ -165,7 +166,10 @@ func run() error {
165166 }()166167 log.Printf("airwavesd %s listening on %s", service.Version, *listen)167168 if feat.antenna {168- log.Printf("antenna: on, Tvheadend %q", *tvhURL)169+ log.Printf("antenna: on, Tvheadend %q; signal measurements in %s", *tvhURL, opt.SignalPath)170+ if *demo {171+ log.Print("demo channels: on until a tuner is found (AIRWAVES_DEMO=true)")172+ }169173 } else {170174 log.Print("antenna: off, custom channels only")171175 }@@ -14,7 +14,6 @@ import (
1414 "io/fs"1515 "log"1616 "net/http"17- "strconv"1817 "strings"1918 2019 "airwaves/internal/dvr"@@ -30,8 +29,10 @@ type Backend interface {
3029 Config(ctx context.Context) (service.Config, error)3130 SetConfig(ctx context.Context, c service.Config) (service.Config, error)3231 Snapshot(ctx context.Context, refresh bool) (*service.Snapshot, error)33- Profile(ctx context.Context, facilityID, rf int, zip string) (*service.PathProfile, error)34- Preview(ctx context.Context, zip string) (*service.Snapshot, error)32+ // Signal is what the tuners measured, by RF channel and by channel.33+ Signal(ctx context.Context) (*service.SignalReport, error)34+ // Measure starts measuring every RF channel on an idle tuner.35+ Measure(ctx context.Context) (*service.SweepStatus, error)3536 Weather(ctx context.Context) (*weather.Report, error)3637 Tune(ctx context.Context, client, number string) (*stream.Playback, error)3738 Stop(ctx context.Context, client string) error@@ -51,6 +52,10 @@ var (
5152 _ Backend = (*Client)(nil)5253 )5354 55+// ErrEstimates answers the calls for reception estimates the app used to56+// make.57+var ErrEstimates = errors.New("reception estimates moved to the otascan planning tool: the app shows what the tuner measures")58+5459 // Server serves a Backend.5560 type Server struct {5661 Backend Backend@@ -104,8 +109,8 @@ func (s *Server) Handler() http.Handler {
104109 mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {105110 reply(w)(b.Config(r.Context()))106111 })107- // The antenna presets reception is estimated for, which the desktop108- // app has built in, for the web app.112+ // The antenna presets of the reception estimates, which TV pages from113+ // before the measured lineup still ask for when they start.109114 mux.HandleFunc("GET /api/presets", func(w http.ResponseWriter, r *http.Request) {110115 reply(w)(reception.Presets, nil)111116 })@@ -119,16 +124,22 @@ func (s *Server) Handler() http.Handler {
119124 mux.HandleFunc("GET /api/snapshot", func(w http.ResponseWriter, r *http.Request) {120125 reply(w)(b.Snapshot(r.Context(), r.URL.Query().Get("refresh") == "1"))121126 })127+ mux.HandleFunc("GET /api/signal", func(w http.ResponseWriter, r *http.Request) {128+ reply(w)(b.Signal(r.Context()))129+ })130+ mux.HandleFunc("POST /api/signal/measure", func(w http.ResponseWriter, r *http.Request) {131+ reply(w)(b.Measure(r.Context()))132+ })133+ // Reception estimates (terrain profiles, ZIP code previews) are for134+ // planning an antenna, in otascan; the app shows what was measured.122135 mux.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {123- fac, _ := strconv.Atoi(r.URL.Query().Get("facility"))124- rf, _ := strconv.Atoi(r.URL.Query().Get("rf"))125- reply(w)(b.Profile(r.Context(), fac, rf, r.URL.Query().Get("zip")))136+ writeErr(w, http.StatusGone, ErrEstimates)126137 })127138 mux.HandleFunc("GET /api/weather", func(w http.ResponseWriter, r *http.Request) {128139 reply(w)(b.Weather(r.Context()))129140 })130141 mux.HandleFunc("GET /api/preview", func(w http.ResponseWriter, r *http.Request) {131- reply(w)(b.Preview(r.Context(), r.URL.Query().Get("zip")))142+ writeErr(w, http.StatusGone, ErrEstimates)132143 })133144 mux.HandleFunc("POST /api/tune", func(w http.ResponseWriter, r *http.Request) {134145 var req clientReq@@ -285,11 +296,15 @@ func reply(w http.ResponseWriter) func(any, error) {
285296 }286297 287298 // errStatus is the HTTP status for a backend error: 501 for what this288-// server doesn't have (an antenna, recording, a location for the weather),289-// else 500.299+// server doesn't have (an antenna, a tuner, recording, a location for the300+// weather), 503 for a channel with no signal right now, else 500.290301 func errStatus(err error) int {291- if errors.Is(err, service.ErrNoAntenna) || errors.Is(err, service.ErrNoDVR) || errors.Is(err, service.ErrNoLocation) {302+ var noSignal *service.NoSignalError303+ switch {304+ case errors.Is(err, service.ErrNoAntenna), errors.Is(err, service.ErrNoTuner), errors.Is(err, service.ErrNoDVR), errors.Is(err, service.ErrNoLocation):292305 return http.StatusNotImplemented306+ case errors.As(err, &noSignal):307+ return http.StatusServiceUnavailable293308 }294309 return http.StatusInternalServerError295310 }@@ -11,10 +11,10 @@ import (
1111 "testing"1212 1313 "airwaves/internal/dvr"14- "airwaves/internal/geo"1514 "airwaves/internal/guide"1615 "airwaves/internal/lineup"1716 "airwaves/internal/service"17+ "airwaves/internal/signal"1818 "airwaves/internal/stream"1919 "airwaves/internal/weather"2020 )@@ -23,6 +23,7 @@ import (
2323 type fake struct {2424 tuned, stopped, deleted, progress string2525 cfg service.Config26+ measured int2627 }2728 2829 func (f *fake) Info(context.Context) (service.Info, error) {@@ -41,16 +42,24 @@ func (f *fake) Snapshot(context.Context, bool) (*service.Snapshot, error) {
4142 }}, nil4243 }4344 44-func (f *fake) Profile(context.Context, int, int, string) (*service.PathProfile, error) {45- return nil, errors.New("facility 1 not found")45+func (f *fake) Signal(context.Context) (*service.SignalReport, error) {46+ strength := 85.047+ return &service.SignalReport{48+ Tuner: service.TunerInfo{Ready: true, Tuners: 2, Standards: []string{"ATSC 1.0"}},49+ Channels: []service.ChannelSignal{{Number: "20.1", RF: 31, Signal: &signal.Reading{Lock: true, StrengthPct: &strength}}},50+ }, nil4651 }4752 48-func (f *fake) Weather(context.Context) (*weather.Report, error) {49- return &weather.Report{Alerts: []weather.Alert{{Event: "Winter Storm Warning"}}}, nil53+func (f *fake) Measure(context.Context) (*service.SweepStatus, error) {54+ f.measured++55+ if f.measured > 1 {56+ return nil, errors.New("tuner unplugged")57+ }58+ return &service.SweepStatus{Running: true, Total: 12}, nil5059 }5160 52-func (f *fake) Preview(_ context.Context, zip string) (*service.Snapshot, error) {53- return &service.Snapshot{Report: &lineup.Report{Place: geo.Place{ZIP: zip}}}, nil61+func (f *fake) Weather(context.Context) (*weather.Report, error) {62+ return &weather.Report{Alerts: []weather.Alert{{Event: "Winter Storm Warning"}}}, nil5463 }5564 5665 func (f *fake) Tune(_ context.Context, client, number string) (*stream.Playback, error) {@@ -138,14 +147,61 @@ func TestClientServerRoundTrip(t *testing.T) {
138147 if wx, err := c.Weather(ctx); err != nil || len(wx.Alerts) != 1 {139148 t.Errorf("weather: %+v, %v", wx, err)140149 }141- if snap, err := c.Preview(ctx, "80302"); err != nil || snap.Report.Place.ZIP != "80302" {142- t.Errorf("preview: %+v, %v", snap, err)150+ if sig, err := c.Signal(ctx); err != nil || !sig.Tuner.Ready || len(sig.Channels) != 1 || sig.Channels[0].RF != 31 ||151+ !sig.Channels[0].Signal.Lock || *sig.Channels[0].Signal.StrengthPct != 85 || sig.Channels[0].Signal.QualityPct != nil {152+ t.Errorf("signal: %+v, %v", sig, err)153+ }154+ if st, err := c.Measure(ctx); err != nil || !st.Running || st.Total != 12 {155+ t.Errorf("measure: %+v, %v", st, err)143156 }144- if _, err := c.Profile(ctx, 1, 2, ""); err == nil || err.Error() != "facility 1 not found" {157+ if _, err := c.Measure(ctx); err == nil || err.Error() != "tuner unplugged" {145158 t.Errorf("backend error not passed through: %v", err)146159 }147160 }148161 162+// TestNoEstimates: the reception estimates the app used to ask for are163+// gone, with a reason old app versions show; the presets they still load164+// at start remain.165+func TestNoEstimates(t *testing.T) {166+ srv := httptest.NewServer((&Server{Backend: &fake{}}).Handler())167+ defer srv.Close()168+ for _, path := range []string{"/api/preview?zip=80302", "/api/profile?facility=1&rf=2"} {169+ resp, err := http.Get(srv.URL + path)170+ if err != nil {171+ t.Fatal(err)172+ }173+ body, _ := io.ReadAll(resp.Body)174+ resp.Body.Close()175+ if resp.StatusCode != http.StatusGone || !strings.Contains(string(body), "otascan") {176+ t.Errorf("%s: %d %s", path, resp.StatusCode, body)177+ }178+ }179+ resp, err := http.Get(srv.URL + "/api/presets")180+ if err != nil {181+ t.Fatal(err)182+ }183+ resp.Body.Close()184+ if resp.StatusCode != http.StatusOK {185+ t.Errorf("presets: %d", resp.StatusCode)186+ }187+}188+189+// TestErrorStatus: what a server lacks is 501, a channel with no signal190+// now 503.191+func TestErrorStatus(t *testing.T) {192+ for err, want := range map[error]int{193+ service.ErrNoAntenna: http.StatusNotImplemented,194+ fmt.Errorf("x: %w", service.ErrNoTuner): http.StatusNotImplemented,195+ &service.NoSignalError{Channel: "20.1"}: http.StatusServiceUnavailable,196+ fmt.Errorf("x: %w", &service.NoSignalError{}): http.StatusServiceUnavailable,197+ errors.New("ffmpeg exited"): http.StatusInternalServerError,198+ } {199+ if got := errStatus(err); got != want {200+ t.Errorf("%v: %d, want %d", err, got, want)201+ }202+ }203+}204+149205 // TestLogosNeedNoToken: players and guides fetch logos as images, with no150206 // way to send the API's token.151207 func TestLogosNeedNoToken(t *testing.T) {@@ -94,7 +94,7 @@ func TestHealthNeedsNoToken(t *testing.T) {
9494 // noAntenna is a server with custom channels only.9595 type noAntenna struct{ fake }9696 97-func (noAntenna) Preview(context.Context, string) (*service.Snapshot, error) {97+func (noAntenna) Signal(context.Context) (*service.SignalReport, error) {9898 return nil, service.ErrNoAntenna9999 }100100 @@ -103,7 +103,7 @@ func (noAntenna) Preview(context.Context, string) (*service.Snapshot, error) {
103103 func TestNotAvailable(t *testing.T) {104104 srv := httptest.NewServer((&Server{Backend: &noAntenna{}}).Handler())105105 defer srv.Close()106- resp, err := http.Get(srv.URL + "/api/preview?zip=80302")106+ resp, err := http.Get(srv.URL + "/api/signal")107107 if err != nil {108108 t.Fatal(err)109109 }@@ -111,7 +111,7 @@ func TestNotAvailable(t *testing.T) {
111111 if resp.StatusCode != http.StatusNotImplemented {112112 t.Errorf("status %d", resp.StatusCode)113113 }114- if _, err := NewClient(srv.URL, "").Preview(t.Context(), "80302"); err == nil || err.Error() != service.ErrNoAntenna.Error() {114+ if _, err := NewClient(srv.URL, "").Signal(t.Context()); err == nil || err.Error() != service.ErrNoAntenna.Error() {115115 t.Errorf("client error: %v", err)116116 }117117 if errStatus(errors.New("boom")) != http.StatusInternalServerError {@@ -72,11 +72,16 @@ func (c *Client) Snapshot(ctx context.Context, refresh bool) (*service.Snapshot,
7272 return &s, c.call(ctx, http.MethodGet, p, nil, &s)7373 }7474 75-// Profile implements Backend.76-func (c *Client) Profile(ctx context.Context, facilityID, rf int, zip string) (*service.PathProfile, error) {77- var p service.PathProfile78- path := fmt.Sprintf("/api/profile?facility=%d&rf=%d&zip=%s", facilityID, rf, url.QueryEscape(zip))79- return &p, c.call(ctx, http.MethodGet, path, nil, &p)75+// Signal implements Backend.76+func (c *Client) Signal(ctx context.Context) (*service.SignalReport, error) {77+ var r service.SignalReport78+ return &r, c.call(ctx, http.MethodGet, "/api/signal", nil, &r)79+}80+81+// Measure implements Backend.82+func (c *Client) Measure(ctx context.Context) (*service.SweepStatus, error) {83+ var st service.SweepStatus84+ return &st, c.call(ctx, http.MethodPost, "/api/signal/measure", nil, &st)8085 }8186 8287 // Weather implements Backend.@@ -85,12 +90,6 @@ func (c *Client) Weather(ctx context.Context) (*weather.Report, error) {
8590 return &r, c.call(ctx, http.MethodGet, "/api/weather", nil, &r)8691 }8792 88-// Preview implements Backend.89-func (c *Client) Preview(ctx context.Context, zip string) (*service.Snapshot, error) {90- var s service.Snapshot91- return &s, c.call(ctx, http.MethodGet, "/api/preview?zip="+url.QueryEscape(zip), nil, &s)92-}93-9493 // Tune implements Backend.9594 func (c *Client) Tune(ctx context.Context, client, number string) (*stream.Playback, error) {9695 var pb stream.Playback@@ -1,6 +1,7 @@
1-// Package lineup combines transmitter data, terrain-based reception2-// estimates, ATSC 3.0 hosting and guide listings into one view of what is3-// on the air at a location.1+// Package lineup combines transmitter data, ATSC 3.0 hosting and guide2+// listings into one view of what is on the air at a location: with3+// terrain-based reception estimates for planning (otascan), or matched to4+// what a tuner actually receives (Match) for the app.45 package lineup56 67 import (@@ -24,11 +25,13 @@ import (
2425 "airwaves/internal/terrain"2526 )2627 27-// Station is a transmitter with its predicted reception.28+// Station is a transmitter, with its predicted reception when the report29+// was built with terrain.2830 type Station struct {2931 fcc.Facility3032 Band string `json:"band"`31- // Signal is the estimate for each antenna preset, keyed by preset name.33+ // Signal is the estimate for each antenna preset, keyed by preset name;34+ // empty without terrain.3235 Signal map[string]reception.Estimate `json:"signal"`3336 ATSC3 bool `json:"atsc3"`3437 // Carries lists the virtual channels this transmitter is matched to.@@ -80,7 +83,7 @@ type Report struct {
8083 Place geo.Place `json:"place"`8184 Point geo.Point `json:"point"`8285 RadiusKm float64 `json:"radiusKm"`83- Presets []reception.Preset `json:"presets"`86+ Presets []reception.Preset `json:"presets,omitempty"` // with estimates only8487 Stations []Station `json:"stations"`8588 Channels []Channel `json:"channels"`8689 ATSC3 []atsc3.Host `json:"atsc3"`@@ -99,8 +102,11 @@ type Options struct {
99102 100103 // Builder fetches and merges the data sources.101104 type Builder struct {102- HTTP *http.Client103- Cache *store.Cache105+ HTTP *http.Client106+ Cache *store.Cache107+ // Terrain, when set, has every transmitter's reception estimated from108+ // the terrain between it and the location. Without it the report is109+ // the records alone, and its stations are ordered by distance.104110 Terrain *terrain.Source105111 Progress func(string)106112 }@@ -114,7 +120,15 @@ func (b *Builder) progress(format string, args ...any) {
114120 // Build produces a report and the guide it was built from. Failures of115121 // optional sources (terrain, ATSC 3.0 list, listings) become warnings.116122 func (b *Builder) Build(ctx context.Context, opt Options) (*Report, *guide.Guide, error) {117- rep := &Report{Generated: time.Now(), RadiusKm: opt.RadiusKm, Presets: reception.Presets, Sources: sources}123+ rep := &Report{Generated: time.Now(), RadiusKm: opt.RadiusKm}124+ for _, src := range sources {125+ if b.Terrain != nil || src.Name != terrainSource {126+ rep.Sources = append(rep.Sources, src)127+ }128+ }129+ if b.Terrain != nil {130+ rep.Presets = reception.Presets131+ }118132 119133 b.progress("Locating ZIP %s", opt.ZIP)120134 place, err := store.Load(ctx, b.Cache, "zip-"+opt.ZIP, 365*24*time.Hour, false, func(ctx context.Context) (geo.Place, error) {@@ -163,9 +177,11 @@ func (b *Builder) Build(ctx context.Context, opt Options) (*Report, *guide.Guide
163177 return rep, g, nil164178 }165179 180+const terrainSource = "AWS Terrain Tiles"181+166182 var sources = []Source{167183 {"FCC TV Query", "https://www.fcc.gov/media/television/tv-query", "Licensed transmitters, power, height, location"},168- {"AWS Terrain Tiles", "https://registry.opendata.aws/terrain-tiles/", "Terrain profiles for reception estimates"},184+ {terrainSource, "https://registry.opendata.aws/terrain-tiles/", "Terrain profiles for reception estimates"},169185 {"RabbitEars.Info", "https://www.rabbitears.info/market.php?request=atsc3", "ATSC 3.0 (NextGen TV) hosting"},170186 {"Gracenote TV Listings", "https://tvlistings.gracenote.com/", "Over-the-air lineup and program guide"},171187 {"Zippopotam.us", "https://zippopotam.us/", "Location lookup"},@@ -176,8 +192,17 @@ func coordKey(p geo.Point) string {
176192 }177193 178194 // predict fetches a terrain profile per tower site and evaluates every179-// facility against each antenna preset.195+// facility against each antenna preset. Without terrain it lists the196+// facilities, nearest first.180197 func (b *Builder) predict(ctx context.Context, rep *Report, facilities []fcc.Facility) []Station {198+ if b.Terrain == nil {199+ stations := make([]Station, 0, len(facilities))200+ for _, f := range facilities {201+ stations = append(stations, Station{Facility: f, Band: fcc.Band(f.RFChannel), Signal: map[string]reception.Estimate{}})202+ }203+ slices.SortStableFunc(stations, func(a, b Station) int { return cmp.Compare(a.DistanceKm, b.DistanceKm) })204+ return stations205+ }181206 b.progress("Sampling terrain to %d transmitters", len(facilities))182207 var mu sync.Mutex183208 profiles := make(map[geo.Point]terrain.Profile)@@ -0,0 +1,102 @@
1+// Package lineuptest holds a report and listings for downtown Denver, to2+// match against the tuner lineup in internal/tvh/tvhtest: the stations on3+// Lookout Mountain whose RF channels that tuner's scan found, and some it4+// didn't (KMGH on VHF RF 7, KUSA's main channel, KWGN's ATSC 3.0 signal).5+package lineuptest6+7+import (8+ "time"9+10+ "airwaves/internal/atsc3"11+ "airwaves/internal/fcc"12+ "airwaves/internal/geo"13+ "airwaves/internal/guide"14+ "airwaves/internal/lineup"15+ "airwaves/internal/reception"16+)17+18+// Point is downtown Denver.19+var Point = geo.Point{Lat: 39.74, Lon: -104.99}20+21+// lookout is the Lookout Mountain antenna farm.22+var lookout = geo.Point{Lat: 39.7299, Lon: -105.2361}23+24+func station(call string, fac, rf, virtual int, kw float64) lineup.Station {25+ return lineup.Station{26+ Facility: fcc.Facility{27+ CallSign: call, BaseCall: fcc.BaseCall(call), Service: "DTV", RFChannel: rf, VirtualChannel: virtual, Status: "LIC",28+ City: "Denver", State: "CO", FacilityID: fac, ERPkW: kw, HAATm: 330, Point: lookout, DistanceKm: 21.1, BearingDeg: 267.9,29+ },30+ Band: fcc.Band(rf),31+ Signal: map[string]reception.Estimate{},32+ }33+}34+35+// Report is what is on record around downtown Denver, without estimates.36+func Report() *lineup.Report {37+ rep := &lineup.Report{38+ Generated: time.Now(),39+ Place: geo.Place{ZIP: "80302", Name: "Boulder", State: "CO", Point: geo.Point{Lat: 40.04, Lon: -105.28}},40+ Point: Point,41+ RadiusKm: 160,42+ Stations: []lineup.Station{43+ station("KMGH-TV", 40875, 7, 7, 30),44+ station("KETD", 83289, 15, 53, 400),45+ station("KUSA", 23074, 16, 9, 1000),46+ station("KTFD-DT", 68695, 28, 50, 500),47+ station("KDEN-TV", 10178, 29, 25, 475),48+ station("KTVD", 68581, 31, 20, 1000),49+ station("KCEC", 125, 32, 14, 450),50+ station("KRMA-TV", 14040, 33, 6, 1000),51+ station("KWGN-TV", 35883, 34, 2, 1000),52+ station("KCNC-TV", 47903, 35, 4, 1000),53+ station("KDVR", 126, 36, 31, 1000),54+ },55+ ATSC3: []atsc3.Host{{56+ Market: "Denver, Colorado", CallSign: "KWGN-TV", FacilityID: 35883, RF: "34", Launched: "2020-07-14", Simulcast: "KDVR",57+ Services: []atsc3.Service{58+ {Display: "02-1", Major: 2, Minor: 1, Network: "CW", Name: "KWGN", ATSC1Call: "KDVR", ATSC1Display: "02-1"},59+ {Display: "31-1", Major: 31, Minor: 1, Network: "FOX", Name: "KDVR", ATSC1Call: "KDVR", ATSC1Display: "31-1"},60+ {Display: "04-1", Major: 4, Minor: 1, Network: "CBS", Name: "KCNC", ATSC1Call: "KCNC-TV", ATSC1Display: "04-1"},61+ },62+ }},63+ Sources: []lineup.Source{{Name: "FCC TV Query", URL: "https://www.fcc.gov/media/television/tv-query", Use: "Licensed transmitters"}},64+ Warnings: []string{},65+ }66+ // A low-power station on KTVD's RF channel, nearer but on another67+ // virtual channel: not where 20.x comes from.68+ lp := station("K31AB-D", 999001, 31, 0, 3)69+ lp.Service, lp.DistanceKm, lp.BearingDeg = "LPD", 8, 9070+ rep.Stations = append(rep.Stations, lp)71+ return rep72+}73+74+// Guide is the listings: the main channels, and some subchannels.75+func Guide() *guide.Guide {76+ now := time.Now().Truncate(time.Hour)77+ ch := func(id, number, call, network string) guide.Channel {78+ return guide.Channel{ID: id, Number: number, CallSign: call, Network: network, Logo: "https://example.com/" + id + ".png"}79+ }80+ g := &guide.Guide{81+ Lineup: guide.LineupID("80302"), Start: now, End: now.Add(6 * time.Hour), Fetched: now,82+ Channels: []guide.Channel{83+ ch("g2", "2.1", "KWGNDT", "CW"),84+ ch("g4", "4.1", "KCNCDT", "CBS"),85+ ch("g6", "6.1", "KRMADT", "PBS"),86+ ch("g7", "7.1", "KMGHDT", "ABC"),87+ ch("g9", "9.1", "KUSADT", "NBC"),88+ ch("g94", "9.4", "KUSADT4", "NBC"),89+ ch("g14", "14.1", "KCECDT", "Univision"),90+ ch("g20", "20.1", "KTVDDT", "MyNetworkTV"),91+ ch("g31", "31.1", "KDVRDT", "FOX"),92+ },93+ Programs: map[string][]guide.Program{},94+ }95+ for _, c := range g.Channels {96+ g.Programs[c.ID] = []guide.Program{97+ {Start: now, End: now.Add(time.Hour), Title: c.CallSign + " News", ProgramID: "EP" + c.ID},98+ {Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Title: c.Network + " Movie", ProgramID: "MV" + c.ID},99+ }100+ }101+ return g102+}@@ -0,0 +1,228 @@
1+package lineup2+3+import (4+ "cmp"5+ "fmt"6+ "slices"7+ "strings"8+9+ "airwaves/internal/fcc"10+ "airwaves/internal/guide"11+ "airwaves/internal/reception"12+)13+14+// Scanned is a channel a tuner's scan found.15+type Scanned struct {16+ Number string // "20.1"17+ // Name is the broadcast's own name for it (its PSIP short name, as18+ // Tvheadend names the service): "KTVD-HD", "H & I".19+ Name string20+ // RF is the RF channel of the multiplex it came in on; 0 when unknown.21+ RF int22+}23+24+// TunerChannel is a channel the tuner receives, with what the FCC records,25+// the listings and the ATSC 3.0 list say about it. Those only describe it:26+// every TunerChannel is one the tuner found.27+type TunerChannel struct {28+ Number string `json:"number"`29+ Major int `json:"major"`30+ Minor int `json:"minor"`31+ // Name is the broadcast's own name for the channel.32+ Name string `json:"name"`33+ // GuideID, Network and Logo come from the listings, when they have34+ // the channel. CallSign is theirs too, or else the call sign of the35+ // station whose virtual channel it is, or else Name.36+ GuideID string `json:"guideId,omitempty"`37+ CallSign string `json:"callSign"`38+ BaseCall string `json:"baseCall"`39+ Network string `json:"network"`40+ Logo string `json:"logo,omitempty"`41+ // RF is the RF channel the tuner receives it on; 0 when unknown.42+ RF int `json:"rf"`43+ // FacilityID and Transmitter are the licensed transmitter on RF that44+ // the channel comes from: 0 and "" when none in the FCC's records45+ // within the search radius is on RF.46+ FacilityID int `json:"facilityId"`47+ Transmitter string `json:"transmitter,omitempty"`48+ // Via is the transmitter's call sign when it is another station's49+ // (common after a station moves to ATSC 3.0 and another hosts its50+ // ATSC 1.0 signal).51+ Via string `json:"via,omitempty"`52+ // NextGen is where the station also broadcasts this channel in ATSC53+ // 3.0, a fact about the station; nil when it doesn't.54+ NextGen *NextGen `json:"atsc3,omitempty"`55+}56+57+// NextGen is a channel's ATSC 3.0 broadcast.58+type NextGen struct {59+ HostCall string `json:"hostCall"`60+ FacilityID int `json:"facilityId"`61+ RF string `json:"rf"`62+ Display string `json:"display"` // "02-1"63+}64+65+// nameBase is a broadcast name's call sign base, for names that are call66+// signs: "KTVD-HD" and "KRMADT1" give "KTVD" and "KRMA".67+func nameBase(name string) string {68+ return guide.BaseCall(fcc.BaseCall(strings.TrimSpace(name)))69+}70+71+// Match describes the tuner's channels from rep's transmitters and ATSC 3.072+// hosts and from the listings g (nil without them), in channel order.73+func Match(rep *Report, g *guide.Guide, found []Scanned) []TunerChannel {74+ // What each multiplex carries decides which transmitter it is: the75+ // one on its RF channel whose virtual channel or call sign is there.76+ majors := map[int]map[int]bool{}77+ bases := map[int]map[string]bool{}78+ note := func(rf int, major int, base string) {79+ if majors[rf] == nil {80+ majors[rf], bases[rf] = map[int]bool{}, map[string]bool{}81+ }82+ majors[rf][major] = true83+ if base != "" {84+ bases[rf][base] = true85+ }86+ }87+ listed := map[string][]guide.Channel{}88+ if g != nil {89+ for _, gc := range g.Channels {90+ listed[gc.Number] = append(listed[gc.Number], gc)91+ }92+ }93+ for _, f := range found {94+ major, _ := guide.SplitNumber(f.Number)95+ note(f.RF, major, nameBase(f.Name))96+ for _, gc := range listed[f.Number] {97+ note(f.RF, major, guide.BaseCall(gc.CallSign))98+ }99+ }100+ tx := map[int]*Station{}101+ for rf := range majors {102+ var best *Station103+ score := func(s *Station) int {104+ n := 0105+ if majors[rf][s.VirtualChannel] {106+ n += 2107+ }108+ if bases[rf][s.BaseCall] {109+ n++110+ }111+ return n112+ }113+ for i := range rep.Stations {114+ s := &rep.Stations[i]115+ if rf == 0 || s.RFChannel != rf {116+ continue117+ }118+ if best == nil || score(s) > score(best) || score(s) == score(best) && s.DistanceKm < best.DistanceKm {119+ best = s120+ }121+ }122+ tx[rf] = best123+ }124+ // A station by its virtual channel, for channels the listings lack.125+ byVirtual := map[int]*Station{}126+ for i := range rep.Stations {127+ s := &rep.Stations[i]128+ if o := byVirtual[s.VirtualChannel]; s.VirtualChannel > 0 && (o == nil || s.DistanceKm < o.DistanceKm) {129+ byVirtual[s.VirtualChannel] = s130+ }131+ }132+133+ out := make([]TunerChannel, 0, len(found))134+ for _, f := range found {135+ major, minor := guide.SplitNumber(f.Number)136+ ch := TunerChannel{Number: f.Number, Major: major, Minor: minor, Name: strings.TrimSpace(f.Name), RF: f.RF}137+ t := tx[f.RF]138+ if t != nil {139+ ch.FacilityID, ch.Transmitter = t.FacilityID, t.CallSign140+ }141+ if gc, ok := pickListing(listed[f.Number], nameBase(f.Name), t); ok {142+ ch.GuideID, ch.CallSign, ch.BaseCall, ch.Network, ch.Logo = gc.ID, gc.CallSign, guide.BaseCall(gc.CallSign), gc.Network, gc.Logo143+ } else if t != nil && t.VirtualChannel == major {144+ ch.CallSign, ch.BaseCall = t.CallSign, t.BaseCall145+ } else if s := byVirtual[major]; s != nil {146+ ch.CallSign, ch.BaseCall = s.CallSign, s.BaseCall147+ } else {148+ ch.CallSign, ch.BaseCall = ch.Name, nameBase(f.Name)149+ }150+ if t != nil && t.BaseCall != ch.BaseCall && t.VirtualChannel != major {151+ ch.Via = t.CallSign152+ }153+ ch.NextGen = nextGen(rep, ch, t)154+ out = append(out, ch)155+ }156+ slices.SortStableFunc(out, func(a, b TunerChannel) int {157+ return cmp.Or(cmp.Compare(a.Major, b.Major), cmp.Compare(a.Minor, b.Minor))158+ })159+ return out160+}161+162+// pickListing chooses among the listings' channels with one number: the163+// one named like the broadcast or its transmitter, else the first.164+func pickListing(cands []guide.Channel, base string, t *Station) (guide.Channel, bool) {165+ if len(cands) == 0 {166+ return guide.Channel{}, false167+ }168+ for _, gc := range cands {169+ b := guide.BaseCall(gc.CallSign)170+ if b == base || t != nil && b == t.BaseCall {171+ return gc, true172+ }173+ }174+ return cands[0], true175+}176+177+// nextGen finds the channel among the ATSC 3.0 hosts' services: the same178+// number, from the same station.179+func nextGen(rep *Report, ch TunerChannel, t *Station) *NextGen {180+ for _, h := range rep.ATSC3 {181+ for _, svc := range h.Services {182+ if svc.Major != ch.Major || svc.Minor != ch.Minor {183+ continue184+ }185+ same := fcc.BaseCall(svc.Name) == ch.BaseCall ||186+ t != nil && svc.ATSC1Call != "" && fcc.BaseCall(svc.ATSC1Call) == t.BaseCall187+ if same {188+ return &NextGen{HostCall: h.CallSign, FacilityID: h.FacilityID, RF: h.RF, Display: svc.Display}189+ }190+ }191+ }192+ return nil193+}194+195+// ForTuner is rep for an app whose lineup is the tuner's: the same196+// records, with no estimates (no presets, and each station's Signal197+// empty), each station's Carries the channels the tuner receives from it198+// and the ATSC 3.0 services it hosts, and no Channels (the caller has199+// them).200+func ForTuner(rep *Report, chans []TunerChannel) *Report {201+ out := *rep202+ out.Presets = nil203+ out.Channels = []Channel{}204+ carried := map[[2]int][]string{}205+ for _, c := range chans {206+ if c.FacilityID != 0 {207+ k := [2]int{c.FacilityID, c.RF}208+ carried[k] = append(carried[k], c.Number)209+ }210+ }211+ hosted := map[int][]string{}212+ for _, h := range rep.ATSC3 {213+ for _, svc := range h.Services {214+ hosted[h.FacilityID] = append(hosted[h.FacilityID], fmt.Sprintf("%d.%d (3.0)", svc.Major, svc.Minor))215+ }216+ }217+ out.Stations = make([]Station, len(rep.Stations))218+ for i, s := range rep.Stations {219+ s.Signal = map[string]reception.Estimate{}220+ s.Carries = append(slices.Clone(carried[[2]int{s.FacilityID, s.RFChannel}]), hosted[s.FacilityID]...)221+ if s.Carries == nil {222+ s.Carries = []string{}223+ }224+ out.Stations[i] = s225+ }226+ slices.SortStableFunc(out.Stations, func(a, b Station) int { return cmp.Compare(a.DistanceKm, b.DistanceKm) })227+ return &out228+}@@ -0,0 +1,111 @@
1+package lineup_test2+3+import (4+ "encoding/json"5+ "slices"6+ "strings"7+ "testing"8+9+ "airwaves/internal/lineup"10+ "airwaves/internal/lineup/lineuptest"11+)12+13+// found is part of what the tuner's scan found around Denver.14+var found = []lineup.Scanned{15+ {Number: "20.1", Name: "KTVD-HD", RF: 31},16+ {Number: "20.2", Name: "H & I", RF: 31},17+ {Number: "9.4", Name: "KUSA-HD", RF: 31},18+ {Number: "9.7", Name: "TBD", RF: 31},19+ {Number: "31.1", Name: "KDVR-DT", RF: 36},20+ {Number: "2.1", Name: "KWGN-DT", RF: 36},21+ {Number: "6.1", Name: "KRMADT1", RF: 33},22+ {Number: "14.1", Name: "Univision Denver", RF: 32},23+ {Number: "88.1", Name: "Mystery", RF: 0},24+}25+26+// TestMatch describes the tuner's channels from the records, and never27+// adds one: KMGH 7.1 and KUSA 9.1 are listed and licensed, but the tuner28+// didn't find them.29+func TestMatch(t *testing.T) {30+ chans := lineup.Match(lineuptest.Report(), lineuptest.Guide(), found)31+ var numbers []string32+ by := map[string]lineup.TunerChannel{}33+ for _, c := range chans {34+ numbers = append(numbers, c.Number)35+ by[c.Number] = c36+ }37+ if want := []string{"2.1", "6.1", "9.4", "9.7", "14.1", "20.1", "20.2", "31.1", "88.1"}; !slices.Equal(numbers, want) {38+ t.Fatalf("channels %v, want %v", numbers, want)39+ }40+ check := func(number, call, base, tx, via, guideID string, rf int) {41+ t.Helper()42+ c := by[number]43+ if c.CallSign != call || c.BaseCall != base || c.Transmitter != tx || c.Via != via || c.GuideID != guideID || c.RF != rf {44+ t.Errorf("%s = %+v", number, c)45+ }46+ }47+ // From the listings, on its own transmitter (not the nearer48+ // low-power one on the same RF channel).49+ check("20.1", "KTVDDT", "KTVD", "KTVD", "", "g20", 31)50+ // Not listed: the transmitter's own virtual channel.51+ check("20.2", "KTVD", "KTVD", "KTVD", "", "", 31)52+ // KUSA's subchannels ride KTVD's transmitter.53+ check("9.4", "KUSADT4", "KUSA", "KTVD", "KTVD", "g94", 31)54+ check("9.7", "KUSA", "KUSA", "KTVD", "KTVD", "", 31)55+ // KWGN's ATSC 1.0 signal is on KDVR's transmitter; it broadcasts in56+ // ATSC 3.0 itself.57+ check("2.1", "KWGNDT", "KWGN", "KDVR", "KDVR", "g2", 36)58+ check("6.1", "KRMADT", "KRMA", "KRMA-TV", "", "g6", 33)59+ check("14.1", "KCECDT", "KCEC", "KCEC", "", "g14", 32)60+ // Nothing on record: only what the broadcast says.61+ check("88.1", "Mystery", "MYSTERY", "", "", "", 0)62+ if c := by["2.1"]; c.NextGen == nil || c.NextGen.HostCall != "KWGN-TV" || c.NextGen.RF != "34" || c.Network != "CW" || c.Name != "KWGN-DT" {63+ t.Errorf("2.1 = %+v, %+v", c, c.NextGen)64+ }65+ if c := by["31.1"]; c.NextGen == nil || c.NextGen.HostCall != "KWGN-TV" || c.FacilityID != 126 {66+ t.Errorf("31.1 = %+v", c)67+ }68+ if c := by["20.1"]; c.NextGen != nil {69+ t.Errorf("20.1 in ATSC 3.0: %+v", c.NextGen)70+ }71+72+ // Without listings or records: every channel still, as broadcast.73+ bare := lineup.Match(&lineup.Report{}, nil, found)74+ if len(bare) != len(found) || bare[0].Number != "2.1" || bare[0].CallSign != "KWGN-DT" || bare[0].Transmitter != "" {75+ t.Errorf("bare = %+v", bare)76+ }77+}78+79+// TestForTuner: the app's report is the records without estimates, its80+// stations carrying what the tuner receives from them.81+func TestForTuner(t *testing.T) {82+ rep := lineuptest.Report()83+ rep.Presets = nil84+ chans := lineup.Match(rep, lineuptest.Guide(), found)85+ out := lineup.ForTuner(rep, chans)86+ carries := map[string][]string{}87+ for _, s := range out.Stations {88+ carries[s.CallSign] = s.Carries89+ if s.Signal == nil || len(s.Signal) != 0 {90+ t.Errorf("%s signal %v", s.CallSign, s.Signal)91+ }92+ }93+ if got := carries["KTVD"]; !slices.Equal(got, []string{"9.4", "9.7", "20.1", "20.2"}) {94+ t.Errorf("KTVD carries %v", got)95+ }96+ if got := carries["KWGN-TV"]; !slices.Equal(got, []string{"2.1 (3.0)", "31.1 (3.0)", "4.1 (3.0)"}) {97+ t.Errorf("KWGN-TV carries %v", got)98+ }99+ if got := carries["KMGH-TV"]; got == nil || len(got) != 0 {100+ t.Errorf("KMGH-TV carries %v", got)101+ }102+ if out.Stations[0].CallSign != "K31AB-D" || len(out.Channels) != 0 || len(rep.Stations[5].Carries) != 0 {103+ t.Errorf("stations not nearest first, or the report changed: %+v", out.Stations[0])104+ }105+ raw, _ := json.Marshal(out)106+ for _, bad := range []string{"noiseMarginDb", "tier", `"presets"`} {107+ if strings.Contains(string(raw), bad) {108+ t.Errorf("report has %s", bad)109+ }110+ }111+}@@ -0,0 +1,451 @@
1+package service2+3+import (4+ "cmp"5+ "context"6+ "errors"7+ "fmt"8+ "log"9+ "slices"10+ "strconv"11+ "strings"12+ "time"13+14+ "airwaves/internal/fcc"15+ "airwaves/internal/guide"16+ "airwaves/internal/lineup"17+ "airwaves/internal/reception"18+ "airwaves/internal/signal"19+ "airwaves/internal/tuner"20+ "airwaves/internal/tvh"21+)22+23+// ErrNoTuner is returned for an antenna channel by a server that has no24+// tuner to tune it with.25+var ErrNoTuner = errors.New("no tuner: the server's Tvheadend has found no ATSC tuner")26+27+// Antenna is what the app shows of the antenna: the tuner, the channels it28+// receives and what was measured on each RF channel. Every channel is one29+// the tuner's scan found; what the FCC records, the listings and the ATSC30+// 3.0 list say only describes them.31+type Antenna struct {32+ Tuner TunerInfo `json:"tuner"`33+ Channels []AntennaChannel `json:"channels"`34+ Muxes []MuxSignal `json:"muxes"`35+ Sweep SweepStatus `json:"sweep"`36+}37+38+// TunerInfo describes the server's tuners.39+type TunerInfo struct {40+ // Ready is set when there is a tuner with its antenna network.41+ Ready bool `json:"ready"`42+ // Reason says why there are no antenna channels, when there aren't.43+ Reason string `json:"reason,omitempty"`44+ // Model is the tuner's model as Tvheadend reports it45+ // ("hdhomerun_dvr_atsc").46+ Model string `json:"model,omitempty"`47+ Tuners int `json:"tuners"`48+ // InUse counts tuners tuned now: to a viewer, a recording, a49+ // measurement or Tvheadend's own guide scan.50+ InUse int `json:"inUse"`51+ // Standards lists what the tuners receive; ATSC3 is whether that takes52+ // in ATSC 3.0 (NextGen TV). Tvheadend's ATSC tuners are ATSC 1.0 only.53+ Standards []string `json:"standards"`54+ ATSC3 bool `json:"atsc3"`55+ // Scanning is set while Tvheadend scans RF channels for services.56+ Scanning bool `json:"scanning"`57+ // Demo is set while the channels are generated test patterns (demo58+ // mode, before a tuner).59+ Demo bool `json:"demo,omitempty"`60+}61+62+// AntennaChannel is a channel the tuner receives.63+type AntennaChannel struct {64+ lineup.TunerChannel65+ // Signal is the latest reading of the channel's multiplex; null when66+ // it has never been measured.67+ Signal *signal.Reading `json:"signal"`68+ // Demo marks a generated test pattern (demo mode).69+ Demo bool `json:"demo,omitempty"`70+}71+72+// MuxSignal is one RF channel: what Tvheadend's scan found there, what73+// the FCC licenses on it nearby, and what the tuners measured on it.74+type MuxSignal struct {75+ RF int `json:"rf"`76+ FrequencyMHz float64 `json:"frequencyMhz"`77+ Band string `json:"band"`78+ // Name is Tvheadend's for the multiplex: "605MHz".79+ Name string `json:"name"`80+ // Scan is how Tvheadend's last scan went; null before it scanned.81+ Scan *signal.Scan `json:"scan"`82+ Scanning bool `json:"scanning"`83+ // Tuned is set while a tuner is on it.84+ Tuned bool `json:"tuned"`85+ // Channels are the tuner's channels on it.86+ Channels []string `json:"channels"`87+ // Stations are the licensed transmitters on this RF channel within88+ // the search radius, nearest first.89+ Stations []MuxStation `json:"stations"`90+ // Signal is the latest reading, History all those kept; null when91+ // never measured.92+ Signal *signal.Reading `json:"signal"`93+ History *signal.History `json:"history"`94+}95+96+// MuxStation is a licensed transmitter on a multiplex's RF channel, from97+// the FCC's records.98+type MuxStation struct {99+ FacilityID int `json:"facilityId"`100+ CallSign string `json:"callSign"`101+ VirtualChannel int `json:"virtualChannel"`102+ Service string `json:"service"` // DTV, DCA, LPD...103+ City string `json:"city"`104+ State string `json:"state"`105+ DistanceKm float64 `json:"distanceKm"`106+ BearingDeg float64 `json:"bearingDeg"`107+ ERPkW float64 `json:"erpKw"`108+ HAATm float64 `json:"haatM"`109+ // ATSC3 is set when the station broadcasts ATSC 3.0 on this RF110+ // channel (per RabbitEars), which an ATSC 1.0 tuner can't decode.111+ ATSC3 bool `json:"atsc3"`112+}113+114+// ChannelSignal is a channel's latest reading, for the app to poll.115+type ChannelSignal struct {116+ Number string `json:"number"`117+ RF int `json:"rf"`118+ Signal *signal.Reading `json:"signal"`119+}120+121+// SignalReport is the measurements alone, light enough to poll while122+// watching or measuring.123+type SignalReport struct {124+ Tuner TunerInfo `json:"tuner"`125+ Channels []ChannelSignal `json:"channels"`126+ Muxes []MuxSignal `json:"muxes"`127+ Sweep SweepStatus `json:"sweep"`128+}129+130+// frontendsTTL is how long a hardware lookup stands.131+const frontendsTTL = time.Minute132+133+// frontends returns the ATSC tuners Tvheadend has, looked up at most once134+// a minute (or now, with fresh). A failed lookup keeps the last answer.135+func (s *Service) frontends(ctx context.Context, fresh bool) ([]tvh.Frontend, error) {136+ s.mu.Lock()137+ fes, at, known := s.fes, s.fesAt, s.fesKnown138+ s.mu.Unlock()139+ if known && !fresh && time.Since(at) < frontendsTTL {140+ return fes, nil141+ }142+ got, err := s.opt.Tvheadend.Frontends(ctx)143+ if err != nil {144+ if known {145+ return fes, nil146+ }147+ return nil, err148+ }149+ s.mu.Lock()150+ s.fes, s.fesAt, s.fesKnown = got, time.Now(), true151+ s.mu.Unlock()152+ return got, nil153+}154+155+// offeredNetworks are the Tvheadend networks whose channels the app,156+// the emulated HDHomeRun and recording use: the antenna network while157+// there's a tuner, else the demo network in demo mode, until the antenna158+// network exists. Demo channels never come back once it does.159+func (s *Service) offeredNetworks() []string {160+ ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)161+ defer cancel()162+ fes, err := s.frontends(ctx, false)163+ if err != nil {164+ return nil165+ }166+ if len(fes) > 0 {167+ return []string{tvh.ATSCNetwork}168+ }169+ if !s.opt.Demo {170+ return nil171+ }172+ if l, err := s.tvhTuner.Lineup(ctx); err != nil || l.HasNetwork(tvh.ATSCNetwork) {173+ return nil174+ }175+ return []string{tvh.DemoNetwork}176+}177+178+// tunerInfo describes the tuners: fes, and what's tuned now.179+func (s *Service) tunerInfo(ctx context.Context, l *tvh.Lineup) TunerInfo {180+ if s.opt.NoAntenna {181+ return TunerInfo{Reason: ErrNoAntenna.Error(), Standards: []string{}}182+ }183+ if s.opt.Tvheadend == nil {184+ return TunerInfo{Reason: "no tuner: the server has no Tvheadend", Standards: []string{}}185+ }186+ ti := TunerInfo{Standards: []string{}}187+ fes, err := s.frontends(ctx, false)188+ if err != nil {189+ ti.Reason = "Tvheadend is not reachable: " + err.Error()190+ return ti191+ }192+ ti.Tuners = len(fes)193+ for _, fe := range fes {194+ ti.Model = cmp.Or(ti.Model, fe.Model)195+ }196+ if len(fes) > 0 {197+ ti.Standards = []string{"ATSC 1.0"}198+ }199+ if busy, ok := s.inputsNow(); ok {200+ ti.InUse = busyTuners(busy)201+ }202+ switch {203+ case l == nil:204+ case len(fes) > 0 && l.HasNetwork(tvh.ATSCNetwork):205+ ti.Ready = true206+ for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {207+ ti.Scanning = ti.Scanning || bool(m.Enabled) && m.Scanning()208+ }209+ case len(fes) > 0:210+ ti.Reason = "the tuner was just found: its antenna network is being set up"211+ case s.opt.Demo && slices.Contains(s.offeredNetworks(), tvh.DemoNetwork):212+ ti.Demo = true213+ ti.Reason = "demo mode: the channels are test patterns until a tuner is found"214+ case l.HasNetwork(tvh.ATSCNetwork):215+ ti.Reason = "Tvheadend sees no tuner right now"216+ default:217+ ti.Reason = ErrNoTuner.Error()218+ }219+ return ti220+}221+222+// antenna is the tuner's lineup described from rep and g, with the223+// measurements; rep and g may be nil.224+func (s *Service) antenna(ctx context.Context, rep *lineup.Report, g *guide.Guide) *Antenna {225+ a := &Antenna{Channels: []AntennaChannel{}, Muxes: []MuxSignal{}, Sweep: s.sweepStatus()}226+ if s.opt.NoAntenna || s.opt.Tvheadend == nil {227+ a.Tuner = s.tunerInfo(ctx, nil)228+ return a229+ }230+ l, err := s.tvhTuner.Lineup(ctx)231+ if err != nil {232+ a.Tuner = s.tunerInfo(ctx, nil)233+ a.Tuner.Reason = "Tvheadend is not reachable: " + err.Error()234+ a.Muxes = s.muxSignals(nil, rep, nil)235+ return a236+ }237+ a.Tuner = s.tunerInfo(ctx, l)238+ s.noteScans(l)239+ chans, tuned, err := s.matched(ctx, rep, g)240+ if err != nil {241+ log.Printf("antenna channels: %v", err)242+ }243+ onMux := map[string][]string{} // channel numbers by multiplex UUID244+ for i, c := range chans {245+ ac := AntennaChannel{TunerChannel: c, Demo: tuned[i].Mux.Network == tvh.DemoNetwork}246+ if m, ok := s.signals.Get(tuned[i].Mux.FrequencyHz); ok {247+ ac.Signal = m.Latest248+ }249+ a.Channels = append(a.Channels, ac)250+ onMux[tuned[i].Mux.UUID] = append(onMux[tuned[i].Mux.UUID], c.Number)251+ }252+ a.Muxes = s.muxSignals(l, rep, onMux)253+ return a254+}255+256+// matched is the offered channels described from rep and g, with the257+// Tvheadend channel each one is, in the same order.258+func (s *Service) matched(ctx context.Context, rep *lineup.Report, g *guide.Guide) ([]lineup.TunerChannel, []tuner.Channel, error) {259+ if s.tvhTuner == nil {260+ return nil, nil, nil261+ }262+ tuned, err := s.tvhTuner.Channels(ctx)263+ if err != nil {264+ return nil, nil, err265+ }266+ found := make([]lineup.Scanned, len(tuned))267+ for i, c := range tuned {268+ found[i] = lineup.Scanned{Number: c.Number, Name: cmp.Or(c.Service.Name, c.Name), RF: signal.RF(c.Mux.FrequencyHz)}269+ }270+ if rep == nil {271+ rep = &lineup.Report{}272+ }273+ chans := lineup.Match(rep, g, found)274+ byNumber := make(map[string]tuner.Channel, len(tuned))275+ for _, c := range tuned {276+ byNumber[c.Number] = c277+ }278+ same := make([]tuner.Channel, len(chans))279+ for i, c := range chans {280+ same[i] = byNumber[c.Number]281+ }282+ return chans, same, nil283+}284+285+// muxSignals lists the antenna network's multiplexes (or, without286+// Tvheadend's lineup, those measured before) with what's known of each.287+func (s *Service) muxSignals(l *tvh.Lineup, rep *lineup.Report, chans map[string][]string) []MuxSignal {288+ busy, _ := s.inputsNow()289+ tunedTo := map[string]bool{}290+ for _, in := range busy {291+ if mux, net, ok := in.Mux(); ok && net == tvh.ATSCNetwork {292+ tunedTo[mux] = true293+ }294+ }295+ var out []MuxSignal296+ add := func(freq int64, name string) *MuxSignal {297+ rf := signal.RF(freq)298+ ms := MuxSignal{RF: rf, FrequencyMHz: float64(freq) / 1e6, Band: fcc.Band(rf), Name: name,299+ Channels: []string{}, Stations: muxStations(rep, rf), Tuned: tunedTo[name]}300+ if m, ok := s.signals.Get(freq); ok {301+ ms.Scan, ms.Signal, ms.History = m.Scan, m.Latest, m.History()302+ }303+ out = append(out, ms)304+ return &out[len(out)-1]305+ }306+ if l != nil {307+ for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {308+ if m.FrequencyHz <= 0 || !bool(m.Enabled) {309+ continue310+ }311+ ms := add(m.FrequencyHz, m.Name)312+ ms.Scanning = m.Scanning()313+ if c := chans[m.UUID]; c != nil {314+ slices.SortFunc(c, guide.CompareNumbers)315+ ms.Channels = c316+ }317+ }318+ } else {319+ for _, m := range s.signals.All() {320+ add(m.FrequencyHz, fmt.Sprintf("%dMHz", m.FrequencyHz/1_000_000))321+ }322+ }323+ slices.SortFunc(out, func(a, b MuxSignal) int { return cmp.Compare(a.FrequencyMHz, b.FrequencyMHz) })324+ return out325+}326+327+// muxStations lists rep's transmitters on RF channel rf, nearest first.328+func muxStations(rep *lineup.Report, rf int) []MuxStation {329+ out := []MuxStation{}330+ if rep == nil || rf == 0 {331+ return out332+ }333+ for _, st := range rep.Stations {334+ if st.RFChannel != rf {335+ continue336+ }337+ ms := MuxStation{338+ FacilityID: st.FacilityID, CallSign: st.CallSign, VirtualChannel: st.VirtualChannel, Service: st.Service,339+ City: st.City, State: st.State, DistanceKm: st.DistanceKm, BearingDeg: st.BearingDeg, ERPkW: st.ERPkW, HAATm: st.HAATm,340+ }341+ for _, h := range rep.ATSC3 {342+ if h.FacilityID == st.FacilityID && hostsRF(h.RF, rf) {343+ ms.ATSC3 = true344+ }345+ }346+ out = append(out, ms)347+ }348+ slices.SortStableFunc(out, func(a, b MuxStation) int { return cmp.Compare(a.DistanceKm, b.DistanceKm) })349+ return out350+}351+352+// hostsRF reports whether an ATSC 3.0 host's RF ("34", or "2 → 3" for a353+// move) is rf, now.354+func hostsRF(s string, rf int) bool {355+ f := strings.FieldsFunc(s, func(r rune) bool { return r < '0' || r > '9' })356+ if len(f) == 0 {357+ return false358+ }359+ n, err := strconv.Atoi(f[len(f)-1])360+ return err == nil && n == rf361+}362+363+// compatChannels is the tuner's lineup in the report's older Channel364+// shape, for app versions from before the measured lineup, which list a365+// channel only when its "tier" reads as receivable. Its tier is measured,366+// not estimated, and the same for every antenna: "good" when the latest367+// reading of its multiplex (or, before any, Tvheadend's scan) locked,368+// "weak" when it didn't.369+func compatChannels(a *Antenna) []lineup.Channel {370+ scans := map[int]*signal.Scan{}371+ for _, m := range a.Muxes {372+ scans[m.RF] = m.Scan373+ }374+ out := make([]lineup.Channel, 0, len(a.Channels))375+ for _, c := range a.Channels {376+ t := reception.Unknown377+ switch {378+ case c.Signal != nil && c.Signal.Lock:379+ t = reception.Good380+ case c.Signal != nil:381+ t = reception.Weak382+ case scans[c.RF] != nil && scans[c.RF].Lock, c.Demo:383+ t = reception.Good384+ }385+ lc := lineup.Channel{386+ Number: c.Number, Major: c.Major, Minor: c.Minor, GuideID: c.GuideID, CallSign: c.CallSign, BaseCall: c.BaseCall,387+ Network: c.Network, Logo: c.Logo, FacilityID: c.FacilityID, Via: c.Via,388+ Tier: map[string]reception.Tier{"indoor": t, "attic": t, "rooftop": t},389+ }390+ if c.NextGen != nil {391+ lc.ATSC3 = &lineup.Carriage{HostCall: c.NextGen.HostCall, FacilityID: c.NextGen.FacilityID, RF: c.NextGen.RF, Display: c.NextGen.Display}392+ }393+ out = append(out, lc)394+ }395+ return out396+}397+398+// dvrChannels is the tuner's lineup for recording: number, call sign and399+// listings.400+func dvrChannels(chans []lineup.TunerChannel) []lineup.Channel {401+ out := make([]lineup.Channel, 0, len(chans))402+ for _, c := range chans {403+ out = append(out, lineup.Channel{Number: c.Number, Major: c.Major, Minor: c.Minor, GuideID: c.GuideID, CallSign: c.CallSign, BaseCall: c.BaseCall})404+ }405+ return out406+}407+408+// listedOnly keeps g's channels and listings for the tuner's channels:409+// listings of stations the tuner doesn't receive aren't the app's.410+func listedOnly(g *guide.Guide, chans []AntennaChannel) *guide.Guide {411+ if g == nil {412+ return nil413+ }414+ ids := map[string]bool{}415+ for _, c := range chans {416+ if c.GuideID != "" {417+ ids[c.GuideID] = true418+ }419+ }420+ out := *g421+ out.Channels = []guide.Channel{}422+ out.Programs = map[string][]guide.Program{}423+ for _, c := range g.Channels {424+ if ids[c.ID] {425+ out.Channels = append(out.Channels, c)426+ }427+ }428+ for id, p := range g.Programs {429+ if ids[id] {430+ out.Programs[id] = p431+ }432+ }433+ return &out434+}435+436+// Signal is what was measured, for the app to poll: by multiplex, and by437+// channel.438+func (s *Service) Signal(ctx context.Context) (*SignalReport, error) {439+ if s.opt.NoAntenna {440+ return nil, ErrNoAntenna441+ }442+ s.mu.Lock()443+ rep, g := s.report, s.guide444+ s.mu.Unlock()445+ a := s.antenna(ctx, rep, g)446+ out := &SignalReport{Tuner: a.Tuner, Channels: make([]ChannelSignal, 0, len(a.Channels)), Muxes: a.Muxes, Sweep: a.Sweep}447+ for _, c := range a.Channels {448+ out.Channels = append(out.Channels, ChannelSignal{Number: c.Number, RF: c.RF, Signal: c.Signal})449+ }450+ return out, nil451+}@@ -0,0 +1,598 @@
1+package service2+3+import (4+ "context"5+ "encoding/json"6+ "encoding/xml"7+ "errors"8+ "io"9+ "net/http"10+ "net/http/httptest"11+ "path/filepath"12+ "slices"13+ "strings"14+ "testing"15+ "time"16+17+ "airwaves/internal/guide"18+ "airwaves/internal/hdhr"19+ "airwaves/internal/lineup/lineuptest"20+ "airwaves/internal/reception"21+ "airwaves/internal/signal"22+ "airwaves/internal/tuner"23+ "airwaves/internal/tvh"24+ "airwaves/internal/tvh/tvhtest"25+)26+27+// fastTiming measures in milliseconds rather than seconds.28+var fastTiming = measureTiming{29+ sample: 20 * time.Millisecond,30+ appear: 2 * time.Second, settle: 20 * time.Millisecond, interval: 20 * time.Millisecond, readings: 3,31+ idleWait: 300 * time.Millisecond,32+ poll: 20 * time.Millisecond, noLock: 200 * time.Millisecond,33+}34+35+// newTunerService is a server with the fake Tvheadend (an HDHomeRun whose36+// scan found 38 channels around Denver), and Denver's records and37+// listings already loaded.38+func newTunerService(t *testing.T, change ...func(*Options)) (*Service, *tvhtest.Server) {39+ t.Helper()40+ fake := tvhtest.New(t)41+ dir := t.TempDir()42+ opt := Options{43+ Name: "nas", Mode: "server", CacheDir: filepath.Join(dir, "cache"), Config: Config{ZIP: "80302"},44+ Tvheadend: tvh.New(fake.URL, fake.Client()), DVRPath: filepath.Join(dir, "dvr.json"), SignalPath: filepath.Join(dir, "signal.json"),45+ }46+ for _, f := range change {47+ f(&opt)48+ }49+ s, err := New(opt)50+ if err != nil {51+ t.Fatal(err)52+ }53+ t.Cleanup(s.Close)54+ s.timing = fastTiming55+ s.mu.Lock()56+ s.report, s.guide, s.built = lineuptest.Report(), lineuptest.Guide(), time.Now()57+ s.mu.Unlock()58+ return s, fake59+}60+61+// tvhNumbers lists the fake's channel numbers, in order.62+func tvhNumbers(fake *tvhtest.Server) []string {63+ var out []string64+ for _, c := range fake.Entries("channel") {65+ out = append(out, c["number"].(string))66+ }67+ slices.SortFunc(out, guide.CompareNumbers)68+ return out69+}70+71+// TestTunerLineup: the app's antenna channels are exactly the tuner's, the72+// records and listings only describing them; nothing estimated is served.73+func TestTunerLineup(t *testing.T) {74+ s, fake := newTunerService(t)75+ ctx := t.Context()76+ snap, err := s.Snapshot(ctx, false)77+ if err != nil {78+ t.Fatal(err)79+ }80+ a := snap.Antenna81+ var numbers []string82+ by := map[string]AntennaChannel{}83+ for _, c := range a.Channels {84+ numbers = append(numbers, c.Number)85+ by[c.Number] = c86+ }87+ if want := tvhNumbers(fake); len(want) != 38 || !slices.Equal(numbers, want) {88+ t.Fatalf("antenna channels %v, want the tuner's %v", numbers, want)89+ }90+ // Listed and licensed, but not found by the scan.91+ for _, n := range []string{"7.1", "9.1"} {92+ if _, ok := by[n]; ok {93+ t.Errorf("%s listed without the tuner having it", n)94+ }95+ }96+ if c := by["20.1"]; c.CallSign != "KTVDDT" || c.Transmitter != "KTVD" || c.RF != 31 || c.Signal != nil || c.Name != "KTVD-HD" || c.Demo {97+ t.Errorf("20.1 = %+v", c)98+ }99+ if c := by["2.1"]; c.Via != "KDVR" || c.NextGen == nil || c.NextGen.HostCall != "KWGN-TV" {100+ t.Errorf("2.1 = %+v", c)101+ }102+ ti := a.Tuner103+ if !ti.Ready || ti.Tuners != 2 || ti.Model != "hdhomerun_dvr_atsc" || !slices.Equal(ti.Standards, []string{"ATSC 1.0"}) || ti.ATSC3 ||104+ ti.InUse != 0 || ti.Reason != "" || ti.Demo || ti.Scanning {105+ t.Errorf("tuner = %+v", ti)106+ }107+108+ // Every RF channel Tvheadend scans, with its scan and what's licensed109+ // on it.110+ if len(a.Muxes) != 36 {111+ t.Fatalf("%d multiplexes", len(a.Muxes))112+ }113+ mux := map[int]MuxSignal{}114+ for _, m := range a.Muxes {115+ mux[m.RF] = m116+ }117+ if m := mux[31]; m.Name != "575MHz" || m.FrequencyMHz != 575 || m.Band != "UHF" || m.Scan == nil || !m.Scan.Lock || m.Scan.At.Unix() != 1791129355 ||118+ !slices.Equal(m.Channels, []string{"9.4", "9.7", "20.1", "20.2", "20.3", "20.4"}) || len(m.Stations) != 2 ||119+ m.Stations[0].CallSign != "K31AB-D" || m.Stations[1].CallSign != "KTVD" || m.Signal != nil || m.History != nil || m.Tuned {120+ t.Errorf("RF 31 = %+v", m)121+ }122+ if m := mux[7]; m.Scan == nil || m.Scan.Lock || len(m.Channels) != 0 || len(m.Stations) != 1 || m.Stations[0].CallSign != "KMGH-TV" || m.Band != "VHF-Hi" {123+ t.Errorf("RF 7 = %+v", m)124+ }125+ if m := mux[34]; len(m.Stations) != 1 || !m.Stations[0].ATSC3 || m.Scan.Lock {126+ t.Errorf("RF 34 = %+v", m)127+ }128+129+ // The report: the same channels for app versions that read it, with130+ // a measured tier; the records with no estimates; the listings of the131+ // tuner's channels only.132+ var compat []string133+ for _, c := range snap.Report.Channels {134+ compat = append(compat, c.Number)135+ if c.Tier["indoor"] != reception.Good || c.Tier["rooftop"] != reception.Good {136+ t.Errorf("%s tier %v", c.Number, c.Tier)137+ }138+ }139+ if !slices.Equal(compat, numbers) {140+ t.Errorf("report channels %v", compat)141+ }142+ var ids []string143+ for _, c := range snap.Guide.Channels {144+ ids = append(ids, c.ID)145+ }146+ if !slices.Equal(ids, []string{"g2", "g4", "g6", "g94", "g14", "g20", "g31"}) || snap.Guide.Programs["g7"] != nil || len(snap.Guide.Programs["g20"]) != 2 {147+ t.Errorf("guide channels %v, programs for 7.1: %v", ids, snap.Guide.Programs["g7"])148+ }149+ if len(s.guide.Channels) != 9 {150+ t.Error("the server's own listings were trimmed")151+ }152+ raw, _ := json.Marshal(snap)153+ for _, bad := range []string{"noiseMarginDb", `"presets"`, `"strong"`, `"fair"`} {154+ if strings.Contains(string(raw), bad) {155+ t.Errorf("snapshot has %s", bad)156+ }157+ }158+ for _, want := range []string{`"signal":{}`, `"antenna":{"tuner":{"ready":true`, `"standards":["ATSC 1.0"],"atsc3":false`, `"history":null`} {159+ if !strings.Contains(string(raw), want) {160+ t.Errorf("snapshot lacks %s", want)161+ }162+ }163+164+ info, _ := s.Info(ctx)165+ if info.Tuner.Tuners != 2 || info.Tuner.Model != "hdhomerun_dvr_atsc" || !slices.Equal(info.Tuner.Standards, []string{"ATSC 1.0"}) || info.Tuner.ATSC3 {166+ t.Errorf("info tuner = %+v", info.Tuner)167+ }168+ if got := s.AntennaChannels(ctx); len(got) != 38 || got["20.1"] != "KTVDDT" || got["9.7"] != "KUSA" || got["7.1"] != "" {169+ t.Errorf("antenna channels = %v", got)170+ }171+}172+173+// TestNoTuner: no tuner, no antenna channels; nothing made up in their174+// place, and tuning one says why.175+func TestNoTuner(t *testing.T) {176+ s, fake := newTunerService(t)177+ fake.SetTuner(false)178+ ctx := t.Context()179+ snap, err := s.Snapshot(ctx, false)180+ if err != nil {181+ t.Fatal(err)182+ }183+ if a := snap.Antenna; len(a.Channels) != 0 || a.Tuner.Ready || a.Tuner.Reason != "Tvheadend sees no tuner right now" || len(snap.Report.Channels) != 0 {184+ t.Errorf("antenna = %+v", a)185+ }186+ if !slices.Contains(snap.Report.Warnings, "No antenna channels: Tvheadend sees no tuner right now") {187+ t.Errorf("warnings = %q", snap.Report.Warnings)188+ }189+ if err := s.tuneAntenna(ctx, "tv", "20.1", nil); !errors.Is(err, ErrNoTuner) {190+ t.Errorf("tune: %v", err)191+ }192+ if _, err := s.Measure(ctx); !errors.Is(err, ErrNoTuner) {193+ t.Errorf("measure: %v", err)194+ }195+ if l, err := s.HDHR().Lineup(ctx); err != nil || len(l) != 0 {196+ t.Errorf("HDHomeRun lineup = %v, %v", l, err)197+ }198+199+ // No Tvheadend at all.200+ bare, err := New(Options{Mode: "server", CacheDir: t.TempDir(), Config: Config{ZIP: "80302"}})201+ if err != nil {202+ t.Fatal(err)203+ }204+ defer bare.Close()205+ bare.report, bare.guide, bare.built = lineuptest.Report(), lineuptest.Guide(), time.Now()206+ snap, err = bare.Snapshot(ctx, false)207+ if err != nil {208+ t.Fatal(err)209+ }210+ if a := snap.Antenna; len(a.Channels) != 0 || a.Tuner.Reason != "no tuner: the server has no Tvheadend" || len(snap.Report.Channels) != 0 || len(snap.Guide.Channels) != 0 {211+ t.Errorf("no Tvheadend: %+v, %d report channels", a, len(snap.Report.Channels))212+ }213+ if _, err := bare.tuner.Input(ctx, "20.1"); !errors.Is(err, ErrNoTuner) {214+ t.Errorf("tune without Tvheadend: %v", err)215+ }216+ if info, _ := bare.Info(ctx); info.Tuner.Kind != "none" {217+ t.Errorf("info = %+v", info.Tuner)218+ }219+}220+221+// TestDemo: demo channels stand in for the antenna only in demo mode222+// before a tuner; once a tuner's network exists they're removed, and they223+// don't come back while the tuner is away.224+func TestDemo(t *testing.T) {225+ s, fake := newTunerService(t, func(o *Options) { o.Demo = true })226+ ctx := t.Context()227+ // Before the tuner: a Tvheadend with the demo network only.228+ fake.SetTuner(false)229+ for _, n := range fake.Entries("network") {230+ if err := s.opt.Tvheadend.RemoveNetwork(ctx, n["networkname"].(string)); err != nil {231+ t.Fatal(err)232+ }233+ }234+ fake.Add("network", map[string]any{"uuid": "demo-net", "networkname": tvh.DemoNetwork})235+ fake.Add("mux", map[string]any{"uuid": "demo-mux", "name": "Demo 7.1 KMGH", "network": tvh.DemoNetwork, "network_uuid": "demo-net", "enabled": true, "iptv_url": "pipe://ffmpeg"})236+ fake.Add("service", map[string]any{"uuid": "demo-svc", "svcname": "KMGH", "network": tvh.DemoNetwork, "multiplex": "Demo 7.1 KMGH", "multiplex_uuid": "demo-mux", "enabled": true, "channel": []any{"demo-ch"}})237+ fake.Add("channel", map[string]any{"uuid": "demo-ch", "name": "KMGH", "number": "7.1", "enabled": true, "services": []any{"demo-svc"}})238+ s.tvhTuner.Invalidate()239+240+ snap, err := s.Snapshot(ctx, false)241+ if err != nil {242+ t.Fatal(err)243+ }244+ if a := snap.Antenna; len(a.Channels) != 1 || a.Channels[0].Number != "7.1" || !a.Channels[0].Demo || !a.Tuner.Demo || a.Tuner.Ready {245+ t.Fatalf("demo antenna = %+v", a)246+ }247+ if err := s.setupTuners(ctx); err != nil {248+ t.Fatal(err)249+ }250+251+ // The tuner arrives: its network is made, and the demo channels go.252+ fake.SetTuner(true)253+ s.mu.Lock()254+ s.fesKnown = false255+ s.mu.Unlock()256+ s.maintain(ctx, false)257+ var nets []string258+ for _, n := range fake.Entries("network") {259+ nets = append(nets, n["networkname"].(string))260+ }261+ if !slices.Equal(nets, []string{tvh.ATSCNetwork}) || len(fake.Entries("mux")) != 0 {262+ t.Errorf("networks %v, muxes %v", nets, fake.Entries("mux"))263+ }264+ for _, c := range fake.Entries("channel") {265+ if c["uuid"] == "demo-ch" {266+ t.Errorf("demo channel left: %v", c)267+ }268+ }269+270+ // The tuner goes away: no demo channels made, none offered.271+ fake.SetTuner(false)272+ before := len(fake.Requests())273+ s.mu.Lock()274+ s.fesKnown = false275+ s.mu.Unlock()276+ if err := s.setupTuners(ctx); err != nil {277+ t.Fatal(err)278+ }279+ for _, r := range fake.Requests()[before:] {280+ if strings.Contains(r, "network/create") || strings.Contains(r, "mux_create") {281+ t.Errorf("demo channels made again: %s", r)282+ }283+ }284+ s.tvhTuner.Invalidate()285+ if got := s.offeredNetworks(); got != nil {286+ t.Errorf("offered %v", got)287+ }288+}289+290+// TestSampleInputs: while a tuner is in use, its readings are recorded for291+// its RF channel, once it has been on it a while, and saved.292+func TestSampleInputs(t *testing.T) {293+ s, fake := newTunerService(t)294+ ctx := t.Context()295+ fake.Busy(tvhtest.Tuner0, "575MHz", 150)296+ s.sampleInputs(ctx)297+ if _, ok := s.signals.Get(575_000_000); ok {298+ t.Fatal("read a tuner just tuned")299+ }300+ s.sampleInputs(ctx)301+ m, ok := s.signals.Get(575_000_000)302+ if !ok || m.RF != 31 || m.Latest == nil || !m.Latest.Lock || *m.Latest.StrengthPct != 85 || *m.Latest.QualityPct != 83 || m.Latest.Source != signal.Active {303+ t.Fatalf("RF 31 = %+v", m)304+ }305+306+ sig, err := s.Signal(ctx)307+ if err != nil {308+ t.Fatal(err)309+ }310+ if sig.Tuner.InUse != 1 {311+ t.Errorf("in use: %d", sig.Tuner.InUse)312+ }313+ for _, c := range sig.Channels {314+ if (c.RF == 31) != (c.Signal != nil) {315+ t.Errorf("%s (RF %d) signal %+v", c.Number, c.RF, c.Signal)316+ }317+ }318+ for _, m := range sig.Muxes {319+ if m.RF == 31 && (!m.Tuned || m.History == nil || m.History.Samples != 1 || m.History.LockedPct != 100) {320+ t.Errorf("RF 31 = %+v", m)321+ }322+ }323+ raw, _ := json.Marshal(sig)324+ if !strings.Contains(string(raw), `"number":"4.1","rf":35,"signal":null`) {325+ t.Errorf("unmeasured channel: %s", raw)326+ }327+328+ // Saved on the way out, and read back.329+ s.Close()330+ again, err := signal.Open(s.opt.SignalPath)331+ if err != nil {332+ t.Fatal(err)333+ }334+ if m, ok := again.Get(575_000_000); !ok || m.Latest == nil || m.Scan == nil || !m.Scan.Lock {335+ t.Errorf("saved RF 31 = %+v", m)336+ }337+}338+339+// waitSweep waits for Measure now to finish.340+func waitSweep(t *testing.T, s *Service) SweepStatus {341+ t.Helper()342+ deadline := time.Now().Add(20 * time.Second)343+ for {344+ st := s.sweepStatus()345+ if !st.Running {346+ return st347+ }348+ if time.Now().After(deadline) {349+ t.Fatalf("sweep still running: %+v", st)350+ }351+ time.Sleep(10 * time.Millisecond)352+ }353+}354+355+// TestMeasure: Measure now reads every RF channel with channels or a356+// licensed station, one at a time on an idle tuner, at the lowest weight.357+func TestMeasure(t *testing.T) {358+ s, fake := newTunerService(t)359+ ctx := t.Context()360+ st, err := s.Measure(ctx)361+ if err != nil || !st.Running || st.StartedAt == nil {362+ t.Fatalf("measure: %+v, %v", st, err)363+ }364+ if again, _ := s.Measure(ctx); !again.Running || !again.StartedAt.Equal(*st.StartedAt) {365+ t.Errorf("a second sweep started: %+v", again)366+ }367+ done := waitSweep(t, s)368+ // The 8 multiplexes with channels, and RF 7 (KMGH), 16 (KUSA) and 34369+ // (KWGN's ATSC 3.0), licensed but not found.370+ if done.Total != 11 || done.Done != 11 || done.Note != "" || done.FinishedAt == nil {371+ t.Errorf("sweep = %+v", done)372+ }373+ opened, most := fake.Opened()374+ if len(opened) != 11 || most != 1 {375+ t.Errorf("opened %v, %d at once", opened, most)376+ }377+ for _, r := range fake.Requests() {378+ if strings.HasPrefix(r, "GET /stream/") && !strings.HasSuffix(r, "?pids=0&weight=10") {379+ t.Errorf("subscribed as %s", r)380+ }381+ }382+ for rf, freq := range map[int]int64{31: 575_000_000, 36: 605_000_000, 7: 177_000_000, 34: 593_000_000} {383+ m, ok := s.signals.Get(freq)384+ if !ok || m.Latest == nil || m.Latest.Source != signal.Sweep || m.History().Samples != 3 || m.Latest.Lock != (rf == 31 || rf == 36) {385+ t.Errorf("RF %d = %+v", rf, m)386+ }387+ }388+ if _, ok := s.signals.Get(473_000_000); ok {389+ t.Error("measured RF 14, which nothing uses")390+ }391+}392+393+// TestMeasureLeavesTunersAlone: the sweep reads a viewer's tuner rather394+// than tune another to the same RF channel, waits for a free tuner and395+// stops when there's none, and stops when a viewer takes its tuner.396+func TestMeasureLeavesTunersAlone(t *testing.T) {397+ s, fake := newTunerService(t)398+ ctx := t.Context()399+400+ // Someone watches 31.1 on tuner 0: the sweep uses tuner 1 only, and401+ // reads RF 36 from tuner 0.402+ fake.Busy(tvhtest.Tuner0, "605MHz", 150)403+ if _, err := s.Measure(ctx); err != nil {404+ t.Fatal(err)405+ }406+ if st := waitSweep(t, s); st.Done != 11 || st.Note != "" {407+ t.Errorf("sweep = %+v", st)408+ }409+ opened, _ := fake.Opened()410+ for _, o := range opened {411+ if !strings.HasPrefix(o, tvhtest.Tuner1) || strings.HasSuffix(o, "605MHz") {412+ t.Errorf("opened %s", o)413+ }414+ }415+ if m, _ := s.signals.Get(605_000_000); m.Latest == nil || !m.Latest.Lock || m.Latest.Source != signal.Sweep {416+ t.Errorf("RF 36 = %+v", m)417+ }418+419+ // Both tuners in use: nothing tuned, and the sweep says why.420+ fake.Busy(tvhtest.Tuner1, "587MHz", 300)421+ if _, err := s.Measure(ctx); err != nil {422+ t.Fatal(err)423+ }424+ if st := waitSweep(t, s); st.Done != 0 || !strings.Contains(st.Note, "no tuner was free") {425+ t.Errorf("busy sweep = %+v", st)426+ }427+ if now, _ := fake.Opened(); len(now) != len(opened) {428+ t.Errorf("tuned a busy tuner: %v", now[len(opened):])429+ }430+431+ // A viewer takes the sweep's tuner: it stops there.432+ fake.Free(tvhtest.Tuner0)433+ fake.Free(tvhtest.Tuner1)434+ fake.OnStream = func(mux string) {435+ if mux == "575MHz" {436+ go func() {437+ time.Sleep(30 * time.Millisecond)438+ fake.TakeOver("587MHz")439+ }()440+ }441+ }442+ if _, err := s.Measure(ctx); err != nil {443+ t.Fatal(err)444+ }445+ if st := waitSweep(t, s); st.Done >= st.Total || !strings.Contains(st.Note, "stopped at RF 31: a viewer or a recording needed the tuner") {446+ t.Errorf("taken over: %+v", st)447+ }448+}449+450+// TestTuneNoSignal: a channel whose RF channel won't lock fails with what451+// the tuner measured, not ffmpeg's complaint; one that locks starts and452+// leaves a reading.453+func TestTuneNoSignal(t *testing.T) {454+ s, fake := newTunerService(t)455+ ctx := t.Context()456+ fake.SetSignal("575MHz", tvhtest.Signal{Strength: 41, Quality: 0})457+ // start reads the channel's stream as ffmpeg would, until data comes458+ // or the start is called off.459+ start := func(ctx context.Context, in tuner.Input) error {460+ url := in.Args[len(in.Args)-1]461+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)462+ resp, err := http.DefaultClient.Do(req)463+ if err != nil {464+ return err465+ }466+ defer resp.Body.Close()467+ if resp.StatusCode != http.StatusOK {468+ return errors.New("ffmpeg exited: Server returned 503 Service Unavailable")469+ }470+ buf := make([]byte, 188)471+ if _, err := io.ReadFull(resp.Body, buf); err != nil {472+ return errors.New("stream did not start in time: ")473+ }474+ return nil475+ }476+ err := s.tuneAntenna(ctx, "tv", "20.1", start)477+ var ns *NoSignalError478+ if !errors.As(err, &ns) || ns.RF != 31 || ns.Label != "KTVD 20.x" ||479+ err.Error() != "No signal on RF 31 (KTVD 20.x) right now: the tuner measured strength 41%, quality 0% and couldn't lock" {480+ t.Fatalf("tune 20.1: %v", err)481+ }482+ if m, ok := s.signals.Get(575_000_000); !ok || m.Latest.Lock || *m.Latest.StrengthPct != 41 || m.Latest.Source != signal.Active {483+ t.Errorf("RF 31 = %+v", m)484+ }485+ if err := s.tuneAntenna(ctx, "tv", "9.4", start); err == nil || !strings.HasPrefix(err.Error(), "No signal on RF 31 (KUSA 9.x on KTVD) right now") {486+ t.Errorf("tune 9.4: %v", err)487+ }488+489+ // Locked: it starts.490+ if err := s.tuneAntenna(ctx, "tv", "31.1", start); err != nil {491+ t.Errorf("tune 31.1: %v", err)492+ }493+ for deadline := time.Now().Add(2 * time.Second); len(fake.Streams()) > 0 && time.Now().Before(deadline); {494+ time.Sleep(10 * time.Millisecond)495+ }496+ // Not a channel the tuner found.497+ if err := s.tuneAntenna(ctx, "tv", "7.1", start); !errors.Is(err, tuner.ErrNotFound) || err.Error() != "channel 7.1: not a channel the tuner found" {498+ t.Errorf("tune 7.1: %v", err)499+ }500+ // Both tuners busy.501+ fake.Busy(tvhtest.Tuner0, "587MHz", 300)502+ fake.Busy(tvhtest.Tuner1, "599MHz", 300)503+ slow := func(ctx context.Context, in tuner.Input) error {504+ time.Sleep(5 * fastTiming.poll)505+ return start(ctx, in)506+ }507+ if err := s.tuneAntenna(ctx, "tv", "31.1", slow); err == nil || !strings.HasPrefix(err.Error(), "every tuner is in use right now") {508+ t.Errorf("tune with both tuners busy: %v", err)509+ }510+}511+512+// TestHDHomeRunAntenna: the emulated HDHomeRun lists the tuner's channels,513+// named and guided as the app has them, and nothing else.514+func TestHDHomeRunAntenna(t *testing.T) {515+ s, fake := newTunerService(t)516+ dev := &hdhr.Server{Backend: s.HDHR(), FriendlyName: "Airwaves", Tuners: s.TunerCount}517+ srv := httptest.NewServer(dev.Handler())518+ defer srv.Close()519+ resp, err := http.Get(srv.URL + "/lineup.json")520+ if err != nil {521+ t.Fatal(err)522+ }523+ var entries []map[string]any524+ err = json.NewDecoder(resp.Body).Decode(&entries)525+ resp.Body.Close()526+ if err != nil {527+ t.Fatal(err)528+ }529+ var numbers []string530+ names := map[string]string{}531+ for _, e := range entries {532+ n := e["GuideNumber"].(string)533+ numbers = append(numbers, n)534+ names[n] = e["GuideName"].(string)535+ }536+ if !slices.Equal(numbers, tvhNumbers(fake)) || names["20.1"] != "KTVDDT" || names["20.2"] != "KTVD" || names["9.7"] != "KUSA" {537+ t.Errorf("lineup %v, names %v", numbers, names)538+ }539+ if s.TunerCount() != 2 {540+ t.Errorf("tuners %d", s.TunerCount())541+ }542+543+ resp, err = http.Get(srv.URL + "/xmltv.xml")544+ if err != nil {545+ t.Fatal(err)546+ }547+ raw, _ := io.ReadAll(resp.Body)548+ resp.Body.Close()549+ var doc xmltvOut550+ if err := xml.Unmarshal(raw, &doc); err != nil {551+ t.Fatal(err)552+ }553+ progs := map[string]int{}554+ for _, p := range doc.Programmes {555+ progs[p.Channel]++556+ }557+ if len(doc.Channels) != 38 || progs["airwaves.20.1"] != 2 || progs["airwaves.7.1"] != 0 || len(progs) != 7 {558+ t.Errorf("%d channels, programmes %v", len(doc.Channels), progs)559+ }560+}561+562+// TestMeasureFallbacks: a Tvheadend that won't stream whole multiplexes563+// is measured through a channel on each, and the RF channels with none564+// are skipped, saying so; tuners busy with Tvheadend's own scans are left565+// to them, saying that.566+func TestMeasureFallbacks(t *testing.T) {567+ s, fake := newTunerService(t)568+ ctx := t.Context()569+ fake.NoMuxStreams = true570+ if _, err := s.Measure(ctx); err != nil {571+ t.Fatal(err)572+ }573+ st := waitSweep(t, s)574+ if st.Done != 11 || st.Note != "3 RF channels skipped: Tvheadend won't stream them" {575+ t.Errorf("sweep = %+v", st)576+ }577+ channels := 0578+ for _, r := range fake.Requests() {579+ if strings.HasPrefix(r, "GET /stream/channel/") {580+ channels++581+ if !strings.HasSuffix(r, "?profile=pass&weight=10") {582+ t.Errorf("subscribed as %s", r)583+ }584+ }585+ }586+ if m, _ := s.signals.Get(575_000_000); channels != 8 || m.Latest == nil || !m.Latest.Lock {587+ t.Errorf("%d channel streams; RF 31 = %+v", channels, m)588+ }589+590+ fake.Busy(tvhtest.Tuner0, "587MHz", 4)591+ fake.Busy(tvhtest.Tuner1, "605MHz", 4)592+ if _, err := s.Measure(ctx); err != nil {593+ t.Fatal(err)594+ }595+ if st := waitSweep(t, s); !strings.Contains(st.Note, "busy with Tvheadend's own guide or channel scan") {596+ t.Errorf("sweep = %+v", st)597+ }598+}@@ -1,6 +1,7 @@
11 package service22 33 import (4+ "cmp"45 "context"56 "encoding/xml"67 "fmt"@@ -19,8 +20,8 @@ import (
1920 "airwaves/internal/vchan"2021 )2122 22-// HDHR adapts the service to the emulated HDHomeRun: Tvheadend's channels23-// passed straight through, plus the custom channels.23+// HDHR adapts the service to the emulated HDHomeRun: the tuner's channels,24+// as the app has them, plus the custom channels.2425 func (s *Service) HDHR() hdhr.Backend { return hdhrBackend{s} }2526 2627 // TunerCount is what the emulated HDHomeRun reports: the server's real@@ -28,27 +29,26 @@ func (s *Service) HDHR() hdhr.Backend { return hdhrBackend{s} }
2829 func (s *Service) TunerCount() int {2930 s.mu.Lock()3031 defer s.mu.Unlock()31- return max(s.tuners, 2)32+ return max(len(s.fes), 2)3233 }3334 3435 type hdhrBackend struct{ s *Service }3536 36-// reportChannel finds the guide channel for a virtual number, preferring37-// one with listings.38-func (b hdhrBackend) reportChannel(number string) *lineup.Channel {39- _, chans := b.s.guideAndChannels()40- var found *lineup.Channel41- for i := range chans {42- if chans[i].Number == number {43- if chans[i].GuideID != "" {44- return &chans[i]45- }46- if found == nil {47- found = &chans[i]48- }49- }37+// antenna is the tuner's channels described from the records, by number,38+// and the listings.39+func (b hdhrBackend) antenna(ctx context.Context) (map[string]lineup.TunerChannel, *guide.Guide, error) {40+ b.s.mu.Lock()41+ rep, g := b.s.report, b.s.guide42+ b.s.mu.Unlock()43+ chans, _, err := b.s.matched(ctx, rep, g)44+ if err != nil {45+ return nil, nil, err5046 }51- return found47+ out := make(map[string]lineup.TunerChannel, len(chans))48+ for _, c := range chans {49+ out[c.Number] = c50+ }51+ return out, g, nil5252 }5353 5454 // Lineup implements hdhr.Backend. Custom channels keep the group@@ -62,21 +62,15 @@ func (b hdhrBackend) Lineup(ctx context.Context) ([]hdhr.Entry, error) {
6262 custom[vchan.NumberKey(d.Number)] = true6363 out = append(out, hdhr.Entry{Number: d.Number, Name: d.Name, HD: true, Group: "Airwaves", Logo: logoPath(d)})6464 }65- if b.s.tvhTuner != nil {66- chans, err := b.s.tvhTuner.Channels(ctx)67- if err != nil {68- return nil, err69- }70- for _, c := range chans {71- if !c.Enabled || c.Number == "0" || len(c.Services) == 0 || custom[vchan.NumberKey(c.Number)] {72- continue73- }74- e := hdhr.Entry{Number: c.Number, Name: c.Name, HD: true, Group: "Antenna"}75- if rc := b.reportChannel(c.Number); rc != nil {76- e.Name, e.Logo = rc.CallSign, rc.Logo77- }78- out = append(out, e)65+ chans, _, err := b.antenna(ctx)66+ if err != nil {67+ return nil, err68+ }69+ for _, c := range chans {70+ if custom[vchan.NumberKey(c.Number)] {71+ continue7972 }73+ out = append(out, hdhr.Entry{Number: c.Number, Name: cmp.Or(c.CallSign, c.Name), HD: true, Group: "Antenna", Logo: c.Logo})8074 }8175 slices.SortStableFunc(out, func(a, b hdhr.Entry) int { return guide.CompareNumbers(a.Number, b.Number) })8276 return out, nil@@ -159,7 +153,10 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
159153 if err != nil {160154 return err161155 }162- g, _ := b.s.guideAndChannels()156+ antenna, g, err := b.antenna(ctx)157+ if err != nil {158+ return err159+ }163160 custom := map[string]vchan.Channel{}164161 for _, v := range b.s.customChannels() {165162 custom[v.Number()] = v@@ -218,11 +215,11 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
218215 }219216 continue220217 }221- rc := b.reportChannel(e.Number)222- if rc == nil || g == nil {218+ ac, ok := antenna[e.Number]219+ if !ok || ac.GuideID == "" || g == nil {223220 continue224221 }225- for _, p := range g.Programs[rc.GuideID] {222+ for _, p := range g.Programs[ac.GuideID] {226223 if p.End.Before(from) || p.Start.After(to) {227224 continue228225 }@@ -0,0 +1,661 @@
1+package service2+3+import (4+ "cmp"5+ "context"6+ "errors"7+ "fmt"8+ "log"9+ "net/http"10+ "slices"11+ "strings"12+ "sync"13+ "sync/atomic"14+ "time"15+16+ "airwaves/internal/fcc"17+ "airwaves/internal/lineup"18+ "airwaves/internal/signal"19+ "airwaves/internal/tuner"20+ "airwaves/internal/tvh"21+)22+23+// sweepWeight is the weight of the sweep's subscriptions: the lowest a24+// user's can be in Tvheadend, so any viewer or recording takes the tuner25+// over, while Tvheadend's own scans (weights 1 to 7) don't.26+const sweepWeight = 1027+28+// measureTiming holds the measuring's pauses; tests shorten them.29+type measureTiming struct {30+ // sample is how often the inputs are read while tuners are in use.31+ sample time.Duration32+ // appear is how long the sweep waits for its tuner to show the33+ // multiplex, settle how long it then waits before reading, and34+ // interval the time between its readings, readings of them.35+ appear, settle, interval time.Duration36+ readings int37+ // idleWait is how long the sweep waits for an idle tuner before it38+ // gives up.39+ idleWait time.Duration40+ // poll is how often the tuner is read while a channel starts, and41+ // noLock how long it may go without a lock before the start fails.42+ poll, noLock time.Duration43+}44+45+var defaultTiming = measureTiming{46+ sample: 10 * time.Second,47+ appear: 6 * time.Second, settle: 1500 * time.Millisecond, interval: time.Second, readings: 3,48+ idleWait: 30 * time.Second,49+ poll: time.Second, noLock: 10 * time.Second,50+}51+52+// SweepStatus is how Measure now is going, or went.53+type SweepStatus struct {54+ Running bool `json:"running"`55+ StartedAt *time.Time `json:"startedAt"`56+ FinishedAt *time.Time `json:"finishedAt"`57+ // Total is how many RF channels it measures, Done how many it has.58+ Total int `json:"total"`59+ Done int `json:"done"`60+ // RF is the RF channel being measured now.61+ RF int `json:"rf,omitempty"`62+ // Note says why it stopped early or what it left out.63+ Note string `json:"note,omitempty"`64+}65+66+// ---------- inputs ----------67+68+// inputsNow returns the tuned inputs, as read in the last few seconds;69+// ok is false when Tvheadend can't be read.70+func (s *Service) inputsNow() ([]tvh.InputStatus, bool) {71+ if s.opt.Tvheadend == nil {72+ return nil, false73+ }74+ s.mu.Lock()75+ ins, at := s.inputs, s.inputsAt76+ s.mu.Unlock()77+ if !at.IsZero() && time.Since(at) < 5*time.Second {78+ return ins, true79+ }80+ ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second)81+ defer cancel()82+ ins, err := s.readInputs(ctx)83+ return ins, err == nil84+}85+86+// readInputs reads the tuned inputs from Tvheadend and keeps them for87+// inputsNow.88+func (s *Service) readInputs(ctx context.Context) ([]tvh.InputStatus, error) {89+ ins, err := s.opt.Tvheadend.Inputs(ctx)90+ if err != nil {91+ return nil, err92+ }93+ s.mu.Lock()94+ s.inputs, s.inputsAt = ins, time.Now()95+ s.mu.Unlock()96+ return ins, nil97+}98+99+// antennaMuxes maps the antenna network's multiplexes by name.100+func (s *Service) antennaMuxes(ctx context.Context) (map[string]tvh.Mux, error) {101+ l, err := s.tvhTuner.Lineup(ctx)102+ if err != nil {103+ return nil, err104+ }105+ out := map[string]tvh.Mux{}106+ for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {107+ out[m.Name] = m108+ }109+ return out, nil110+}111+112+// sampleInputs records a reading of every antenna multiplex a tuner has113+// been on since the last call, whoever tuned it: a viewer, a recording or114+// Tvheadend's guide scan. An input just tuned isn't read yet, as it may115+// not have locked; the sweep reads its own.116+func (s *Service) sampleInputs(ctx context.Context) {117+ ins, err := s.readInputs(ctx)118+ if err != nil {119+ return120+ }121+ muxes, err := s.antennaMuxes(ctx)122+ if err != nil {123+ return124+ }125+ s.mu.Lock()126+ prev := s.lastStreams127+ sweeping := s.sweepMux128+ s.mu.Unlock()129+ now := time.Now()130+ seen := map[string]string{}131+ for _, in := range ins {132+ name, net, ok := in.Mux()133+ if !ok || net != tvh.ATSCNetwork || in.Subs == 0 {134+ continue135+ }136+ seen[in.Input] = in.Stream137+ m, ok := muxes[name]138+ if !ok || prev[in.Input] != in.Stream || name == sweeping {139+ continue140+ }141+ s.signals.Record(m.FrequencyHz, signal.RF(m.FrequencyHz), signal.FromInput(in, now, signal.Active))142+ }143+ s.mu.Lock()144+ s.lastStreams = seen145+ s.mu.Unlock()146+}147+148+// syncScans notes Tvheadend's scan results of the antenna multiplexes.149+func (s *Service) syncScans(ctx context.Context) {150+ if l, err := s.tvhTuner.Lineup(ctx); err == nil {151+ s.noteScans(l)152+ }153+}154+155+// noteScans notes the scan results in l.156+func (s *Service) noteScans(l *tvh.Lineup) {157+ now := time.Now()158+ for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {159+ if sc, ok := signal.ScanOf(m, now); ok && m.FrequencyHz > 0 {160+ s.signals.SetScan(m.FrequencyHz, signal.RF(m.FrequencyHz), sc)161+ }162+ }163+}164+165+// sample reads the inputs every timing.sample until ctx ends, saving the166+// measurements every minute.167+func (s *Service) sample(ctx context.Context) {168+ if s.opt.Tvheadend == nil {169+ return170+ }171+ tick := time.NewTicker(s.timing.sample)172+ defer tick.Stop()173+ for {174+ select {175+ case <-ctx.Done():176+ if err := s.signals.Save(); err != nil {177+ log.Printf("signal measurements: %v", err)178+ }179+ return180+ case <-tick.C:181+ }182+ s.sampleInputs(ctx)183+ if err := s.signals.SaveEvery(time.Minute); err != nil {184+ log.Printf("signal measurements: %v", err)185+ }186+ }187+}188+189+// ---------- Measure now ----------190+191+var (192+ errTakenOver = errors.New("a viewer or a recording needed the tuner")193+ errNotTuned = errors.New("Tvheadend didn't tune it")194+)195+196+// Measure starts a sweep that measures every RF channel worth measuring,197+// one at a time, on an idle tuner: those the scan found channels on, and198+// those a licensed station nearby uses. It never takes a tuner that's in199+// use, and stops as soon as a viewer or a recording needs the one it200+// uses. It returns at once; Signal reports progress and the readings.201+func (s *Service) Measure(ctx context.Context) (*SweepStatus, error) {202+ if s.opt.NoAntenna {203+ return nil, ErrNoAntenna204+ }205+ if s.opt.Tvheadend == nil {206+ return nil, errors.New("no tuner: the server has no Tvheadend")207+ }208+ fes, err := s.frontends(ctx, false)209+ if err != nil {210+ return nil, fmt.Errorf("Tvheadend is not reachable: %w", err)211+ }212+ if len(fes) == 0 {213+ return nil, ErrNoTuner214+ }215+ s.mu.Lock()216+ defer s.mu.Unlock()217+ if s.sweep.Running {218+ st := s.sweep219+ return &st, nil220+ }221+ now := time.Now().UTC().Truncate(time.Second)222+ s.sweep = SweepStatus{Running: true, StartedAt: &now}223+ st := s.sweep224+ go s.runSweep(s.ctx, len(fes))225+ return &st, nil226+}227+228+func (s *Service) sweepStatus() SweepStatus {229+ s.mu.Lock()230+ defer s.mu.Unlock()231+ return s.sweep232+}233+234+func (s *Service) setSweep(f func(*SweepStatus)) {235+ s.mu.Lock()236+ f(&s.sweep)237+ s.mu.Unlock()238+}239+240+// sweepTargets lists the antenna multiplexes worth measuring, by RF:241+// those with services, and those a licensed station within the report's242+// radius broadcasts on. It also names a channel on each, for servers that243+// won't stream a whole multiplex.244+func (s *Service) sweepTargets(ctx context.Context) ([]tvh.Mux, map[string]string, error) {245+ s.tvhTuner.Invalidate()246+ l, err := s.tvhTuner.Lineup(ctx)247+ if err != nil {248+ return nil, nil, err249+ }250+ s.mu.Lock()251+ rep := s.report252+ s.mu.Unlock()253+ licensed := map[int]bool{}254+ if rep != nil {255+ for _, st := range rep.Stations {256+ licensed[st.RFChannel] = true257+ }258+ }259+ channelOn := map[string]string{}260+ for _, c := range l.Channels {261+ if _, m, ok := l.Source(c); ok && c.Enabled && channelOn[m.UUID] == "" {262+ channelOn[m.UUID] = c.UUID263+ }264+ }265+ var out []tvh.Mux266+ for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {267+ if bool(m.Enabled) && m.FrequencyHz > 0 && (m.NumSvc > 0 || licensed[signal.RF(m.FrequencyHz)]) {268+ out = append(out, m)269+ }270+ }271+ slices.SortFunc(out, func(a, b tvh.Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })272+ return out, channelOn, nil273+}274+275+func (s *Service) runSweep(ctx context.Context, tuners int) {276+ note := ""277+ skipped := 0278+ defer func() {279+ if skipped > 0 {280+ note = strings.TrimPrefix(note+fmt.Sprintf("; %d RF channels skipped: Tvheadend won't stream them", skipped), "; ")281+ }282+ if err := s.signals.Save(); err != nil {283+ log.Printf("signal measurements: %v", err)284+ }285+ now := time.Now().UTC().Truncate(time.Second)286+ s.mu.Lock()287+ s.sweepMux = ""288+ s.sweep.Running, s.sweep.FinishedAt, s.sweep.RF, s.sweep.Note = false, &now, 0, note289+ s.mu.Unlock()290+ }()291+ muxes, channelOn, err := s.sweepTargets(ctx)292+ if err != nil {293+ note = "Tvheadend is not reachable: " + err.Error()294+ return295+ }296+ s.setSweep(func(st *SweepStatus) { st.Total = len(muxes) })297+ log.Printf("measuring %d RF channels", len(muxes))298+ for _, m := range muxes {299+ if ctx.Err() != nil {300+ note = "stopped: the server is shutting down"301+ return302+ }303+ rf := signal.RF(m.FrequencyHz)304+ s.setSweep(func(st *SweepStatus) { st.RF = rf })305+ ins, err := s.readInputs(ctx)306+ if on := onMux(ins, m.Name); err == nil && on != nil {307+ // A tuner is on it already (a viewer, a recording): read that308+ // rather than take another.309+ s.signals.Record(m.FrequencyHz, rf, signal.FromInput(*on, time.Now(), signal.Sweep))310+ s.setSweep(func(st *SweepStatus) { st.Done++ })311+ continue312+ }313+ if ins, ok := s.waitIdle(ctx, tuners); !ok {314+ note = fmt.Sprintf("stopped at RF %d: %s", rf, noTunerFree(ins))315+ return316+ }317+ err = s.measureMux(ctx, m, channelOn[m.UUID])318+ var se *tvh.StreamError319+ switch {320+ case errors.Is(err, errTakenOver):321+ note = fmt.Sprintf("stopped at RF %d: %v", rf, err)322+ return323+ case errors.As(err, &se) && se.Status == http.StatusServiceUnavailable:324+ note = fmt.Sprintf("stopped at RF %d: %s", rf, noTunerFree(nil))325+ return326+ case errors.As(err, &se):327+ skipped++328+ case err != nil:329+ log.Printf("measure RF %d: %v", rf, err)330+ }331+ s.setSweep(func(st *SweepStatus) { st.Done++ })332+ }333+ log.Printf("measured %d RF channels", len(muxes))334+}335+336+// onMux finds an input tuned to the antenna multiplex named mux.337+func onMux(ins []tvh.InputStatus, mux string) *tvh.InputStatus {338+ for i := range ins {339+ if name, net, ok := ins[i].Mux(); ok && name == mux && net == tvh.ATSCNetwork {340+ return &ins[i]341+ }342+ }343+ return nil344+}345+346+// waitIdle waits up to timing.idleWait for a tuner nobody uses, and347+// returns the inputs in use then; ok is false when none came free.348+func (s *Service) waitIdle(ctx context.Context, tuners int) ([]tvh.InputStatus, bool) {349+ deadline := time.Now().Add(s.timing.idleWait)350+ for {351+ ins, err := s.readInputs(ctx)352+ if err == nil && busyTuners(ins) < tuners {353+ return ins, true354+ }355+ if time.Now().After(deadline) || sleep(ctx, s.timing.poll) != nil {356+ return ins, false357+ }358+ }359+}360+361+// noTunerFree says why the sweep found no tuner: in use by viewers or362+// recordings, which it leaves alone, or by Tvheadend's own guide or363+// channel scans (weights below a user's), which end on their own.364+func noTunerFree(ins []tvh.InputStatus) string {365+ own := len(ins) > 0366+ for _, in := range ins {367+ own = own && in.Weight < sweepWeight368+ }369+ if own {370+ return "every tuner is busy with Tvheadend's own guide or channel scan; try again in a few minutes"371+ }372+ return "no tuner was free (they're left to viewers and recordings)"373+}374+375+// busyTuners counts the inputs in use.376+func busyTuners(ins []tvh.InputStatus) int {377+ seen := map[string]bool{}378+ for _, in := range ins {379+ seen[in.Input] = true380+ }381+ return len(seen)382+}383+384+func sleep(ctx context.Context, d time.Duration) error {385+ t := time.NewTimer(d)386+ defer t.Stop()387+ select {388+ case <-ctx.Done():389+ return ctx.Err()390+ case <-t.C:391+ return nil392+ }393+}394+395+// measureMux subscribes to a multiplex at the sweep's weight, waits for a396+// tuner to take it, and records timing.readings readings of it. A397+// subscription Tvheadend refuses (a server that won't stream whole398+// multiplexes) is retried on channelUUID, a channel of the multiplex,399+// when it has one.400+func (s *Service) measureMux(ctx context.Context, m tvh.Mux, channelUUID string) error {401+ ctx, cancel := context.WithCancel(ctx)402+ defer cancel()403+ s.mu.Lock()404+ s.sweepMux = m.Name405+ s.mu.Unlock()406+ rf := signal.RF(m.FrequencyHz)407+408+ var received atomic.Int64409+ opened := make(chan error, 1)410+ go func() {411+ body, err := s.opt.Tvheadend.OpenMux(ctx, m.UUID, sweepWeight)412+ var se *tvh.StreamError413+ if errors.As(err, &se) && se.Status != http.StatusServiceUnavailable && channelUUID != "" {414+ body, err = s.opt.Tvheadend.OpenChannel(ctx, channelUUID, sweepWeight)415+ }416+ opened <- err417+ if err != nil {418+ return419+ }420+ defer body.Close()421+ buf := make([]byte, 32*1024)422+ for {423+ n, err := body.Read(buf)424+ received.Add(int64(n))425+ if err != nil {426+ return427+ }428+ }429+ }()430+431+ // Wait for a tuner to show the multiplex.432+ var input string433+ deadline := time.Now().Add(s.timing.appear)434+ for input == "" {435+ select {436+ case err := <-opened:437+ if err != nil {438+ return err439+ }440+ default:441+ }442+ if ins, err := s.readInputs(ctx); err == nil {443+ if on := onMux(ins, m.Name); on != nil {444+ input = on.Input445+ break446+ }447+ }448+ if time.Now().After(deadline) {449+ return fmt.Errorf("RF %d: %w", rf, errNotTuned)450+ }451+ if err := sleep(ctx, s.timing.poll); err != nil {452+ return err453+ }454+ }455+ if err := sleep(ctx, s.timing.settle); err != nil {456+ return err457+ }458+ for i := range s.timing.readings {459+ if i > 0 {460+ if err := sleep(ctx, s.timing.interval); err != nil {461+ return err462+ }463+ }464+ ins, err := s.readInputs(ctx)465+ if err != nil {466+ return err467+ }468+ on := onMux(ins, m.Name)469+ if on == nil {470+ for _, in := range ins {471+ if in.Input == input {472+ // Its tuner is on another multiplex now: someone473+ // else's subscription took it over.474+ return errTakenOver475+ }476+ }477+ // Tvheadend let the multiplex go (or is between478+ // subscriptions): there's nothing to read, and nothing is479+ // made up.480+ return fmt.Errorf("RF %d: the tuner left it after %d readings", rf, i)481+ }482+ // Tvheadend may move a subscription that can't lock to another483+ // tuner; follow it there.484+ input = on.Input485+ r := signal.FromInput(*on, time.Now(), signal.Sweep)486+ r.Lock = r.Lock || received.Load() > 0487+ s.signals.Record(m.FrequencyHz, rf, r)488+ }489+ return nil490+}491+492+// ---------- tuning ----------493+494+// tuneWatch follows the tuner while a channel starts.495+type tuneWatch struct {496+ done chan struct{}497+498+ mu sync.Mutex499+ // reading is the latest of the channel's multiplex, taken at last; a500+ // tuner has been on it since since.501+ reading *signal.Reading502+ since, last time.Time503+ // busy is set when every tuner was in use by others while it waited.504+ busy bool505+}506+507+// errNoLock cancels a start that has had no lock for timing.noLock.508+var errNoLock = errors.New("no lock")509+510+// watchTune reads the tuner every timing.poll while ctx lasts, and511+// cancels it when the tuner has been on mux without a lock for512+// timing.noLock.513+func (s *Service) watchTune(ctx context.Context, cancel context.CancelCauseFunc, mux tvh.Mux, tuners int) *tuneWatch {514+ w := &tuneWatch{done: make(chan struct{})}515+ go func() {516+ defer close(w.done)517+ var unlocked time.Time518+ for {519+ if sleep(ctx, s.timing.poll) != nil {520+ return521+ }522+ ins, err := s.readInputs(ctx)523+ if err != nil {524+ continue525+ }526+ on := onMux(ins, mux.Name)527+ w.mu.Lock()528+ if on == nil {529+ w.busy = w.busy || tuners > 0 && busyTuners(ins) >= tuners530+ w.mu.Unlock()531+ continue532+ }533+ now := time.Now()534+ if w.since.IsZero() {535+ w.since = now536+ }537+ r := signal.FromInput(*on, now, signal.Active)538+ w.reading, w.last = &r, now539+ w.mu.Unlock()540+ switch {541+ case r.Lock:542+ unlocked = time.Time{}543+ case unlocked.IsZero():544+ unlocked = now545+ case now.Sub(unlocked) >= s.timing.noLock:546+ cancel(errNoLock)547+ return548+ }549+ }550+ }()551+ return w552+}553+554+// result waits for the watch to end and returns what it saw.555+func (w *tuneWatch) result() (r *signal.Reading, on time.Duration, busy bool) {556+ <-w.done557+ w.mu.Lock()558+ defer w.mu.Unlock()559+ if w.reading != nil {560+ on = w.last.Sub(w.since)561+ }562+ return w.reading, on, w.busy563+}564+565+// NoSignalError is a channel that didn't start because its RF channel had566+// no lock.567+type NoSignalError struct {568+ Channel string569+ RF int570+ Label string // "KTVD 20.x"571+ Reading *signal.Reading572+}573+574+func (e *NoSignalError) Error() string {575+ msg := fmt.Sprintf("No signal on RF %d", e.RF)576+ if e.Label != "" {577+ msg += " (" + e.Label + ")"578+ }579+ msg += " right now"580+ if r := e.Reading; r != nil {581+ var parts []string582+ if r.StrengthPct != nil {583+ parts = append(parts, fmt.Sprintf("strength %.0f%%", *r.StrengthPct))584+ }585+ if r.StrengthDBm != nil {586+ parts = append(parts, fmt.Sprintf("strength %.1f dBm", *r.StrengthDBm))587+ }588+ if r.QualityPct != nil {589+ parts = append(parts, fmt.Sprintf("quality %.0f%%", *r.QualityPct))590+ }591+ if r.SNRdB != nil {592+ parts = append(parts, fmt.Sprintf("SNR %.1f dB", *r.SNRdB))593+ }594+ if len(parts) > 0 {595+ msg += ": the tuner measured " + strings.Join(parts, ", ") + " and couldn't lock"596+ }597+ }598+ return msg599+}600+601+// rfLabel names what a channel's RF channel carries: "KTVD 20.x", or602+// "KUSA 9.x on KTVD" when another station's transmitter carries it.603+func rfLabel(c lineup.TunerChannel) string {604+ call := c.BaseCall605+ if call == "" {606+ call = fcc.BaseCall(c.Transmitter)607+ }608+ label := strings.TrimSpace(fmt.Sprintf("%s %d.x", call, c.Major))609+ if c.Via != "" {610+ label += " on " + fcc.BaseCall(c.Via)611+ }612+ return label613+}614+615+// tuneAntenna starts an antenna channel, watching the tuner meanwhile so616+// a channel that won't lock fails with what was measured.617+func (s *Service) tuneAntenna(ctx context.Context, client, number string, start func(context.Context, tuner.Input) error) error {618+ ch, err := s.tvhTuner.Channel(ctx, number)619+ if errors.Is(err, tuner.ErrNotFound) && len(s.offeredNetworks()) == 0 {620+ return ErrNoTuner621+ }622+ if err != nil {623+ return err624+ }625+ in, err := s.tvhTuner.Input(ctx, number)626+ if err != nil {627+ return err628+ }629+ fes, _ := s.frontends(ctx, false)630+ tctx, cancel := context.WithCancelCause(ctx)631+ w := s.watchTune(tctx, cancel, ch.Mux, len(fes))632+ err = start(tctx, in)633+ cancel(nil)634+ reading, on, busy := w.result()635+ // A tuner that has been on it a while has had time to lock, or not.636+ if reading != nil && on >= 3*s.timing.poll && ch.Mux.FrequencyHz > 0 {637+ s.signals.Record(ch.Mux.FrequencyHz, signal.RF(ch.Mux.FrequencyHz), *reading)638+ }639+ if err == nil {640+ return nil641+ }642+ label := ""643+ s.mu.Lock()644+ rep, g := s.report, s.guide645+ s.mu.Unlock()646+ if chans, _, merr := s.matched(ctx, rep, g); merr == nil {647+ for _, c := range chans {648+ if c.Number == number {649+ label = rfLabel(c)650+ }651+ }652+ }653+ rf := signal.RF(ch.Mux.FrequencyHz)654+ switch {655+ case reading != nil && !reading.Lock:656+ return &NoSignalError{Channel: number, RF: rf, Label: label, Reading: reading}657+ case reading == nil && busy:658+ return fmt.Errorf("every tuner is in use right now (by a viewer or a recording), so %s can't be tuned", number)659+ }660+ return fmt.Errorf("%s (RF %d) didn't start: %w", number, rf, err)661+}@@ -50,7 +50,7 @@ func TestNoAntenna(t *testing.T) {
5050 t.Errorf("report point %+v, guide %v", snap.Report.Point, snap.Guide)5151 }5252 raw, _ := json.Marshal(snap)53- for _, want := range []string{`"stations":[]`, `"channels":[]`, `"sources":[]`, `"warnings":[]`, `"kind":"weather"`} {53+ for _, want := range []string{`"stations":[]`, `"channels":[]`, `"sources":[]`, `"warnings":[]`, `"kind":"weather"`, `"antenna":{"tuner":{"ready":false`} {5454 if !strings.Contains(string(raw), want) {5555 t.Errorf("snapshot JSON lacks %s: %s", want, raw)5656 }@@ -61,11 +61,14 @@ func TestNoAntenna(t *testing.T) {
6161 t.Errorf("tune an antenna channel: %v", err)6262 }6363 }64- if _, err := s.Preview(ctx, "80302"); !errors.Is(err, ErrNoAntenna) {65- t.Errorf("preview: %v", err)64+ if _, err := s.Signal(ctx); !errors.Is(err, ErrNoAntenna) {65+ t.Errorf("signal: %v", err)6666 }67- if _, err := s.Profile(ctx, 1, 2, ""); !errors.Is(err, ErrNoAntenna) {68- t.Errorf("profile: %v", err)67+ if _, err := s.Measure(ctx); !errors.Is(err, ErrNoAntenna) {68+ t.Errorf("measure: %v", err)69+ }70+ if snap.Antenna == nil || len(snap.Antenna.Channels) != 0 || snap.Antenna.Tuner.Ready || snap.Antenna.Tuner.Reason != ErrNoAntenna.Error() {71+ t.Errorf("antenna = %+v", snap.Antenna)6972 }7073 if st, err := s.DVR(ctx); err != nil || st.Available || st.Reason != ErrNoAntenna.Error() {7174 t.Errorf("dvr = %+v, %v", st, err)@@ -1,10 +1,14 @@
1-// Package service is the Airwaves engine: lineup and reception, guide,2-// live streams and recordings. The desktop app runs it in-process when no3-// server is configured; airwavesd runs it on a home server and exposes it4-// over HTTP (see package api).1+// Package service is the Airwaves engine: the tuner's lineup and what was2+// measured of its signal, the guide, live streams and recordings. airwavesd3+// runs it on a home server and exposes it over HTTP (see package api).4+//5+// What it serves the app is either measured (the tuner's channels and6+// signal) or on record (the FCC's transmitters, the listings, the ATSC 3.07+// list). Reception estimates are for planning, in otascan, not here.58 package service69 710 import (11+ "cmp"812 "context"913 "encoding/json"1014 "errors"@@ -27,10 +31,9 @@ import (
2731 "airwaves/internal/geo"2832 "airwaves/internal/guide"2933 "airwaves/internal/lineup"30- "airwaves/internal/reception"34+ "airwaves/internal/signal"3135 "airwaves/internal/store"3236 "airwaves/internal/stream"33- "airwaves/internal/terrain"3437 "airwaves/internal/tuner"3538 "airwaves/internal/tvh"3639 "airwaves/internal/vchan"@@ -48,7 +51,7 @@ var ErrNoDVR = errors.New("recording needs an Airwaves server with Tvheadend")
4851 var ErrNotRecordable = errors.New("custom channels play from the server and can't be recorded")4952 5053 // ErrNoAntenna is returned for what needs an antenna (antenna channels,51-// reception reports, recording) by a server that has only custom channels.54+// signal, recording) by a server that has only custom channels.5255 var ErrNoAntenna = errors.New("this Airwaves server has no antenna, only custom channels")5356 5457 // ErrNoLocation is returned for the weather by a server without an@@ -96,15 +99,22 @@ type Info struct {
9699 Antenna bool `json:"antenna"`97100 }98101 99-// Snapshot is a report together with its listings.102+// Snapshot is the lineup with its listings.100103 type Snapshot struct {104+ // Report is what is on record at the location: the FCC's transmitters105+ // (with no estimates: their "signal" is empty), the ATSC 3.0 hosts and106+ // the data sources. Its Channels are the tuner's lineup, as Antenna107+ // has it, in the shape app versions before the measured lineup read.101108 Report *lineup.Report `json:"report"`102- Guide *guide.Guide `json:"guide"`109+ // Guide is the listings of the tuner's channels.110+ Guide *guide.Guide `json:"guide"`103111 // Custom lists the custom channels that are on, by number, with their104112 // details and schedules: the weather channel and the folder channels.105113 // It's always a list in a Snapshot, if an empty one, so the app can tell106114 // a server that lists the weather channel here from one before that.107115 Custom []CustomChannel `json:"custom"`116+ // Antenna is the tuner, its channels and what was measured of them.117+ Antenna *Antenna `json:"antenna"`108118 }109119 110120 // CustomChannel is a custom channel and its schedule, for the app's guide.@@ -124,16 +134,6 @@ type CustomChannel struct {
124134 Programs []guide.Program `json:"programs"`125135 }126136 127-// PathProfile is a terrain cross-section from the viewer to a transmitter.128-type PathProfile struct {129- DistanceKm float64 `json:"distanceKm"`130- Elevations []float64 `json:"elevations"` // downsampled, viewer first131- RxGroundM float64 `json:"rxGroundM"`132- TxHeightM float64 `json:"txHeightM"` // antenna height above sea level133- Presets []reception.Preset `json:"presets"`134- Signal map[string]reception.Estimate `json:"signal"`135-}136-137137 // Options configure a Service.138138 type Options struct {139139 Name string@@ -148,7 +148,9 @@ type Options struct {
148148 // Tvheadend enables server tuners and recording.149149 Tvheadend *tvh.Client150150 DVRPath string151- // Demo fills Tvheadend with generated channels while it has no tuner.151+ // Demo fills Tvheadend with generated channels, for development before152+ // there's a tuner. They're never made, and are removed, once a tuner's153+ // antenna network exists.152154 Demo bool153155 // DemoFFmpeg is the ffmpeg path inside the Tvheadend container.154156 DemoFFmpeg string@@ -179,15 +181,22 @@ type Options struct {
179181 // it streams, in LUFS (vchan.DefaultLoudness is antenna TV's); 0180182 // leaves it as it is.181183 Loudness float64182- Progress func(string)184+ // SignalPath keeps the signal measurements; empty keeps them in185+ // memory.186+ SignalPath string187+ Progress func(string)183188 }184189 185190 // Service is the engine.186191 type Service struct {187- opt Options188- http *http.Client189- cache *store.Cache190- terrain *terrain.Source192+ opt Options193+ http *http.Client194+ cache *store.Cache195+ signals *signal.Store196+ timing measureTiming197+ // ctx lasts until Close, for work that outlives a request (sweeps).198+ ctx context.Context199+ cancel context.CancelFunc191200 streams *stream.Server192201 streamErr error193202 tvhTuner *tuner.Tvheadend@@ -203,20 +212,25 @@ type Service struct {
203212 204213 buildMu sync.Mutex // serializes report builds205214 206- mu sync.Mutex207- cfg Config208- tuner tuner.Tuner209- report *lineup.Report210- guide *guide.Guide211- built time.Time212- previews map[string]preview // by ZIP213- tuners int // physical tuners Tvheadend reports214- wxUp bool // the display at WeatherStarURL answered last time215-}216-217-type preview struct {215+ mu sync.Mutex216+ cfg Config217+ tuner tuner.Tuner218218 report *lineup.Report219+ guide *guide.Guide219220 built time.Time221+ wxUp bool // the display at WeatherStarURL answered last time222+ // fes are the ATSC tuners Tvheadend has, as of fesAt; fesKnown once223+ // looked up.224+ fes []tvh.Frontend225+ fesAt time.Time226+ fesKnown bool227+ // inputs are the tuned inputs as of inputsAt; lastStreams what each228+ // was tuned to at the last sample.229+ inputs []tvh.InputStatus230+ inputsAt time.Time231+ lastStreams map[string]string232+ sweep SweepStatus233+ sweepMux string // the multiplex the sweep is on220234 }221235 222236 // New builds a Service. Streaming failures (no ffmpeg) are reported by@@ -226,7 +240,8 @@ func New(opt Options) (*Service, error) {
226240 opt.Tvheadend, opt.Demo = nil, false227241 opt.Tuner = noAntenna{}228242 }229- s := &Service{opt: opt, http: web.NewClient(), tuner: opt.Tuner, previews: map[string]preview{}}243+ s := &Service{opt: opt, http: web.NewClient(), tuner: opt.Tuner, timing: defaultTiming}244+ s.ctx, s.cancel = context.WithCancel(context.Background())230245 s.cfg = s.withDefaults(opt.Config)231246 if opt.ConfigPath != "" {232247 if raw, err := os.ReadFile(opt.ConfigPath); err == nil {@@ -243,18 +258,21 @@ func New(opt Options) (*Service, error) {
243258 } else {244259 s.cache, err = store.NewCache("airwaves")245260 }246- tiles := filepath.Join(os.TempDir(), "airwaves-terrain")247261 if err != nil {248262 log.Printf("cache: %v; data will be refetched", err)249- } else {250- tiles = filepath.Join(s.cache.Dir(), "terrain")251263 }252- s.terrain = terrain.NewSource(s.http, tiles)264+ if s.signals, err = signal.Open(opt.SignalPath); err != nil {265+ if s.signals == nil {266+ return nil, err267+ }268+ log.Print(err)269+ }253270 s.weather = weather.NewSource(s.http)254271 s.streams, s.streamErr = stream.NewServer(opt.Loopback)255272 256273 if opt.Tvheadend != nil {257274 s.tvhTuner = tuner.NewTvheadend(opt.Tvheadend)275+ s.tvhTuner.Networks = s.offeredNetworks258276 s.tuner = s.tvhTuner259277 s.dvr, err = dvr.Open(opt.DVRPath, opt.Tvheadend, s.tvhTuner)260278 if err != nil {@@ -262,7 +280,7 @@ func New(opt Options) (*Service, error) {
262280 }263281 }264282 if s.tuner == nil {265- s.tuner = tuner.Demo{}283+ s.tuner = noTuner{}266284 }267285 if opt.WeatherStarPort > 0 {268286 s.wxLocal = strings.TrimRight(opt.WeatherStarURL, "/")@@ -328,6 +346,18 @@ func (noAntenna) Input(_ context.Context, number string) (tuner.Input, error) {
328346 return tuner.Input{}, fmt.Errorf("no channel %s: %w", number, ErrNoAntenna)329347 }330348 349+// noTuner is the tuner of a server with an antenna but no Tvheadend: it350+// has no channels.351+type noTuner struct{}352+353+func (noTuner) Device() tuner.Device {354+ return tuner.Device{ID: "none", Name: "No tuner", Kind: "none", Detail: "the server has no Tvheadend"}355+}356+357+func (noTuner) Input(_ context.Context, number string) (tuner.Input, error) {358+ return tuner.Input{}, fmt.Errorf("no channel %s: %w", number, ErrNoTuner)359+}360+331361 // wxOn reports whether the weather channel can be shown: there's a332362 // WeatherStar display, answering when it runs apart from the server, and a333363 // location for it to show.@@ -396,11 +426,15 @@ func (s *Service) customChannel(number string) vchan.Channel {
396426 return nil397427 }398428 399-// Close stops all streams.429+// Close stops all streams and measuring, and saves the measurements.400430 func (s *Service) Close() {431+ s.cancel()401432 if s.streams != nil {402433 s.streams.Close()403434 }435+ if err := s.signals.Save(); err != nil {436+ log.Printf("signal measurements: %v", err)437+ }404438 }405439 406440 // StreamHandler serves /live/ HLS files.@@ -421,6 +455,20 @@ func (s *Service) Info(context.Context) (Info, error) {
421455 s.mu.Unlock()422456 i := Info{Name: s.opt.Name, Mode: s.opt.Mode, Version: Version, Tuner: t.Device(), DVR: s.dvr != nil, WeatherStar: s.wxOn(),423457 Antenna: !s.opt.NoAntenna}458+ if s.opt.Tvheadend != nil {459+ ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)460+ defer cancel()461+ if fes, err := s.frontends(ctx, false); err == nil {462+ i.Tuner.Tuners = len(fes)463+ for _, fe := range fes {464+ i.Tuner.Model = cmp.Or(i.Tuner.Model, fe.Model)465+ }466+ if len(fes) > 0 {467+ // Tvheadend's ATSC tuners receive ATSC 1.0 only.468+ i.Tuner.Standards = []string{"ATSC 1.0"}469+ }470+ }471+ }424472 if s.streamErr != nil {425473 i.Playback = s.streamErr.Error()426474 }@@ -465,16 +513,33 @@ func (s *Service) SetConfig(_ context.Context, c Config) (Config, error) {
465513 return c, nil466514 }467515 468-// Snapshot returns the current report and guide, rebuilding when stale,469-// with the custom channels' details and schedules. With refresh it also516+// Snapshot returns the tuner's lineup described from the records (the517+// report, rebuilt when stale) with its listings, what was measured, and518+// the custom channels' details and schedules. With refresh it also470519 // bypasses cached source data.471520 func (s *Service) Snapshot(ctx context.Context, refresh bool) (*Snapshot, error) {472521 snap, err := s.snapshot(ctx, refresh)473522 if err != nil {474523 return nil, err475524 }476- snap.Custom = s.custom()477- return snap, nil525+ out := &Snapshot{Report: snap.Report, Guide: snap.Guide, Custom: s.custom()}526+ if s.opt.NoAntenna {527+ out.Antenna = s.antenna(ctx, nil, nil)528+ return out, nil529+ }530+ a := s.antenna(ctx, snap.Report, snap.Guide)531+ chans := make([]lineup.TunerChannel, len(a.Channels))532+ for i, c := range a.Channels {533+ chans[i] = c.TunerChannel534+ }535+ out.Report = lineup.ForTuner(snap.Report, chans)536+ out.Report.Channels = compatChannels(a)537+ if len(a.Channels) == 0 && a.Tuner.Reason != "" {538+ out.Report.Warnings = append(slices.Clone(out.Report.Warnings), "No antenna channels: "+a.Tuner.Reason)539+ }540+ out.Guide = listedOnly(snap.Guide, a.Channels)541+ out.Antenna = a542+ return out, nil478543 }479544 480545 // custom lists the custom channels for the app: the weather channel (which@@ -565,34 +630,20 @@ func (s *Service) LogoHandler() http.Handler {
565630 return mux566631 }567632 568-// AntennaChannels lists the antenna channels by number, with their names:569-// those in Tvheadend's lineup when there's a Tvheadend, else those the570-// lineup report has as receivable. Custom channels take the place of571-// antenna channels with the same number in the HDHomeRun lineup.633+// AntennaChannels lists the tuner's channels by number, with their call634+// signs; none without a tuner. Custom channels take the place of antenna635+// channels with the same number in the HDHomeRun lineup.572636 func (s *Service) AntennaChannels(ctx context.Context) map[string]string {573637 out := map[string]string{}574- if s.tvhTuner != nil {575- chans, err := s.tvhTuner.Channels(ctx)576- if err == nil {577- b := hdhrBackend{s}578- for _, c := range chans {579- if !c.Enabled || c.Number == "0" || len(c.Services) == 0 {580- continue581- }582- out[c.Number] = c.Name583- if rc := b.reportChannel(c.Number); rc != nil && rc.CallSign != "" {584- out[c.Number] = rc.CallSign585- }586- }587- return out588- }638+ s.mu.Lock()639+ rep, g := s.report, s.guide640+ s.mu.Unlock()641+ chans, _, err := s.matched(ctx, rep, g)642+ if err != nil {589643 log.Printf("antenna channels: %v", err)590644 }591- _, chans := s.guideAndChannels()592645 for _, c := range chans {593- if _, ok := out[c.Number]; !ok && c.Tier["rooftop"].Receivable() {594- out[c.Number] = c.CallSign595- }646+ out[c.Number] = cmp.Or(c.CallSign, c.Name)596647 }597648 return out598649 }@@ -623,7 +674,8 @@ func (s *Service) snapshot(ctx context.Context, refresh bool) (*Snapshot, error)
623674 return &Snapshot{Report: rep}, nil624675 }625676 626- b := &lineup.Builder{HTTP: s.http, Cache: s.cache, Terrain: s.terrain, Progress: s.opt.Progress}677+ // The records only: no terrain, so no estimates.678+ b := &lineup.Builder{HTTP: s.http, Cache: s.cache, Progress: s.opt.Progress}627679 opt := lineup.Options{ZIP: cfg.ZIP, RadiusKm: cfg.RadiusKm, GuideHours: cfg.GuideHours, Refresh: refresh}628680 if cfg.Lat != 0 && cfg.Lon != 0 {629681 opt.Point = &geo.Point{Lat: cfg.Lat, Lon: cfg.Lon}@@ -644,7 +696,7 @@ func (s *Service) snapshot(ctx context.Context, refresh bool) (*Snapshot, error)
644696 // reports false when the ZIP code couldn't be looked up.645697 func (s *Service) locate(ctx context.Context, cfg Config) (*lineup.Report, bool) {646698 rep := &lineup.Report{647- Generated: time.Now(), Presets: []reception.Preset{}, Stations: []lineup.Station{},699+ Generated: time.Now(), Stations: []lineup.Station{},648700 Channels: []lineup.Channel{}, Sources: []lineup.Source{}, Warnings: []string{},649701 }650702 ok := true@@ -771,81 +823,6 @@ func (s *Service) weatherStarBase(r *http.Request) string {
771823 return "http://" + net.JoinHostPort(host, strconv.Itoa(s.opt.WeatherStarPort))772824 }773825 774-// Preview builds a reception report for another ZIP code, to share what775-// someone there would receive. It leaves the server's own location alone.776-func (s *Service) Preview(ctx context.Context, zip string) (*Snapshot, error) {777- if s.opt.NoAntenna {778- return nil, ErrNoAntenna779- }780- if len(zip) != 5 || strings.Trim(zip, "0123456789") != "" {781- return nil, fmt.Errorf("%q is not a ZIP code", zip)782- }783- s.mu.Lock()784- if p, ok := s.previews[zip]; ok && time.Since(p.built) < reportTTL {785- s.mu.Unlock()786- return &Snapshot{Report: p.report}, nil787- }788- s.mu.Unlock()789- b := &lineup.Builder{HTTP: s.http, Cache: s.cache, Terrain: s.terrain}790- // A short guide window is enough to name the channels.791- rep, _, err := b.Build(context.WithoutCancel(ctx), lineup.Options{ZIP: zip, RadiusKm: 160, GuideHours: 6})792- if err != nil {793- return nil, err794- }795- s.mu.Lock()796- if len(s.previews) >= 8 {797- clear(s.previews)798- }799- s.previews[zip] = preview{report: rep, built: time.Now()}800- s.mu.Unlock()801- return &Snapshot{Report: rep}, nil802-}803-804-// Profile returns the terrain between the viewer and a transmitter: at the805-// server's location, or at a previewed ZIP code.806-func (s *Service) Profile(ctx context.Context, facilityID, rf int, zip string) (*PathProfile, error) {807- if s.opt.NoAntenna {808- return nil, ErrNoAntenna809- }810- var rep *lineup.Report811- if zip != "" {812- snap, err := s.Preview(ctx, zip)813- if err != nil {814- return nil, err815- }816- rep = snap.Report817- } else {818- snap, err := s.snapshot(ctx, false)819- if err != nil {820- return nil, err821- }822- rep = snap.Report823- }824- i := slices.IndexFunc(rep.Stations, func(st lineup.Station) bool { return st.FacilityID == facilityID && st.RFChannel == rf })825- if i < 0 {826- return nil, fmt.Errorf("facility %d RF %d not found", facilityID, rf)827- }828- st := rep.Stations[i]829- prof, err := s.terrain.Profile(ctx, rep.Point, st.Point)830- if err != nil {831- return nil, err832- }833- const points = 240834- el := prof.Elevations835- out := make([]float64, 0, points)836- for i := range points {837- out = append(out, el[i*(len(el)-1)/(points-1)])838- }839- tx := st.RCAMSLm840- if tx <= 0 {841- tx = el[len(el)-1] + max(st.RCAGLm, st.HAATm, 30)842- }843- return &PathProfile{844- DistanceKm: prof.DistanceKm, Elevations: out, RxGroundM: el[0], TxHeightM: tx,845- Presets: reception.Presets, Signal: st.Signal,846- }, nil847-}848-849826 // Tune starts live playback of a channel for client.850827 func (s *Service) Tune(ctx context.Context, client, number string) (*stream.Playback, error) {851828 if s.streamErr != nil {@@ -865,6 +842,15 @@ func (s *Service) Tune(ctx context.Context, client, number string) (*stream.Play
865842 }866843 return s.streams.Start(ctx, client, in)867844 }845+ if s.tvhTuner != nil {846+ var pb *stream.Playback847+ err := s.tuneAntenna(ctx, client, number, func(ctx context.Context, in tuner.Input) error {848+ var err error849+ pb, err = s.streams.Start(ctx, client, in)850+ return err851+ })852+ return pb, err853+ }868854 s.mu.Lock()869855 t := s.tuner870856 s.mu.Unlock()@@ -888,7 +874,7 @@ func (s *Service) DVR(ctx context.Context) (*dvr.State, error) {
888874 if s.dvr == nil {889875 return &dvr.State{Reason: s.noDVR().Error()}, nil890876 }891- g, chans := s.guideAndChannels()877+ g, chans, _ := s.recordingChannels(ctx)892878 return s.dvr.State(ctx, g, chans)893879 }894880 @@ -914,7 +900,10 @@ func (s *Service) Record(ctx context.Context, req dvr.Request) (*dvr.Rule, error
914900 if _, err := s.snapshot(ctx, false); err != nil {915901 return nil, err916902 }917- g, chans := s.guideAndChannels()903+ g, chans, err := s.recordingChannels(ctx)904+ if err != nil {905+ return nil, err906+ }918907 return s.dvr.Add(ctx, req, g, chans)919908 }920909 @@ -1013,19 +1002,22 @@ func (s *Service) SetDVRPrefs(_ context.Context, p dvr.Prefs) (dvr.Prefs, error)
10131002 return s.dvr.SetPrefs(p)10141003 }10151004 1016-func (s *Service) guideAndChannels() (*guide.Guide, []lineup.Channel) {1005+// recordingChannels is the listings and the tuner's channels, for1006+// recording.1007+func (s *Service) recordingChannels(ctx context.Context) (*guide.Guide, []lineup.Channel, error) {10171008 s.mu.Lock()1018- defer s.mu.Unlock()1019- if s.report == nil {1020- return s.guide, nil1021- }1022- return s.guide, s.report.Channels1009+ rep, g := s.report, s.guide1010+ s.mu.Unlock()1011+ chans, _, err := s.matched(ctx, rep, g)1012+ return g, dvrChannels(chans), err10231013 }10241014 10251015 // Run keeps a server healthy until ctx ends: refreshes listings, keeps1026-// Tvheadend's channels matching the hardware (demo channels until a tuner1027-// appears, then the antenna network), and reconciles recordings.1016+// Tvheadend's channels matching the hardware (the antenna network once a1017+// tuner appears; demo channels before, in demo mode), measures the signal1018+// while tuners are in use, and reconciles recordings.10281019 func (s *Service) Run(ctx context.Context) {1020+ go s.sample(ctx)10291021 tick := time.NewTicker(time.Minute)10301022 defer tick.Stop()10311023 for n := 0; ; n++ {@@ -1043,9 +1035,9 @@ func (s *Service) Run(ctx context.Context) {
10431035 // mapped now are scheduled on the next pass.10441036 func (s *Service) maintain(ctx context.Context, prune bool) {10451037 s.checkWeatherStar(ctx)1046- if _, err := s.snapshot(ctx, false); err != nil {1047- log.Printf("snapshot: %v", err)1048- return1038+ _, snapErr := s.snapshot(ctx, false)1039+ if snapErr != nil {1040+ log.Printf("snapshot: %v", snapErr)10491041 }10501042 if s.opt.Tvheadend == nil {10511043 return@@ -1065,7 +1057,17 @@ func (s *Service) maintain(ctx context.Context, prune bool) {
10651057 log.Printf("mapped %d new channels", n)10661058 s.tvhTuner.Invalidate()10671059 }1068- g, chans := s.guideAndChannels()1060+ s.syncScans(ctx)1061+ if snapErr != nil {1062+ return1063+ }1064+ // Without the tuner's lineup, recordings stay as they are rather than1065+ // being unscheduled for channels that seem gone.1066+ g, chans, err := s.recordingChannels(ctx)1067+ if err != nil {1068+ log.Printf("reconcile recordings: %v", err)1069+ return1070+ }10691071 if err := s.dvr.Reconcile(ctx, g, chans); err != nil {10701072 log.Printf("reconcile recordings: %v", err)10711073 }@@ -1079,17 +1081,16 @@ func (s *Service) maintain(ctx context.Context, prune bool) {
10791081 }10801082 }10811083 1082-// setupTuners attaches real tuners to the antenna network when present and1083-// otherwise keeps the demo channels in place.1084+// setupTuners attaches real tuners to the antenna network when present.1085+// Once that network exists (a tuner has been found), demo channels are1086+// removed and never made again, even while the tuner is away; before, in1087+// demo mode, they stand in for the antenna.10841088 func (s *Service) setupTuners(ctx context.Context) error {10851089 c := s.opt.Tvheadend1086- fes, err := c.Frontends(ctx)1090+ fes, err := s.frontends(ctx, true)10871091 if err != nil {10881092 return err10891093 }1090- s.mu.Lock()1091- s.tuners = len(fes)1092- s.mu.Unlock()10931094 if len(fes) > 0 {10941095 changed, err := c.EnsureATSC(ctx, fes)10951096 if err != nil {@@ -1097,47 +1098,68 @@ func (s *Service) setupTuners(ctx context.Context) error {
10971098 }10981099 if changed {10991100 log.Printf("antenna network created for %d tuners; Tvheadend is scanning", len(fes))1101+ s.tvhTuner.Invalidate()11001102 }1101- // Retire demo channels once real ones exist.1102- nets, err := c.Networks(ctx)1103- if err != nil {1104- return err1105- }1106- for _, n := range nets {1107- if n.Name == tvh.ATSCNetwork && n.NumChn > 0 {1108- return c.RemoveNetwork(ctx, tvh.DemoNetwork)1109- }1110- }1111- return nil11121103 }1113- if !s.opt.Demo {1114- return nil1104+ nets, err := c.Networks(ctx)1105+ if err != nil {1106+ return err1107+ }1108+ var real, demo bool1109+ for _, n := range nets {1110+ real = real || n.Name == tvh.ATSCNetwork1111+ demo = demo || n.Name == tvh.DemoNetwork1112+ }1113+ switch {1114+ case real && demo:1115+ if err := c.RemoveNetwork(ctx, tvh.DemoNetwork); err != nil {1116+ return fmt.Errorf("remove the demo channels: %w", err)1117+ }1118+ log.Print("removed the demo channels: a tuner's antenna network exists")1119+ s.tvhTuner.Invalidate()1120+ case real, !s.opt.Demo:1121+ default:1122+ return c.EnsureDemo(ctx, s.demoChannels())11151123 }1116- return c.EnsureDemo(ctx, s.demoChannels())1124+ return nil11171125 }11181126 1119-// demoChannels mirrors the receivable lineup as generated channels.1127+// demoChannels are the main channels of the stations in the listings (or,1128+// without listings, in the FCC's records) as generated test patterns.11201129 func (s *Service) demoChannels() []tvh.DemoChannel {1121- _, chans := s.guideAndChannels()1122- ffmpeg := s.opt.DemoFFmpeg1123- if ffmpeg == "" {1124- ffmpeg = "/usr/bin/ffmpeg"1125- }1126- seen := make(map[string]bool)1130+ s.mu.Lock()1131+ rep, g := s.report, s.guide1132+ s.mu.Unlock()1133+ ffmpeg := cmp.Or(s.opt.DemoFFmpeg, "/usr/bin/ffmpeg")1134+ seen := make(map[int]bool)11271135 var out []tvh.DemoChannel1128- for _, c := range chans {1129- if seen[c.Number] || c.GuideID == "" || !c.Tier["rooftop"].Receivable() {1130- continue1136+ add := func(major int, name string) {1137+ if major <= 0 || seen[major] {1138+ return11311139 }1132- seen[c.Number] = true1133- name := c.BaseCall1134- if name == "" {1135- name = "CH" + strconv.Itoa(c.Major)1140+ seen[major] = true1141+ // Tvheadend splits the command on spaces, so one word.1142+ if f := strings.Fields(name); len(f) > 0 {1143+ name = f[0]1144+ } else {1145+ name = "CH" + strconv.Itoa(major)11361146 }11371147 out = append(out, tvh.DemoChannel{1138- Major: c.Major, Minor: c.Minor, Name: name,1139- Command: tuner.DemoCommand(ffmpeg, c.Number, name),1148+ Major: major, Minor: 1, Name: name,1149+ Command: tuner.DemoCommand(ffmpeg, fmt.Sprintf("%d.1", major), name),11401150 })11411151 }1152+ if g != nil {1153+ for _, c := range g.Channels {1154+ if major, minor := guide.SplitNumber(c.Number); minor == 1 {1155+ add(major, guide.BaseCall(c.CallSign))1156+ }1157+ }1158+ }1159+ if len(out) == 0 && rep != nil {1160+ for _, st := range rep.Stations {1161+ add(st.VirtualChannel, st.BaseCall)1162+ }1163+ }11421164 return out11431165 }@@ -16,8 +16,6 @@ import (
1616 1717 "airwaves/internal/hdhr"1818 "airwaves/internal/jellyfin/jellyfintest"19- "airwaves/internal/lineup"20- "airwaves/internal/reception"2119 "airwaves/internal/vchan"2220 )2321 @@ -100,20 +98,6 @@ func TestSnapshotCustomChannels(t *testing.T) {
10098 }10199 }102100 103-func TestAntennaChannels(t *testing.T) {104- s, _ := newCustomService(t)105- s.report = &lineup.Report{Channels: []lineup.Channel{106- {Number: "7.1", CallSign: "KMGH", Tier: map[string]reception.Tier{"rooftop": reception.Strong}},107- {Number: "7.1", CallSign: "KMGH-LD", Tier: map[string]reception.Tier{"rooftop": reception.Strong}},108- {Number: "1.4", CallSign: "KTOON", Tier: map[string]reception.Tier{"rooftop": reception.Good}},109- {Number: "50.1", CallSign: "KFAR", Tier: map[string]reception.Tier{"rooftop": reception.Unlikely}},110- }}111- got := s.AntennaChannels(t.Context())112- if len(got) != 2 || got["7.1"] != "KMGH" || got["1.4"] != "KTOON" {113- t.Errorf("antenna = %v", got)114- }115-}116-117101 type xmltvOut struct {118102 Channels []struct {119103 ID string `xml:"id,attr"`@@ -0,0 +1,493 @@
1+// Package signal keeps what the tuners measured on each multiplex (each RF2+// channel): Tvheadend's scan results, and readings of signal strength and3+// quality taken while a tuner was on it. Nothing here is estimated: a4+// value is there because a tuner reported it, and is null otherwise.5+//6+// Readings come from Tvheadend's input status. An HDHomeRun reports7+// strength and signal quality (its SNQ) as percentages, so their dB fields8+// stay null; tuners that report decibels fill those in instead.9+package signal10+11+import (12+ "cmp"13+ "encoding/json"14+ "errors"15+ "fmt"16+ "io/fs"17+ "math"18+ "os"19+ "path/filepath"20+ "slices"21+ "sync"22+ "time"23+24+ "airwaves/internal/tvh"25+)26+27+// Sources of readings.28+const (29+ // Active readings are taken while something had a tuner on the30+ // multiplex: a viewer, a recording, or Tvheadend's own guide scan.31+ Active = "active"32+ // Sweep readings are taken by Measure now, on an idle tuner.33+ Sweep = "sweep"34+)35+36+// Reading is one sample of a tuner on a multiplex.37+type Reading struct {38+ At time.Time `json:"at"`39+ Source string `json:"source"` // Active or Sweep40+ // Lock is whether data arrived: the tuner locked to the signal.41+ Lock bool `json:"lock"`42+ // StrengthPct is signal strength on the tuner's relative scale, 0 to43+ // 100; StrengthDBm is set instead by tuners that report dBm.44+ StrengthPct *float64 `json:"strengthPct"`45+ StrengthDBm *float64 `json:"strengthDbm"`46+ // QualityPct is the signal-to-noise quality on the tuner's relative47+ // scale (an HDHomeRun's SNQ); SNRdB is set instead by tuners that48+ // report the ratio in dB.49+ QualityPct *float64 `json:"qualityPct"`50+ SNRdB *float64 `json:"snrDb"`51+ // BER is the bit error ratio, and UNC the uncorrected blocks, from52+ // tuners that count them.53+ BER *float64 `json:"ber"`54+ UNC *int64 `json:"unc"`55+}56+57+// FromInput reads an input's status as Tvheadend reports it.58+func FromInput(in tvh.InputStatus, at time.Time, source string) Reading {59+ r := Reading{At: at.UTC().Truncate(time.Second), Source: source, Lock: in.BPS > 0}60+ switch in.SignalScale {61+ case tvh.ScaleRelative:62+ r.StrengthPct = ptr(round1(float64(in.Signal) * 100 / 65535))63+ case tvh.ScaleDecibel:64+ r.StrengthDBm = ptr(round1(float64(in.Signal) / 1000))65+ }66+ switch in.SNRScale {67+ case tvh.ScaleRelative:68+ r.QualityPct = ptr(round1(float64(in.SNR) * 100 / 65535))69+ case tvh.ScaleDecibel:70+ r.SNRdB = ptr(round1(float64(in.SNR) / 1000))71+ }72+ // A driver's own BER and UNC counters read 0 when it has none, so only73+ // a count Tvheadend made, or one above zero, says anything.74+ if in.TCBit > 0 {75+ r.BER = ptr(float64(in.ECBit) / float64(in.TCBit))76+ }77+ switch {78+ case in.TCBlock > 0:79+ r.UNC = ptr(in.ECBlock)80+ case in.UNC > 0:81+ r.UNC = ptr(in.UNC)82+ }83+ return r84+}85+86+func ptr[T any](v T) *T { return &v }87+88+func round1(v float64) float64 { return math.Round(v*10) / 10 }89+90+// Scan is how Tvheadend's last scan of a multiplex went.91+type Scan struct {92+ // Lock is whether the scan found the multiplex and its services.93+ Lock bool `json:"lock"`94+ // Partial is set when it locked but missed some of the tables.95+ Partial bool `json:"partial,omitempty"`96+ // At is when the scan found it, from Tvheadend; for a scan that found97+ // nothing, which Tvheadend doesn't date, when Airwaves first saw that.98+ At time.Time `json:"at"`99+}100+101+// ScanOf turns a multiplex's scan result into a Scan; ok is false while102+// it has none. seen is when the result was read.103+func ScanOf(m tvh.Mux, seen time.Time) (Scan, bool) {104+ switch m.ScanResult {105+ case tvh.ScanOK, tvh.ScanPartial:106+ at := seen107+ if m.ScanLast > 0 {108+ at = time.Unix(m.ScanLast, 0)109+ }110+ return Scan{Lock: true, Partial: m.ScanResult == tvh.ScanPartial, At: at.UTC()}, true111+ case tvh.ScanFail:112+ return Scan{At: seen.UTC().Truncate(time.Second)}, true113+ }114+ return Scan{}, false115+}116+117+// Stat is the spread of one quantity over readings.118+type Stat struct {119+ Min float64 `json:"min"`120+ Max float64 `json:"max"`121+ Sum float64 `json:"sum"`122+ N int `json:"n"`123+}124+125+func (s *Stat) add(v *float64) *Stat {126+ if v == nil {127+ return s128+ }129+ if s == nil {130+ return &Stat{Min: *v, Max: *v, Sum: *v, N: 1}131+ }132+ s.Min, s.Max, s.Sum, s.N = min(s.Min, *v), max(s.Max, *v), s.Sum+*v, s.N+1133+ return s134+}135+136+func merge(a, b *Stat) *Stat {137+ switch {138+ case a == nil && b == nil:139+ return nil140+ case a == nil:141+ c := *b142+ return &c143+ case b == nil:144+ c := *a145+ return &c146+ }147+ return &Stat{Min: min(a.Min, b.Min), Max: max(a.Max, b.Max), Sum: a.Sum + b.Sum, N: a.N + b.N}148+}149+150+// Window gathers consecutive readings of one source: a stretch of viewing,151+// or one sweep's readings.152+type Window struct {153+ From time.Time `json:"from"`154+ To time.Time `json:"to"`155+ Source string `json:"source"`156+ Samples int `json:"samples"`157+ Locked int `json:"locked"`158+ StrengthPct *Stat `json:"strengthPct,omitempty"`159+ StrengthDBm *Stat `json:"strengthDbm,omitempty"`160+ QualityPct *Stat `json:"qualityPct,omitempty"`161+ SNRdB *Stat `json:"snrDb,omitempty"`162+}163+164+func (w *Window) add(r Reading) {165+ w.To = r.At166+ w.Samples++167+ if r.Lock {168+ w.Locked++169+ }170+ w.StrengthPct = w.StrengthPct.add(r.StrengthPct)171+ w.StrengthDBm = w.StrengthDBm.add(r.StrengthDBm)172+ w.QualityPct = w.QualityPct.add(r.QualityPct)173+ w.SNRdB = w.SNRdB.add(r.SNRdB)174+}175+176+// Limits of the history kept per multiplex.177+const (178+ // MaxWindows is how many windows a multiplex keeps.179+ MaxWindows = 24180+ // windowSpan is the longest a window grows; windowGap the longest181+ // pause between readings within one.182+ windowSpan = 30 * time.Minute183+ windowGap = 2 * time.Minute184+)185+186+// Mux is what was measured on one multiplex.187+type Mux struct {188+ FrequencyHz int64 `json:"frequencyHz"`189+ RF int `json:"rf"`190+ Scan *Scan `json:"scan,omitempty"`191+ Latest *Reading `json:"latest,omitempty"`192+ Windows []Window `json:"windows,omitempty"`193+}194+195+func (m *Mux) add(r Reading) {196+ m.Latest = &r197+ if n := len(m.Windows); n > 0 {198+ w := &m.Windows[n-1]199+ if w.Source == r.Source && r.At.Sub(w.To) <= windowGap && r.At.Sub(w.From) < windowSpan && !r.At.Before(w.To) {200+ w.add(r)201+ return202+ }203+ }204+ w := Window{From: r.At, Source: r.Source}205+ w.add(r)206+ m.Windows = append(m.Windows, w)207+ if len(m.Windows) > MaxWindows {208+ m.Windows = slices.Delete(m.Windows, 0, len(m.Windows)-MaxWindows)209+ }210+}211+212+// Range is a quantity's spread for the app: lowest, mean and highest.213+type Range struct {214+ Min float64 `json:"min"`215+ Avg float64 `json:"avg"`216+ Max float64 `json:"max"`217+}218+219+func (s *Stat) rng() *Range {220+ if s == nil || s.N == 0 {221+ return nil222+ }223+ return &Range{Min: s.Min, Avg: round1(s.Sum / float64(s.N)), Max: s.Max}224+}225+226+// Period summarizes readings over a span: all the history a multiplex227+// keeps, or one window of it.228+type Period struct {229+ From time.Time `json:"from"`230+ To time.Time `json:"to"`231+ Source string `json:"source,omitempty"` // set on a window232+ Samples int `json:"samples"`233+ // LockedPct is the share of readings with a lock.234+ LockedPct float64 `json:"lockedPct"`235+ StrengthPct *Range `json:"strengthPct"`236+ StrengthDBm *Range `json:"strengthDbm"`237+ QualityPct *Range `json:"qualityPct"`238+ SNRdB *Range `json:"snrDb"`239+}240+241+func period(w Window) Period {242+ p := Period{From: w.From, To: w.To, Source: w.Source, Samples: w.Samples,243+ StrengthPct: w.StrengthPct.rng(), StrengthDBm: w.StrengthDBm.rng(), QualityPct: w.QualityPct.rng(), SNRdB: w.SNRdB.rng()}244+ if w.Samples > 0 {245+ p.LockedPct = round1(float64(w.Locked) * 100 / float64(w.Samples))246+ }247+ return p248+}249+250+// History is a multiplex's kept readings for the app: their summary and251+// each window, oldest first.252+type History struct {253+ Period254+ Windows []Period `json:"windows"`255+}256+257+// History summarizes the multiplex's readings; nil when there are none.258+func (m *Mux) History() *History {259+ if len(m.Windows) == 0 {260+ return nil261+ }262+ var all Window263+ h := &History{Windows: make([]Period, 0, len(m.Windows))}264+ for i, w := range m.Windows {265+ if i == 0 {266+ all.From = w.From267+ }268+ if w.To.After(all.To) {269+ all.To = w.To270+ }271+ all.Samples += w.Samples272+ all.Locked += w.Locked273+ all.StrengthPct = merge(all.StrengthPct, w.StrengthPct)274+ all.StrengthDBm = merge(all.StrengthDBm, w.StrengthDBm)275+ all.QualityPct = merge(all.QualityPct, w.QualityPct)276+ all.SNRdB = merge(all.SNRdB, w.SNRdB)277+ h.Windows = append(h.Windows, period(w))278+ }279+ h.Period = period(all)280+ return h281+}282+283+// Store keeps measurements by multiplex frequency, and saves them to a284+// file so they outlive restarts.285+type Store struct {286+ path string287+288+ mu sync.Mutex289+ muxes map[int64]*Mux290+ dirty bool291+ saved time.Time292+}293+294+// file is the saved form.295+type file struct {296+ Version int `json:"version"`297+ Muxes []*Mux `json:"muxes"`298+}299+300+// Open loads the measurements saved at path, if any. An empty path keeps301+// them in memory only. A file that can't be read is set aside (as302+// path.bad) and the store starts empty.303+func Open(path string) (*Store, error) {304+ s := &Store{path: path, muxes: map[int64]*Mux{}}305+ if path == "" {306+ return s, nil307+ }308+ raw, err := os.ReadFile(path)309+ if errors.Is(err, fs.ErrNotExist) {310+ return s, nil311+ }312+ if err != nil {313+ return nil, err314+ }315+ var f file316+ if err := json.Unmarshal(raw, &f); err != nil {317+ _ = os.Rename(path, path+".bad")318+ return s, fmt.Errorf("signal measurements in %s unreadable, set aside: %w", path, err)319+ }320+ for _, m := range f.Muxes {321+ if m != nil && m.FrequencyHz > 0 {322+ s.muxes[m.FrequencyHz] = m323+ }324+ }325+ return s, nil326+}327+328+func (s *Store) mux(freq int64, rf int) *Mux {329+ m := s.muxes[freq]330+ if m == nil {331+ m = &Mux{FrequencyHz: freq}332+ s.muxes[freq] = m333+ }334+ if rf > 0 {335+ m.RF = rf336+ }337+ return m338+}339+340+// Record adds a reading of the multiplex at freq (Hz), RF channel rf.341+func (s *Store) Record(freq int64, rf int, r Reading) {342+ if freq <= 0 {343+ return344+ }345+ s.mu.Lock()346+ defer s.mu.Unlock()347+ s.mux(freq, rf).add(r)348+ s.dirty = true349+}350+351+// SetScan notes a scan result. A result that hasn't changed keeps its352+// date, so a failed scan stays dated from when it was first seen.353+func (s *Store) SetScan(freq int64, rf int, sc Scan) {354+ if freq <= 0 {355+ return356+ }357+ s.mu.Lock()358+ defer s.mu.Unlock()359+ m := s.mux(freq, rf)360+ if old := m.Scan; old != nil && old.Lock == sc.Lock && old.Partial == sc.Partial && (!sc.Lock || !sc.At.After(old.At)) {361+ return362+ }363+ m.Scan = &sc364+ s.dirty = true365+}366+367+// Get returns a copy of what was measured at freq; ok is false when368+// nothing was.369+func (s *Store) Get(freq int64) (Mux, bool) {370+ s.mu.Lock()371+ defer s.mu.Unlock()372+ m := s.muxes[freq]373+ if m == nil {374+ return Mux{}, false375+ }376+ return clone(m), true377+}378+379+// All returns copies of every multiplex's measurements, by frequency.380+func (s *Store) All() []Mux {381+ s.mu.Lock()382+ defer s.mu.Unlock()383+ out := make([]Mux, 0, len(s.muxes))384+ for _, m := range s.muxes {385+ out = append(out, clone(m))386+ }387+ slices.SortFunc(out, func(a, b Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })388+ return out389+}390+391+func clone(m *Mux) Mux {392+ c := *m393+ if m.Scan != nil {394+ sc := *m.Scan395+ c.Scan = &sc396+ }397+ if m.Latest != nil {398+ r := *m.Latest399+ c.Latest = &r400+ }401+ c.Windows = make([]Window, len(m.Windows))402+ for i, w := range m.Windows {403+ c.Windows[i] = w404+ for _, p := range []**Stat{&c.Windows[i].StrengthPct, &c.Windows[i].StrengthDBm, &c.Windows[i].QualityPct, &c.Windows[i].SNRdB} {405+ if *p != nil {406+ st := **p407+ *p = &st408+ }409+ }410+ }411+ return c412+}413+414+// Save writes the measurements when they changed since the last save.415+func (s *Store) Save() error {416+ s.mu.Lock()417+ if !s.dirty || s.path == "" {418+ s.mu.Unlock()419+ return nil420+ }421+ f := file{Version: 1}422+ for _, m := range s.muxes {423+ c := clone(m)424+ f.Muxes = append(f.Muxes, &c)425+ }426+ s.dirty = false427+ s.saved = time.Now()428+ s.mu.Unlock()429+ slices.SortFunc(f.Muxes, func(a, b *Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })430+ raw, err := json.MarshalIndent(f, "", " ")431+ if err == nil {432+ err = writeAtomic(s.path, raw)433+ }434+ if err != nil {435+ s.mu.Lock()436+ s.dirty = true437+ s.mu.Unlock()438+ }439+ return err440+}441+442+// SaveEvery saves when there are changes and the last save was at least443+// every ago, so frequent readings don't mean frequent writes.444+func (s *Store) SaveEvery(every time.Duration) error {445+ s.mu.Lock()446+ due := s.dirty && time.Since(s.saved) >= every447+ s.mu.Unlock()448+ if !due {449+ return nil450+ }451+ return s.Save()452+}453+454+func writeAtomic(path string, raw []byte) error {455+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {456+ return err457+ }458+ tmp, err := os.CreateTemp(filepath.Dir(path), ".signal-*")459+ if err != nil {460+ return err461+ }462+ if _, err := tmp.Write(raw); err != nil {463+ tmp.Close()464+ os.Remove(tmp.Name())465+ return err466+ }467+ if err := tmp.Close(); err != nil {468+ os.Remove(tmp.Name())469+ return err470+ }471+ if err := os.Chmod(tmp.Name(), 0o644); err != nil {472+ os.Remove(tmp.Name())473+ return err474+ }475+ return os.Rename(tmp.Name(), path)476+}477+478+// RF is the US broadcast RF channel centered on freq (Hz), 0 when freq479+// isn't one.480+func RF(freq int64) int {481+ mhz := float64(freq) / 1e6482+ switch {483+ case mhz > 54 && mhz < 72:484+ return 2 + int(math.Round((mhz-57)/6))485+ case mhz > 76 && mhz < 88:486+ return 5 + int(math.Round((mhz-79)/6))487+ case mhz > 174 && mhz < 216:488+ return 7 + int(math.Round((mhz-177)/6))489+ case mhz > 470 && mhz < 698:490+ return 14 + int(math.Round((mhz-473)/6))491+ }492+ return 0493+}@@ -0,0 +1,177 @@
1+package signal2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+11+ "airwaves/internal/tvh"12+ "airwaves/internal/tvh/tvhtest"13+)14+15+// TestFromInput reads an HDHomeRun's status as Tvheadend reports it:16+// strength and quality as percentages, no dB, and no BER or UNC since it17+// counts none.18+func TestFromInput(t *testing.T) {19+ var body struct {20+ Entries []tvh.InputStatus `json:"entries"`21+ }22+ if err := json.Unmarshal(tvhtest.Raw(t, "status_inputs.json"), &body); err != nil {23+ t.Fatal(err)24+ }25+ at := time.Date(2026, 10, 4, 18, 0, 0, 0, time.UTC)26+ r := FromInput(body.Entries[0], at, Active)27+ if !r.Lock || *r.StrengthPct != 85 || *r.QualityPct != 83 || r.StrengthDBm != nil || r.SNRdB != nil ||28+ r.BER != nil || r.UNC != nil || r.Source != Active || !r.At.Equal(at) {29+ t.Errorf("reading = %+v", r)30+ }31+ raw, _ := json.Marshal(r)32+ for _, want := range []string{`"lock":true`, `"strengthPct":85`, `"qualityPct":83`, `"snrDb":null`, `"strengthDbm":null`, `"ber":null`, `"unc":null`} {33+ if !strings.Contains(string(raw), want) {34+ t.Errorf("JSON lacks %s: %s", want, raw)35+ }36+ }37+38+ // A tuner reporting decibels, counting bits and blocks, with no lock.39+ r = FromInput(tvh.InputStatus{Signal: -48250, SignalScale: tvh.ScaleDecibel, SNR: 21400, SNRScale: tvh.ScaleDecibel,40+ ECBit: 3, TCBit: 1000, ECBlock: 7, TCBlock: 500}, at, Sweep)41+ if r.Lock || *r.StrengthDBm != -48.3 || *r.SNRdB != 21.4 || r.StrengthPct != nil || r.QualityPct != nil || *r.BER != 0.003 || *r.UNC != 7 {42+ t.Errorf("decibel reading = %+v", r)43+ }44+ // Nothing reported: nothing claimed.45+ r = FromInput(tvh.InputStatus{}, at, Sweep)46+ if r.StrengthPct != nil || r.StrengthDBm != nil || r.QualityPct != nil || r.SNRdB != nil || r.UNC != nil || r.Lock {47+ t.Errorf("empty reading = %+v", r)48+ }49+}50+51+func TestScanOf(t *testing.T) {52+ seen := time.Date(2026, 10, 4, 18, 0, 0, 0, time.UTC)53+ if sc, ok := ScanOf(tvh.Mux{ScanResult: tvh.ScanOK, ScanLast: 1791129355}, seen); !ok || !sc.Lock || sc.Partial || sc.At.Unix() != 1791129355 {54+ t.Errorf("ok: %+v %v", sc, ok)55+ }56+ if sc, ok := ScanOf(tvh.Mux{ScanResult: tvh.ScanPartial}, seen); !ok || !sc.Lock || !sc.Partial || !sc.At.Equal(seen) {57+ t.Errorf("partial: %+v %v", sc, ok)58+ }59+ if sc, ok := ScanOf(tvh.Mux{ScanResult: tvh.ScanFail}, seen); !ok || sc.Lock || !sc.At.Equal(seen) {60+ t.Errorf("fail: %+v %v", sc, ok)61+ }62+ if _, ok := ScanOf(tvh.Mux{ScanResult: tvh.ScanNone}, seen); ok {63+ t.Error("an unscanned multiplex has a scan")64+ }65+}66+67+func TestRF(t *testing.T) {68+ for freq, want := range map[int64]int{69+ 57_000_000: 2, 69_000_000: 4, 79_000_000: 5, 85_000_000: 6, 177_000_000: 7, 213_000_000: 13,70+ 473_000_000: 14, 479_000_000: 15, 575_000_000: 31, 605_000_000: 36, 611_000_000: 37, 0: 0, 100_000_000: 0,71+ } {72+ if got := RF(freq); got != want {73+ t.Errorf("RF(%d) = %d, want %d", freq, got, want)74+ }75+ }76+}77+78+func reading(at time.Time, source string, lock bool, strength, quality float64) Reading {79+ return Reading{At: at, Source: source, Lock: lock, StrengthPct: &strength, QualityPct: &quality}80+}81+82+// TestHistory: readings gather into windows by source and time, the83+// windows into a summary with lows and means, and only so many are kept.84+func TestHistory(t *testing.T) {85+ s, err := Open("")86+ if err != nil {87+ t.Fatal(err)88+ }89+ t0 := time.Date(2026, 10, 4, 18, 0, 0, 0, time.UTC)90+ const freq = 575_000_00091+ if _, ok := s.Get(freq); ok {92+ t.Fatal("measured before any reading")93+ }94+ // Watching for a minute, every 10 s; the picture breaks up once.95+ for i, q := range []float64{83, 82, 40, 83, 84, 83} {96+ s.Record(freq, 31, reading(t0.Add(time.Duration(i)*10*time.Second), Active, q > 50, 85, q))97+ }98+ // Measure now, an hour later.99+ s.Record(freq, 31, reading(t0.Add(time.Hour), Sweep, true, 86, 84))100+ m, ok := s.Get(freq)101+ if !ok || m.RF != 31 || len(m.Windows) != 2 || m.Latest.Source != Sweep || *m.Latest.QualityPct != 84 {102+ t.Fatalf("mux = %+v", m)103+ }104+ h := m.History()105+ if h.Samples != 7 || h.QualityPct.Min != 40 || h.QualityPct.Max != 84 || h.QualityPct.Avg != 77 || h.StrengthPct.Avg != 85.1 ||106+ h.LockedPct != 85.7 || h.SNRdB != nil || !h.From.Equal(t0) || !h.To.Equal(t0.Add(time.Hour)) || len(h.Windows) != 2 {107+ t.Errorf("history = %+v", h)108+ }109+ if w := h.Windows[0]; w.Source != Active || w.Samples != 6 || w.LockedPct != 83.3 || w.QualityPct.Min != 40 || !w.To.Equal(t0.Add(50*time.Second)) {110+ t.Errorf("first window = %+v", w)111+ }112+113+ // A long stretch of watching splits into windows, and the oldest go.114+ for i := range 3 * 60 * 6 {115+ s.Record(freq, 31, reading(t0.Add(2*time.Hour+time.Duration(i)*10*time.Second), Active, true, 85, 83))116+ }117+ m, _ = s.Get(freq)118+ if len(m.Windows) != 8 {119+ t.Errorf("3 hours of watching: %d windows", len(m.Windows))120+ }121+ for i := range 3 * MaxWindows {122+ s.Record(freq, 31, reading(t0.Add(6*time.Hour+time.Duration(i)*time.Hour), Sweep, true, 85, 83))123+ }124+ if m, _ = s.Get(freq); len(m.Windows) != MaxWindows {125+ t.Errorf("windows kept = %d", len(m.Windows))126+ }127+}128+129+// TestStoreSaves: measurements and scan results outlive a restart, and a130+// scan that keeps failing keeps the date it was first seen failing.131+func TestStoreSaves(t *testing.T) {132+ path := filepath.Join(t.TempDir(), "signal.json")133+ s, err := Open(path)134+ if err != nil {135+ t.Fatal(err)136+ }137+ t0 := time.Date(2026, 10, 4, 18, 0, 0, 0, time.UTC)138+ s.Record(605_000_000, 36, reading(t0, Sweep, true, 85, 83))139+ s.SetScan(605_000_000, 36, Scan{Lock: true, At: t0.Add(-time.Hour)})140+ s.SetScan(177_000_000, 7, Scan{At: t0})141+ s.SetScan(177_000_000, 7, Scan{At: t0.Add(time.Hour)})142+ if err := s.Save(); err != nil {143+ t.Fatal(err)144+ }145+146+ again, err := Open(path)147+ if err != nil {148+ t.Fatal(err)149+ }150+ all := again.All()151+ if len(all) != 2 || all[0].RF != 7 || all[1].RF != 36 {152+ t.Fatalf("muxes = %+v", all)153+ }154+ if sc := all[0].Scan; sc == nil || sc.Lock || !sc.At.Equal(t0) || all[0].Latest != nil || all[0].History() != nil {155+ t.Errorf("RF 7 = %+v", all[0])156+ }157+ if m := all[1]; !m.Scan.Lock || !m.Latest.Lock || *m.Latest.StrengthPct != 85 || m.History().Samples != 1 {158+ t.Errorf("RF 36 = %+v", m)159+ }160+ // A lock found later replaces the failure, dated by Tvheadend.161+ again.SetScan(177_000_000, 7, Scan{Lock: true, At: t0.Add(2 * time.Hour)})162+ if m, _ := again.Get(177_000_000); !m.Scan.Lock || !m.Scan.At.Equal(t0.Add(2*time.Hour)) {163+ t.Errorf("RF 7 found: %+v", m.Scan)164+ }165+166+ // An unreadable file is set aside rather than lost or fatal.167+ if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {168+ t.Fatal(err)169+ }170+ bad, err := Open(path)171+ if err == nil || bad == nil || len(bad.All()) != 0 {172+ t.Errorf("unreadable: %v, %+v", err, bad)173+ }174+ if _, err := os.Stat(path + ".bad"); err != nil {175+ t.Errorf("not set aside: %v", err)176+ }177+}@@ -1,5 +1,5 @@
1-// Package tuner provides video sources for live channels: Tvheadend (which2-// drives the HDHomeRun), and a demo signal generator for development.1+// Package tuner provides video sources for live channels: Tvheadend, which2+// drives the HDHomeRun, and the test patterns its demo channels play.33 package tuner44 55 import (@@ -16,11 +16,16 @@ import (
1616 type Device struct {1717 ID string `json:"id"`1818 Name string `json:"name"`19- Kind string `json:"kind"` // "tvheadend" or "demo"19+ Kind string `json:"kind"` // "tvheadend" or "none"2020 Model string `json:"model,omitempty"`2121 BaseURL string `json:"baseUrl,omitempty"`2222 Tuners int `json:"tuners,omitempty"`2323 Detail string `json:"detail,omitempty"`24+ // Standards lists the broadcast standards the tuners receive ("ATSC25+ // 1.0"); none without a tuner. ATSC3 is whether that includes ATSC 3.026+ // (NextGen TV), which no tuner Tvheadend drives does.27+ Standards []string `json:"standards,omitempty"`28+ ATSC3 bool `json:"atsc3"`2429 }2530 2631 // AudioTrack is one audio stream of an input.@@ -66,15 +71,6 @@ type Tuner interface {
6671 Input(ctx context.Context, number string) (Input, error)6772 }6873 69-// Demo generates a distinct test pattern per channel, with an English and a70-// Spanish tone, so the whole TV experience works without hardware.71-type Demo struct{}72-73-// Device implements Tuner.74-func (Demo) Device() Device {75- return Device{ID: "demo", Name: "Demo signal", Kind: "demo", Detail: "ffmpeg test patterns, no hardware needed"}76-}77-7874 var patterns = []string{7975 "smptehdbars=s=1280x720:r=30",8076 "testsrc2=s=1280x720:r=30",@@ -86,8 +82,10 @@ var patterns = []string{
8682 "cellauto=s=640x360:r=30:rule=110:scroll=1,scale=1280:720:flags=neighbor",8783 }8884 89-// Input implements Tuner.90-func (Demo) Input(_ context.Context, number string) (Input, error) {85+// TestPattern is a channel's demo test pattern read straight from ffmpeg's86+// generators, with an English and a Spanish tone: for testing streaming87+// without Tvheadend. Nothing tunes it in place of a channel.88+func TestPattern(number string) Input {9189 video, eng, spa := demoSources(number)9290 return Input{9391 Args: []string{@@ -97,7 +95,7 @@ func (Demo) Input(_ context.Context, number string) (Input, error) {
9795 },9896 Video: "0:v:0",9997 Audio: []AudioTrack{{Map: "1:a:0", Lang: "eng"}, {Map: "2:a:0", Lang: "spa"}},100- }, nil98+ }10199 }102100 103101 // demoSources picks a stable pattern and two tones (main and second@@ -1,15 +1,13 @@
11 package tuner22 3-import (4- "context"5- "strings"6- "testing"7-)3+import "testing"84 9-func TestDemoIsStablePerChannel(t *testing.T) {10- a, _ := Demo{}.Input(context.Background(), "7.1")11- b, _ := Demo{}.Input(context.Background(), "7.1")12- if strings.Join(a.Args, " ") != strings.Join(b.Args, " ") {13- t.Error("demo pattern changed between tunes of the same channel")5+func TestDemoCommandIsStablePerChannel(t *testing.T) {6+ a := DemoCommand("ffmpeg", "7.1", "KMGH")7+ if b := DemoCommand("ffmpeg", "7.1", "KMGH"); a != b {8+ t.Error("demo pattern changed between runs of the same channel")9+ }10+ if c := DemoCommand("ffmpeg", "9.1", "KUSA"); a == c {11+ t.Error("two channels share a demo command")1412 }1513 }@@ -2,20 +2,28 @@ package tuner
22 33 import (44 "context"5+ "errors"56 "fmt"7+ "slices"68 "sync"79 "time"810 11+ "airwaves/internal/guide"912 "airwaves/internal/tvh"1013 )1114 1215 // Tvheadend streams channels from a Tvheadend server, which owns the13-// physical tuners (and, before one is attached, the demo channels).16+// physical tuners (and, in demo mode before one is attached, the demo17+// channels).1418 type Tvheadend struct {1519 tvh *tvh.Client20+ // Networks names the Tvheadend networks whose channels are offered;21+ // channels of any other network (anything not Airwaves') aren't. Nil22+ // offers the antenna network's.23+ Networks func() []string1624 1725 mu sync.Mutex18- chans []tvh.Channel26+ lineup *tvh.Lineup1927 fetched time.Time2028 }2129 @@ -27,9 +35,17 @@ func (t *Tvheadend) Device() Device {
2735 return Device{ID: "tvheadend", Name: "Tvheadend", Kind: "tvheadend", BaseURL: t.tvh.Base, Detail: "Tuners and recordings on the server"}2836 }2937 38+// Channel is a channel Tvheadend offers, with the service and multiplex39+// it plays from.40+type Channel struct {41+ tvh.Channel42+ Service tvh.Service43+ Mux tvh.Mux44+}45+3046 // Input implements Tuner.3147 func (t *Tvheadend) Input(ctx context.Context, number string) (Input, error) {32- ch, err := t.channel(ctx, number)48+ ch, err := t.Channel(ctx, number)3349 if err != nil {3450 return Input{}, err3551 }@@ -39,11 +55,9 @@ func (t *Tvheadend) Input(ctx context.Context, number string) (Input, error) {
3955 Audio: []AudioTrack{{Map: "0:a:0"}},4056 Broadcast: true,4157 }42- if len(ch.Services) > 0 {43- if streams, err := t.tvh.ServiceStreams(ctx, ch.Services[0]); err == nil {44- if audio, note := AudioTracks(streams); audio != nil || note != "" {45- in.Audio, in.Note = audio, note46- }58+ if streams, err := t.tvh.ServiceStreams(ctx, ch.Service.UUID); err == nil {59+ if audio, note := AudioTracks(streams); audio != nil || note != "" {60+ in.Audio, in.Note = audio, note4761 }4862 }4963 return in, nil@@ -76,44 +90,79 @@ func AudioTracks(streams []tvh.Stream) ([]AudioTrack, string) {
7690 7791 // ChannelUUID maps a virtual channel number to a Tvheadend channel.7892 func (t *Tvheadend) ChannelUUID(ctx context.Context, number string) (string, error) {79- ch, err := t.channel(ctx, number)93+ ch, err := t.Channel(ctx, number)8094 if err != nil {8195 return "", err8296 }8397 return ch.UUID, nil8498 }8599 86-func (t *Tvheadend) channel(ctx context.Context, number string) (tvh.Channel, error) {100+// ErrNotFound is returned for a number that isn't among the channels101+// Tvheadend offers.102+var ErrNotFound = errors.New("not a channel the tuner found")103+104+// Channel finds an offered channel by number.105+func (t *Tvheadend) Channel(ctx context.Context, number string) (Channel, error) {87106 chans, err := t.Channels(ctx)88107 if err != nil {89- return tvh.Channel{}, err108+ return Channel{}, err90109 }91110 for _, c := range chans {92- if c.Number == number && c.Enabled {111+ if c.Number == number {93112 return c, nil94113 }95114 }96- return tvh.Channel{}, fmt.Errorf("channel %s is not available on the server", number)115+ return Channel{}, fmt.Errorf("channel %s: %w", number, ErrNotFound)116+}117+118+// Channels lists the channels Tvheadend offers, by number: enabled,119+// numbered and mapped from a service of a multiplex in one of the offered120+// networks. Of two with one number, the first stays.121+func (t *Tvheadend) Channels(ctx context.Context) ([]Channel, error) {122+ l, err := t.Lineup(ctx)123+ if err != nil {124+ return nil, err125+ }126+ nets := []string{tvh.ATSCNetwork}127+ if t.Networks != nil {128+ nets = t.Networks()129+ }130+ var out []Channel131+ seen := map[string]bool{}132+ for _, c := range l.Channels {133+ if !c.Enabled || c.Number == "0" || len(c.Services) == 0 || seen[c.Number] {134+ continue135+ }136+ svc, mux, ok := l.Source(c)137+ if !ok || !slices.Contains(nets, mux.Network) {138+ continue139+ }140+ seen[c.Number] = true141+ out = append(out, Channel{Channel: c, Service: *svc, Mux: *mux})142+ }143+ slices.SortStableFunc(out, func(a, b Channel) int { return guide.CompareNumbers(a.Number, b.Number) })144+ return out, nil97145 }98146 99-// Channels lists Tvheadend's channels, cached for a minute.100-func (t *Tvheadend) Channels(ctx context.Context) ([]tvh.Channel, error) {147+// Lineup returns Tvheadend's networks, multiplexes, services and channels,148+// fetched at most once a minute.149+func (t *Tvheadend) Lineup(ctx context.Context) (*tvh.Lineup, error) {101150 t.mu.Lock()102151 defer t.mu.Unlock()103- if t.chans != nil && time.Since(t.fetched) < time.Minute {104- return t.chans, nil152+ if t.lineup != nil && time.Since(t.fetched) < time.Minute {153+ return t.lineup, nil105154 }106- chans, err := t.tvh.Channels(ctx)155+ l, err := t.tvh.Lineup(ctx)107156 if err != nil {108157 return nil, err109158 }110- t.chans, t.fetched = chans, time.Now()111- return chans, nil159+ t.lineup, t.fetched = l, time.Now()160+ return l, nil112161 }113162 114-// Invalidate drops the cached channel list.163+// Invalidate drops the cached lineup.115164 func (t *Tvheadend) Invalidate() {116165 t.mu.Lock()117- t.chans = nil166+ t.lineup = nil118167 t.mu.Unlock()119168 }@@ -1,6 +1,7 @@
11 package tvh22 33 import (4+ "cmp"45 "context"56 "fmt"67 "net/url"@@ -44,22 +45,60 @@ func (c *Client) network(ctx context.Context, name string) (*Network, error) {
4445 return nil, nil4546 }4647 47-type mux struct {48- UUID string `json:"uuid"`49- Name string `json:"name"`50- NetworkUUID string `json:"network_uuid"`51- URL string `json:"iptv_url"`48+// Mux is a multiplex: what one RF channel (or one IPTV source) carries.49+type Mux struct {50+ UUID string `json:"uuid"`51+ Name string `json:"name"` // "605MHz"52+ Network string `json:"network"`53+ NetworkUUID string `json:"network_uuid"`54+ Enabled flexBool `json:"enabled"`55+ // FrequencyHz is an antenna multiplex's center frequency; 0 for IPTV.56+ FrequencyHz int64 `json:"frequency"`57+ // ScanState is 0 when idle, 1 while queued and 2 while Tvheadend scans58+ // the multiplex.59+ ScanState int `json:"scan_state"`60+ ScanResult ScanResult `json:"scan_result"`61+ // ScanLast is when a scan last found the multiplex (unix seconds); 062+ // when none has.63+ ScanLast int64 `json:"scan_last"`64+ NumSvc int `json:"num_svc"`65+ NumChn int `json:"num_chn"`66+ URL string `json:"iptv_url"`5267 }5368 54-func (c *Client) muxes(ctx context.Context, networkUUID string) ([]mux, error) {69+// ScanResult is how Tvheadend's last scan of a multiplex went.70+type ScanResult int71+72+// Scan results, as Tvheadend numbers them.73+const (74+ ScanNone ScanResult = 0 // not scanned yet75+ ScanOK ScanResult = 1 // locked, and found the services76+ ScanFail ScanResult = 2 // no lock, or nothing found77+ ScanPartial ScanResult = 3 // locked, with some tables missing78+ ScanIgnore ScanResult = 479+)80+81+// Scanning reports whether Tvheadend is scanning the multiplex, or about to.82+func (m Mux) Scanning() bool { return m.ScanState != 0 }83+84+// Muxes lists every multiplex.85+func (c *Client) Muxes(ctx context.Context) ([]Mux, error) {5586 var body struct {56- Entries []mux `json:"entries"`87+ Entries []Mux `json:"entries"`5788 }5889 if err := c.get(ctx, "/api/mpegts/mux/grid", url.Values{"limit": {"5000"}}, &body); err != nil {5990 return nil, err6091 }61- var out []mux62- for _, m := range body.Entries {92+ return body.Entries, nil93+}94+95+func (c *Client) muxes(ctx context.Context, networkUUID string) ([]Mux, error) {96+ all, err := c.Muxes(ctx)97+ if err != nil {98+ return nil, err99+ }100+ var out []Mux101+ for _, m := range all {63102 if m.NetworkUUID == networkUUID {64103 out = append(out, m)65104 }@@ -151,22 +190,30 @@ func (c *Client) RemoveNetwork(ctx context.Context, name string) error {
151190 return err152191 }153192 for _, s := range svcs {154- for _, ch := range s.channels() {193+ for _, ch := range s.Channels() {155194 _ = c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {ch}}, nil)156195 }157196 }158197 return c.post(ctx, "/api/idnode/delete", url.Values{"uuid": {net.UUID}}, nil)159198 }160199 161-type service struct {200+// Service is a program in a multiplex, which Tvheadend maps to a channel.201+type Service struct {162202 UUID string `json:"uuid"`163- Name string `json:"svcname"`203+ Name string `json:"svcname"` // as broadcast, "KTVD-HD"204+ Network string `json:"network"`205+ Multiplex string `json:"multiplex"` // the multiplex's name164206 MultiplexUUID string `json:"multiplex_uuid"`165- Channel any `json:"channel"` // list of channel UUIDs, or a string207+ Enabled bool `json:"enabled"`166208 Encrypted bool `json:"encrypted"`209+ // Major and Minor are the virtual channel the broadcast gives (PSIP).210+ Major int `json:"lcn"`211+ Minor int `json:"lcn_minor"`212+ Channel any `json:"channel"` // list of channel UUIDs, or a string167213 }168214 169-func (s service) channels() []string {215+// Channels lists the UUIDs of the channels mapped from the service.216+func (s Service) Channels() []string {170217 switch v := s.Channel.(type) {171218 case string:172219 if v != "" {@@ -184,7 +231,18 @@ func (s service) channels() []string {
184231 return nil185232 }186233 187-func (c *Client) services(ctx context.Context, networkUUID string) ([]service, error) {234+// Services lists every service.235+func (c *Client) Services(ctx context.Context) ([]Service, error) {236+ var body struct {237+ Entries []Service `json:"entries"`238+ }239+ if err := c.get(ctx, "/api/mpegts/service/grid", url.Values{"limit": {"5000"}}, &body); err != nil {240+ return nil, err241+ }242+ return body.Entries, nil243+}244+245+func (c *Client) services(ctx context.Context, networkUUID string) ([]Service, error) {188246 muxes, err := c.muxes(ctx, networkUUID)189247 if err != nil {190248 return nil, err@@ -193,14 +251,12 @@ func (c *Client) services(ctx context.Context, networkUUID string) ([]service, e
193251 for _, m := range muxes {194252 inNet[m.UUID] = true195253 }196- var body struct {197- Entries []service `json:"entries"`198- }199- if err := c.get(ctx, "/api/mpegts/service/grid", url.Values{"limit": {"5000"}}, &body); err != nil {254+ all, err := c.Services(ctx)255+ if err != nil {200256 return nil, err201257 }202- var out []service203- for _, s := range body.Entries {258+ var out []Service259+ for _, s := range all {204260 if inNet[s.MultiplexUUID] {205261 out = append(out, s)206262 }@@ -225,7 +281,7 @@ func (c *Client) MapServices(ctx context.Context) (int, error) {
225281 return 0, err226282 }227283 for _, s := range svcs {228- if !s.Encrypted && len(s.channels()) == 0 {284+ if !s.Encrypted && len(s.Channels()) == 0 {229285 todo = append(todo, s.UUID)230286 }231287 }@@ -243,8 +299,37 @@ func (c *Client) MapServices(ctx context.Context) (int, error) {
243299 // Frontend is a tuner input on the server.244300 type Frontend struct {245301 UUID string246- Class string247- Name string302+ Class string // "tvhdhomerun_frontend_atsc_t", "linuxdvb_frontend_atsc_t"303+ // Name is how Tvheadend names the input, also in its input status:304+ // "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)".305+ Name string306+ // Model is the device's model as Tvheadend reports it307+ // ("hdhomerun_dvr_atsc"), when it does.308+ Model string309+}310+311+// treeNode is one entry of Tvheadend's hardware tree. Newer versions send312+// each node's class and parameters along; older ones only the text.313+type treeNode struct {314+ UUID string `json:"uuid"`315+ Text string `json:"text"`316+ Class string `json:"class"`317+ Leaf flexBool `json:"leaf"`318+ Params []struct {319+ ID string `json:"id"`320+ Value any `json:"value"`321+ } `json:"params"`322+}323+324+func (n treeNode) param(id string) string {325+ for _, p := range n.Params {326+ if p.ID == id {327+ if s, ok := p.Value.(string); ok {328+ return s329+ }330+ }331+ }332+ return ""248333 }249334 250335 // Frontends finds ATSC tuner frontends: USB or PCIe tuners@@ -252,33 +337,33 @@ type Frontend struct {
252337 // and network HDHomeRuns (tvhdhomerun_frontend_atsc_t).253338 func (c *Client) Frontends(ctx context.Context) ([]Frontend, error) {254339 var out []Frontend255- var walk func(uuid string, depth int) error256- walk = func(uuid string, depth int) error {257- var nodes []struct {258- UUID string `json:"uuid"`259- Text string `json:"text"`260- Leaf flexBool `json:"leaf"`261- }340+ var walk func(uuid, model string, depth int) error341+ walk = func(uuid, model string, depth int) error {342+ var nodes []treeNode262343 if err := c.get(ctx, "/api/hardware/tree", url.Values{"uuid": {uuid}}, &nodes); err != nil {263344 return err264345 }265346 for _, n := range nodes {266- class, err := c.class(ctx, n.UUID)267- if err != nil {268- return err347+ class := n.Class348+ if class == "" {349+ var err error350+ if class, err = c.class(ctx, n.UUID); err != nil {351+ return err352+ }269353 }354+ m := cmp.Or(n.param("deviceModel"), model)270355 if strings.Contains(class, "_frontend_atsc") {271- out = append(out, Frontend{UUID: n.UUID, Class: class, Name: n.Text})356+ out = append(out, Frontend{UUID: n.UUID, Class: class, Name: cmp.Or(n.param("displayname"), n.Text), Model: m})272357 }273358 if !bool(n.Leaf) && depth < 3 {274- if err := walk(n.UUID, depth+1); err != nil {359+ if err := walk(n.UUID, m, depth+1); err != nil {275360 return err276361 }277362 }278363 }279364 return nil280365 }281- return out, walk("root", 0)366+ return out, walk("root", "", 0)282367 }283368 284369 // flexBool is a JSON boolean that Tvheadend sometimes writes as 0 or 1@@ -0,0 +1,214 @@
1+package tvh2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "net/http"8+ "net/url"9+ "strconv"10+ "strings"11+)12+13+// InputStatus is a tuner input that is tuned now, as /api/status/inputs14+// reports it. Idle inputs aren't listed there.15+type InputStatus struct {16+ UUID string `json:"uuid"`17+ // Input is the input's name, as Frontend.Name has it.18+ Input string `json:"input"`19+ // Stream is what the input is tuned to: "605MHz in Airwaves antenna".20+ Stream string `json:"stream"`21+ Subs int `json:"subs"`22+ Weight int `json:"weight"`23+ // Signal and SNR are in the units their scales say (see Scale).24+ Signal int64 `json:"signal"`25+ SignalScale Scale `json:"signal_scale"`26+ SNR int64 `json:"snr"`27+ SNRScale Scale `json:"snr_scale"`28+ // BER and UNC are the driver's own counters, 0 where it has none.29+ BER int64 `json:"ber"`30+ UNC int64 `json:"unc"`31+ // BPS is the data rate the input receives, in bits per second: above32+ // zero only while the tuner holds a lock.33+ BPS int64 `json:"bps"`34+ // TE and CC count transport and continuity errors in what arrived.35+ TE int64 `json:"te"`36+ CC int64 `json:"cc"`37+ // ECBit/TCBit (bit errors of bits counted) and ECBlock/TCBlock38+ // (uncorrected blocks of blocks counted) are filled in by tuners that39+ // count them; TCBit and TCBlock are 0 otherwise.40+ ECBit int64 `json:"ec_bit"`41+ TCBit int64 `json:"tc_bit"`42+ ECBlock int64 `json:"ec_block"`43+ TCBlock int64 `json:"tc_block"`44+}45+46+// Scale is how a tuner reports signal strength or SNR.47+type Scale int48+49+// Scales, as Tvheadend numbers them.50+const (51+ ScaleUnknown Scale = 0 // not reported52+ ScaleRelative Scale = 1 // 0 to 65535 for 0 to 100%53+ ScaleDecibel Scale = 2 // thousandths of a dB (dBm for strength)54+)55+56+// Mux names the multiplex and network the input is tuned to, from Stream;57+// ok is false for an input that isn't on a multiplex.58+func (in InputStatus) Mux() (mux, network string, ok bool) {59+ i := strings.LastIndex(in.Stream, " in ")60+ if i <= 0 {61+ return "", "", false62+ }63+ return in.Stream[:i], in.Stream[i+len(" in "):], true64+}65+66+// Inputs lists the tuner inputs in use, with their signal.67+func (c *Client) Inputs(ctx context.Context) ([]InputStatus, error) {68+ var body struct {69+ Entries []InputStatus `json:"entries"`70+ }71+ if err := c.get(ctx, "/api/status/inputs", nil, &body); err != nil {72+ return nil, err73+ }74+ return body.Entries, nil75+}76+77+// Subscription is something using a tuner (or another source) now: a78+// viewer, a recording, or Tvheadend's own guide and channel scans.79+type Subscription struct {80+ ID int64 `json:"id"`81+ Start int64 `json:"start"`82+ State string `json:"state"` // "Running", "Testing", "Bad"...83+ Title string `json:"title"` // "epggrab", "DVR: ...", the HTTP client84+ Service string `json:"service"` // "<input>/<network>/<mux>/<service>"85+ Errors int `json:"errors"`86+}87+88+// Subscriptions lists what is using Tvheadend's inputs now.89+func (c *Client) Subscriptions(ctx context.Context) ([]Subscription, error) {90+ var body struct {91+ Entries []Subscription `json:"entries"`92+ }93+ if err := c.get(ctx, "/api/status/subscriptions", nil, &body); err != nil {94+ return nil, err95+ }96+ return body.Entries, nil97+}98+99+// Lineup is Tvheadend's channels with the services and multiplexes they100+// play from.101+type Lineup struct {102+ Networks []Network103+ Muxes []Mux104+ Services []Service105+ Channels []Channel106+}107+108+// Lineup fetches the networks, multiplexes, services and channels.109+func (c *Client) Lineup(ctx context.Context) (*Lineup, error) {110+ var l Lineup111+ var err error112+ if l.Networks, err = c.Networks(ctx); err != nil {113+ return nil, err114+ }115+ if l.Muxes, err = c.Muxes(ctx); err != nil {116+ return nil, err117+ }118+ if l.Services, err = c.Services(ctx); err != nil {119+ return nil, err120+ }121+ if l.Channels, err = c.Channels(ctx); err != nil {122+ return nil, err123+ }124+ return &l, nil125+}126+127+// Source finds the service a channel plays from, its first one, and the128+// multiplex that carries it.129+func (l *Lineup) Source(ch Channel) (*Service, *Mux, bool) {130+ for _, id := range ch.Services {131+ for i := range l.Services {132+ if l.Services[i].UUID != id {133+ continue134+ }135+ s := &l.Services[i]136+ for j := range l.Muxes {137+ if l.Muxes[j].UUID == s.MultiplexUUID {138+ return s, &l.Muxes[j], true139+ }140+ }141+ return s, nil, false142+ }143+ }144+ return nil, nil, false145+}146+147+// NetworkMuxes lists the multiplexes of the named network.148+func (l *Lineup) NetworkMuxes(name string) []Mux {149+ var out []Mux150+ for _, m := range l.Muxes {151+ if m.Network == name {152+ out = append(out, m)153+ }154+ }155+ return out156+}157+158+// HasNetwork reports whether the named network exists.159+func (l *Lineup) HasNetwork(name string) bool {160+ for _, n := range l.Networks {161+ if n.Name == name {162+ return true163+ }164+ }165+ return false166+}167+168+// OpenMux subscribes to a multiplex as a raw stream of its PAT (PID 0)169+// only, at weight: the tuner tunes and locks if it can, while hardly any170+// data flows. The subscription lasts until the body is closed. A171+// subscription of higher weight (a viewer, a recording) takes the tuner172+// over; weight 10 is the lowest a user's can have, above Tvheadend's own173+// scans (1 to 7).174+func (c *Client) OpenMux(ctx context.Context, muxUUID string, weight int) (io.ReadCloser, error) {175+ q := url.Values{"pids": {"0"}, "weight": {strconv.Itoa(weight)}}176+ return c.open(ctx, "/stream/mux/"+url.PathEscape(muxUUID)+"?"+q.Encode())177+}178+179+// OpenChannel subscribes to a channel at weight, as OpenMux does, for180+// servers that don't stream whole multiplexes.181+func (c *Client) OpenChannel(ctx context.Context, channelUUID string, weight int) (io.ReadCloser, error) {182+ q := url.Values{"profile": {"pass"}, "weight": {strconv.Itoa(weight)}}183+ return c.open(ctx, "/stream/channel/"+url.PathEscape(channelUUID)+"?"+q.Encode())184+}185+186+// StreamError is a stream Tvheadend refused.187+type StreamError struct {188+ Path string189+ Status int190+}191+192+func (e *StreamError) Error() string {193+ return fmt.Sprintf("tvheadend %s: HTTP %d", e.Path, e.Status)194+}195+196+func (c *Client) open(ctx context.Context, path string) (io.ReadCloser, error) {197+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)198+ if err != nil {199+ return nil, err200+ }201+ // The client's timeout would end a stream; ctx ends it instead.202+ hc := *c.HTTP203+ hc.Timeout = 0204+ resp, err := hc.Do(req)205+ if err != nil {206+ return nil, fmt.Errorf("tvheadend: %w", err)207+ }208+ if resp.StatusCode != http.StatusOK {209+ resp.Body.Close()210+ p, _, _ := strings.Cut(path, "?")211+ return nil, &StreamError{Path: p, Status: resp.StatusCode}212+ }213+ return resp.Body, nil214+}@@ -0,0 +1,158 @@
1+package tvh_test2+3+import (4+ "encoding/json"5+ "errors"6+ "io"7+ "strings"8+ "testing"9+ "time"10+11+ "airwaves/internal/tvh"12+ "airwaves/internal/tvh/tvhtest"13+)14+15+// TestInputsShape reads /api/status/inputs as a real Tvheadend 4.3 writes16+// it for an HDHomeRun: both tuners on guide scans, strength and quality on17+// the relative scale.18+func TestInputsShape(t *testing.T) {19+ var body struct {20+ Entries []tvh.InputStatus `json:"entries"`21+ }22+ if err := json.Unmarshal(tvhtest.Raw(t, "status_inputs.json"), &body); err != nil {23+ t.Fatal(err)24+ }25+ if len(body.Entries) != 2 {26+ t.Fatalf("entries = %d", len(body.Entries))27+ }28+ in := body.Entries[0]29+ mux, net, ok := in.Mux()30+ if !ok || mux != "605MHz" || net != "Airwaves antenna" || in.Input != tvhtest.Tuner0 {31+ t.Errorf("mux %q, network %q, %v, input %q", mux, net, ok, in.Input)32+ }33+ if in.Signal != 55704 || in.SignalScale != tvh.ScaleRelative || in.SNR != 54394 || in.SNRScale != tvh.ScaleRelative ||34+ in.BPS != 231616 || in.Subs != 1 || in.Weight != 4 || in.TCBit != 0 {35+ t.Errorf("input = %+v", in)36+ }37+ if _, _, ok := (tvh.InputStatus{Stream: ""}).Mux(); ok {38+ t.Error("an input on nothing named a multiplex")39+ }40+41+ var subs struct {42+ Entries []tvh.Subscription `json:"entries"`43+ }44+ if err := json.Unmarshal(tvhtest.Raw(t, "status_subscriptions.json"), &subs); err != nil {45+ t.Fatal(err)46+ }47+ if len(subs.Entries) != 2 || subs.Entries[0].Title != "epggrab" || subs.Entries[0].State != "Running" ||48+ !strings.HasSuffix(subs.Entries[0].Service, "/587MHz/Raw PID Subscription") {49+ t.Errorf("subscriptions = %+v", subs.Entries)50+ }51+}52+53+// TestLineup joins the real grids: 38 channels from 38 services on 8 of the54+// antenna network's 36 multiplexes.55+func TestLineup(t *testing.T) {56+ srv := tvhtest.New(t)57+ c := tvh.New(srv.URL, srv.Client())58+ l, err := c.Lineup(t.Context())59+ if err != nil {60+ t.Fatal(err)61+ }62+ if len(l.Networks) != 1 || !l.HasNetwork(tvh.ATSCNetwork) || l.HasNetwork(tvh.DemoNetwork) ||63+ len(l.Muxes) != 36 || len(l.Services) != 38 || len(l.Channels) != 38 || len(l.NetworkMuxes(tvh.ATSCNetwork)) != 36 {64+ t.Fatalf("lineup: %d networks, %d muxes, %d services, %d channels", len(l.Networks), len(l.Muxes), len(l.Services), len(l.Channels))65+ }66+ byNumber := map[string]tvh.Channel{}67+ for _, ch := range l.Channels {68+ byNumber[ch.Number] = ch69+ }70+ svc, mux, ok := l.Source(byNumber["9.4"])71+ if !ok || svc.Name != "KUSA-HD" || svc.Major != 9 || svc.Minor != 4 || mux.Name != "575MHz" || mux.FrequencyHz != 575_000_000 ||72+ mux.ScanResult != tvh.ScanOK || mux.ScanLast != 1791129355 || !bool(mux.Enabled) || mux.Scanning() || mux.NumSvc != 6 {73+ t.Errorf("9.4: %+v on %+v (%v)", svc, mux, ok)74+ }75+ failed := 076+ for _, m := range l.Muxes {77+ if m.ScanResult == tvh.ScanFail {78+ failed++79+ if m.ScanLast != 0 || m.NumSvc != 0 {80+ t.Errorf("failed mux %+v", m)81+ }82+ }83+ }84+ if failed != 28 {85+ t.Errorf("%d multiplexes failed their scan, want 28", failed)86+ }87+}88+89+// TestFrontendsFromTree reads the tuners, their names as the input status90+// gives them and the model, from the hardware tree as Tvheadend 4.3 sends91+// it, class and parameters included, so without a lookup per node.92+func TestFrontendsFromTree(t *testing.T) {93+ srv := tvhtest.New(t)94+ fes, err := tvh.New(srv.URL, srv.Client()).Frontends(t.Context())95+ if err != nil {96+ t.Fatal(err)97+ }98+ if len(fes) != 2 || fes[0].Name != tvhtest.Tuner0 || fes[1].Name != tvhtest.Tuner1 || fes[0].Model != "hdhomerun_dvr_atsc" ||99+ fes[0].Class != "tvhdhomerun_frontend_atsc_t" {100+ t.Fatalf("frontends: %+v", fes)101+ }102+ for _, r := range srv.Requests() {103+ if strings.Contains(r, "/api/idnode/load") {104+ t.Errorf("looked up a class the tree had: %s", r)105+ }106+ }107+}108+109+// TestOpenMux subscribes to a multiplex at a weight, and the tuner shows it110+// in the input status until the stream is closed.111+func TestOpenMux(t *testing.T) {112+ srv := tvhtest.New(t)113+ c := tvh.New(srv.URL, srv.Client())114+ ctx := t.Context()115+ l, err := c.Lineup(ctx)116+ if err != nil {117+ t.Fatal(err)118+ }119+ var m tvh.Mux120+ for _, x := range l.Muxes {121+ if x.Name == "575MHz" {122+ m = x123+ }124+ }125+ body, err := c.OpenMux(ctx, m.UUID, 10)126+ if err != nil {127+ t.Fatal(err)128+ }129+ buf := make([]byte, 188)130+ if _, err := io.ReadFull(body, buf); err != nil || buf[0] != 0x47 {131+ t.Fatalf("read %x, %v", buf[:1], err)132+ }133+ ins, err := c.Inputs(ctx)134+ if err != nil || len(ins) != 1 || ins[0].Stream != "575MHz in Airwaves antenna" || ins[0].Weight != 10 || ins[0].BPS == 0 {135+ t.Fatalf("inputs %+v, %v", ins, err)136+ }137+ body.Close()138+ deadline := time.Now().Add(2 * time.Second)139+ for len(srv.Streams()) > 0 && time.Now().Before(deadline) {140+ time.Sleep(10 * time.Millisecond)141+ }142+ if s := srv.Streams(); len(s) != 0 {143+ t.Errorf("still streaming %v", s)144+ }145+ var found bool146+ for _, r := range srv.Requests() {147+ found = found || r == "GET /stream/mux/"+m.UUID+"?pids=0&weight=10"148+ }149+ if !found {150+ t.Errorf("requests: %v", srv.Requests())151+ }152+153+ srv.NoMuxStreams = true154+ var se *tvh.StreamError155+ if _, err := c.OpenMux(ctx, m.UUID, 10); !errors.As(err, &se) || se.Status != 403 {156+ t.Errorf("refused: %v", err)157+ }158+}@@ -0,0 +1,765 @@
1+{2+ "entries": [3+ {4+ "uuid": "9ba1bb859520d32e45a0bb82c44fee0e",5+ "enabled": true,6+ "autoname": true,7+ "name": "KWGN-DT",8+ "number": "2.1",9+ "icon": "picon://1_0_0_6_1D3_10000_DDDD0000_0_0_0.png",10+ "icon_public_url": "imagecache/44",11+ "epgauto": true,12+ "epglimit": 0,13+ "epggrab": [],14+ "dvr_pre_time": 0,15+ "dvr_pst_time": 0,16+ "epg_running": -1,17+ "services": [18+ "d3e0d1efd2f9babfdb41a47b36607800"19+ ],20+ "tags": [],21+ "bouquet": ""22+ },23+ {24+ "uuid": "db312e0991f9bb43ba7eb4980775698a",25+ "enabled": true,26+ "autoname": true,27+ "name": "KCNC-TV",28+ "number": "4.1",29+ "icon": "picon://1_0_0_1_1C9_10000_DDDD0000_0_0_0.png",30+ "icon_public_url": "imagecache/38",31+ "epgauto": true,32+ "epglimit": 0,33+ "epggrab": [],34+ "dvr_pre_time": 0,35+ "dvr_pst_time": 0,36+ "epg_running": -1,37+ "services": [38+ "c2b03cefe9378724647ead9aa93e8d22"39+ ],40+ "tags": [],41+ "bouquet": ""42+ },43+ {44+ "uuid": "c582ec8231fce59f932452b879c29c02",45+ "enabled": true,46+ "autoname": true,47+ "name": "StartTV",48+ "number": "4.2",49+ "icon": "file:///picons/starttv.png",50+ "icon_public_url": "imagecache/37",51+ "epgauto": true,52+ "epglimit": 0,53+ "epggrab": [],54+ "dvr_pre_time": 0,55+ "dvr_pst_time": 0,56+ "epg_running": -1,57+ "services": [58+ "894e10974ce0ae452c67212ed5811e4b"59+ ],60+ "tags": [],61+ "bouquet": ""62+ },63+ {64+ "uuid": "4a4a8e58de8c9cd3dc4bfc97e6ebecbb",65+ "enabled": true,66+ "autoname": true,67+ "name": "Dabl ",68+ "number": "4.3",69+ "icon": "picon://1_0_0_3_1C9_10000_DDDD0000_0_0_0.png",70+ "icon_public_url": "imagecache/41",71+ "epgauto": true,72+ "epglimit": 0,73+ "epggrab": [],74+ "dvr_pre_time": 0,75+ "dvr_pst_time": 0,76+ "epg_running": -1,77+ "services": [78+ "9f1ba02d59cb9b3206dae9a447706906"79+ ],80+ "tags": [],81+ "bouquet": ""82+ },83+ {84+ "uuid": "1f20573bd1f045333d53a31e213912a9",85+ "enabled": true,86+ "autoname": true,87+ "name": "MeTV ",88+ "number": "4.4",89+ "icon": "picon://1_0_0_4_1C9_10000_DDDD0000_0_0_0.png",90+ "icon_public_url": "imagecache/40",91+ "epgauto": true,92+ "epglimit": 0,93+ "epggrab": [],94+ "dvr_pre_time": 0,95+ "dvr_pst_time": 0,96+ "epg_running": -1,97+ "services": [98+ "088dda0508522f57035031c2c1a2e1c1"99+ ],100+ "tags": [],101+ "bouquet": ""102+ },103+ {104+ "uuid": "11c7e5a354ae2a786a3d2761f52ca4bb",105+ "enabled": true,106+ "autoname": true,107+ "name": "Catchy ",108+ "number": "4.5",109+ "icon": "picon://1_0_0_5_1C9_10000_DDDD0000_0_0_0.png",110+ "icon_public_url": "imagecache/39",111+ "epgauto": true,112+ "epglimit": 0,113+ "epggrab": [],114+ "dvr_pre_time": 0,115+ "dvr_pst_time": 0,116+ "epg_running": -1,117+ "services": [118+ "f3d0461bc09c28d2fcbc913729e5ccd6"119+ ],120+ "tags": [],121+ "bouquet": ""122+ },123+ {124+ "uuid": "a48eb7daf4c16680458ed9b655212cb1",125+ "enabled": true,126+ "autoname": true,127+ "name": "Story ",128+ "number": "4.6",129+ "icon": "picon://1_0_0_6_1C9_10000_DDDD0000_0_0_0.png",130+ "icon_public_url": "imagecache/42",131+ "epgauto": true,132+ "epglimit": 0,133+ "epggrab": [],134+ "dvr_pre_time": 0,135+ "dvr_pst_time": 0,136+ "epg_running": -1,137+ "services": [138+ "e3af94e9356e90b3cd8e304a35c01684"139+ ],140+ "tags": [],141+ "bouquet": ""142+ },143+ {144+ "uuid": "48737af5fc18cd696caf456ccfa55c81",145+ "enabled": true,146+ "autoname": true,147+ "name": "KRMADT1",148+ "number": "6.1",149+ "icon": "picon://1_0_0_1_1CB_10000_DDDD0000_0_0_0.png",150+ "icon_public_url": "imagecache/35",151+ "epgauto": true,152+ "epglimit": 0,153+ "epggrab": [],154+ "dvr_pre_time": 0,155+ "dvr_pst_time": 0,156+ "epg_running": -1,157+ "services": [158+ "22c312f15bac109268c7c710ae7e3d50"159+ ],160+ "tags": [],161+ "bouquet": ""162+ },163+ {164+ "uuid": "e1338eee484b3d8cb923a0882054cbb0",165+ "enabled": true,166+ "autoname": true,167+ "name": "PBSKIDS",168+ "number": "6.2",169+ "icon": "picon://1_0_0_2_1CB_10000_DDDD0000_0_0_0.png",170+ "icon_public_url": "imagecache/34",171+ "epgauto": true,172+ "epglimit": 0,173+ "epggrab": [],174+ "dvr_pre_time": 0,175+ "dvr_pst_time": 0,176+ "epg_running": -1,177+ "services": [178+ "7240cb614ca5b54b71f147253287def0"179+ ],180+ "tags": [],181+ "bouquet": ""182+ },183+ {184+ "uuid": "001853fe86e76154a8e42c7253824bad",185+ "enabled": true,186+ "autoname": true,187+ "name": "Create",188+ "number": "6.3",189+ "icon": "picon://1_0_0_3_1CB_10000_DDDD0000_0_0_0.png",190+ "icon_public_url": "imagecache/36",191+ "epgauto": true,192+ "epglimit": 0,193+ "epggrab": [],194+ "dvr_pre_time": 0,195+ "dvr_pst_time": 0,196+ "epg_running": -1,197+ "services": [198+ "33dcb45cef71321fcac42e1c93974632"199+ ],200+ "tags": [],201+ "bouquet": ""202+ },203+ {204+ "uuid": "dd8fcfcb3cc53ac991098f3a89935305",205+ "enabled": true,206+ "autoname": true,207+ "name": "World",208+ "number": "6.4",209+ "icon": "picon://1_0_0_4_1CB_10000_DDDD0000_0_0_0.png",210+ "icon_public_url": "imagecache/32",211+ "epgauto": true,212+ "epglimit": 0,213+ "epggrab": [],214+ "dvr_pre_time": 0,215+ "dvr_pst_time": 0,216+ "epg_running": -1,217+ "services": [218+ "971fa8d4b9e7b068f74fbea2b8131b5a"219+ ],220+ "tags": [],221+ "bouquet": ""222+ },223+ {224+ "uuid": "eb442304fd95c719db3ea629e64eab61",225+ "enabled": true,226+ "autoname": true,227+ "name": "KUSA-HD",228+ "number": "9.4",229+ "icon": "picon://1_0_0_5_1D1_10000_DDDD0000_0_0_0.png",230+ "icon_public_url": "imagecache/58",231+ "epgauto": true,232+ "epglimit": 0,233+ "epggrab": [],234+ "dvr_pre_time": 0,235+ "dvr_pst_time": 0,236+ "epg_running": -1,237+ "services": [238+ "d25af62736923486a16dbc4851a1817e"239+ ],240+ "tags": [],241+ "bouquet": ""242+ },243+ {244+ "uuid": "990802969f8cb1b3996f62fb08e1b5b4",245+ "enabled": true,246+ "autoname": true,247+ "name": "TBD",248+ "number": "9.7",249+ "icon": "picon://1_0_0_6_1D1_10000_DDDD0000_0_0_0.png",250+ "icon_public_url": "imagecache/61",251+ "epgauto": true,252+ "epglimit": 0,253+ "epggrab": [],254+ "dvr_pre_time": 0,255+ "dvr_pst_time": 0,256+ "epg_running": -1,257+ "services": [258+ "7cb46ae4dbfaafae4717f1cff4b38ac0"259+ ],260+ "tags": [],261+ "bouquet": ""262+ },263+ {264+ "uuid": "0b9aad3e1d75ab86cbeb5ba55d15a0f3",265+ "enabled": true,266+ "autoname": true,267+ "name": "Univision Denver",268+ "number": "14.1",269+ "icon": "picon://1_0_0_1_1BB_10000_DDDD0000_0_0_0.png",270+ "icon_public_url": "imagecache/51",271+ "epgauto": true,272+ "epglimit": 0,273+ "epggrab": [],274+ "dvr_pre_time": 0,275+ "dvr_pst_time": 0,276+ "epg_running": -1,277+ "services": [278+ "7d0bb97dd33e4898f003d376c70c48c6"279+ ],280+ "tags": [],281+ "bouquet": ""282+ },283+ {284+ "uuid": "4efb26cadcc26256659820b727f6d5a7",285+ "enabled": true,286+ "autoname": true,287+ "name": "MovieSphere Gold by Lionsgate",288+ "number": "14.2",289+ "icon": "picon://1_0_0_2_1BB_10000_DDDD0000_0_0_0.png",290+ "icon_public_url": "imagecache/54",291+ "epgauto": true,292+ "epglimit": 0,293+ "epggrab": [],294+ "dvr_pre_time": 0,295+ "dvr_pst_time": 0,296+ "epg_running": -1,297+ "services": [298+ "27d1a645af75c40a86ac9e3577aba002"299+ ],300+ "tags": [],301+ "bouquet": ""302+ },303+ {304+ "uuid": "d8938a1a97ef7da945c65ac3f88a8039",305+ "enabled": true,306+ "autoname": true,307+ "name": "GREAT",308+ "number": "14.3",309+ "icon": "picon://1_0_0_3_1BB_10000_DDDD0000_0_0_0.png",310+ "icon_public_url": "imagecache/48",311+ "epgauto": true,312+ "epglimit": 0,313+ "epggrab": [],314+ "dvr_pre_time": 0,315+ "dvr_pst_time": 0,316+ "epg_running": -1,317+ "services": [318+ "bd0f91f6971fc9563e5b5d6d72812dcf"319+ ],320+ "tags": [],321+ "bouquet": ""322+ },323+ {324+ "uuid": "fa620714a05ae1f843887bc953366200",325+ "enabled": true,326+ "autoname": true,327+ "name": "Better Tomorrows, Today",328+ "number": "14.4",329+ "icon": "picon://1_0_0_4_1BB_10000_DDDD0000_0_0_0.png",330+ "icon_public_url": "imagecache/46",331+ "epgauto": true,332+ "epglimit": 0,333+ "epggrab": [],334+ "dvr_pre_time": 0,335+ "dvr_pst_time": 0,336+ "epg_running": -1,337+ "services": [338+ "5c710da05e3bfa8e8b1c92f112cd6431"339+ ],340+ "tags": [],341+ "bouquet": ""342+ },343+ {344+ "uuid": "2f70543839972c243e5567b9f65ece7b",345+ "enabled": true,346+ "autoname": true,347+ "name": "ShopLC ",348+ "number": "14.5",349+ "icon": "file:///picons/shoplc.png",350+ "icon_public_url": "imagecache/50",351+ "epgauto": true,352+ "epglimit": 0,353+ "epggrab": [],354+ "dvr_pre_time": 0,355+ "dvr_pst_time": 0,356+ "epg_running": -1,357+ "services": [358+ "cf23872345bce1e684ced4893fcf5aad"359+ ],360+ "tags": [],361+ "bouquet": ""362+ },363+ {364+ "uuid": "72d240992e805284e97681c6618c86a9",365+ "enabled": true,366+ "autoname": true,367+ "name": "KTVD-HD",368+ "number": "20.1",369+ "icon": "picon://1_0_0_3_1D1_10000_DDDD0000_0_0_0.png",370+ "icon_public_url": "imagecache/62",371+ "epgauto": true,372+ "epglimit": 0,373+ "epggrab": [],374+ "dvr_pre_time": 0,375+ "dvr_pst_time": 0,376+ "epg_running": -1,377+ "services": [378+ "617ed6401aed7c80a980556d7a0d7891"379+ ],380+ "tags": [],381+ "bouquet": ""382+ },383+ {384+ "uuid": "5708ec8efb91a5f9192991093c2cf9b9",385+ "enabled": true,386+ "autoname": true,387+ "name": "H & I",388+ "number": "20.2",389+ "icon": "picon://1_0_0_4_1D1_10000_DDDD0000_0_0_0.png",390+ "icon_public_url": "imagecache/59",391+ "epgauto": true,392+ "epglimit": 0,393+ "epggrab": [],394+ "dvr_pre_time": 0,395+ "dvr_pst_time": 0,396+ "epg_running": -1,397+ "services": [398+ "fdb95ecaa794d10dbbf3baa5dd1a9662"399+ ],400+ "tags": [],401+ "bouquet": ""402+ },403+ {404+ "uuid": "1c42f1c5b2fb3e6555f81dd68acd08b5",405+ "enabled": true,406+ "autoname": true,407+ "name": "ShopLC",408+ "number": "20.3",409+ "icon": "file:///picons/shoplc.png",410+ "icon_public_url": "imagecache/50",411+ "epgauto": true,412+ "epglimit": 0,413+ "epggrab": [],414+ "dvr_pre_time": 0,415+ "dvr_pst_time": 0,416+ "epg_running": -1,417+ "services": [418+ "6b411daf53c43f3e84eb5e786552a21c"419+ ],420+ "tags": [],421+ "bouquet": ""422+ },423+ {424+ "uuid": "f7dc96d6abb45feb7ee55cdf42b8232a",425+ "enabled": true,426+ "autoname": true,427+ "name": "OUTLAW",428+ "number": "20.4",429+ "icon": "picon://1_0_0_8_1D1_10000_DDDD0000_0_0_0.png",430+ "icon_public_url": "imagecache/66",431+ "epgauto": true,432+ "epglimit": 0,433+ "epggrab": [],434+ "dvr_pre_time": 0,435+ "dvr_pst_time": 0,436+ "epg_running": -1,437+ "services": [438+ "de4c31eec5d047e390fa7ba473637c69"439+ ],440+ "tags": [],441+ "bouquet": ""442+ },443+ {444+ "uuid": "ba4838da5820c5a2ad448a10b3170d0a",445+ "enabled": true,446+ "autoname": true,447+ "name": "KDEN-DT",448+ "number": "25.1",449+ "icon": "picon://1_0_0_3_1EB_10000_DDDD0000_0_0_0.png",450+ "icon_public_url": "imagecache/55",451+ "epgauto": true,452+ "epglimit": 0,453+ "epggrab": [],454+ "dvr_pre_time": 0,455+ "dvr_pst_time": 0,456+ "epg_running": -1,457+ "services": [458+ "0b2c0d8b2bd49ea36c2ce66ad8477b4b"459+ ],460+ "tags": [],461+ "bouquet": ""462+ },463+ {464+ "uuid": "61cd1c079d0072d54139e1ee37dd31d6",465+ "enabled": true,466+ "autoname": true,467+ "name": "Exitos ",468+ "number": "25.2",469+ "icon": "picon://1_0_0_4_1EB_10000_DDDD0000_0_0_0.png",470+ "icon_public_url": "imagecache/45",471+ "epgauto": true,472+ "epglimit": 0,473+ "epggrab": [],474+ "dvr_pre_time": 0,475+ "dvr_pst_time": 0,476+ "epg_running": -1,477+ "services": [478+ "4924d25d6812bb21f7db4ca7a770402e"479+ ],480+ "tags": [],481+ "bouquet": ""482+ },483+ {484+ "uuid": "f64a3b3448a637c96b98254eb4ac28aa",485+ "enabled": true,486+ "autoname": true,487+ "name": "COZI ",488+ "number": "25.3",489+ "icon": "picon://1_0_0_5_1EB_10000_DDDD0000_0_0_0.png",490+ "icon_public_url": "imagecache/49",491+ "epgauto": true,492+ "epglimit": 0,493+ "epggrab": [],494+ "dvr_pre_time": 0,495+ "dvr_pst_time": 0,496+ "epg_running": -1,497+ "services": [498+ "985a338dcb0347e1f5c587a311782acd"499+ ],500+ "tags": [],501+ "bouquet": ""502+ },503+ {504+ "uuid": "1fd265f1fddc419c9986149dbfcf30bd",505+ "enabled": true,506+ "autoname": true,507+ "name": "CRIMES ",508+ "number": "25.4",509+ "icon": "picon://1_0_0_6_1EB_10000_DDDD0000_0_0_0.png",510+ "icon_public_url": "imagecache/56",511+ "epgauto": true,512+ "epglimit": 0,513+ "epggrab": [],514+ "dvr_pre_time": 0,515+ "dvr_pst_time": 0,516+ "epg_running": -1,517+ "services": [518+ "972030cb37a60317117a5e3207de5f23"519+ ],520+ "tags": [],521+ "bouquet": ""522+ },523+ {524+ "uuid": "99384d01dd1d7b0eb5430cd404b9f4de",525+ "enabled": true,526+ "autoname": true,527+ "name": "Oxygen",528+ "number": "25.5",529+ "icon": "file:///picons/oxygen.png",530+ "icon_public_url": "imagecache/43",531+ "epgauto": true,532+ "epglimit": 0,533+ "epggrab": [],534+ "dvr_pre_time": 0,535+ "dvr_pst_time": 0,536+ "epg_running": -1,537+ "services": [538+ "6a48a0f43c195309a57c19a792874209"539+ ],540+ "tags": [],541+ "bouquet": ""542+ },543+ {544+ "uuid": "69fa3e482c3199b00d004ff00ca26e9d",545+ "enabled": true,546+ "autoname": true,547+ "name": "COZI 2 ",548+ "number": "25.6",549+ "icon": "picon://1_0_0_8_1EB_10000_DDDD0000_0_0_0.png",550+ "icon_public_url": "imagecache/52",551+ "epgauto": true,552+ "epglimit": 0,553+ "epggrab": [],554+ "dvr_pre_time": 0,555+ "dvr_pst_time": 0,556+ "epg_running": -1,557+ "services": [558+ "c140b2af79da24160c3aa8a9178020df"559+ ],560+ "tags": [],561+ "bouquet": ""562+ },563+ {564+ "uuid": "3b437d7e58b015b39fb6cc56eca6088a",565+ "enabled": true,566+ "autoname": true,567+ "name": "KDVR-DT",568+ "number": "31.1",569+ "icon": "picon://1_0_0_3_1D3_10000_DDDD0000_0_0_0.png",570+ "icon_public_url": "imagecache/57",571+ "epgauto": true,572+ "epglimit": 0,573+ "epggrab": [],574+ "dvr_pre_time": 0,575+ "dvr_pst_time": 0,576+ "epg_running": -1,577+ "services": [578+ "ada0e376fbb2cf9f283dcaf122ded316"579+ ],580+ "tags": [],581+ "bouquet": ""582+ },583+ {584+ "uuid": "530650964dfa607b85770efdbcd0ff6a",585+ "enabled": true,586+ "autoname": true,587+ "name": "Antenna",588+ "number": "31.2",589+ "icon": "picon://1_0_0_4_1D3_10000_DDDD0000_0_0_0.png",590+ "icon_public_url": "imagecache/47",591+ "epgauto": true,592+ "epglimit": 0,593+ "epggrab": [],594+ "dvr_pre_time": 0,595+ "dvr_pst_time": 0,596+ "epg_running": -1,597+ "services": [598+ "7a265b4601d53de0569d1120af37f28b"599+ ],600+ "tags": [],601+ "bouquet": ""602+ },603+ {604+ "uuid": "24164b4984eaf55b6fbe38dd2bec3663",605+ "enabled": true,606+ "autoname": true,607+ "name": "ROAR-TV",608+ "number": "31.3",609+ "icon": "picon://1_0_0_5_1D3_10000_DDDD0000_0_0_0.png",610+ "icon_public_url": "imagecache/53",611+ "epgauto": true,612+ "epglimit": 0,613+ "epggrab": [],614+ "dvr_pre_time": 0,615+ "dvr_pst_time": 0,616+ "epg_running": -1,617+ "services": [618+ "3bd985dc5b72f2ff2e7cdcb17871414f"619+ ],620+ "tags": [],621+ "bouquet": ""622+ },623+ {624+ "uuid": "81343fafc243483c90a22b221d474770",625+ "enabled": true,626+ "autoname": true,627+ "name": "UniMas ",628+ "number": "50.1",629+ "icon": "picon://1_0_0_1_1D7_10000_DDDD0000_0_0_0.png",630+ "icon_public_url": "imagecache/31",631+ "epgauto": true,632+ "epglimit": 0,633+ "epggrab": [],634+ "dvr_pre_time": 0,635+ "dvr_pst_time": 0,636+ "epg_running": -1,637+ "services": [638+ "bf242c3fcf5f91031f09d2240f0d297d"639+ ],640+ "tags": [],641+ "bouquet": ""642+ },643+ {644+ "uuid": "7e34196b38c1f0d8dd833b8cc700c5b1",645+ "enabled": true,646+ "autoname": true,647+ "name": "LATV ",648+ "number": "50.2",649+ "icon": "picon://1_0_0_2_1D7_10000_DDDD0000_0_0_0.png",650+ "icon_public_url": "imagecache/33",651+ "epgauto": true,652+ "epglimit": 0,653+ "epggrab": [],654+ "dvr_pre_time": 0,655+ "dvr_pst_time": 0,656+ "epg_running": -1,657+ "services": [658+ "3862305d56e8953b2503d1d86bfb1fc0"659+ ],660+ "tags": [],661+ "bouquet": ""662+ },663+ {664+ "uuid": "9fa555c4b5afe976ba0646afb28430f7",665+ "enabled": true,666+ "autoname": true,667+ "name": "KETD-HD",668+ "number": "53.1",669+ "icon": "picon://1_0_0_1_1BF_10000_DDDD0000_0_0_0.png",670+ "icon_public_url": "imagecache/64",671+ "epgauto": true,672+ "epglimit": 0,673+ "epggrab": [],674+ "dvr_pre_time": 0,675+ "dvr_pst_time": 0,676+ "epg_running": -1,677+ "services": [678+ "d36cf3eea8129c725a83f228cc105336"679+ ],680+ "tags": [],681+ "bouquet": ""682+ },683+ {684+ "uuid": "4475289de8d0136d1636b6c4e65cfbc1",685+ "enabled": true,686+ "autoname": true,687+ "name": "E-NEWS",688+ "number": "53.2",689+ "icon": "picon://1_0_0_2_1BF_10000_DDDD0000_0_0_0.png",690+ "icon_public_url": "imagecache/63",691+ "epgauto": true,692+ "epglimit": 0,693+ "epggrab": [],694+ "dvr_pre_time": 0,695+ "dvr_pst_time": 0,696+ "epg_running": -1,697+ "services": [698+ "45823f68556b4b3032f374d2345bbab0"699+ ],700+ "tags": [],701+ "bouquet": ""702+ },703+ {704+ "uuid": "ac5e6f525460147bf91918aa9164b78e",705+ "enabled": true,706+ "autoname": true,707+ "name": "Confess",708+ "number": "53.3",709+ "icon": "picon://1_0_0_3_1BF_10000_DDDD0000_0_0_0.png",710+ "icon_public_url": "imagecache/65",711+ "epgauto": true,712+ "epglimit": 0,713+ "epggrab": [],714+ "dvr_pre_time": 0,715+ "dvr_pst_time": 0,716+ "epg_running": -1,717+ "services": [718+ "853caf4273f10132ce8c638b152368ed"719+ ],720+ "tags": [],721+ "bouquet": ""722+ },723+ {724+ "uuid": "2e3ac514d812e7ec4d8d3f03717b18d4",725+ "enabled": true,726+ "autoname": true,727+ "name": "JTV",728+ "number": "53.4",729+ "icon": "file:///picons/jtv.png",730+ "icon_public_url": "imagecache/60",731+ "epgauto": true,732+ "epglimit": 0,733+ "epggrab": [],734+ "dvr_pre_time": 0,735+ "dvr_pst_time": 0,736+ "epg_running": -1,737+ "services": [738+ "faa4f276ba271ebaf9b4e37b21cd67a2"739+ ],740+ "tags": [],741+ "bouquet": ""742+ },743+ {744+ "uuid": "8a55576aeb1ef62b52e42c6fe30ea5bf",745+ "enabled": true,746+ "autoname": true,747+ "name": "BUZZR",748+ "number": "53.5",749+ "icon": "picon://1_0_0_5_1BF_10000_DDDD0000_0_0_0.png",750+ "icon_public_url": "imagecache/67",751+ "epgauto": true,752+ "epglimit": 0,753+ "epggrab": [],754+ "dvr_pre_time": 0,755+ "dvr_pst_time": 0,756+ "epg_running": -1,757+ "services": [758+ "d83381e0b1421cdcbaee5cb3ac2b9434"759+ ],760+ "tags": [],761+ "bouquet": ""762+ }763+ ],764+ "total": 38765+}@@ -0,0 +1,310 @@
1+[2+ {3+ "uuid": "966aa49ec18feb963e17f7fdb1b78db0",4+ "id": "966aa49ec18feb963e17f7fdb1b78db0",5+ "text": "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)",6+ "caption": "HDHomeRun ATSC-T frontend",7+ "class": "tvhdhomerun_frontend_atsc_t",8+ "event": "mpegts_input",9+ "params": [10+ {11+ "id": "active",12+ "type": "bool",13+ "caption": "Active",14+ "default": false,15+ "rdonly": true,16+ "nosave": true,17+ "noui": true,18+ "value": true19+ },20+ {21+ "id": "enabled",22+ "type": "bool",23+ "caption": "Enabled",24+ "description": "Enable/disable tuner/adapter.",25+ "default": true,26+ "value": true27+ },28+ {29+ "id": "priority",30+ "type": "int",31+ "caption": "Priority",32+ "description": "The tuner priority value (a higher value means to use this tuner out of preference). See Help for details.",33+ "default": 1,34+ "advanced": true,35+ "value": 036+ },37+ {38+ "id": "spriority",39+ "type": "int",40+ "caption": "Streaming priority",41+ "description": "The tuner priority value for streamed channels through HTTP or HTSP (a higher value means to use this tuner out of preference). If not set (zero), the standard priority value is used. See Help for details.",42+ "default": 1,43+ "advanced": true,44+ "value": 045+ },46+ {47+ "id": "displayname",48+ "type": "str",49+ "caption": "Name",50+ "description": "Name of the tuner/adapter.",51+ "default": "",52+ "value": "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)"53+ },54+ {55+ "id": "ota_epg",56+ "type": "bool",57+ "caption": "Over-the-air EPG",58+ "description": "Enable over-the-air program guide (EPG) scanning on this input device.",59+ "default": true,60+ "value": true61+ },62+ {63+ "id": "initscan",64+ "type": "bool",65+ "caption": "Initial scan",66+ "description": "Allow the initial scan tuning on this device (scan when Tvheadend starts or when a new multiplex is added automatically). At least one tuner or input should have this settings turned on. See also 'Skip Startup Scan' in the network settings for further details.",67+ "default": true,68+ "advanced": true,69+ "value": true70+ },71+ {72+ "id": "idlescan",73+ "type": "bool",74+ "caption": "Idle scan",75+ "description": "Allow idle scan tuning on this device.",76+ "default": true,77+ "advanced": true,78+ "value": true79+ },80+ {81+ "id": "free_weight",82+ "type": "u32",83+ "caption": "Free subscription weight",84+ "description": "If the subscription weight for the input is below the specified threshold, the tuner is handled as free (according the priority settings). Otherwise, the next tuner (without any subscriptions) is used. Set this value to 10, if you are willing to override scan and epggrab subscriptions.",85+ "default": 1,86+ "advanced": true,87+ "value": 088+ },89+ {90+ "id": "remove_scrambled",91+ "type": "bool",92+ "caption": "Remove scrambled bits",93+ "description": "The scrambled bits in MPEG-TS packets are always cleared. It is a workaround for the special streams which are descrambled, but these bits are not touched.",94+ "default": true,95+ "expert": true,96+ "value": false97+ },98+ {99+ "id": "networks",100+ "type": "str",101+ "caption": "Networks",102+ "description": "Associate this device with one or more networks.",103+ "list": 1,104+ "enum": {105+ "type": "api",106+ "uri": "mpegts/input/network_list",107+ "event": "mpegts_network",108+ "params": {109+ "uuid": "966aa49ec18feb963e17f7fdb1b78db0",110+ "enum": true111+ }112+ },113+ "value": [114+ "67fb94e0bcce720866f6a6253cae95be"115+ ]116+ },117+ {118+ "id": "linked",119+ "type": "str",120+ "caption": "Linked input",121+ "description": "Wake up the linked input whenever this adapter is used. The subscriptions are named as \"keep\". Note that this isn't normally needed, and is here simply as a workaround to driver bugs in certain dual tuner cards that otherwise lock the second tuner.",122+ "default": "",123+ "advanced": true,124+ "enum": [125+ {126+ "key": "",127+ "val": "Not linked"128+ },129+ {130+ "key": "9fa2dd024c6683a288ebb77929612508",131+ "val": "HDHomeRun ATSC-T Tuner #1 (169.254.1.2)"132+ },133+ {134+ "key": "916eb08d882370004953cf1efbb0bc0f",135+ "val": "IPTV thread #2"136+ },137+ {138+ "key": "4b6b13545e6f5c46729026bbfd547328",139+ "val": "IPTV thread #1"140+ }141+ ],142+ "value": ""143+ },144+ {145+ "id": "fe_number",146+ "type": "int",147+ "caption": "Frontend number",148+ "default": 0,149+ "rdonly": true,150+ "nosave": true,151+ "value": 0152+ }153+ ],154+ "leaf": 1155+ },156+ {157+ "uuid": "9fa2dd024c6683a288ebb77929612508",158+ "id": "9fa2dd024c6683a288ebb77929612508",159+ "text": "HDHomeRun ATSC-T Tuner #1 (169.254.1.2)",160+ "caption": "HDHomeRun ATSC-T frontend",161+ "class": "tvhdhomerun_frontend_atsc_t",162+ "event": "mpegts_input",163+ "params": [164+ {165+ "id": "active",166+ "type": "bool",167+ "caption": "Active",168+ "default": false,169+ "rdonly": true,170+ "nosave": true,171+ "noui": true,172+ "value": true173+ },174+ {175+ "id": "enabled",176+ "type": "bool",177+ "caption": "Enabled",178+ "description": "Enable/disable tuner/adapter.",179+ "default": true,180+ "value": true181+ },182+ {183+ "id": "priority",184+ "type": "int",185+ "caption": "Priority",186+ "description": "The tuner priority value (a higher value means to use this tuner out of preference). See Help for details.",187+ "default": 1,188+ "advanced": true,189+ "value": 0190+ },191+ {192+ "id": "spriority",193+ "type": "int",194+ "caption": "Streaming priority",195+ "description": "The tuner priority value for streamed channels through HTTP or HTSP (a higher value means to use this tuner out of preference). If not set (zero), the standard priority value is used. See Help for details.",196+ "default": 1,197+ "advanced": true,198+ "value": 0199+ },200+ {201+ "id": "displayname",202+ "type": "str",203+ "caption": "Name",204+ "description": "Name of the tuner/adapter.",205+ "default": "",206+ "value": "HDHomeRun ATSC-T Tuner #1 (169.254.1.2)"207+ },208+ {209+ "id": "ota_epg",210+ "type": "bool",211+ "caption": "Over-the-air EPG",212+ "description": "Enable over-the-air program guide (EPG) scanning on this input device.",213+ "default": true,214+ "value": true215+ },216+ {217+ "id": "initscan",218+ "type": "bool",219+ "caption": "Initial scan",220+ "description": "Allow the initial scan tuning on this device (scan when Tvheadend starts or when a new multiplex is added automatically). At least one tuner or input should have this settings turned on. See also 'Skip Startup Scan' in the network settings for further details.",221+ "default": true,222+ "advanced": true,223+ "value": true224+ },225+ {226+ "id": "idlescan",227+ "type": "bool",228+ "caption": "Idle scan",229+ "description": "Allow idle scan tuning on this device.",230+ "default": true,231+ "advanced": true,232+ "value": true233+ },234+ {235+ "id": "free_weight",236+ "type": "u32",237+ "caption": "Free subscription weight",238+ "description": "If the subscription weight for the input is below the specified threshold, the tuner is handled as free (according the priority settings). Otherwise, the next tuner (without any subscriptions) is used. Set this value to 10, if you are willing to override scan and epggrab subscriptions.",239+ "default": 1,240+ "advanced": true,241+ "value": 0242+ },243+ {244+ "id": "remove_scrambled",245+ "type": "bool",246+ "caption": "Remove scrambled bits",247+ "description": "The scrambled bits in MPEG-TS packets are always cleared. It is a workaround for the special streams which are descrambled, but these bits are not touched.",248+ "default": true,249+ "expert": true,250+ "value": false251+ },252+ {253+ "id": "networks",254+ "type": "str",255+ "caption": "Networks",256+ "description": "Associate this device with one or more networks.",257+ "list": 1,258+ "enum": {259+ "type": "api",260+ "uri": "mpegts/input/network_list",261+ "event": "mpegts_network",262+ "params": {263+ "uuid": "9fa2dd024c6683a288ebb77929612508",264+ "enum": true265+ }266+ },267+ "value": [268+ "67fb94e0bcce720866f6a6253cae95be"269+ ]270+ },271+ {272+ "id": "linked",273+ "type": "str",274+ "caption": "Linked input",275+ "description": "Wake up the linked input whenever this adapter is used. The subscriptions are named as \"keep\". Note that this isn't normally needed, and is here simply as a workaround to driver bugs in certain dual tuner cards that otherwise lock the second tuner.",276+ "default": "",277+ "advanced": true,278+ "enum": [279+ {280+ "key": "",281+ "val": "Not linked"282+ },283+ {284+ "key": "966aa49ec18feb963e17f7fdb1b78db0",285+ "val": "HDHomeRun ATSC-T Tuner #0 (169.254.1.2)"286+ },287+ {288+ "key": "916eb08d882370004953cf1efbb0bc0f",289+ "val": "IPTV thread #2"290+ },291+ {292+ "key": "4b6b13545e6f5c46729026bbfd547328",293+ "val": "IPTV thread #1"294+ }295+ ],296+ "value": ""297+ },298+ {299+ "id": "fe_number",300+ "type": "int",301+ "caption": "Frontend number",302+ "default": 0,303+ "rdonly": true,304+ "nosave": true,305+ "value": 1306+ }307+ ],308+ "leaf": 1309+ }310+]Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.