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 / cmd / otascan / main.go
main.go 5.3K
1// Command otascan reports which over-the-air TV channels are likely2// receivable at a US ZIP code, with ATSC 3.0 status and what is on now.3package main45import (6 "context"7 "encoding/json"8 "errors"9 "flag"10 "fmt"11 "os"12 "os/signal"13 "path/filepath"14 "strings"15 "text/tabwriter"16 "time"1718 "airwaves/internal/geo"19 "airwaves/internal/guide"20 "airwaves/internal/lineup"21 "airwaves/internal/reception"22 "airwaves/internal/store"23 "airwaves/internal/terrain"24 "airwaves/internal/web"25)2627func main() {28 if err := run(); err != nil {29 fmt.Fprintln(os.Stderr, "otascan:", err)30 os.Exit(1)31 }32}3334func run() error {35 zip := flag.String("zip", "", "US ZIP code (required)")36 lat := flag.Float64("lat", 0, "latitude override (use with -lon)")37 lon := flag.Float64("lon", 0, "longitude override (use with -lat)")38 radius := flag.Float64("radius", 160, "transmitter search radius in km")39 antenna := flag.String("antenna", "rooftop", "antenna preset: indoor, attic, rooftop")40 all := flag.Bool("all", false, "include channels unlikely to be received")41 asJSON := flag.Bool("json", false, "print the full report as JSON")42 refresh := flag.Bool("refresh", false, "ignore cached data")43 flag.Parse()44 if *zip == "" {45 return errors.New("give the antenna's ZIP code with -zip")46 }4748 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)49 defer stop()5051 cache, err := store.NewCache("airwaves")52 if err != nil {53 return err54 }55 client := web.NewClient()56 b := &lineup.Builder{57 HTTP: client,58 Cache: cache,59 Terrain: terrain.NewSource(client, filepath.Join(cache.Dir(), "terrain")),60 Progress: func(s string) { fmt.Fprintln(os.Stderr, "-", s) },61 }62 opt := lineup.Options{ZIP: *zip, RadiusKm: *radius, GuideHours: 6, Refresh: *refresh}63 if *lat != 0 && *lon != 0 {64 opt.Point = &geo.Point{Lat: *lat, Lon: *lon}65 }66 rep, g, err := b.Build(ctx, opt)67 if err != nil {68 return err69 }70 if *asJSON {71 enc := json.NewEncoder(os.Stdout)72 enc.SetIndent("", " ")73 return enc.Encode(rep)74 }75 printReport(rep, g, *antenna, *all)76 return nil77}7879func printReport(rep *lineup.Report, g *guide.Guide, antenna string, all bool) {80 fmt.Printf("\nOver-the-air TV near %s %s, %s (%.4f, %.4f)\n", rep.Place.ZIP, rep.Place.Name, rep.Place.State, rep.Point.Lat, rep.Point.Lon)81 fmt.Printf("Antenna preset for the channel list: %s\n", antenna)82 for _, w := range rep.Warnings {83 fmt.Println("warning:", w)84 }8586 byID := make(map[int]lineup.Station)87 for _, s := range rep.Stations {88 if _, ok := byID[s.FacilityID]; !ok {89 byID[s.FacilityID] = s90 }91 }9293 fmt.Println("\nTRANSMITTERS (best first; margin in dB for indoor / attic / rooftop)")94 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)95 fmt.Fprintln(tw, "CALL\tRF\tBAND\tVIRT\tCITY\tKM\tDIR\tERP kW\tLOS\tINDOOR\tATTIC\tROOF\t3.0\tCARRIES")96 for _, s := range rep.Stations {97 roof := s.Signal["rooftop"]98 if !all && !roof.Tier.Receivable() && roof.Tier != reception.Weak {99 continue100 }101 atsc := ""102 if s.ATSC3 {103 atsc = "yes"104 }105 fmt.Fprintf(tw, "%s\t%d\t%s\t%d\t%s\t%.0f\t%s\t%.1f\t%s\t%s\t%s\t%s\t%s\t%s\n",106 s.CallSign, s.RFChannel, s.Band, s.VirtualChannel, s.City, s.DistanceKm, compass(s.BearingDeg), s.ERPkW,107 yesNo(roof.LineOfSight), margin(s.Signal["indoor"]), margin(s.Signal["attic"]), margin(roof), atsc,108 strings.Join(s.Carries, " "))109 }110 tw.Flush()111112 if len(rep.ATSC3) > 0 {113 fmt.Println("\nATSC 3.0 / NEXTGEN TV")114 for _, h := range rep.ATSC3 {115 s := byID[h.FacilityID]116 fmt.Printf(" %s RF %s (%s market, launched %s, rooftop %s)\n", h.CallSign, h.RF, h.Market, orDash(h.Launched), margin(s.Signal["rooftop"]))117 for _, svc := range h.Services {118 src := ""119 if svc.ATSC1Call != "" {120 src = fmt.Sprintf(" 1.0 simulcast on %s %s", svc.ATSC1Call, svc.ATSC1Display)121 }122 fmt.Printf(" %-6s %-10s %-10s%s\n", svc.Display, svc.Network, svc.Name, src)123 }124 }125 }126127 now := time.Now()128 fmt.Printf("\nCHANNELS (%s)\n", antenna)129 tw = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)130 fmt.Fprintln(tw, "CH\tCALL\tNETWORK\tSIGNAL\tVIA\t3.0\tON NOW")131 shown := 0132 for _, ch := range rep.Channels {133 t := ch.Tier[antenna]134 if !all && !t.Receivable() {135 continue136 }137 shown++138 atsc := ""139 switch {140 case ch.ATSC3Only:141 atsc = "only"142 case ch.ATSC3 != nil:143 atsc = "yes"144 }145 fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ch.Number, ch.CallSign, ch.Network, t, ch.Via, atsc, onNow(g, ch.GuideID, now))146 }147 tw.Flush()148 fmt.Printf("\n%d of %d lineup channels shown. Estimates use FCC data and terrain only; local obstructions and multipath are not modeled.\n", shown, len(rep.Channels))149}150151func onNow(g *guide.Guide, id string, now time.Time) string {152 if g == nil || id == "" {153 return ""154 }155 for _, p := range g.Programs[id] {156 if !p.Start.After(now) && p.End.After(now) {157 if len(p.Title) > 40 {158 return p.Title[:39] + "…"159 }160 return p.Title161 }162 }163 return ""164}165166func margin(e reception.Estimate) string {167 if e.Tier == reception.Unknown || e.Tier == "" {168 return "?"169 }170 return fmt.Sprintf("%+.0f", e.NoiseMarginDB)171}172173func compass(deg float64) string {174 dirs := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}175 return dirs[int((deg+11.25)/22.5)%16]176}177178func yesNo(b bool) string {179 if b {180 return "yes"181 }182 return "no"183}184185func orDash(s string) string {186 if s == "" {187 return "-"188 }189 return s190}