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.

Photo of a group of friends with a magnificent dog having a picnic

If you are already familar 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.

Diagram showing sending a multicast message on a network

Multicasting allows a node to transmit one message onto the network and for that message to be forwarded on to all interested nodes without the sender node having to know anything about any other nodes (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 port 1900 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.

Diagram showing sending a unicast message on a network

That's a lot of information to take in there 😥. If it doesn't all make sense that's ok, most of the details will come up again later and when seen in context are easier to understand.

SSDP messages fall into 2 categories:

  1. Discovery
  2. 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

A discovery message can take 2 forms:

  1. Request
  2. Response

A request message is when a control point transmits an M-SEARCH message onto the network 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 an M-Search message is multicast (like the example above) but can be unicast.
  • The message type (MAN), for an M-Search this is always ssdp:discover.
  • The search target (ST) of the service the search request is attempting to discover.
  • The maximum wait response time (MX) in seconds that a root device can take before responding. The MX 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 an M-SEARCH message could result in accidentally DDOS-ing the questing node due to too many services responding at once. The MX field instructs the root device to wait a random time between 0 and MX before attempting to respond - this should allow the responses to be spaced out enough to ease the processing strain on the control point. The MX value should be between 1 and 5. Even with the MX 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 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 the control 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.

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 3 types of advertisement:

  1. Alive
  2. Update
  3. 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 of ST in an M-Search message).
  • The notification subtype (NTS), for an alive message this will always be ssdp:alive (the equivalent of MAN in an M-Search message).
  • The location URL (LOCATION) to allow a receiving control 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 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 see some code.

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, SSDPServiceParser and SSDPService to see how things end up.

Getting to know who's there 🔭

As SSDP communication is built on top of UDP, it isn't possible to use URLSession (which is concerned with making HTTP requests over TCP) 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 my own 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.

3 pieces of information are required to send an M-Search message:

  1. Search target (ST).
  2. The IP address and port (HOST).
  3. 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 of maximumWaitResponseTime (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, in this post 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 to ssdp:all should cause all root devices to respond with their full range of SSDP services.

With this configuration it is possible to build a simple searcher class to transmit an M-Search message:

enum SSDPSearchSessionError: Error {
    case addressCreationFailure
}

class SSDPSearchSession {
    private let configuration: SSDPSearchSessionConfiguration
    private var socket: Socket

    // MARK: - Init

    init?(configuration: SSDPSearchSessionConfiguration) {
        guard let socket = try? Socket.create(type: .datagram, proto: .udp) else {
            return nil
        }
        self.socket = socket
        self.configuration = configuration
    }

    deinit {
        stopSearch()
    }

    // MARK: - Search

    func startSearch() {
        os_log(.info, "SSDP search session starting")
        let searchMessage = self.searchMessage()
        writeToSocket(searchMessage)
    }

    func stopSearch() {
        os_log(.info, "SSDP search session stopping")
        socket.close()
    }

    // MARK: - Error

    private func handleError(_ error: Error) {
        os_log(.error, "SSDP discovery error: %{public}@", error.localizedDescription)
        stopSearch()
    }

    // MARK: Write

    private func writeToSocket(_ datagram: String) {
        guard let address = Socket.createAddress(for: configuration.host, on: Int32(configuration.port)) else {
            handleError(SSDPSearchSessionError.addressCreationFailure)
            return
        }

        do {
            os_log(.info, "Writing datagram to socket: \r%{public}@", datagram)
            try socket.write(from: datagram, to: address)
        } catch {
            handleError(error)
        }
    }

    private func searchMessage() -> String {
        // 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"
    }
}

In the above class, a Socket instance configured to send datagram messages using UDP is created. To this socket is written an M-Search message which has been constructed by plugging the correct configuration values into it. 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 the M-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 the os_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'

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.

Screenshot of tcpdump running

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 the UDID 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 and Ctrl-C to kill tcpdump.

Now that we have confirmation that M-Search messages are being sent, let's build the functionality to parse any responses.

class SSDPSearchSession {
    //Omitted properties

    private var isListening = false
    private let listeningQueue = DispatchQueue(label: "com.williamboles.listening")

    //Omitted methods

    func startSearch() {
        os_log(.info, "SSDP search session starting")
        prepareSocketForResponses()
        let searchMessage = self.searchMessage()
        writeToSocket(searchMessage)
    }

    func stopSearch() {
        os_log(.info, "SSDP search session stopping")
        isListening = false
        socket.close()
    }

    //Omitted methods

    // MARK: - Read

    private func prepareSocketForResponses() {
        listeningQueue.async() { [weak self] in
            self?.isListening = true
            self?.readResponse() // contains blocking call
        }
    }

    private func readResponse() {
        defer {
            if isListening {
                readResponse()
            }
        }

        do {
            var data = Data()
            let (bytesRead, _) = try socket.readDatagram(into: &data) //blocking call

            guard bytesRead > 0,
                let response = String(data: data, encoding: .utf8) else {
                    return
            }

            os_log(.info, "Response received: \r%{public}@", response)
        } catch {
            if isListening {
                handleError(error)
            }
        }
    }
}

To read responses, we need to poll the socket to check if data has been written to it. BlueSocket instances are blocking sockets so that calling readDatagram(into:) will cause the thread it is called on to block until there is data to be read. To avoid the app from freezing, reading from the socket must be pushed off the main queue and onto a background queue: listeningQueue. Once a response is received, it is converted into a string and (for the moment) logged. Finally, if the session is still listening to responses, the socket is polled again.

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, it's time to turn the response 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. 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"
}

class SSDPServiceParser {

    private static let dateFormatter = DateFormatter()

    // MARK: - Parse

    static 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)

        var responseDict = SSDPServiceParser.parseResponseIntoDictionary(responseString)

        guard let cacheControl = SSDPServiceParser.parseCacheControl(responseDict[SSDPServiceResponseKey.cacheControl.rawValue]),
            let location = SSDPServiceParser.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
        }

        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)

        return SSDPService(cacheControl: cacheControl, location: location, server: server, searchTarget: searchTarget, uniqueServiceName: uniqueServiceName, otherHeaders: responseDict)
    }

    private static 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 static 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
        }

        return Date(timeIntervalSinceNow: cacheControlTimeInterval)
    }

    private static func parseLocation(_ value: String?) -> URL? {
        guard let urlString = value,
            let url = URL(string: urlString) else {
                return nil
        }

        return url
    }
}

I could have combined SSDPService and SSDPServiceParser into the one type, but I prefer to have them as two separate types to make reading SSDPService easier.

With SSDPServiceParser the raw data response is converted into a string and that string is split into a dictionary. That dictionary is then mapped into local variables, and those local variables are used to create a new SSDPService instance. If any of the mandatory fields are missing from the SSDP response nil is returned.

Let's update SSDPSearchSession to use SSDPServiceParser:

class SSDPSearchSession {
    //Omitted properties & methods

    private func readResponse() {
        //Omitted code

        do {
            //Omitted code

            guard bytesRead > 0,
                let service = SSDPServiceParser.parse(data) else {
                    return
            }

            os_log(.info, "Service found and parsed: \r%{public}@", String(describing: service))
        } catch {
            //Omitted code
        }
    }

    //Omitted methods
}

Once we start parsing responses, you will notice that some root devices respond to any M-Search message they receive. To counter these chatty root devices we need to ensure that the parsed SSDPService instance represents the search for service:

class SSDPSearchSession {
    //Omitted properties & methods

    private func readResponse() {
        //Omitted code

        do {
            //Omitted code

            guard bytesRead > 0,
                let service = SSDPServiceParser.parse(data),
                searchedForService(service) else {
                    return
            }

            //Omitted code
        } catch {
            //Omitted code
        }
    }

    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: class {
    func searchSession(_ searchSession: SSDPSearchSession, didFindService service: SSDPService)
    func searchSession(_ searchSession: SSDPSearchSession, didAbortWithError error: SSDPSearchSessionError)
    func searchSessionDidStopSearch(_ searchSession: SSDPSearchSession, foundServices: [SSDPService])
}

enum SSDPSearchSessionError: Error {
    case addressCreationFailure
    case searchAborted(Error)
}

class SSDPSearchSession {
    //Omitted properties

    private var servicesFoundDuringSearch = [SSDPService]()

    weak var delegate: SSDPSearchSessionDelegate?

    //Omitted methods

    func stopSearch() {
        os_log(.info, "SSDP search session stopping")
        close(dueToError: nil)
    }

    // MARK: - Close

    private func close(dueToError error: SSDPSearchSessionError?) {
        guard isListening else {
            return
        }
        isListening = false
        socket.close()

        if let error = error {
            delegate?.searchSession(self, didAbortWithError: error)
        } else {
            delegate?.searchSessionDidStopSearch(self, foundServices: servicesFoundDuringSearch)
        }
    }

    private func handleError(_ error: Error) {
        os_log(.error, "SSDP discovery error: %{public}@", error.localizedDescription)
        let wrappedError = SSDPSearchSessionError.searchAborted(error)
        close(dueToError: wrappedError)
    }

    //Omitted methods

    private func readResponse() {
        //Omitted code

        do {
            //Omitted code

            servicesFoundDuringSearch.append(service)

            delegate?.searchSession(self, didFindService: service) // Replacing `os_log` with delegate call
        } catch {
            //Omitted code
        }
    }

    //Omitted methods
}

With the above changes, the SSDPSearchSession instance can now inform any interested parties: when a service is found and when the search is aborted or when the search is stopped.

It's interesting to note that searchSession(_:, didFindService:) is called as soon as a valid SSDPService instance is parsed rather than wait 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 {
    //Omitted properties

    private var timeoutTimer: Timer?

    //Omitted methods

    // MARK: - Search

    func startSearch() {
        //Omitted code

        let searchTimeout = configuration.maximumWaitResponseTime + 0.1
        timeoutTimer = Timer.scheduledTimer(withTimeInterval: searchTimeout, repeats: false, block: { [weak self] (timer) in
            self?.searchTimedOut()
        })
    }

    private func searchTimedOut() {
        os_log(.info, "SSDP search timed out")
        close(dueToError: nil)
    }

    //Omitted methods

    // MARK: - Close

    private func close(dueToError error: SSDPSearchSessionError?) {
        //Omitted code

        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, an SSDPSearchService instance must exist longer than the MX value before timing out:

struct SSDPSearchSessionConfiguration {
    //Omitted properties

    let maximumBroadcastsBeforeClosing: UInt

    // MARK: - Init

    init(searchTarget: String, host: String, port: UInt, maximumWaitResponseTime: TimeInterval, maximumBroadcastsBeforeClosing: UInt) {
        //Omitted code

        assert(maximumBroadcastsBeforeClosing >= 1, "maximumBroadcastsBeforeClosing should be greater than or equal to 1")

        //Omitted code

        self.maximumBroadcastsBeforeClosing = maximumBroadcastsBeforeClosing
    }
}

extension SSDPSearchSessionConfiguration {

    static func createMulticastConfiguration(forSearchTarget searchTarget: String, maximumWaitResponseTime: TimeInterval = 3, maximumBroadcastsBeforeClosing: UInt = 3) -> SSDPSearchSessionConfiguration {
        let configuration = SSDPSearchSessionConfiguration(searchTarget: searchTarget, host: "239.255.255.250", port: 1900, maximumWaitResponseTime: maximumWaitResponseTime, maximumBroadcastsBeforeClosing: maximumBroadcastsBeforeClosing)

        return configuration
    }
}

maximumBroadcastsBeforeClosing will control how many M-Search messages are sent before the search session is closed. maximumBroadcastsBeforeClosing needs to be at least 1, or no M-Search will be sent.

An alternative to using an UInt property would have been to use a TimeInterval property. However, the TimeInterval 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 last M-Search iteration's maximumWaitResponseTime had expired - potentially resulting in ignored responses because the root devices waited until the maximumWaitResponseTime value before responding. It's easy to imagine this mistake happening. By expressing the timeout as the value to multiply maximumWaitResponseTime by, we ensure that this error scenario can never happen.

class SSDPSearchSession {
    //Omitted properties

    private var broadcastTimer: Timer?

    //Omitted methods

    // MARK: - Search

    func startSearch() {
        os_log(.info, "SSDP search session starting")
        prepareSocketForResponses()
        broadcastMultipleSearchRequests()

        let searchTimeout = (TimeInterval(configuration.maximumBroadcastsBeforeClosing) * configuration.maximumWaitResponseTime) + 0.1 // Replacing previous `searchTimeout` calculation

        //Omitted code
    }

    //Omitted methods

    // MARK: - Close

    private func close(dueToError error: SSDPSearchSessionError?) {
        //Omitted code

        broadcastTimer?.invalidate()
        broadcastTimer = nil

        //Omitted code
    }

    // MARK: Write

    private func broadcastMultipleSearchRequests() {
        let searchMessage = self.searchMessage()
        broadcastTimer = Timer.scheduledTimer(withTimeInterval: configuration.maximumWaitResponseTime, repeats: true, block: { [weak self] (timer) in
            self?.writeToSocket(searchMessage)
        })
        broadcastTimer?.fire()
    }

    //Omitted methods
}

With the above changes, a new M-Search message is sent every maximumWaitResponseTime seconds.

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 {
    //Omitted properties and methods

    private func readResponse() {
        //Omitted code

        do {
            //Omitted code

            guard bytesRead > 0,
                let service = SSDPServiceParser.parse(data),
                searchedForService(service),
                !servicesFoundDuringSearch.contains(service) else {
                    return
            }

            //Omitted code
        } catch {
            //Omitted code
        }
    }

    //Omitted methods
}

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
    }
}
🎉🎉🎉

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