clone

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

main
airwaves / internal / service / antenna.go
antenna.go 16.6K
1package service23import (4 "cmp"5 "context"6 "errors"7 "fmt"8 "log"9 "slices"10 "strconv"11 "strings"12 "time"1314 "airwaves/internal/fcc"15 "airwaves/internal/guide"16 "airwaves/internal/hdhr"17 "airwaves/internal/lineup"18 "airwaves/internal/signal"19 "airwaves/internal/tuner"20)2122// ErrNoTuner is returned for an antenna channel by a server that has found23// no HDHomeRun to tune it with.24var ErrNoTuner = tuner.ErrNoDevice2526// Antenna is what the app shows of the antenna: the tuner, the channels it27// receives and what was measured on each RF channel. Every channel is one28// the tuner's scan found; what the FCC records, the listings and the ATSC29// 3.0 list say only describes them.30type Antenna struct {31 Tuner TunerInfo `json:"tuner"`32 Channels []AntennaChannel `json:"channels"`33 Muxes []MuxSignal `json:"muxes"`34 Sweep SweepStatus `json:"sweep"`35}3637// TunerInfo describes the server's tuners.38type TunerInfo struct {39 // Ready is set when there is a tuner.40 Ready bool `json:"ready"`41 // Reason says why there are no antenna channels, when there aren't.42 Reason string `json:"reason,omitempty"`43 // Name and Model are the tuner's, "HDHomeRun FLEX DUO" and "HDFX-2US".44 Name string `json:"name,omitempty"`45 Model string `json:"model,omitempty"`46 Tuners int `json:"tuners"`47 // InUse counts tuners tuned now: to a viewer, a recording or a48 // measurement.49 InUse int `json:"inUse"`50 // Standards lists what the tuners receive; ATSC3 is whether that takes51 // in ATSC 3.0 (NextGen TV).52 Standards []string `json:"standards"`53 ATSC3 bool `json:"atsc3"`54 // Scanning is set while the tuner scans for channels.55 Scanning bool `json:"scanning"`56}5758// AntennaChannel is a channel the tuner receives.59type AntennaChannel struct {60 lineup.TunerChannel61 // Signal is the latest reading of the channel's multiplex, and Recent62 // the latest stretch of readings (the last sweep, or the last while63 // it was watched) with lows and means, steadier than one reading on a64 // marginal signal; null when it has never been measured.65 Signal *signal.Reading `json:"signal"`66 Recent *signal.Period `json:"recent"`67}6869// MuxSignal is one RF channel: the tuner's channels on it, what the FCC70// licenses on it nearby, and what the tuners measured on it.71type MuxSignal struct {72 RF int `json:"rf"`73 FrequencyMHz float64 `json:"frequencyMhz"`74 Band string `json:"band"`75 // Tuned is set while a tuner is on it.76 Tuned bool `json:"tuned"`77 // Channels are the tuner's channels on it: what its scan found there.78 Channels []string `json:"channels"`79 // Stations are the licensed transmitters on this RF channel within80 // the search radius, nearest first.81 Stations []MuxStation `json:"stations"`82 // Signal is the latest reading, History all those kept; null when83 // never measured.84 Signal *signal.Reading `json:"signal"`85 History *signal.History `json:"history"`86}8788// MuxStation is a licensed transmitter on a multiplex's RF channel, from89// the FCC's records.90type MuxStation struct {91 FacilityID int `json:"facilityId"`92 CallSign string `json:"callSign"`93 VirtualChannel int `json:"virtualChannel"`94 Service string `json:"service"` // DTV, DCA, LPD...95 City string `json:"city"`96 State string `json:"state"`97 DistanceKm float64 `json:"distanceKm"`98 BearingDeg float64 `json:"bearingDeg"`99 ERPkW float64 `json:"erpKw"`100 HAATm float64 `json:"haatM"`101 // ATSC3 is set when the station broadcasts ATSC 3.0 on this RF102 // channel (per RabbitEars), which an ATSC 1.0 tuner can't decode.103 ATSC3 bool `json:"atsc3"`104}105106// ChannelSignal is a channel's latest reading and stretch of readings,107// for the app to poll.108type ChannelSignal struct {109 Number string `json:"number"`110 RF int `json:"rf"`111 Signal *signal.Reading `json:"signal"`112 Recent *signal.Period `json:"recent"`113}114115// SignalReport is the measurements alone, light enough to poll while116// watching or measuring.117type SignalReport struct {118 Tuner TunerInfo `json:"tuner"`119 Channels []ChannelSignal `json:"channels"`120 Muxes []MuxSignal `json:"muxes"`121 Sweep SweepStatus `json:"sweep"`122}123124// device is the HDHomeRun, nil until one is found.125func (s *Service) device() *hdhr.Device {126 s.mu.Lock()127 defer s.mu.Unlock()128 return s.dev129}130131// findTuner looks for the HDHomeRun until one is found, then keeps its132// details current, looking again when it stops answering.133func (s *Service) findTuner(ctx context.Context) {134 if s.tuners == nil {135 return136 }137 if dev := s.device(); dev != nil {138 fresh := *dev139 if err := fresh.Refresh(ctx); err == nil {140 s.setDevice(&fresh)141 return142 }143 log.Printf("tuner: %s stopped answering; looking for it again", dev.FriendlyName)144 }145 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)146 defer cancel()147 dev, err := hdhr.Find(ctx, s.opt.HDHomeRun, s.opt.SkipDeviceID, s.http)148 if err != nil {149 s.mu.Lock()150 changed := s.devErr != err.Error()151 s.devErr = err.Error()152 s.mu.Unlock()153 if changed {154 log.Printf("tuner: %v", err)155 }156 s.setDevice(nil)157 return158 }159 if s.device() == nil {160 log.Printf("tuner: %s (%s, %d tuners, firmware %s) at %s", dev.FriendlyName, dev.Model, dev.Tuners, dev.Version, dev.BaseURL)161 }162 s.setDevice(dev)163}164165// setDevice makes dev the tuner to use; nil for none.166func (s *Service) setDevice(dev *hdhr.Device) {167 s.mu.Lock()168 same := dev != nil && s.dev != nil && dev.ID == s.dev.ID && dev.BaseURL == s.dev.BaseURL && dev.Tuners == s.dev.Tuners169 if dev == nil && s.dev == nil || same {170 if same {171 s.dev = dev172 }173 s.mu.Unlock()174 return175 }176 s.dev, s.devErr = dev, ""177 s.lineupAt = time.Time{}178 s.mu.Unlock()179 if dev == nil {180 s.tuners.SetDevice(nil, 0)181 return182 }183 s.tuners.SetDevice(dev, dev.Tuners)184}185186// lineupTTL is how long the tuner's lineup is used before asking again.187const lineupTTL = time.Minute188189// tunerLineup is the channels the tuner's scan found that it can show:190// not ATSC 3.0 ones, whose audio can't be decoded, nor encrypted ones, by191// number (of two with one number, the first stays). A failure keeps the192// last lineup.193func (s *Service) tunerLineup(ctx context.Context) ([]hdhr.Channel, error) {194 dev := s.device()195 if dev == nil {196 return nil, ErrNoTuner197 }198 s.mu.Lock()199 l, at := s.lineup, s.lineupAt200 s.mu.Unlock()201 if !at.IsZero() && time.Since(at) < lineupTTL {202 return l, nil203 }204 got, err := dev.Lineup(ctx)205 if err != nil {206 if !at.IsZero() {207 return l, nil208 }209 return nil, err210 }211 seen := map[string]bool{}212 l = got[:0]213 for _, c := range got {214 if c.ATSC3 || c.DRM || c.FrequencyHz <= 0 || c.Program <= 0 || seen[c.Number] {215 continue216 }217 seen[c.Number] = true218 l = append(l, c)219 }220 slices.SortStableFunc(l, func(a, b hdhr.Channel) int { return guide.CompareNumbers(a.Number, b.Number) })221 s.mu.Lock()222 s.lineup, s.lineupAt = l, time.Now()223 s.mu.Unlock()224 return l, nil225}226227// tuning finds where a channel is broadcast: its RF channel and program.228func (s *Service) tuning(ctx context.Context, number string) (int64, int, error) {229 l, err := s.tunerLineup(ctx)230 if err != nil {231 return 0, 0, err232 }233 for _, c := range l {234 if c.Number == number {235 return c.FrequencyHz, c.Program, nil236 }237 }238 return 0, 0, fmt.Errorf("channel %s isn't in the tuner's lineup", number)239}240241// scanStatus is the tuner's channel scan, read at most every few seconds.242func (s *Service) scanStatus(ctx context.Context) hdhr.ScanStatus {243 dev := s.device()244 if dev == nil {245 return hdhr.ScanStatus{}246 }247 s.mu.Lock()248 st, at := s.scan, s.scanAt249 s.mu.Unlock()250 if !at.IsZero() && time.Since(at) < 5*time.Second {251 return st252 }253 st, err := dev.ScanStatus(ctx)254 if err != nil {255 return hdhr.ScanStatus{}256 }257 s.mu.Lock()258 if s.scan.InProgress && !st.InProgress {259 s.lineupAt = time.Time{} // a scan just finished: read its lineup260 }261 s.scan, s.scanAt = st, time.Now()262 s.mu.Unlock()263 return st264}265266// ScanChannels has the tuner scan for channels again, as after moving the267// antenna. Every tuner is taken for the few minutes it takes, ending268// whatever was watched or measured; recordings in progress make it wait.269func (s *Service) ScanChannels(ctx context.Context) (TunerInfo, error) {270 if s.opt.NoAntenna {271 return TunerInfo{}, ErrNoAntenna272 }273 dev := s.device()274 if dev == nil {275 return TunerInfo{}, ErrNoTuner276 }277 if busy := s.recordingNow(ctx); busy != "" {278 return TunerInfo{}, fmt.Errorf("not while recording %s: a scan takes every tuner", busy)279 }280 s.tuners.SetDevice(nil, 0) // let go of every tuner281 err := dev.StartScan(ctx)282 s.tuners.SetDevice(dev, dev.Tuners)283 if err != nil {284 return TunerInfo{}, err285 }286 log.Print("tuner: scanning for channels")287 s.mu.Lock()288 s.scanAt = time.Time{}289 s.mu.Unlock()290 return s.tunerInfo(ctx), nil291}292293// recordingNow names a recording in progress, "" when there's none.294func (s *Service) recordingNow(ctx context.Context) string {295 tuners, err := s.tuners.Tuners(ctx)296 if err != nil {297 return ""298 }299 for _, t := range tuners {300 for _, u := range t.Users {301 if u.Priority == tuner.Recording {302 return u.Who303 }304 }305 }306 return ""307}308309// tunerDevice describes the tuner for Info.310func (s *Service) tunerDevice() tuner.Device {311 if s.opt.NoAntenna {312 return tuner.Device{ID: "none", Name: "No antenna", Kind: "none", Detail: "custom channels only"}313 }314 dev := s.device()315 if dev == nil {316 return tuner.Device{ID: "none", Name: "No tuner", Kind: "none", Detail: "no HDHomeRun found on the network"}317 }318 return tuner.Device{319 ID: dev.ID, Name: dev.FriendlyName, Kind: "hdhomerun", Model: dev.Model, Tuners: dev.Tuners,320 Detail: "firmware " + dev.Version, Standards: standards(dev), ATSC3: dev.ATSC3(),321 }322}323324// standards lists what dev's tuners receive.325func standards(dev *hdhr.Device) []string {326 if dev.ATSC3() {327 return []string{"ATSC 1.0", "ATSC 3.0"}328 }329 return []string{"ATSC 1.0"}330}331332// tunerInfo describes the tuners and what's tuned now.333func (s *Service) tunerInfo(ctx context.Context) TunerInfo {334 if s.opt.NoAntenna {335 return TunerInfo{Reason: ErrNoAntenna.Error(), Standards: []string{}}336 }337 dev := s.device()338 if dev == nil {339 reason := ErrNoTuner.Error()340 s.mu.Lock()341 if s.devErr != "" && s.opt.HDHomeRun != "" {342 reason = "no tuner: " + s.devErr343 }344 s.mu.Unlock()345 return TunerInfo{Reason: reason, Standards: []string{}}346 }347 ti := TunerInfo{Ready: true, Name: dev.FriendlyName, Model: dev.Model, Tuners: dev.Tuners, Standards: standards(dev), ATSC3: dev.ATSC3()}348 if tuners, err := s.tuners.Tuners(ctx); err == nil {349 for _, t := range tuners {350 if t.FrequencyHz > 0 {351 ti.InUse++352 }353 }354 }355 ti.Scanning = s.scanStatus(ctx).InProgress356 if ti.Scanning {357 ti.Reason = "the tuner is scanning for channels"358 }359 return ti360}361362// antenna is the tuner's lineup described from rep and g, with the363// measurements; rep and g may be nil.364func (s *Service) antenna(ctx context.Context, rep *lineup.Report, g *guide.Guide) *Antenna {365 a := &Antenna{Channels: []AntennaChannel{}, Muxes: []MuxSignal{}, Sweep: s.sweepStatus(), Tuner: s.tunerInfo(ctx)}366 if s.opt.NoAntenna {367 return a368 }369 chans, found, err := s.matched(ctx, rep, g)370 if err != nil {371 log.Printf("antenna channels: %v", err)372 if a.Tuner.Reason == "" {373 a.Tuner.Reason = "the tuner's lineup can't be read: " + err.Error()374 }375 }376 onRF := map[int64][]string{} // channel numbers by frequency377 for i, c := range chans {378 ac := AntennaChannel{TunerChannel: c}379 if m, ok := s.signals.Get(found[i].FrequencyHz); ok {380 ac.Signal, ac.Recent = m.Latest, m.Recent()381 }382 a.Channels = append(a.Channels, ac)383 onRF[found[i].FrequencyHz] = append(onRF[found[i].FrequencyHz], c.Number)384 }385 a.Muxes = s.muxSignals(ctx, rep, onRF)386 return a387}388389// matched is the tuner's channels described from rep and g, with the390// tuner's own entry for each, in the same order.391func (s *Service) matched(ctx context.Context, rep *lineup.Report, g *guide.Guide) ([]lineup.TunerChannel, []hdhr.Channel, error) {392 if s.tuners == nil {393 return nil, nil, nil394 }395 l, err := s.tunerLineup(ctx)396 if errors.Is(err, ErrNoTuner) {397 return nil, nil, nil // no tuner, no channels398 }399 if err != nil {400 return nil, nil, err401 }402 found := make([]lineup.Scanned, len(l))403 for i, c := range l {404 found[i] = lineup.Scanned{Number: c.Number, Name: c.Name, RF: signal.RF(c.FrequencyHz)}405 }406 if rep == nil {407 rep = &lineup.Report{}408 }409 chans := lineup.Match(rep, g, found)410 byNumber := make(map[string]hdhr.Channel, len(l))411 for _, c := range l {412 byNumber[c.Number] = c413 }414 same := make([]hdhr.Channel, len(chans))415 for i, c := range chans {416 same[i] = byNumber[c.Number]417 }418 return chans, same, nil419}420421// muxSignals lists the RF channels worth showing: those the tuner's scan422// found channels on (onRF, by frequency), those a licensed station nearby423// uses, and any measured before.424func (s *Service) muxSignals(ctx context.Context, rep *lineup.Report, onRF map[int64][]string) []MuxSignal {425 tuned := map[int64]bool{}426 if tuners, err := s.tuners.Tuners(ctx); err == nil {427 for _, t := range tuners {428 tuned[t.FrequencyHz] = t.FrequencyHz > 0429 }430 }431 freqs := map[int64]bool{}432 for f := range onRF {433 freqs[f] = true434 }435 if rep != nil {436 for _, st := range rep.Stations {437 if f := signal.Frequency(st.RFChannel); f > 0 {438 freqs[f] = true439 }440 }441 }442 for _, m := range s.signals.All() {443 freqs[m.FrequencyHz] = true444 }445 out := make([]MuxSignal, 0, len(freqs))446 for f := range freqs {447 rf := signal.RF(f)448 if rf == 0 {449 continue450 }451 ms := MuxSignal{RF: rf, FrequencyMHz: float64(f) / 1e6, Band: fcc.Band(rf), Tuned: tuned[f],452 Channels: []string{}, Stations: muxStations(rep, rf)}453 if c := onRF[f]; c != nil {454 ms.Channels = slices.SortedFunc(slices.Values(c), guide.CompareNumbers)455 }456 if m, ok := s.signals.Get(f); ok {457 ms.Signal, ms.History = m.Latest, m.History()458 }459 out = append(out, ms)460 }461 slices.SortFunc(out, func(a, b MuxSignal) int { return cmp.Compare(a.FrequencyMHz, b.FrequencyMHz) })462 return out463}464465// muxStations lists rep's transmitters on RF channel rf, nearest first.466func muxStations(rep *lineup.Report, rf int) []MuxStation {467 out := []MuxStation{}468 if rep == nil || rf == 0 {469 return out470 }471 for _, st := range rep.Stations {472 if st.RFChannel != rf {473 continue474 }475 ms := MuxStation{476 FacilityID: st.FacilityID, CallSign: st.CallSign, VirtualChannel: st.VirtualChannel, Service: st.Service,477 City: st.City, State: st.State, DistanceKm: st.DistanceKm, BearingDeg: st.BearingDeg, ERPkW: st.ERPkW, HAATm: st.HAATm,478 }479 for _, h := range rep.ATSC3 {480 if h.FacilityID == st.FacilityID && hostsRF(h.RF, rf) {481 ms.ATSC3 = true482 }483 }484 out = append(out, ms)485 }486 slices.SortStableFunc(out, func(a, b MuxStation) int { return cmp.Compare(a.DistanceKm, b.DistanceKm) })487 return out488}489490// hostsRF reports whether an ATSC 3.0 host's RF ("34", or "2 → 3" for a491// move) is rf, now.492func hostsRF(s string, rf int) bool {493 f := strings.FieldsFunc(s, func(r rune) bool { return r < '0' || r > '9' })494 if len(f) == 0 {495 return false496 }497 n, err := strconv.Atoi(f[len(f)-1])498 return err == nil && n == rf499}500501// dvrChannels is the tuner's lineup for recording: number, call sign (the502// listings', as recording rules have it) and listings.503func dvrChannels(chans []lineup.TunerChannel) []lineup.Channel {504 out := make([]lineup.Channel, 0, len(chans))505 for _, c := range chans {506 out = append(out, lineup.Channel{Number: c.Number, Major: c.Major, Minor: c.Minor, GuideID: c.GuideID, CallSign: cmp.Or(c.GuideCallSign, c.CallSign), BaseCall: c.BaseCall})507 }508 return out509}510511// listedOnly keeps g's channels and listings for the tuner's channels,512// from from to to: listings of stations the tuner doesn't receive aren't513// the app's, and it shows a day or two while recording looks two weeks514// ahead.515func listedOnly(g *guide.Guide, chans []AntennaChannel, from, to time.Time) *guide.Guide {516 if g == nil {517 return nil518 }519 ids := map[string]bool{}520 for _, c := range chans {521 if c.GuideID != "" {522 ids[c.GuideID] = true523 }524 }525 out := *g526 out.Channels = []guide.Channel{}527 out.Programs = map[string][]guide.Program{}528 for _, c := range g.Channels {529 if ids[c.ID] {530 out.Channels = append(out.Channels, c)531 }532 }533 for id, ps := range g.Programs {534 if !ids[id] {535 continue536 }537 var keep []guide.Program538 for _, p := range ps {539 if p.End.After(from) && p.Start.Before(to) {540 keep = append(keep, p)541 }542 }543 out.Programs[id] = keep544 }545 return &out546}547548// Signal is what was measured, for the app to poll: by multiplex, and by549// channel.550func (s *Service) Signal(ctx context.Context) (*SignalReport, error) {551 if s.opt.NoAntenna {552 return nil, ErrNoAntenna553 }554 s.mu.Lock()555 rep, g := s.report, s.guide556 s.mu.Unlock()557 a := s.antenna(ctx, rep, g)558 out := &SignalReport{Tuner: a.Tuner, Channels: make([]ChannelSignal, 0, len(a.Channels)), Muxes: a.Muxes, Sweep: a.Sweep}559 for _, c := range a.Channels {560 out.Channels = append(out.Channels, ChannelSignal{Number: c.Number, RF: c.RF, Signal: c.Signal, Recent: c.Recent})561 }562 return out, nil563}