Discovering what's out there
Many of us feel nervous when meeting a group of people for the first time. What are the dynamics of the group, what are the in-jokes, will I find common ground with someone - are just a few questions that can plague you. A lot of your hard work at suppressing your crippling social self-doubt can unravel with a shaky introduction or misplaced comment. You may even find yourself wondering:
"Are they laughing with me because I'm funny or at me because of the overly pointy brogues that I'm wearing?"
Of course, that's nonsense. Pointy brogues are the height of fashion 👞.
It's not just you who can be anxious about new introductions; your app can be too. Computers are notoriously fickle about who they speak to and how they expect to be addressed. A barrage of errors await any app that breaks these social norms - it can be enough to make your app want to stay quietly in the corner rather than jump in and face potential rejection.
Thankfully, this social anxiety can be eased if your app already knows someone who understands the dynamics of the group - someone cool and suave like SSDP.
This post will gradually build up to a working example however if you want to jump ahead then head on over to the completed example and take a look at
SSDPSearchSessionConfiguration
,SSDPSearchSession
,UDPSocketController
,UDPSocket
,SSDPServiceParser
andSSDPService
to see how things end up.
If you are already familiar with SSDP, feel free to skip the next section and head directly to building a working Swift solution.
Getting to know SSDP
SSDP (Simple Service Discovery Protocol) is a discovery protocol used to determine what services are available on a network. It is defined as part of the UPnP spec. SSDP is a zero-configuration networking protocol designed to allow nodes to be added and removed from a network without any involvement from a central service such as DNS or by assigning static IP addresses to specific nodes. This decentralised, dynamic approach is possible because SSDP uses UDP as it's underlying transportation protocol which allows for multicast communication.
Multicasting allows a node to transmit one message onto the network and for that message to be forwarded on to all interested nodes on the network without the sender node having to know what other network nodes are available (forwarding happens at the IP routing level). SSDP takes advantage of this forwarding functionality to allow any node to ask other nodes if they support a particular service, or conversely for a node which offers services to tell other nodes about those services.
For multicast messages, IANA has reserved the IPv4 address
239.255.255.250
and port1900
for SSDP.
SSDP messages conform to the header field format of HTTP 1.1. It's important to note that SSDP does not allow any message to contain a body; everything is shared via those header fields.
An SSDP node is either a root device
or control point
. A root device
offers one or more SSDP services; a control point
uses SSDP services.
When a root device
is responding to a discovery message it does so by sending a unicast (to a single specific node) message directly to the control point
.
That's a lot of information to take in 😥. If it doesn't all make sense that's ok, most of the details will come up again later, and when seen in context, those details are easier to understand.
SSDP messages fall into two categories:
- Discovery
- Advertisement
This post is mainly concerned with Discovery messages but to ensure that we have the fullest possible understanding of SSDP I'll cover Advertisement messages as well (feel free to skip the Advertisement section).
Discovery
Discovery involves two message types:
- Request
- Response
A request message is when a control point
transmits an M-SEARCH
message onto the network looking for a particular service (or as we shall see soon, any service) e.g.
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 1
ST: urn:dial-multiscreen-org:service:dial:1
An M-SEARCH
request message contains:
- The host and port (
HOST
) the message will be sent to. Typically anM-Search
message is multicast (like the example above) but can be unicast. - The message type (
MAN
), for anM-Search
this is alwaysssdp:discover
. - The search target (
ST
) of the service the search request is attempting to discover. - The maximum wait response time (
MX
) in seconds that aroot device
can take before responding. TheMX
field is an attempt to overcome a scaling issue implicit with SSDP. SSDP is a chatty protocol, in a network with a significant number of nodes that host SSDP services, sending anM-SEARCH
message could result in accidentally DDOS-ing the questing node due to too many services responding at once. TheMX
field instructs theroot device
to wait a random time between 0 andMX
before attempting to respond - this should allow the responses to be spaced out enough to ease the processing strain on thecontrol point
. TheMX
value should be between 1 and 5. Even with theMX
workaround, SSDP is recommended only to be used in home or small office networks.
A root device
should only respond with services that match the search target field of the the request e.g.
HTTP/1.1 200 OK
CACHE-CONTROL: max-age=3600
ST: urn:dial-multiscreen-org:service:dial:1
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1
EXT:
SERVER: Roku UPnP/1.0 MiniUPnPd/1.4
LOCATION: http://192.168.1.104:8060/dial/dd.xml
An M-Search
response message contains:
- The cache-control (
CACHE-CONTROL
) value to determine for how long the message is valid. - The search target (
ST
) of the service that is responding.ST
should be common across all devices of this type. - The unique service name (
USN
) to identify the service. - The server system information (
SERVER
) value providing information in the following format:[OS-Name] UPnP/[Version] [Product-Name]/[Product-Version]
. - The location URL (
LOCATION
) to allow thecontrol point
to gain more information about this service.
The
EXT
field is required for backwards compatibility with UPnP 1.0 but can otherwise be ignored.
Advertisement
An advertisement message is when a root device
shares the status of each service it offers with the other nodes on the network.
There are three types of advertisement:
- Alive
- Update
- ByeBye
An alive
message allows interested devices to know that a service is available. An alive
message is a multicast NOTIFY
message e.g.
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
CACHE-CONTROL: max-age=3600
NT: urn:dial-multiscreen-org:service:dial:1
NTS: ssdp:alive
LOCATION: http://192.168.1.104:8060/dial/dd.xml
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1
An alive
message contains:
- The host and port (
HOST
) the message will be sent to. - The cache-control (
CACHE-CONTROL
) value to determine for how long the message is valid. - The notification type (
NT
) that defines the service it offers (the equivalent ofST
in anM-Search
message). - The notification subtype (
NTS
), for analive
message this will always bessdp:alive
(the equivalent ofMAN
in anM-Search
message). - The location URL (
LOCATION
) to allow a receivingcontrol point
to gain more information about this service. - The unique service name (
USN
) to identify the service.
An update
message allows changes to a service to be shared. An update
message is also a multicast NOTIFY
message like the alive
message e.g.
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
NT: urn:dial-multiscreen-org:service:dial:1
NTS: ssdp:update
LOCATION: http://192.168.1.160:8060/dial/dd.xml
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1
An update
message has the same header fields as an alive
message with only the NTS
value differing between them.
A byebye
message allows any interested nodes to know when a service is about to be removed from the network. A byebye
message should be sent for each valid (non-expired) alive
message that was sent. A byebye
message is a multicast NOTIFY
message e.g.
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:1900
NT: urn:dial-multiscreen-org:service:dial:1
NTS: ssdp:byebye
USN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1
Again a byebye
message has a very similar structure to both an alive
and update
message only omitting the LOCATION
header field and having a different NTS
value.
Now that we have an understanding of what SSDP is let's get back to the solution.
Getting to know who's there ðŸ”
As SSDP communication is built on top of UDP, it isn't possible to use URLSession
to send an M-Search
message instead we need to read and write from a socket manually. I was tempted to dive in and write a simple socket layer to handle this, but the more I read up about the various network setups I would have to support, the more writing a socket layer started to look like an unforgiving task. Instead, I decided to introduce a 3rd-party dependency into the project: BlueSocket. BlueSocket
will handle the nitty-gritty socket communication and free us up to focus on sending and receiving SSDP messages.
In the example project, CocoaPods is used to manage this dependency.
At the time of writing (April, 2019) the new(ish)
Network
framework doesn't support UDP multicasting.
Since I wrote this article, Apple has released iOS 14, which contained a range of user privacy improvements. One area of focus was around how an app accesses the local network. There are two changes that affect the below example; firstly, before accessing the local network, iOS will request permission from the user as it does for, e.g. accessing the camera and secondly, any app that wants to multicast on a network must also have the
com.apple.developer.networking.multicast
entitlement enabled - this entitlement needs to be requested from Apple. Without both the user permission and the entitlement, the below example won't be able to send multicast messages from a device (everything will still work on the simulator) - see this note for more details.
Before we get started, let's look at what we are going to build:
SSDPSearchSessionConfiguration
represents the configuration of an SSDP search session.SSDPSearchSession
is responsible for triggering a search request, parsing and filtering received responses and passing back those services that match our search terms to whoever requested the search. A typical search session will write multiple search requests to the socket to try and overcome the unreliable nature of UDP.SSDPSearchSessionDelegate
is a protocol that informs the delegator of any found services, any errors encounter and ultimately that the search session has ended.SocketControllerFactory
is responsible for producing sockets controllers configured to use sockets that use a specific transport protocol.UDPSocketController
is a wrapper around aSocket
instance configured to use UDP.UDPSocketController
helps to hide the messiness of socket communication.UDPSocketControllerDelegate
a protocol that informs the delegator of any responses received and any errors encountered.SocketFactory
is responsible for producing fully configured sockets that are ready to be written/read to/from.UDPSocket
aSocket
instance configured to use UDP. AsSocket
is from theBlueSocket
dependency,Socket
is wrapped in a protocol to limit the spread ofBlueSocket
in the project.SSDPServiceParser
is responsible for parsing an SSDP response intoService
instance. If the SSDP response is invalid, no service is created.SSDPService
represents an SSDP service.
Don't worry if that doesn't all make sense yet, we will look into each class in greater depth below.
Now that we know where we are going let's start at the bottom with UDPSocket
and work our way up.
Ideally when adding a 3rd-party dependency to our projects we want to hide that dependency behind a facade which should make removing that dependency easier by limiting it's spread in the project. In Swift we can use a protocol and an extension to wrap that 3rd-party dependency inside our facade:
enum UDPSocketError: Error {
case addressCreationFailure
case writeError(underlayingError: Error)
case readError(underlayingError: Error)
}
protocol UDPSocketProtocol {
func write(_ string: String, to host: String, on port: UInt) throws
func readDatagram(into data: inout Data) throws
func close()
}
extension Socket: UDPSocketProtocol {
// 1
func write(_ string: String, to host: String, on port: UInt) throws {
guard let signature = self.signature, signature.socketType == .datagram, signature.proto == .udp else {
fatalError("Only UDP sockets can use this method")
}
guard let address = Socket.createAddress(for: host, on: Int32(port)) else {
throw(UDPSocketError.addressCreationFailure)
}
do {
try write(from: string, to: address)
} catch {
throw(UDPSocketError.writeError(underlayingError: error))
}
}
// 2
func readDatagram(into data: inout Data) throws {
guard let signature = self.signature, signature.socketType == .datagram, signature.proto == .udp else {
fatalError("Only UDP sockets can use this method")
}
do {
let (_,_) = try readDatagram(into: &data)
} catch {
throw(UDPSocketError.readError(underlayingError: error))
}
}
}
Socket
does a lot more than what we need for our SSDP solution. UDPSocketProtocol
reduces the range of tasks that can be performed on a Socket
instance to only the three that we need: write
, read
and close
. As well as reducing scope, UDPSocketProtocol
also simplifies the interface of Socket
by wrapping the Socket
methods in our own methods.
- The
Socket
type inBlueSocket
can be used to create sockets with different configurations. InUDPSocketProtocol
, we only want to cater for sockets configured that using UDP to send datagram messages, so inwrite(_:to:on:)
a check is made to determine if thisSocket
instance has been configured for UDP and datagram - if it hasn't then a fatal error is thrown as this is a developer error. Thewrite(from:to:)
method onSocket
takes anAddress
type which is defined inSocket
- as mentioned above we want to limit the spread ofBlueSocket
in the project, so thewrite
method defined inUDPSocketProtocol
doesn't useAddress
but rather sticks with twoString
parametershost
andport
. In the extension, these parameters are used to create anAddress
instance which is then forwarded to thewrite(from:to:)
method onSocket
. If for some reason anAddress
instance can't be created, an exception is thrown. It's also possible for an exception to be thrown when writing to the socket, if an exception is thrown that exception is caught, wrapped inside anUDPSocketError
case before a new exception is thrown. - Just like with
write(_:to:on:)
, inreadDatagram(into:)
a check is made to ensure that we are dealing with a socket configured to UDP and datagram. ThereadDatagram(into:)
method onSocket
returns the number of bytes read as anInt
and the address that sent those bytes as anAddress
. We aren't interested in either of those return values, so thereadDatagram(into:)
defined inSocketProtocol
doesn't have a return type - ourreadDatagram(into:)
just eats those details. If so some reason when reading from the socket an exception is thrown, this exception is caught, wrapped inside anUDPSocketError
case before a new exception is thrown.
The
close()
onSocket
fits our needs perfectly so we don't need to wrap that method.
UDPSocketProtocol
does a good job of hiding Socket
but it can be called on with all configurations of Socket
when we really only intend for it be called on UDP sockets sending datagram messages so lets make making that socket configuration as easy as possible:
extension Socket {
static func createUDPSocket() throws -> UDPSocketProtocol {
return try Socket.create(type: .datagram, proto: .udp)
}
}
You will need to import the
Socket
module into any class that usesSocket
.
Now that we have UDPSocketProtocol
, lets build Socket
instances wrapped in it:
protocol SocketFactoryProtocol {
func createUDPSocket() -> UDPSocketProtocol?
}
class SocketFactory: SocketFactoryProtocol {
// MARK: - UDP
func createUDPSocket() -> UDPSocketProtocol? {
guard let socket = try? Socket.createUDPSocket() else {
return nil
}
return socket
}
}
The above factory, attempts to create a UDP socket, if successful that Socket
instance is returned hidden behind the UDPSocketProtocol
protocol; if unsuccessful, nil
is returned.
SocketFactory
conforms toSocketFactoryProtocol
so that when it is injected intoUDPSocketController
- during unit testing we can replace the actual factory with a mock factory that conforms to that protocol. I use this technique throughout this example, so any other protocols that are named after a concrete type are for this purpose.
Now that we have our socket, let's try writing to it:
protocol UDPSocketControllerProtocol: AnyObject {
var state: UDPSocketControllerState { get }
func write(message: String)
func close()
}
// 1
enum UDPSocketControllerState {
case ready
case active
case closed
var isReady: Bool {
self == .ready
}
var isActive: Bool {
self == .active
}
var isClosed: Bool {
self == .closed
}
}
class UDPSocketController: UDPSocketControllerProtocol {
private(set) var state: UDPSocketControllerState = .ready
private let socket: UDPSocketProtocol
private let host: String
private let port: UInt
private let callbackQueue: OperationQueue
private let socketWriterQueue = DispatchQueue(label: "com.williamboles.udpsocket.writer.queue", attributes: .concurrent)
// MARK: - Init
// 2
init?(host: String, port: UInt, socketFactory: SocketFactoryProtocol) {
guard let socket = socketFactory.createUDPSocket() else {
return nil
}
self.host = host
self.port = port
self.socket = socket
}
// MARK: - Write
// 3
func write(message: String) {
guard !state.isClosed else {
os_log(.info, "Attempting to write to a closed socket")
return
}
state = .active
write(message: message, on: socketWriterQueue)
}
// 4
private func write(message: String, on queue: DispatchQueue) {
queue.async {
do {
try self.socket.write(message, to: self.host, on: self.port)
} catch {
self.closeAndReportError(error)
}
}
}
// MARK: - Close
private func closeAndReportError(_ error: Error) {
close()
os_log(.info, "Error received: \r%{public}@", error)
//TODO: Implement reporting error
}
func close() {
state = .closed
socket.close()
}
}
Let's look at what we did above:
- A socket can be in 3 states:
ready
,active
andclosed
-UDPSocketControllerState
represents these states. AsUDPSocketController
interacts with its socket, it will move through states. This will ensure that we don't try to write to a socket that can't be written to. - To write a message to a socket, we need the host and port on the remote machine that we want to communicate with. The init'er of
UDPSocketController
accepts this host and port. It also accepts a socket factory instance which is used to create a UDP socket. - When writing to a socket, we only want to allow communication with a socket that hasn't been closed so the first action of the
write(message:)
is to check ifstate
is in a closed state. - Writing to a socket can be a time-consuming operation, so the operation is pushed onto a background queue:
socketWriterQueue
. As a write operation can throw an exception, we wrap that operation in ado...catch
whereupon catching an exception the socket is closed, and any error reported (we will implement this shortly).
UDPSocketController
is designed to be tied to one host and port. If that host and port needs to be changed, then a new instance of UDPSocketController
needs to be created.
Let's build a factory to produce socket controllers:
protocol SocketControllerFactoryProtocol {
func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol) -> UDPSocketControllerProtocol?
}
class SocketControllerFactory: SocketControllerFactoryProtocol {
// MARK: - UDP
func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol) -> UDPSocketControllerProtocol? {
UDPSocketController(host: host, port: port, socketFactory: socketFactory)
}
}
Now that we have a socket controller and a socket to write to let's look at how to give it an M-Search
message to send.
3 pieces of information are required to send an M-Search
message:
- Search target (
ST
). - The IP address and port (
HOST
). - Maximum wait response time (
MX
).
These pieces of information can be represented as:
struct SSDPSearchSessionConfiguration {
let searchTarget: String
let host: String
let port: UInt
let maximumWaitResponseTime: TimeInterval
// MARK: - Init
init(searchTarget: String, host: String, port: UInt, maximumWaitResponseTime: TimeInterval) {
assert(maximumWaitResponseTime >= 1 && maximumWaitResponseTime <= 5, "maximumWaitResponseTime should be between 1 and 5 (inclusive)")
self.searchTarget = searchTarget
self.host = host
self.port = port
self.maximumWaitResponseTime = maximumWaitResponseTime
}
}
SSDPSearchSessionConfiguration
has a custom initialiser to allow for an assertion to be performed on the value ofmaximumWaitResponseTime
(which needs to be between 1 and 5 inclusive). I believe having to manually write this custom initialiser is a price worth paying to allow for quicker feedback during development if an invalid value is passed in (by causing the app to crash).
While it's possible to send unicast M-Search
messages, here we are only interested in sending multicast messages so to make things easier let's add a small factory method to return a preconfigured multicast SSDPSearchSessionConfiguration
instance:
extension SSDPSearchSessionConfiguration {
static func createMulticastConfiguration(forSearchTarget searchTarget: String, maximumWaitResponseTime: TimeInterval = 3) -> SSDPSearchSessionConfiguration {
let configuration = SSDPSearchSessionConfiguration(searchTarget: searchTarget, host: "239.255.255.250", port: 1900, maximumWaitResponseTime: maximumWaitResponseTime)
return configuration
}
}
Setting the
searchTarget
tossdp:all
should cause allroot devices
to respond with their full range of SSDP services.
With this configuration it is possible to build a simple searcher class to drive any UDPSocketController
instance:
protocol SSDPSearchSessionProtocol {
func startSearch()
func stopSearch()
}
class SSDPSearchSession: SSDPSearchSessionProtocol {
private let socketController: UDPSocketControllerProtocol
private let configuration: SSDPSearchSessionConfiguration
// 1
private lazy var mSearchMessage = {
// Each line must end in `\r\n`
return "M-SEARCH * HTTP/1.1\r\n" +
"HOST: \(configuration.host):\(configuration.port)\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"ST: \(configuration.searchTarget)\r\n" +
"MX: \(Int(configuration.maximumWaitResponseTime))\r\n" +
"\r\n"
}()
// MARK: - Init
// 2
init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory()) {
guard let socketController = socketControllerFactory.createUDPSocketController(host: configuration.host, port: configuration.port, socketFactory: SocketFactory()) else {
return nil
}
self.socketController = socketController
self.configuration = configuration
}
deinit {
stopSearch()
}
// MARK: - Search
// 3
func startSearch() {
os_log(.info, "SSDP search session starting")
writeMessageToSocket(mSearchMessage)
}
func stopSearch() {
os_log(.info, "SSDP search session stopping")
close()
}
// MARK: - Close
// 4
private func close() {
if socketController.state.isActive {
socketController.close()
}
}
// MARK: - Write
private func writeMessageToSocket(_ message: String) {
os_log(.info, "Writing to socket: \r%{public}@", message)
socketController.write(message: message)
}
}
SSDPSearchSession
above:
- Configures the
M-Search
message using theSSDPSearchSessionConfiguration
instance. - Creates an
UDPSocketController
instance. - Writes the
M-Search
message to the socket controller. - Closes the socket controller if it's active.
With the
M-Search
message you may have noticed the\r\n
character sequence at the end of each line - don't be tempted to remove this from theM-Search
message as the\r\n
sequence is part of the protocol spec.
SSDPSearchSession
is designed to be a single-use instance - once stopSearch()
is called and the socket closed, a new instance of SSDPSearchSession
is needed to perform another search.
Don't forget to add
import os
to get theos_log
statements to compile.
Calling startSearch()
should cause an M-Search
message to be written to the network however as grizzly, well-travelled developers we know that trusting our code to do something without testing it, is a recipe for disappointment 😞. To test that this message is being written to the network, we can snoop on our network traffic using tcpdump
.
To test on the simulator, open your terminal and run:
sudo tcpdump -vv -A -s 0 'port 1900 and host 239.255.255.250 and udp'
-vv
: verbose output.
-A
: print each packet in ASCII.
-s 0
: sets the amount of captured data for each frame. Using0
sets the amount to the default: 65535 bytes - we explicitly set this here for backwards compatibility with recent older versions of tcpdump.
'port 1900 and host 239.255.255.250 and udp'
is using Berkeley Packet Filter (BPF) syntax to filter what traffic we see.
The above command will capture all the UDP traffic using host 239.255.255.250
on port 1900
(i.e. SSDP traffic) on your local machine.
If you are using a VPN, you may need to disable it to see anything in the console.
Testing on a device is slightly more difficult. We need to tell tcpdump
which device to snoop on by creating a remote virtual interface using rvictl
:
Connect your device and grab its UDID
, then open terminal and run:
rvictl -s {UDID} && sudo tcpdump -vv -A -s 0 -i rvi0 'port 1900 and host 239.255.255.250 and udp'
Replacing
{UDID}
with theUDID
of the device.
You should see similar traffic to what you would if testing on the simulator.
Run
rvictl -x {UDID}
to stop the remote virtual interface andCtrl-C
to killtcpdump
.
Now that we have confirmation that M-Search
messages are being sent, let's build the functionality to parse any responses we may receive. First UDPSocketController
needs to read anything sent to the socket:
class UDPSocketController: UDPSocketControllerProtocol {
//Omitted other properties
// 1
private let socketListeningQueue = DispatchQueue(label: "com.williamboles.udpsocket.listen.queue", attributes: .concurrent)
//Omitted other methods
// MARK: - Write
func write(message: String) {
//Omitted code
// 2
let shouldStartListening = state.isReady
state = .active
if shouldStartListening {
startListening(on: socketListeningQueue)
}
//Omitted code
}
// MARK: - Listen
// 3
private func startListening(on queue: DispatchQueue) {
queue.async {
do {
repeat {
var data = Data()
try self.socket.readDatagram(into: &data) //blocking call
self.reportResponseReceived(data)
} while self.state.isActive
} catch {
if self.state.isActive { // ignore any errors for non-active sockets
self.closeAndReportError(error)
}
}
}
}
private func reportResponseReceived(_ data: Data) {
os_log(.info, "Response received: \r%{public}@", response)
//TODO: Implement reporting response received
}
//Omitted other methods
}
With the above changes, UDPSocketController
is now able to read from its socket.
- Reading from a
BlueSocket
socket configured to read datagram messages using UDP is a blocking call - any thread thatreadDatagram(into:)
is called on will be blocked at that line until there is data there to be read. To avoid the app from freezing, reading from the socket must be pushed off the caller queue and onto a background queue:socketWriterQueue
. - We only need to configure the socket to listen once - on the first write.
- Once a response is received, that response is converted into a string and (for the moment) logged. Finally, if the controller is still listening for responses, the socket is polled again. As a read operation can throw an exception, we wrap that operation in a
do...catch
whereupon catching an exception the socket is closed, and any error reported (we will implement this shortly). An interesting point to note is that closing a socket that is being polled will throw an exception. So when an exception is thrown during polling, we only care about that exception if the session is listening.
If you have devices on your network that support SSDP, you should start to see responses in the Console when running the above code. However, if you don't, it's possible to fake a response using netcat. You will need to extract the host and port from the M-Search
request via tcpdump
and run the following command:
echo "HTTP/1.1 200 OK\r\nCache-Control: max-age=3600\r\nST: urn:dial-multiscreen-org:service:dial:1\r\nUSN: uuid:0175c106-5400-10f8-802d-b0a7374360b7::urn:dial-multiscreen-org:service:dial:1\r\nExt: \r\nServer: Roku UPnP/1.0 MiniUPnPd/1.4\r\nLOCATION: http://192.168.1.104:8060/\r\n\r\n" | nc -u {host} {port}
Replacing {host} {port} with the extracted values.
The above command will send a response pretending to be a Roku set-top box.
Now that it is possible to read and write from a socket, lets pass any responses (and any errors) out of our socket controller:
protocol UDPSocketControllerDelegate: AnyObject {
func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data)
func controller(_ controller: UDPSocketControllerProtocol, didEncounterError error: Error)
}
protocol UDPSocketControllerProtocol: AnyObject {
//Omitted other properties
var delegate: UDPSocketControllerDelegate? { get set }
//Omitted methods
}
class UDPSocketController: UDPSocketControllerProtocol {
//Omitted other properties
weak var delegate: UDPSocketControllerDelegate?
private let callbackQueue: OperationQueue
init?(host: String, port: UInt, socketFactory: SocketFactoryProtocol, callbackQueue: OperationQueue) {
//Omitted rest of method
self.callbackQueue = callbackQueue
}
//Omitted other methods
private func reportResponseReceived(_ data: Data) {
callbackQueue.addOperation {
self.delegate?.controller(self, didReceiveResponse: data)
}
}
private func closeAndReportError(_ error: Error) {
close()
callbackQueue.addOperation {
self.delegate?.controller(self, didEncounterError: error)
}
}
}
With the above changes, SSDPSearchSession
can now add itself as the delegate of UDPSocketControllerDelegate
. To make the communication via that delegate more predictable when creating that socket, the thread that the communication will happen on is passed in.
Let's update the SocketControllerFactory
to support the callbackQueue
:
protocol SocketControllerFactoryProtocol {
func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol, callbackQueue: OperationQueue) -> UDPSocketControllerProtocol?
}
class SocketControllerFactory: SocketControllerFactoryProtocol {
// MARK: - UDP
func createUDPSocketController(host: String, port: UInt, socketFactory: SocketFactoryProtocol, callbackQueue: OperationQueue) -> UDPSocketControllerProtocol? {
UDPSocketController(host: host, port: port, socketFactory: socketFactory, callbackQueue: callbackQueue)
}
}
Now, let's update SSDPSearchSession
to be the delegate of UDPSocketControllerDelegate
:
class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
//Omitted properties
init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory()) {
guard let socketController = socketControllerFactory.createUDPSocketController(host: configuration.host, port: configuration.port, socketFactory: SocketFactory(), callbackQueue: .main) else {
return nil
}
//Omitted other assignments
self.socketController.delegate = self
}
//Omitted other methods
// MARK: - UDPSocketControllerDelegate
func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
os_log(.info, "Received response: \r%{public}@", response)
//TODO: Implement
}
func controller(_ controller: UDPSocketControllerProtocol, didEncounterError error: Error) {
os_log(.info, "Encountered socket error: \r%{public}@", error.localizedDescription)
close()
//TODO: Implement
}
}
SSDPSearchSession
can now receive responses from its UDPSocketController
, let's turn those responses into something useful:
struct SSDPService {
let cacheControl: Date
let location: URL
let server: String
let searchTarget: String
let uniqueServiceName: String
let otherHeaders: [String: String]
}
An M-Search
response has mandatory and optional/custom header fields. In SSDPService
, the mandatory header fields are mapped to named properties, and the optional/custom header fields are mapped to the otherHeaders
property. Each optional/custom header field is represented as a dictionary with the field name as the dictionary key and field's value as dictionary value.
To get an SSDPService
instance, it needs to be parsed:
private enum SSDPServiceResponseKey: String {
case cacheControl = "CACHE-CONTROL"
case location = "LOCATION"
case server = "SERVER"
case searchTarget = "ST"
case uniqueServiceName = "USN"
}
protocol SSDPServiceParserProtocol {
func parse(_ data: Data) -> SSDPService?
}
class SSDPServiceParser: SSDPServiceParserProtocol {
private let dateFactory: DateFactoryProtocol
// Init
init(dateFactory: DateFactoryProtocol = DateFactory()) {
self.dateFactory = dateFactory
}
// MARK: - Parse
func parse(_ data: Data) -> SSDPService? {
guard let responseString = String(data: data, encoding: .utf8) else {
return nil
}
os_log(.info, "Received SSDP response: \r%{public}@", responseString)
// 1
var responseDict = parseResponseIntoDictionary(responseString)
// 2
guard let cacheControl = parseCacheControl(responseDict[SSDPServiceResponseKey.cacheControl.rawValue]),
let location = parseLocation(responseDict[SSDPServiceResponseKey.location.rawValue]),
let server = responseDict[SSDPServiceResponseKey.server.rawValue],
let searchTarget = responseDict[SSDPServiceResponseKey.searchTarget.rawValue],
let uniqueServiceName = responseDict[SSDPServiceResponseKey.uniqueServiceName.rawValue] else {
return nil
}
// 3
responseDict.removeValue(forKey: SSDPServiceResponseKey.cacheControl.rawValue)
responseDict.removeValue(forKey: SSDPServiceResponseKey.location.rawValue)
responseDict.removeValue(forKey: SSDPServiceResponseKey.server.rawValue)
responseDict.removeValue(forKey: SSDPServiceResponseKey.searchTarget.rawValue)
responseDict.removeValue(forKey: SSDPServiceResponseKey.uniqueServiceName.rawValue)
// 4
return SSDPService(cacheControl: cacheControl, location: location, server: server, searchTarget: searchTarget, uniqueServiceName: uniqueServiceName, otherHeaders: responseDict)
}
private func parseResponseIntoDictionary(_ response: String) -> [String: String] {
var elements = [String: String]()
for element in response.split(separator: "\r\n") {
let keyValuePair = element.split(separator: ":", maxSplits: 1)
guard keyValuePair.count == 2 else {
continue
}
let key = String(keyValuePair[0]).uppercased().trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
let value = String(keyValuePair[1]).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
elements[key] = value
}
return elements
}
private func parseCacheControl(_ value: String?) -> Date? {
guard let cacheControlRange = value?.range(of: "[0-9]+$", options: .regularExpression),
let cacheControlString = value?[cacheControlRange],
let cacheControlTimeInterval = TimeInterval(cacheControlString) else {
return nil
}
let currentDate = dateFactory.currentDate()
return currentDate.addingTimeInterval(cacheControlTimeInterval)
}
private func parseLocation(_ value: String?) -> URL? {
guard let urlString = value,
let url = URL(string: urlString) else {
return nil
}
return url
}
}
SSDPServiceParser
above takes the string response and attempts to parse it into an SSDPService
instance by:
- Splitting the string into a dictionary using
\r\n
to determine fields and:
to determine key and value pairs. - The response dictionary is checked to ensure that all mandatory fields are present. If any of the mandatory fields are missing, the SSDP response is considered invalid, and
nil
is returned. - As an SSDP response can contain non-mandatory fields, the mandatory fields are stripped from the response dictionary so leaving the only the non-mandatory fields present.
- An
SSDPService
is created using the mandatory and non-mandatory fields.
I could have combined
SSDPService
andSSDPServiceParser
into the one type with the init'er accepting the string response, but I think having an independent parser makes the code easier to read.
The DateFactory
is used to make unit-testing this parser possible:
protocol DateFactoryProtocol {
func currentDate() -> Date
}
class DateFactory: DateFactoryProtocol {
// MARK: - Current
func currentDate() -> Date {
return Date()
}
}
Let's update SSDPSearchSession
to use SSDPServiceParser
:
class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
//Omitted other properties
private let parser: SSDPServiceParserProtocol
// MARK: - Init
init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory(), parser: SSDPServiceParserProtocol = SSDPServiceParser()) {
//Omitted other assignments
self.parser = parser
}
//Omitted methods
// MARK: - UDPSocketControllerDelegate
func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
guard !response.isEmpty,
let service = parser.parse(response) else {
return
}
os_log(.info, "Received service \r%{public}@", service)
}
//Omitted methods
}
Once you start parsing responses, you will notice that some root devices
respond to any M-Search
message they receive rather than just those discovery requests that match one of their services. To counter these chatty root devices
we need to ensure that the parsed SSDPService
instance is the searched-for-service:
class SSDPSearchSession {
//Omitted properties & methods
// MARK: - UDPSocketControllerDelegate
func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
guard !response.isEmpty,
let service = parser.parse(response),
searchedForService(service) else {
return
}
os_log(.info, "Received service \r%{public}@", service)
}
private func searchedForService(_ service: SSDPService) -> Bool {
return service.searchTarget.contains(configuration.searchTarget) || configuration.searchTarget == "ssdp:all"
}
//Omitted methods
}
If the search target is set to the special
ssdp:all
value, all services that respond are treated as valid.
SSDPSearchSession
is doing good work but isn't able to share the fruits of its labour with anyone. Let's add in a delegate to tell interested parties how things are going with the search:
protocol SSDPSearchSessionDelegate: AnyObject {
func searchSession(_ searchSession: SSDPSearchSession, didFindService service: SSDPService)
func searchSession(_ searchSession: SSDPSearchSession, didEncounterError error: SSDPSearchSessionError)
func searchSessionDidStopSearch(_ searchSession: SSDPSearchSession, foundServices: [SSDPService])
}
enum SSDPSearchSessionError: Error {
case searchAborted(Error)
}
class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
//Omitted properties
// 1
private var servicesFoundDuringSearch = [SSDPService]()
weak var delegate: SSDPSearchSessionDelegate?
//Omitted methods
func stopSearch() {
os_log(.info, "SSDP search session stopping")
close()
// 2
delegate?.searchSessionDidStopSearch(self, foundServices:servicesFoundDuringSearch)
}
//Omitted methods
// MARK: - UDPSocketControllerDelegate
func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
guard !response.isEmpty,
let service = parser.parse(response),
searchedForService(service) else {
return
}
os_log(.info, "Received a valid service response")
servicesFoundDuringSearch.append(service)
// 3
delegate?.searchSession(self, didFindService: service)
}
func controller(_ controller: UDPSocketControllerProtocol, didEncounterError error: Error) {
os_log(.info, "Encountered socket error: \r%{public}@", error.localizedDescription)
// 4
let wrappedError = SSDPSearchSessionError.searchAborted(error)
delegate?.searchSession(self, didEncounterError: wrappedError)
close()
}
//Omitted methods
}
With the above changes, we now:
- Store all valid services that have been received during that search session.
- Inform the delegate when the search has been stopped, returning all valid services found.
- Inform the delegate when a valid service has been found.
- Wrap any received error in an
SSDPSearchSessionError
error and inform the delegate of that error.
It's interesting to note that
searchSession(_:, didFindService:)
is called as soon as a validSSDPService
instance is parsed rather than waiting for all services to be parsed. This will allow the app to respond immediately to any found services.
An alternative to delegation would have been to pass a closure into
startSearch()
. In fact, using a closure was my preferred option to begin with. However, after experimenting, I felt that having one closure handling three possible states resulted in code that was very busy and that readability suffered because of this.
Every M-Search
message contains an MX
value that represents the maximum time a service can wait before responding. When this time has elapsed, it can be confidently assumed that all services that can respond, have responded. Meaning that MX
can be used as a timeout for the search session:
class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
//Omitted other properties
private let searchTimeout: TimeInterval
private var timeoutTimer: Timer?
// MARK: - Init
init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory(), parser: SSDPServiceParserProtocol = SSDPServiceParser()) {
//Omitted other assignments
self.searchTimeout = configuration.maximumWaitResponseTime + 0.1
}
//Omitted methods
// MARK: - Search
func startSearch() {
//Omitted code
timeoutTimer = Timer.scheduledTimer(withTimeInterval: searchTimeout, repeats: false, block: { [weak self] (timer) in
self?.searchTimedOut()
})
}
private func searchTimedOut() {
os_log(.info, "SSDP search timed out")
stopSearch()
}
//Omitted methods
// MARK: - Close
private func close() {
timeoutTimer?.invalidate()
timeoutTimer = nil
//Omitted code
}
//Omitted methods
}
With the above changes, the search session is ended after maximumWaitResponseTime
seconds causing the socket
to be closed. The more eagle-eyed reader may have spotted that timeoutTimer
has a trigger time that is 0.1
seconds longer than maximumWaitResponseTime
- this is to allow any responses from root devices
that waited the full maximumWaitResponseTime
seconds before responding to reach the app and be processed before the search session is ended.
Some people are harder to talk to than others
If you have been combining the above code snippets into a working project, you will now be able to search for SSDP services and parse any response received. However, every so often, you may notice that an SSDP service that you know exists on the network does not respond.
🤔
As described above, SSDP uses the unreliable UDP transportation protocol (because UDP supports multicasting). UDP is unreliable because there is no acknowledgement if a message made it to its destination. This means there is no way of knowing if a service hasn't responded because the message was dropped along the way or that the service is no longer available. Unreliability isn't a great characteristic for a discovery service to have. While not foolproof, it is possible to increase the reliability of an SSDP based discovery service by sending multiple M-Search
messages over the lifecycle of an SSDPSearchService
instance. Sending multiple M-Search
messages will increase the chances that at least one message makes it to each root device
on the network. For this to be possible, our SSDPSearchService
instance must exist longer than the MX
value before timing out:
struct SSDPSearchSessionConfiguration {
//Omitted properties
let maximumSearchRequestsBeforeClosing: UInt
// MARK: - Init
init(searchTarget: String, host: String, port: UInt, maximumWaitResponseTime: TimeInterval, maximumSearchRequestsBeforeClosing: UInt) {
//Omitted other assignments
self.maximumSearchRequestsBeforeClosing = maximumSearchRequestsBeforeClosing
}
}
extension SSDPSearchSessionConfiguration {
static func createMulticastConfiguration(forSearchTarget searchTarget: String, maximumWaitResponseTime: TimeInterval = 3, maximumSearchRequestsBeforeClosing: UInt = 3) -> SSDPSearchSessionConfiguration {
let configuration = SSDPSearchSessionConfiguration(searchTarget: searchTarget, host: "239.255.255.250", port: 1900, maximumWaitResponseTime: maximumWaitResponseTime, maximumSearchRequestsBeforeClosing: maximumSearchRequestsBeforeClosing)
return configuration
}
}
maximumSearchRequestsBeforeClosing
will control how many M-Search
messages are sent before the search session is closed. maximumSearchRequestsBeforeClosing
needs to be at least 1, or no M-Search
will be sent.
An alternative to using a count property would have been to use a total-duration property. However, the total-duration approach would have required the config-maintainer to ensure that this timeout property was always a multiple of the
maximumWaitResponseTime
. As having a value which isn't a multiple would result in the situation where the session was stopped before the lastM-Search
iteration'smaximumWaitResponseTime
had expired - potentially resulting in ignored responses because theroot devices
waited until themaximumWaitResponseTime
value before responding. It's easy to imagine this mistake happening. By expressing the timeout as the value to multiplymaximumWaitResponseTime
by, we ensure that this error scenario can never happen.
class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
//Omitted properties
private var searchRequestTimer: Timer?
//Omitted methods
// MARK: - Init
init?(configuration: SSDPSearchSessionConfiguration, socketControllerFactory: SocketControllerFactoryProtocol = SocketControllerFactory(), parser: SSDPServiceParserProtocol = SSDPServiceParser()) {
//Omitted other assignments
// 1
self.searchTimeout = (TimeInterval(configuration.maximumSearchRequestsBeforeClosing) * configuration.maximumWaitResponseTime) + 0.1
}
// MARK: - Search
func startSearch() {
// 2
guard configuration.maximumSearchRequestsBeforeClosing > 0 else {
delegate?.searchSessionDidStopSearch(self, foundServices: servicesFoundDuringSearch)
return
}
os_log(.info, "SSDP search session starting")
sendMSearchMessages()
//Omitted code
}
//Omitted methods
// MARK: - Close
private func close() {
//Omitted code
// 3
searchRequestTimer?.invalidate()
searchRequestTimer = nil
//Omitted code
}
// MARK: Write
private func sendMSearchMessages() {
let message = mSearchMessage
// 4
if configuration.maximumSearchRequestsBeforeClosing > 1 {
let window = searchTimeout - configuration.maximumWaitResponseTime
let interval = window / TimeInterval((configuration.maximumSearchRequestsBeforeClosing - 1))
searchRequestTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: { [weak self] (timer) in
self?.writeMessageToSocket(message)
})
}
writeMessageToSocket(message)
}
//Omitted methods
}
With the above changes, a new M-Search
message is sent every maximumWaitResponseTime
seconds until the desired number of messages have been sent.
- Increased the total search time for multiple
M-Search
message writes. - Check that
maximumSearchRequestsBeforeClosing
is greater than 0 and return if it isn't. - Invalidate new search request timer.
- Send multiple
M-Search
messages inside the maximum send window.
However, while making SSDPSearchSession
a more reliable discovery service, sending multiple M-Search
messages creates a new problem. In ideal network conditions, the same SSDP service would respond to each sent M-Search
message. If these responses were just blindly passed to the delegate
, the SSDPSearchService
instance would in effect be spamming that delegate
with the same service multiple times. Thankfully each service parsed is already stored in the servicesFoundDuringSearch
array (to be used when searchSessionDidStopSearch(_:, foundServices:)
is called), so to prevent becoming a spammer a check can be made to determine if an SSDPService
instance representing the same service has already been passed to the delegate
:
class SSDPSearchSession: SSDPSearchSessionProtocol, UDPSocketControllerDelegate {
//Omitted properties and methods
// MARK: - UDPSocketControllerDelegate
func controller(_ controller: UDPSocketControllerProtocol, didReceiveResponse response: Data) {
guard !response.isEmpty,
let service = parser.parse(response),
searchedForService(service),
// 1
!servicesFoundDuringSearch.contains(service) else {
return
}
//Omitted code
}
//Omitted methods
}
With the above change:
- A check is made to see if
servicesFoundDuringSearch
already contains the service that has been parsed.
In order to get the above code to work, SSDPService
needs to conform to Equatable
:
struct SSDPService: Equatable {
//Omitted properties
// MARK: - Equatable
static func == (lhs: SSDPService, rhs: SSDPService) -> Bool {
return lhs.location == rhs.location &&
lhs.server == rhs.server &&
lhs.searchTarget == rhs.searchTarget &&
lhs.uniqueServiceName == rhs.uniqueServiceName
}
}
The above custom equality check excludes
cacheControl
as this will change with each response because it's based on the date when the service was parsed.
🎉🎉🎉
And that's everything you need for discovering what SSDP services are available on a network - congratulations.
Happy to get to know everyone 🎯
Social situations can be tricky. It's easy to think that you don't have anything of value to add and to allow that thought to leave you alone in the corner. However, with a little bit of effort (and bravery), you can reach out and get to know new people.
This is just as true for your app.
SSDP is a lightweight, widely supported protocol that makes it straightforward to discover services on a network. It has a few gotchas but provided that we treat it with care and don't 100% trust any root devices to behave as expected, SSDP can be a useful tool to have in the toolbox.
To see the above code snippets together in a working example, head over to the repo and clone the project.
What do you think? Let me know by getting in touch on Twitter - @wibosco