1package hdhr23import (4 "bytes"5 "cmp"6 "context"7 "encoding/binary"8 "errors"9 "fmt"10 "net"11 "net/http"12 "net/url"13 "slices"14 "strconv"15 "strings"16 "time"17)1819// ErrNotFound is returned by Find when no HDHomeRun answers discovery.20var ErrNotFound = errors.New("no HDHomeRun answered discovery")2122// Found is a tuner that answered discovery.23type Found struct {24 ID uint3225 Tuners int26 // BaseURL is where its HTTP API is: the address it answered from,27 // with the port it gave, if any.28 BaseURL string29}3031// Find locates a device: at addr when that's an address ("192.168.1.30",32// "hdhomerun.local", "http://..."), by discovery when it's a device ID33// ("1099B035"), and otherwise the first device discovery finds, never the34// one with ID skip (the emulated device of this server).35func Find(ctx context.Context, addr string, skip uint32, c *http.Client) (*Device, error) {36 want, byID := deviceID(addr)37 if addr != "" && !byID {38 return Open(ctx, addr, c)39 }40 found, err := Discover(ctx, 2*time.Second)41 if err != nil {42 return nil, err43 }44 for _, f := range found {45 if f.ID == skip || byID && f.ID != want {46 continue47 }48 return Open(ctx, f.BaseURL, c)49 }50 if byID {51 return nil, fmt.Errorf("no HDHomeRun %08X answered discovery", want)52 }53 return nil, ErrNotFound54}5556// deviceID reports whether s is a device ID: eight hex digits with a57// correct check digit.58func deviceID(s string) (uint32, bool) {59 if len(s) != 8 {60 return 0, false61 }62 id, err := strconv.ParseUint(s, 16, 32)63 if err != nil || !ValidDeviceID(uint32(id)) {64 return 0, false65 }66 return uint32(id), true67}6869// Discover broadcasts a discovery request on every IPv4 interface and70// lists the tuners that answer within wait, by ID. The request goes to71// each interface's own broadcast address as well as 255.255.255.255, so a72// tuner on a link-local port of its own is found too.73func Discover(ctx context.Context, wait time.Duration) ([]Found, error) {74 conn, err := net.ListenUDP("udp4", &net.UDPAddr{})75 if err != nil {76 return nil, err77 }78 defer conn.Close()79 deadline := time.Now().Add(wait)80 if d, ok := ctx.Deadline(); ok && d.Before(deadline) {81 deadline = d82 }83 _ = conn.SetReadDeadline(deadline)84 stop := context.AfterFunc(ctx, func() { _ = conn.SetReadDeadline(time.Now()) })85 defer stop()8687 req := searchPacket()88 send := func() {89 for _, ip := range broadcastTargets() {90 _, _ = conn.WriteToUDP(req, &net.UDPAddr{IP: ip, Port: discoverPort})91 }92 }93 send()94 // Once more halfway, in case the first was lost.95 resend := time.AfterFunc(wait/2, send)96 defer resend.Stop()9798 var out []Found99 buf := make([]byte, 1500)100 for {101 n, from, err := conn.ReadFromUDP(buf)102 if err != nil {103 break // the deadline, or ctx ended104 }105 f, ok := parseReply(buf[:n], from.IP)106 if ok && !slices.ContainsFunc(out, func(x Found) bool { return x.ID == f.ID }) {107 out = append(out, f)108 }109 }110 if len(out) == 0 && ctx.Err() != nil {111 return nil, ctx.Err()112 }113 slices.SortFunc(out, func(a, b Found) int { return cmp.Compare(a.ID, b.ID) })114 return out, nil115}116117// searchPacket asks every tuner to answer.118func searchPacket() []byte {119 var b bytes.Buffer120 tlv(&b, tagDeviceType, u32(deviceTuner))121 tlv(&b, tagDeviceID, u32(wildcard))122 return packet(typeDiscoverRq, b.Bytes())123}124125// parseReply reads a tuner's discovery reply, received from the address126// from; ok is false for anything else.127func parseReply(pkt []byte, from net.IP) (Found, bool) {128 typ, p, ok := parsePacket(pkt)129 if !ok || typ != typeDiscoverRp {130 return Found{}, false131 }132 var f Found133 tuner, hasID := true, false134 ok = eachTag(p, func(tag byte, v []byte) {135 switch tag {136 case tagDeviceType:137 tuner = len(v) == 4 && binary.BigEndian.Uint32(v) == deviceTuner138 case tagDeviceID:139 if len(v) == 4 {140 f.ID, hasID = binary.BigEndian.Uint32(v), true141 }142 case tagTunerCount:143 if len(v) == 1 {144 f.Tuners = int(v[0])145 }146 case tagBaseURL:147 f.BaseURL = strings.TrimRight(string(v), "\x00")148 }149 })150 if !ok || !tuner || !hasID {151 return Found{}, false152 }153 f.BaseURL = reachable(f.BaseURL, from)154 return f, true155}156157// reachable is a device's base URL with its host the address it answered158// from, when it gave a name (newer firmware gives "hdhr-1099b035.local",159// which a container may not resolve) or nothing.160func reachable(base string, from net.IP) string {161 if from == nil {162 return base163 }164 u, err := url.Parse(base)165 if err != nil || u.Host == "" {166 return "http://" + from.String()167 }168 if net.ParseIP(u.Hostname()) != nil {169 return base170 }171 host := from.String()172 if p := u.Port(); p != "" {173 host = net.JoinHostPort(host, p)174 }175 return u.Scheme + "://" + host176}177178// broadcastTargets lists where a discovery request goes: 255.255.255.255,179// and the broadcast address of each IPv4 network this host is on.180func broadcastTargets() []net.IP {181 out := []net.IP{net.IPv4bcast}182 ifs, _ := net.Interfaces()183 for _, ifc := range ifs {184 if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagBroadcast == 0 || ifc.Flags&net.FlagLoopback != 0 {185 continue186 }187 addrs, _ := ifc.Addrs()188 for _, a := range addrs {189 n, ok := a.(*net.IPNet)190 if !ok {191 continue192 }193 if b, ok := broadcastAddr(n); ok && !slices.ContainsFunc(out, b.Equal) {194 out = append(out, b)195 }196 }197 }198 return out199}200201// broadcastAddr is an IPv4 network's broadcast address; ok is false for202// IPv6, loopback, and /31 and /32 networks, which have none.203func broadcastAddr(n *net.IPNet) (net.IP, bool) {204 ip := n.IP.To4()205 if ip == nil || ip.IsLoopback() {206 return nil, false207 }208 mask := n.Mask209 if len(mask) == net.IPv6len {210 mask = mask[12:]211 }212 if len(mask) != net.IPv4len {213 return nil, false214 }215 if ones, _ := mask.Size(); ones >= 31 {216 return nil, false217 }218 b := make(net.IP, net.IPv4len)219 for i := range b {220 b[i] = ip[i] | ^mask[i]221 }222 return b, true223}