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 / fcc / tvq.go
tvq.go 5.8K
1// Package fcc queries the FCC TV Query service for licensed broadcast2// transmitters near a location.3package fcc45import (6 "bufio"7 "bytes"8 "context"9 "fmt"10 "math"11 "net/http"12 "net/url"13 "strconv"14 "strings"1516 "airwaves/internal/geo"17 "airwaves/internal/web"18)1920const tvqURL = "https://transition.fcc.gov/fcc-bin/tvq"2122// Facility is one licensed TV transmitter.23type Facility struct {24 CallSign string `json:"callSign"` // e.g. "KWGN-TV"25 BaseCall string `json:"baseCall"` // e.g. "KWGN"26 // Service is the FCC service code: DTV (full power), DCA (class A),27 // LPD (low power), DRT (replacement translator), LPT, DTS, ...28 Service string `json:"service"`29 RFChannel int `json:"rfChannel"`30 VirtualChannel int `json:"virtualChannel"` // PSIP major number, 0 if unknown31 Status string `json:"status"`32 City string `json:"city"`33 State string `json:"state"`34 FacilityID int `json:"facilityId"`35 Licensee string `json:"licensee"`36 ERPkW float64 `json:"erpKw"`37 HAATm float64 `json:"haatM"` // height above average terrain38 RCAMSLm float64 `json:"rcamslM"` // radiation center above mean sea level39 RCAGLm float64 `json:"rcaglM"` // radiation center above ground40 Directional bool `json:"directional"`41 Point geo.Point `json:"point"`42 DistanceKm float64 `json:"distanceKm"`43 BearingDeg float64 `json:"bearingDeg"` // from the search point to the tower44}4546// Band names the broadcast band an RF channel falls in.47func Band(rf int) string {48 switch {49 case rf >= 2 && rf <= 6:50 return "VHF-Lo"51 case rf >= 7 && rf <= 13:52 return "VHF-Hi"53 default:54 return "UHF"55 }56}5758// CenterMHz returns the center frequency of a US TV RF channel.59func CenterMHz(rf int) float64 {60 switch {61 case rf >= 2 && rf <= 4:62 return 57 + 6*float64(rf-2)63 case rf >= 5 && rf <= 6:64 return 79 + 6*float64(rf-5)65 case rf >= 7 && rf <= 13:66 return 177 + 6*float64(rf-7)67 default:68 return 473 + 6*float64(rf-14)69 }70}7172// Query returns licensed transmitters within radiusKm of p.73func Query(ctx context.Context, c *http.Client, p geo.Point, radiusKm float64) ([]Facility, error) {74 latD, latM, latS, ns := dms(p.Lat, "N", "S")75 lonD, lonM, lonS, ew := dms(p.Lon, "E", "W")76 q := url.Values{77 "call": {""}, "city": {""}, "state": {""}, "arn": {""}, "serv": {""},78 "vac": {""}, "freq": {"0.0"}, "fre2": {"999"}, "facid": {""},79 "emailaddr": {""}, "class": {""}, "list": {"4"}, "size": {"9"},80 "dist": {strconv.FormatFloat(radiusKm, 'f', 0, 64)},81 "dlat2": {strconv.Itoa(latD)}, "mlat2": {strconv.Itoa(latM)}, "slat2": {strconv.FormatFloat(latS, 'f', 1, 64)}, "NS": {ns},82 "dlon2": {strconv.Itoa(lonD)}, "mlon2": {strconv.Itoa(lonM)}, "slon2": {strconv.FormatFloat(lonS, 'f', 1, 64)}, "EW": {ew},83 }84 body, err := web.Get(ctx, c, tvqURL+"?"+q.Encode())85 if err != nil {86 return nil, fmt.Errorf("fcc tv query: %w", err)87 }88 all, err := Parse(body)89 if err != nil {90 return nil, err91 }92 return Licensed(all), nil93}9495// Licensed keeps licensed facilities, dropping duplicate records for the96// same transmitter.97func Licensed(all []Facility) []Facility {98 type key struct {99 id, rf int100 lat, lon float64101 }102 seen := make(map[key]bool)103 var out []Facility104 for _, f := range all {105 if f.Status != "LIC" {106 continue107 }108 k := key{f.FacilityID, f.RFChannel, f.Point.Lat, f.Point.Lon}109 if seen[k] {110 continue111 }112 seen[k] = true113 out = append(out, f)114 }115 return out116}117118// Parse decodes the pipe-delimited TV Query "list=4" format.119func Parse(body []byte) ([]Facility, error) {120 var out []Facility121 sc := bufio.NewScanner(bytes.NewReader(body))122 sc.Buffer(make([]byte, 64*1024), 1024*1024)123 for sc.Scan() {124 line := sc.Text()125 if !strings.HasPrefix(line, "|") {126 continue127 }128 f := strings.Split(line, "|")129 if len(f) < 39 {130 continue131 }132 for i := range f {133 f[i] = strings.TrimSpace(f[i])134 }135 fac := Facility{136 CallSign: f[1],137 BaseCall: BaseCall(f[1]),138 Service: f[3],139 RFChannel: atoi(f[4]),140 Directional: f[5] == "DA",141 Status: f[9],142 City: titleCase(f[10]),143 State: f[11],144 ERPkW: num(f[14]),145 HAATm: num(f[16]),146 FacilityID: atoi(f[18]),147 Licensee: f[27],148 DistanceKm: num(f[28]),149 BearingDeg: num(f[30]),150 RCAMSLm: num(f[31]),151 RCAGLm: num(f[36]),152 VirtualChannel: atoi(f[38]),153 Point: geo.Point{154 Lat: fromDMS(f[19], f[20], f[21], f[22]),155 Lon: fromDMS(f[23], f[24], f[25], f[26]),156 },157 }158 if fac.CallSign == "" || fac.RFChannel == 0 {159 continue160 }161 out = append(out, fac)162 }163 if err := sc.Err(); err != nil {164 return nil, fmt.Errorf("parse tv query: %w", err)165 }166 return out, nil167}168169// BaseCall strips the service suffix from an FCC call sign: "KWGN-TV" and170// "K11QJ-D" become "KWGN" and "K11QJ".171func BaseCall(call string) string {172 base, _, _ := strings.Cut(strings.ToUpper(call), "-")173 return base174}175176func dms(v float64, pos, neg string) (int, int, float64, string) {177 hemi := pos178 if v < 0 {179 hemi = neg180 v = -v181 }182 d := math.Floor(v)183 m := math.Floor((v - d) * 60)184 s := ((v-d)*60 - m) * 60185 return int(d), int(m), s, hemi186}187188func fromDMS(hemi, d, m, s string) float64 {189 v := num(d) + num(m)/60 + num(s)/3600190 if hemi == "S" || hemi == "W" {191 v = -v192 }193 return v194}195196// num parses the leading number of a field such as "1000. kW" or197// "20.73 km"; placeholders like "-" yield 0.198func num(s string) float64 {199 end := 0200 for end < len(s) && (s[end] >= '0' && s[end] <= '9' || s[end] == '.' || s[end] == '-' && end == 0) {201 end++202 }203 v, err := strconv.ParseFloat(s[:end], 64)204 if err != nil {205 return 0206 }207 return v208}209210func atoi(s string) int { return int(num(s)) }211212func titleCase(s string) string {213 words := strings.Fields(strings.ToLower(s))214 for i, w := range words {215 words[i] = strings.ToUpper(w[:1]) + w[1:]216 }217 return strings.Join(words, " ")218}