|
1 | | -import gleam/option |
| 1 | +import esdee/internal/dns.{type ResourceRecord} |
| 2 | +import gleam/list |
| 3 | +import gleam/option.{type Option, None, Some} |
| 4 | +import gleam/result |
| 5 | +import toss |
2 | 6 |
|
| 7 | +const mds_port = 5353 |
| 8 | + |
| 9 | +/// Describes a service fully discovered via DNS-SD. |
3 | 10 | pub type ServiceDescription { |
4 | 11 | ServiceDescription( |
| 12 | + /// The service type string, e.g. _googlecast._tcp.local |
5 | 13 | service_type: String, |
| 14 | + /// The unique instance name for a peer providing the service, e.g. |
| 15 | + /// SHIELD-Android-TV-9693d58e3537dddb118b7b7d17f9c1c2._googlecast._tcp.local |
6 | 16 | instance_name: String, |
| 17 | + /// The target host name, which can be used to actually connect to the service, e.g. |
| 18 | + /// 9693d58e-3537-dddb-118b-7b7d17f9c1c2.local |
7 | 19 | target_name: String, |
| 20 | + /// The priority of the target host, lower value means more preferred. |
| 21 | + /// Originates from the SRV record. |
8 | 22 | priority: Int, |
| 23 | + /// A relative weight for records with the same priority, |
| 24 | + /// higher value means higher chance of getting picked. |
| 25 | + /// Originates from the SRV record. |
9 | 26 | weight: Int, |
| 27 | + /// The port the service is served on. |
10 | 28 | port: Int, |
| 29 | + /// Any TXT records that the service advertises (can be empty). |
11 | 30 | txt_values: List(String), |
12 | | - ipv4: option.Option(#(Int, Int, Int, Int)), |
13 | | - ipv6: option.Option(#(Int, Int, Int, Int, Int, Int, Int, Int)), |
| 31 | + /// The IPv4 address, if advertised. |
| 32 | + ipv4: Option(#(Int, Int, Int, Int)), |
| 33 | + /// The IPv6 address, if advertised. |
| 34 | + ipv6: Option(#(Int, Int, Int, Int, Int, Int, Int, Int)), |
14 | 35 | ) |
15 | 36 | } |
| 37 | + |
| 38 | +// For future options, e.g. IPv6 |
| 39 | +pub opaque type Options { |
| 40 | + Options(max_data_size: Int, broadcast_ip: toss.IpAddress) |
| 41 | +} |
| 42 | + |
| 43 | +pub opaque type ServiceDiscovery { |
| 44 | + ServiceDiscovery(options: Options, socket: toss.Socket) |
| 45 | +} |
| 46 | + |
| 47 | +pub fn new() -> Options { |
| 48 | + Options(max_data_size: 4096, broadcast_ip: toss.Ipv4Address(224, 0, 0, 251)) |
| 49 | +} |
| 50 | + |
| 51 | +pub type StartError { |
| 52 | + CouldNotOpenSocket |
| 53 | + CouldNotJoinMulticast |
| 54 | +} |
| 55 | + |
| 56 | +pub fn start(options: Options) -> Result(ServiceDiscovery, StartError) { |
| 57 | + use socket <- result.try( |
| 58 | + toss.new(mds_port) |
| 59 | + |> toss.use_ipv4() |
| 60 | + |> toss.reuse_address() |
| 61 | + |> toss.using_interface(options.broadcast_ip) |
| 62 | + |> toss.open |
| 63 | + |> result.replace_error(CouldNotOpenSocket), |
| 64 | + ) |
| 65 | + |
| 66 | + let local_addr = toss.Ipv4Address(0, 0, 0, 0) |
| 67 | + use _ <- result.try( |
| 68 | + toss.join_multicast_group(socket, options.broadcast_ip, local_addr) |
| 69 | + |> result.replace_error(CouldNotJoinMulticast), |
| 70 | + ) |
| 71 | + |
| 72 | + Ok(ServiceDiscovery(options, socket)) |
| 73 | +} |
| 74 | + |
| 75 | +pub fn discover( |
| 76 | + discovery: ServiceDiscovery, |
| 77 | + service: String, |
| 78 | +) -> Result(Nil, Nil) { |
| 79 | + let data = dns.encode_question(service) |
| 80 | + toss.send_to(discovery.socket, discovery.options.broadcast_ip, mds_port, data) |
| 81 | + |> result.replace_error(Nil) |
| 82 | +} |
| 83 | + |
| 84 | +pub type DiscoveryError { |
| 85 | + ReceiveTimeout |
| 86 | + ReceiveError |
| 87 | + NotAnAnswer |
| 88 | + InvalidDnsData |
| 89 | + InsufficientData |
| 90 | +} |
| 91 | + |
| 92 | +pub fn receive_next( |
| 93 | + discovery: ServiceDiscovery, |
| 94 | + timeout_ms: Int, |
| 95 | +) -> Result(ServiceDescription, DiscoveryError) { |
| 96 | + use #(_, _, data) <- result.try( |
| 97 | + toss.receive(discovery.socket, discovery.options.max_data_size, timeout_ms) |
| 98 | + |> result.map_error(fn(e) { |
| 99 | + case e { |
| 100 | + toss.Timeout -> ReceiveTimeout |
| 101 | + _ -> ReceiveError |
| 102 | + } |
| 103 | + }), |
| 104 | + ) |
| 105 | + |
| 106 | + use records <- result.try( |
| 107 | + dns.decode_records(data) |
| 108 | + |> result.map_error(fn(e) { |
| 109 | + case e { |
| 110 | + dns.InvalidData -> InvalidDnsData |
| 111 | + dns.NotAnAnswer -> NotAnAnswer |
| 112 | + } |
| 113 | + }), |
| 114 | + ) |
| 115 | + |
| 116 | + description_from_records(records) |> result.replace_error(InsufficientData) |
| 117 | +} |
| 118 | + |
| 119 | +fn description_from_records( |
| 120 | + records: List(ResourceRecord), |
| 121 | +) -> Result(ServiceDescription, Nil) { |
| 122 | + echo records |
| 123 | + |
| 124 | + let try_find = fn(with: fn(ResourceRecord) -> Result(a, Nil), apply) { |
| 125 | + result.try(list.find_map(records, with), apply) |
| 126 | + } |
| 127 | + |
| 128 | + use #(service_type, instance_name) <- try_find(fn(record) { |
| 129 | + case record { |
| 130 | + dns.PtrRecord(service_type:, instance_name:) -> |
| 131 | + Ok(#(service_type, instance_name)) |
| 132 | + _ -> Error(Nil) |
| 133 | + } |
| 134 | + }) |
| 135 | + |
| 136 | + use #(priority, weight, port, target_name) <- try_find(fn(record) { |
| 137 | + case record { |
| 138 | + dns.SrvRecord(priority:, weight:, port:, target_name:, ..) -> |
| 139 | + Ok(#(priority, weight, port, target_name)) |
| 140 | + _ -> Error(Nil) |
| 141 | + } |
| 142 | + }) |
| 143 | + |
| 144 | + let #(ipv4, ipv6) = |
| 145 | + list.fold(records, #(None, None), fn(ips, record) { |
| 146 | + case record { |
| 147 | + dns.ARecord(ip:, ..) -> #(Some(ip), ips.1) |
| 148 | + dns.AaaaRecord(ip:, ..) -> #(ips.0, Some(ip)) |
| 149 | + _ -> ips |
| 150 | + } |
| 151 | + }) |
| 152 | + |
| 153 | + let txt_values = |
| 154 | + list.flat_map(records, fn(record) { |
| 155 | + case record { |
| 156 | + dns.TxtRecord(values:, ..) -> values |
| 157 | + _ -> [] |
| 158 | + } |
| 159 | + }) |
| 160 | + |
| 161 | + case option.is_some(ipv4) || option.is_some(ipv6) { |
| 162 | + True -> |
| 163 | + Ok(ServiceDescription( |
| 164 | + service_type:, |
| 165 | + instance_name:, |
| 166 | + target_name:, |
| 167 | + priority:, |
| 168 | + weight:, |
| 169 | + port:, |
| 170 | + txt_values:, |
| 171 | + ipv4:, |
| 172 | + ipv6:, |
| 173 | + )) |
| 174 | + False -> Error(Nil) |
| 175 | + } |
| 176 | +} |
0 commit comments