1package hdhr23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "io"9 "math"10 "net"11 "net/http"12 "net/url"13 "strconv"14 "strings"15 "time"16)1718// Device is a real HDHomeRun on the network, reached over its HTTP API.19type Device struct {20 ID string // "1099B035"21 FriendlyName string // "HDHomeRun FLEX DUO"22 Model string // its ModelNumber, "HDFX-2US"23 Firmware string // its FirmwareName, "hdhomerun_dvr_atsc"24 Version string // its FirmwareVersion, "20250623"25 Tuners int26 // BaseURL is the API, "http://192.168.1.30", and StreamURL where the27 // streams are, on port 5004 ("http://192.168.1.30:5004"); a BaseURL28 // with a port of its own (a test server) serves both.29 BaseURL string30 StreamURL string31 // HTTP makes the API calls, with a timeout; streams use a copy32 // without one.33 HTTP *http.Client34}3536// Open reads discover.json at base: "192.168.1.30", "hdhomerun.local" or37// "http://...". A nil client gets one with a 10 s timeout.38func Open(ctx context.Context, base string, c *http.Client) (*Device, error) {39 u, err := apiURL(base)40 if err != nil {41 return nil, err42 }43 if c == nil {44 c = &http.Client{Timeout: 10 * time.Second}45 }46 d := &Device{BaseURL: u, StreamURL: streamURL(u), HTTP: c}47 if err := d.Refresh(ctx); err != nil {48 return nil, err49 }50 return d, nil51}5253// apiURL is a device's base URL from an address as someone wrote it.54func apiURL(s string) (string, error) {55 s = strings.TrimSpace(s)56 if s == "" {57 return "", errors.New("no HDHomeRun address")58 }59 if !strings.Contains(s, "://") {60 s = "http://" + s61 }62 u, err := url.Parse(s)63 if err != nil || u.Host == "" {64 return "", fmt.Errorf("HDHomeRun address %q isn't a host", s)65 }66 if u.Port() == "80" {67 return u.Scheme + "://" + u.Hostname(), nil68 }69 return u.Scheme + "://" + u.Host, nil70}7172// streamURL is where a device at base serves streams: port 5004, unless73// base names a port of its own other than HTTP's (a discovery reply gives74// "http://192.168.1.30:80").75func streamURL(base string) string {76 u, err := url.Parse(base)77 if err != nil || u.Port() != "" && u.Port() != "80" {78 return base79 }80 return u.Scheme + "://" + net.JoinHostPort(u.Hostname(), "5004")81}8283// Refresh reads discover.json again, updating the device's fields in84// place; it isn't safe while other goroutines read them.85func (d *Device) Refresh(ctx context.Context) error {86 var body struct {87 FriendlyName flexString88 ModelNumber flexString89 FirmwareName flexString90 FirmwareVersion flexString91 DeviceID flexString92 TunerCount flexInt93 }94 if err := d.get(ctx, "/discover.json", &body); err != nil {95 return err96 }97 d.ID, d.FriendlyName, d.Model = string(body.DeviceID), string(body.FriendlyName), string(body.ModelNumber)98 d.Firmware, d.Version, d.Tuners = string(body.FirmwareName), string(body.FirmwareVersion), int(body.TunerCount)99 return nil100}101102// ATSC3 reports whether the device's firmware receives ATSC 3.0103// ("hdhomerun_dvr_atsc3" on the 4K models).104func (d *Device) ATSC3() bool { return strings.Contains(d.Firmware, "atsc3") }105106// Channel is one entry of the device's lineup, from its last channel scan.107type Channel struct {108 Number string // "2.1"109 Name string // "KWGN-DT"110 FrequencyHz int64111 Program int // the MPEG program number in the RF channel's multiplex112 TSID int113 Modulation string // "8vsb"114 VideoCodec string // "MPEG2", "H264", "HEVC"115 AudioCodec string // "AC3"116 HD bool117 Favorite bool118 DRM bool119 ATSC3 bool120 // SignalStrength and SignalQuality are what the scan measured, in121 // percent.122 SignalStrength int123 SignalQuality int124}125126// Lineup lists the channels the device's last scan found, with how to tune127// each (lineup.json?tuning).128func (d *Device) Lineup(ctx context.Context) ([]Channel, error) {129 var body []struct {130 GuideNumber flexString131 GuideName flexString132 Frequency flexInt133 ProgramNumber flexInt134 TransportStreamID flexInt135 Modulation flexString136 VideoCodec flexString137 AudioCodec flexString138 HD flexInt139 Favorite flexInt140 DRM flexInt141 ATSC3 flexInt142 SignalStrength flexInt143 SignalQuality flexInt144 }145 if err := d.get(ctx, "/lineup.json?tuning", &body); err != nil {146 return nil, err147 }148 out := make([]Channel, 0, len(body))149 for _, e := range body {150 out = append(out, Channel{151 Number: string(e.GuideNumber), Name: strings.TrimSpace(string(e.GuideName)),152 FrequencyHz: int64(e.Frequency), Program: int(e.ProgramNumber), TSID: int(e.TransportStreamID),153 Modulation: string(e.Modulation), VideoCodec: string(e.VideoCodec), AudioCodec: string(e.AudioCodec),154 HD: e.HD != 0, Favorite: e.Favorite != 0, DRM: e.DRM != 0, ATSC3: e.ATSC3 != 0,155 SignalStrength: int(e.SignalStrength), SignalQuality: int(e.SignalQuality),156 })157 }158 return out, nil159}160161// ScanStatus is how the device's channel scan is going.162type ScanStatus struct {163 InProgress bool164 // Possible is whether a scan can start now.165 Possible bool166 // Progress is in percent, and Found the channels found so far, while167 // a scan runs.168 Progress int169 Found int170 Source string // "Antenna"171}172173// ScanStatus reads lineup_status.json.174func (d *Device) ScanStatus(ctx context.Context) (ScanStatus, error) {175 var body struct {176 ScanInProgress flexInt177 ScanPossible flexInt178 Progress flexInt179 Found flexInt180 Source flexString181 }182 if err := d.get(ctx, "/lineup_status.json", &body); err != nil {183 return ScanStatus{}, err184 }185 return ScanStatus{186 InProgress: body.ScanInProgress != 0, Possible: body.ScanPossible != 0,187 Progress: int(body.Progress), Found: int(body.Found), Source: string(body.Source),188 }, nil189}190191// StartScan starts the device's channel scan of its antenna input. While192// it runs, the device takes its tuners and refuses streams (803 System193// Busy); afterwards Lineup lists what it found.194func (d *Device) StartScan(ctx context.Context) error {195 req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL+"/lineup.post?scan=start&source=Antenna", nil)196 if err != nil {197 return err198 }199 resp, err := d.client().Do(req)200 if err != nil {201 return fmt.Errorf("HDHomeRun: %w", err)202 }203 defer resp.Body.Close()204 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))205 if resp.StatusCode != http.StatusOK {206 return deviceError(resp)207 }208 return nil209}210211// TunerStatus is one tuner as status.json reports it.212type TunerStatus struct {213 Tuner int // from its resource name, "tuner1"; -1 if it has another214 // Channel and Name are the virtual channel, when it was tuned to one.215 Channel string216 Name string217 // FrequencyHz is what it's tuned to, 0 while idle.218 FrequencyHz int64219 // StrengthPct is signal strength, QualityPct signal to noise quality220 // (SNQ) and SymbolPct symbol quality (SEQ, the share of data that221 // arrived intact or corrected), in percent; nil while idle.222 StrengthPct *int223 QualityPct *int224 SymbolPct *int225 TargetIP string226 // NetworkRate is what the tuner sends, in bits a second.227 NetworkRate int64228}229230// Tuned reports whether the tuner is in use.231func (t TunerStatus) Tuned() bool { return t.FrequencyHz > 0 || t.Channel != "" }232233// Status reads every tuner's status (status.json). The device measures a234// tuner's signal only while something has it tuned.235func (d *Device) Status(ctx context.Context) ([]TunerStatus, error) {236 var body []struct {237 Resource flexString238 VctNumber flexString239 VctName flexString240 Frequency flexInt241 SignalStrengthPercent *flexInt242 SignalQualityPercent *flexInt243 SymbolQualityPercent *flexInt244 TargetIP flexString245 NetworkRate flexInt246 }247 if err := d.get(ctx, "/status.json", &body); err != nil {248 return nil, err249 }250 out := make([]TunerStatus, 0, len(body))251 for _, e := range body {252 n, ok := tunerNumber(string(e.Resource))253 if !ok {254 n = -1255 }256 out = append(out, TunerStatus{257 Tuner: n, Channel: string(e.VctNumber), Name: string(e.VctName), FrequencyHz: int64(e.Frequency),258 StrengthPct: e.SignalStrengthPercent.int(), QualityPct: e.SignalQualityPercent.int(), SymbolPct: e.SymbolQualityPercent.int(),259 TargetIP: string(e.TargetIP), NetworkRate: int64(e.NetworkRate),260 })261 }262 return out, nil263}264265// Stream is a stream from one of the device's tuners. Closing it frees the266// tuner.267type Stream struct {268 io.ReadCloser269 // Tuner is the tuner the device used (from X-HDHomeRun-Resource).270 Tuner int271}272273// OpenRF streams the whole RF channel at freqHz, every program in its274// multiplex, from tuner (-1 for any free one). It returns once the device275// answers, which for a channel it can't lock is never: ctx bounds the wait,276// and ends the stream.277func (d *Device) OpenRF(ctx context.Context, tuner int, freqHz int64) (*Stream, error) {278 where := "auto"279 if tuner >= 0 {280 where = "tuner" + strconv.Itoa(tuner)281 }282 req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s/ch%d", d.StreamURL, where, freqHz), nil)283 if err != nil {284 return nil, err285 }286 // The client's timeout would end the stream; ctx ends it instead.287 hc := *d.client()288 hc.Timeout = 0289 resp, err := hc.Do(req)290 if err != nil {291 return nil, fmt.Errorf("HDHomeRun: %w", err)292 }293 if resp.StatusCode != http.StatusOK {294 resp.Body.Close()295 return nil, deviceError(resp)296 }297 s := &Stream{ReadCloser: resp.Body, Tuner: tuner}298 if n, ok := tunerNumber(resp.Header.Get("X-HDHomeRun-Resource")); ok {299 s.Tuner = n300 }301 return s, nil302}303304// Error is a request the device refused.305type Error struct {306 Status int // the HTTP status307 // Code and Reason are from its X-HDHomeRun-Error header ("805 All308 // Tuners In Use"), when it gave one.309 Code int310 Reason string311}312313func (e *Error) Error() string {314 if e.Code != 0 {315 return strings.TrimSpace(fmt.Sprintf("HDHomeRun: %d %s", e.Code, e.Reason))316 }317 if e.Reason != "" {318 return "HDHomeRun: " + e.Reason319 }320 return fmt.Sprintf("HDHomeRun: HTTP %d", e.Status)321}322323// Busy reports whether the device refused for want of a tuner: all in use324// (805), the one asked for in use (804), or busy scanning (803).325func (e *Error) Busy() bool {326 switch e.Code {327 case 803, 804, 805:328 return true329 case 0:330 return e.Status == http.StatusServiceUnavailable331 }332 return false333}334335func deviceError(resp *http.Response) *Error {336 e := &Error{Status: resp.StatusCode}337 if h := strings.TrimSpace(resp.Header.Get("X-HDHomeRun-Error")); h != "" {338 code, reason, _ := strings.Cut(h, " ")339 if n, err := strconv.Atoi(code); err == nil {340 e.Code, e.Reason = n, strings.TrimSpace(reason)341 } else {342 e.Reason = h343 }344 }345 return e346}347348// tunerNumber reads a tuner's resource name: "tuner1" is 1.349func tunerNumber(s string) (int, bool) {350 n, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(s), "tuner"))351 if err != nil || !strings.HasPrefix(strings.TrimSpace(s), "tuner") || n < 0 {352 return 0, false353 }354 return n, true355}356357func (d *Device) client() *http.Client {358 if d.HTTP != nil {359 return d.HTTP360 }361 return http.DefaultClient362}363364func (d *Device) get(ctx context.Context, path string, out any) error {365 req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.BaseURL+path, nil)366 if err != nil {367 return err368 }369 resp, err := d.client().Do(req)370 if err != nil {371 return fmt.Errorf("HDHomeRun: %w", err)372 }373 defer resp.Body.Close()374 if resp.StatusCode != http.StatusOK {375 return deviceError(resp)376 }377 if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out); err != nil {378 return fmt.Errorf("HDHomeRun %s: %w", strings.SplitN(path, "?", 2)[0], err)379 }380 return nil381}382383// flexInt is a number as devices write it: a number (rounded), a numeric384// string, or a boolean flag (1 or 0).385type flexInt int64386387func (n *flexInt) UnmarshalJSON(raw []byte) error {388 s := strings.Trim(strings.TrimSpace(string(raw)), `"`)389 switch s {390 case "", "null", "false":391 *n = 0392 return nil393 case "true":394 *n = 1395 return nil396 }397 f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)398 if err != nil {399 return fmt.Errorf("%s is not a number", raw)400 }401 *n = flexInt(math.Round(f))402 return nil403}404405// int is the number, nil when it was absent.406func (n *flexInt) int() *int {407 if n == nil {408 return nil409 }410 v := int(*n)411 return &v412}413414// flexString is a string, or a number or flag written without quotes.415type flexString string416417func (s *flexString) UnmarshalJSON(raw []byte) error {418 var v string419 if err := json.Unmarshal(raw, &v); err == nil {420 *s = flexString(v)421 return nil422 }423 switch t := strings.TrimSpace(string(raw)); {424 case t == "null":425 *s = ""426 case strings.HasPrefix(t, "{") || strings.HasPrefix(t, "["):427 return fmt.Errorf("%s is not a string", raw)428 default:429 *s = flexString(t)430 }431 return nil432}