> ## Content Index
> Fetch the complete content index at: https://williamboles.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Discovering What's Out There with SSDP
- URL: https://williamboles.com/discovering-whats-out-there-with-ssdp/
- Published: 2019-04-08T10:59:35.000Z
- Updated: 2026-09-26T16:33:26.000Z
- Description: Many of us feel nervous when meeting a group of people for the first time. Your app is the same. Here, we look at how SSDP can offer a convenient way for your app to get to know its local network and overcome those nerves.
- Author: William Boles
- Tags: Networking

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 awaits 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](https://en.wikipedia.org/wiki/Simple%5FService%5FDiscovery%5FProtocol?ref=williamboles.com).

This post will explore how to build a solution that allows us to send an SSDP message onto the network to discover if the device we want to connect with is present. And importantly, why we shouldn't trust every device that responds.

![Photo of a group of friends sitting precariously on a range](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2020/05/friends_chatting.jpg)

> 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](https://github.com/wibosco/SSDPDiscovery-Example?ref=williamboles.com) and take a look at `SSDPSearchSessionConfiguration`, `SSDPSearchSession`, `UDPSocketController`, `UDPSocket`, `SSDPServiceParser` and `SSDPService` to see how things end up.

## 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](https://en.wikipedia.org/wiki/Universal%5FPlug%5Fand%5FPlay?ref=williamboles.com) spec. SSDP is a [zero-configuration networking protocol](https://en.wikipedia.org/wiki/Zero-configuration%5Fnetworking?ref=williamboles.com) designed to allow nodes to be added and removed from a network without any involvement from a central service such as [DNS](https://en.wikipedia.org/wiki/Domain%5FName%5FSystem?ref=williamboles.com) or by assigning static IP addresses to specific nodes. This decentralised, dynamic approach is possible because SSDP uses [UDP](https://en.wikipedia.org/wiki/User%5FDatagram%5FProtocol?ref=williamboles.com) as its underlying transportation protocol, which allows for [multicast](https://en.wikipedia.org/wiki/Multicast?ref=williamboles.com) communication.

![Diagram showing sending a multicast message on a network](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/2019/04/multicast_routing_diagram.png)

Multicasting allows a node to transmit one message onto the network and for that message to be forwarded onto 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](https://www.iana.org/?ref=williamboles.com) 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](https://tools.ietf.org/html/rfc2068?ref=williamboles.com). 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` responds to a discovery message, it does so by sending a [unicast](https://en.wikipedia.org/wiki/Unicast?ref=williamboles.com) (to a single specific node) message directly to the `control point`.

![Diagram showing sending a unicast message on a network](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/2019/04/unicast_routing_diagram.png)

> 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:

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 will also cover Advertisement messages (feel free to skip the Advertisement section).

##### Discovery

Discovery involves two message types:

1. Request
2. 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, an `M-Search` message is multicast (like the example above), but it can also be unicast.
- The message type (`MAN`), for an `M-Search`, 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](https://www.cloudflare.com/learning/ddos/ssdp-ddos-attack/?ref=williamboles.com) 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 for use only in home or small office networks.

A `root device` *should only* respond with services that match the search target field of 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 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.

##### 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:

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 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, except it omits the `LOCATION` header field and has 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](https://developer.apple.com/documentation/foundation/urlsession?ref=williamboles.com) 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](https://github.com/IBM-Swift/BlueSocket?ref=williamboles.com). `BlueSocket` will handle the nitty-gritty socket communication, freeing us up to focus on sending and receiving SSDP messages.

> In the [example project](https://github.com/wibosco/SSDPDiscovery-Example?ref=williamboles.com), [CocoaPods](https://cocoapods.org/?ref=williamboles.com) is used to manage this dependency.

> At the time of writing (April 2019), the new(ish) [Network framework](https://developer.apple.com/documentation/network?ref=williamboles.com) doesn't support UDP multicasting.

> Since I wrote this article, Apple has released iOS 14, which contained a [number](https://www.wired.com/story/ios-14-privacy-security-features/?ref=williamboles.com) of user privacy improvements. One area of focus was on 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. **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](https://developer.apple.com/news/?id=0oi77447&ref=williamboles.com) for more details.

Before we get started, let's look at what we are going to build:

![Class diagram showing the completed SSDP Discovery system](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2020/06/ssdp_discovery_class_diagram.jpg)

- `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 to overcome the unreliable nature of UDP.
- `SSDPSearchSessionDelegate` is a protocol that informs the delegator of any found services, any errors encountered and ultimately that the search session has ended.
- `SocketControllerFactory` is responsible for producing socket controllers configured to use sockets that use a specific transport protocol.
- `UDPSocketController` is a wrapper around a `Socket` instance configured to use UDP. `UDPSocketController` helps to hide the *messiness* of socket communication.
- `UDPSocketControllerDelegate` is 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` is a `Socket` instance configured to use UDP. As `Socket` is from the `BlueSocket` dependency, `Socket` is wrapped in a protocol to limit the spread of `BlueSocket` in the project.
- `SSDPServiceParser` is responsible for parsing an SSDP response into `Service` 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](https://en.wikipedia.org/wiki/Facade%5Fpattern?ref=williamboles.com), which should make removing that dependency easier by limiting its 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.

1. The `Socket` type in `BlueSocket` can be used to create sockets with different configurations. In `UDPSocketProtocol`, we only want to cater for sockets configured that using UDP to send [datagram](https://en.wikipedia.org/wiki/Datagram?ref=williamboles.com) messages, so in `write(_:to:on:)` a check is made to determine if this `Socket` instance has been configured for UDP and datagram - if it hasn't then a fatal error is thrown as this is a developer error. The `write(from:to:)` method on `Socket` takes an `Address` type which is defined in `Socket` \- as mentioned above we want to limit the spread of `BlueSocket` in the project, so the `write` method defined in `UDPSocketProtocol` doesn't use `Address` but rather sticks with two `String` parameters `host` and `port`. In the extension, these parameters are used to create an `Address` instance, which is then forwarded to the `write(from:to:)` method on `Socket`. If, for some reason, an `Address` 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 an `UDPSocketError` case, before a new exception is thrown.
2. Just like with `write(_:to:on:)`, in `readDatagram(into:)`, a check is made to ensure that we are dealing with a socket configured for UDP and datagrams. The `readDatagram(into:)` method on `Socket` returns the number of bytes read as an `Int` and the address that sent those bytes as an `Address`. We aren't interested in either of those return values, so the `readDatagram(into:)` defined in `SocketProtocol` doesn't have a return type; our `readDatagram(into:)` simply ignores those details. If, for some reason, an exception is thrown when reading from the socket, this exception is caught, wrapped inside a `UDPSocketError` case, and then a new exception is thrown.

> The `close()` on `Socket` 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 to be called on UDP sockets sending datagram messages, so let's 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 uses `Socket`.

Now that we have `UDPSocketProtocol`, let's 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 to `SocketFactoryProtocol` so that when testing `UDPSocketController`, we can replace the `SocketFactory` instance with a mock factory that conforms to that protocol. I use this technique throughout this example, so any other protocols named after a concrete type serve 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:

1. A socket can be in 3 states: `ready`, `active` and `closed` \- `UDPSocketControllerState` represents these states. As `UDPSocketController` 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.
2. 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.
3. 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 if `state` is in a closed state.
4. 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 a `do...catch` block. If an exception is caught, the socket is closed, and any error is reported (we will implement this shortly).

`UDPSocketController` is designed to be tied to one host and port. If the host and port need to be changed, then a new instance of `UDPSocketController` must 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:

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). Having to write this custom initialiser manually 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` 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 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:

1. Configures the `M-Search` message using the `SSDPSearchSessionConfiguration` instance.
2. Creates an `UDPSocketController` instance.
3. Writes the `M-Search` message to the socket controller.
4. 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 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](https://en.wikipedia.org/wiki/Tcpdump?ref=williamboles.com).

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. Using `0` 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)](https://en.wikipedia.org/wiki/Berkeley%5FPacket%5FFilter?ref=williamboles.com) 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.

![Screenshot of tcpdump running](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2019/04/tcpdump-terminal-screenshot-m4.png)

> If you are using a VPN, you may need to disable it to see anything in the terminal.

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](https://useyourloaf.com/blog/remote-packet-capture-for-ios-devices/?ref=williamboles.com):

Connect your device and grab its `UDID`, then open the 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 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.

1. Reading from a `BlueSocket` socket configured to read datagram messages using UDP is a blocking call - any thread that `readDatagram(into:)` is called on will be blocked at that line until there is data 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`.
2. We only need to configure the socket to listen once - on the first write.
3. 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` block. If an exception is caught, the socket is closed, and any error is 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](https://en.wikipedia.org/wiki/Netcat?ref=williamboles.com). 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](https://www.roku.com/en-gb/?ref=williamboles.com) 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, where the field name serves as the dictionary key and the field's value is the 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:

1. Splitting the string into a dictionary using `\r\n` to determine fields and `:` to determine key and value pairs.
2. 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.
3. As an SSDP response can contain non-mandatory fields, the mandatory fields are stripped from the response dictionary, leaving only the non-mandatory fields present.
4. An `SSDPService` is created using the mandatory and non-mandatory fields.

> I could have combined `SSDPService` and `SSDPServiceParser` into a single type with the initialiser 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:

1. Store all valid services that have been received during that search session.
2. Inform the delegate when the search has been stopped, returning all valid services found.
3. Inform the delegate when a valid service has been found.
4. 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 valid `SSDPService` 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 found that having one closure handle three possible states resulted in code that was very *busy*, and readability suffered as a consequence.

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 ends 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 reaches 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 reaches 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`. Having a value that 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: 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.

1. Increased the total search time for multiple `M-Search` message writes.
2. Check that `maximumSearchRequestsBeforeClosing` is greater than 0 and return if it isn't.
3. Invalidate the new search request timer.
4. 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:

1. A check is made to see if `servicesFoundDuringSearch` already contains the service that has been parsed.

To get the above code to work, `SSDPService` needs to conform to [Equatable](https://developer.apple.com/documentation/swift/equatable?ref=williamboles.com):

```
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` because it changes with each response, as 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](https://github.com/wibosco/SSDPDiscovery-Example?ref=williamboles.com) and clone the project.