Skip to content

SSDP Service

The SSDP Service module provides a complete, self-contained implementation of the Simple Service Discovery Protocol (SSDP) for ESP-IDF. SSDP is the discovery layer of the UPnP protocol stack. It enables the pendant to advertise itself on the local network so that host tools, browsers, and control panels can automatically discover the device’s HTTP endpoint without requiring manual IP address entry.

The module is structured in two layers:

  • components/SSDP_IDF/ — A portable, pure-C ESP-IDF component that implements the SSDP wire protocol using lwIP sockets, FreeRTOS tasks, and semaphores.
  • main/modules/ssdp/esp3d_ssdp.h (ESP3Dssdp) — A thin C++ wrapper that integrates the component into the application’s service lifecycle, reading device identity from ESP3DSettings and coordinating startup through ESP3DNetworkServices.

Build constraint: SSDP_SERVICE is enabled by default for the serial/USB CNC + WiFi remote configuration. It is mutually exclusive with SOCKET_CLIENT_SERVICE — see cmake/sanity_check.cmake. Do not enable both. Refer to feature_resource_matrix.md before changing transport policy.


External Callers

Network Layer

SSDP_IDF Component (C)

Application Layer

deferred begin()

ssdp_init() + ssdp_start()

UDP Multicast

M-SEARCH / NOTIFY

GET /description.xml

get_schema()

ESP3DNetworkServices

(esp3d_network_services.h)

ESP3Dssdp

(esp3d_ssdp.h)

Public API

ssdp_init / ssdp_start / ssdp_stop

get_ssdp_schema_str

ssdp_running_task

(FreeRTOS Task)

ssdp_task_config_t

(Internal runtime state)

IPv4 UDP Multicast Socket

239.255.255.250:1900

Semaphores

ssdp_send / ssdp_on_packet

esp_netif (lwIP)

WIFI_STA / WIFI_AP / ETH

HTTP Service

(esp3d_http_service)

UPnP Client

(Browser / Host Tool)


ssdp.c (Implementation)

ssdp.h (Public Interface)

ssdp_config_t

SDDP_DEFAULT_CONFIG()

ssdp_init()

ssdp_start()

ssdp_stop()

get_ssdp_schema_str()

ssdp_task_config_t

(internal runtime struct)

ssdp_running_task()

onPacket()

ssdp_send()

ssdp_set_UUID()

ssdp_get_LocalIP()

Create binary semaphores

Send BYEBYE

+ free all heap memory

Build XML on demand

via sprintf into calloc buffer


LayerFileLanguageRole
Application wrappermain/modules/ssdp/esp3d_ssdp.hC++Reads settings, integrates with ESP3DNetworkServices, manages deferred start
Protocol componentcomponents/SSDP_IDF/ssdp.c/.hCImplements the SSDP wire protocol, owns the FreeRTOS task and socket

The wrapper calls ssdp_init() once during firmware boot and ssdp_start() when the network is ready, passing a populated ssdp_config_t. The component is stateless from the wrapper’s perspective — all runtime state lives inside the internal ssdp_task_config_t heap allocation, which is fully freed by ssdp_stop().


Defined in components/SSDP_IDF/include/ssdp.h. All string fields are copied into internal heap buffers during ssdp_start() so caller-owned strings can be freed immediately after the call returns.

FieldTypeDefaultDescription
task_priorityunsignedtskIDLE_PRIORITY + 5FreeRTOS task priority
stack_sizesize_t4096Stack size in bytes for the SSDP task
core_idBaseType_ttskNO_AFFINITYCPU core affinity (0, 1, or tskNO_AFFINITY)
FieldTypeDefaultDescription
ttluint8_t2UDP multicast TTL (hops)
portuint16_t80HTTP port advertised in SSDP LOCATION header
intervaluint32_t1200Periodic NOTIFY interval in seconds
mx_max_delayuint16_t10000Maximum MX response delay in milliseconds
FieldTypeMax LengthDescription
uuid_rootconst char*27 charsRoot prefix for MAC-derived UUID (must match SSDP_UUID_ROOT length exactly)
uuidconst char*36 charsFull UUID override; if set, uuid_root is ignored
device_typeconst char*64 charsUPnP device type (e.g. "Basic")
friendly_nameconst char*64 charsHuman-readable device name shown in discovery results
serial_numberconst char*32 charsDevice serial number
FieldTypeMax LengthDescription
schema_urlconst char*64 charsPath to UPnP XML schema (e.g. "description.xml")
presentation_urlconst char*128 charsBrowser landing page URL
manufacturer_urlconst char*128 charsManufacturer website
model_urlconst char*128 charsModel information URL
FieldTypeMax LengthDescription
manufacturer_nameconst char*64 charsManufacturer display name
model_nameconst char*64 charsModel name
model_numberconst char*32 charsModel number / firmware version
model_descriptionconst char*64 charsShort device description
server_nameconst char*64 charsSERVER header string (e.g. "SSDPServer/1.0")
services_descriptionconst char*256 charsRaw XML <serviceList> content
icons_descriptionconst char*256 charsRaw XML <iconList> content

Default configuration macro:

ssdp_config_t cfg = SDDP_DEFAULT_CONFIG();
// Override as needed:
cfg.port = 80;
cfg.friendly_name = "Pibot Pendant";
cfg.model_name = "PibotCNC";
ssdp_start(&cfg);

Multicast Socketssdp_running_taskssdp_init / ssdp_start / ssdp_stopESP3DssdpESP3DNetworkServicesMulticast Socketssdp_running_taskssdp_init / ssdp_start / ssdp_stopESP3DssdpESP3DNetworkServicesCreates two binary semaphores:\nssdp_send + ssdp_on_packetAllocates ssdp_task_config_t\nCopies all string fields\nGenerates UUID from MACselect() loop, 2 s timeoutloop[Every interval seconds]On network shutdownLoop exits -> vTaskDelete(NULL)Frees ssdp_task_config_t\nand all string buffersbegin() [deferred after network ready]ssdp_init()ESP_OKssdp_start(&config)xTaskCreatePinnedToCore()create_multicast_ipv4_socket()\nbind(:1900), IP_ADD_MEMBERSHIPssdp_send(NOTIFY)SSDP NOTIFY sent to 239.255.255.250:1900end()ssdp_stop()ssdp_send(BYEBYE)ssdp_running = falseshutdown + close socket

a14: the socket retry delay is vTaskDelay(pdMS_TO_TICKS(10)) (one tick). The former vTaskDelay(5 / portTICK_PERIOD_MS) was 0 ticks at FREERTOS_HZ=100, a pure yield, so the retry loop was busy.

ssdp_running_task is the core of the component. It runs a select-based socket loop with a 2-second timeout, handling both incoming discovery packets and outgoing periodic notifications. If the socket fails (e.g. network down), the loop closes it and retries socket creation from scratch.

yes

no

yes

no

yes

no

error

ok

true

false

ssdp_running_task starts

create_multicast_ipv4_socket()

PF_INET / SOCK_DGRAM

bind :1900

IP_MULTICAST_TTL

IP_ADD_MEMBERSHIP

socket < 0?

vTaskDelay 10 ms

select()

2 s timeout

data ready?

recvfrom()

buffer up to 1401 bytes

onPacket()

state machine parse + reply

notify_time

expired?

ssdp_send(NOTIFY)

to multicast group

select error?

shutdown + close socket

restart outer loop

ssdp_running

== false?

vTaskDelete NULL

Two binary semaphores prevent concurrent send operations or concurrent packet processing:

SemaphoreGuardsHeld during
ssdp_send_xSemaphoressdp_send()Entire datagram construction + sendto()
ssdp_on_packet_xSemaphoreonPacket()Full M-SEARCH parse + response dispatch

Both are taken with a 10-tick timeout. Failure to acquire is logged as an error and the operation is silently skipped for that cycle.


When a UPnP client sends an M-SEARCH datagram to 239.255.255.250:1900, onPacket() parses it using a character-by-character state machine (METHOD -> URI -> PROTO -> KEY -> VALUE) and dispatches a unicast HTTP/1.1 response back to the querying host.

ssdp_send()onPacket() state machineMulticast Socket :1900UPnP Clientssdp_send()onPacket() state machineMulticast Socket :1900UPnP Clientalt[ST = "ssdp:all" or "upnp:rootdevice"][ST matches configured device_type][no match]M-SEARCH * HTTP/1.1\nHOST: 239.255.255.250:1900\nMAN: "ssdp:discover"\nST: ssdp:all\nMX: 3raw datagram + remote addr/portstmatch=true, usn_suffix="::upnp:rootdevice"stmatch=true, usn_suffix="::<device_type>"state=ABORT, discard packetssdp_send(sock, NONE, remote_addr, remote_port)HTTP/1.1 200 OK\nEXT:\nCACHE-CONTROL: max-age=1200\nSERVER: <server_name> UPNP/1.1 <model>/<version>\nUSN: uuid:<uuid>::upnp:rootdevice\nST: upnp:rootdevice\nLOCATION: http://<ip>:<port>/description.xml

Every interval seconds the task sends an unsolicited ssdp:alive advertisement to the SSDP multicast group, allowing all UPnP clients on the subnet to refresh their device table without polling.

239.255.255.250:1900ssdp_send(NOTIFY)ssdp_running_task239.255.255.250:1900ssdp_send(NOTIFY)ssdp_running_tasknotify_time expired (interval seconds elapsed)Sets respond_type = 'upnp:rootdevice'\nusn_suffix = '::upnp:rootdevice'ssdp_send(sock, NOTIFY, 0, 0)NOTIFY * HTTP/1.1\nHOST: 239.255.255.250:1900\nNTS: ssdp:alive\nCACHE-CONTROL: max-age=1200\nSERVER: ...\nUSN: uuid:<uuid>::upnp:rootdevice\nNT: upnp:rootdevice\nLOCATION: http://<ip>:<port>/description.xml

Sent once during ssdp_stop() to signal removal from the network before the socket is closed. This allows UPnP clients to immediately remove the device from their lists rather than waiting for the cache to expire.

239.255.255.250:1900ssdp_send(BYEBYE)ssdp_stop()239.255.255.250:1900ssdp_send(BYEBYE)ssdp_stop()ssdp_send(sock, BYEBYE, 0, 0)NOTIFY * HTTP/1.1\nHOST: 239.255.255.250:1900\nNTS: ssdp:byebye\nUSN: uuid:<uuid>::upnp:rootdevice\nNT: upnp:rootdevicessdp_running = false\nvTaskDelay(100 ms)\nfree all buffers

The device UUID is derived deterministically from the ESP32’s fused MAC address. ssdp_set_UUID() reads the MAC via esp_efuse_mac_get_default() and appends the lower three bytes (indices 2, 1, 0) as hex digits to a configurable root prefix.

Default root: "38323636-4558-4dda-9188-cda0e6"
MAC bytes 2-0: XX YY ZZ
Result UUID: "38323636-4558-4dda-9188-cda0e6XXYYZZ"

This ensures each physical device has a unique, stable UUID without requiring NVS storage. UUID selection priority:

  1. configuration->uuid set (exactly 36 chars) — used verbatim.
  2. configuration->uuid_root set (exactly 27 chars) — combined with MAC suffix.
  3. Neither set — SSDP_UUID_ROOT default prefix + MAC suffix.

get_ssdp_schema_str() dynamically builds the UPnP device description XML that HTTP clients retrieve from the LOCATION URL (typically GET /description.xml). The XML follows the UPnP 1.1 device schema:

<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion><major>1</major><minor>0</minor></specVersion>
<URLBase>http://<ip>:<port>/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:<device_type>:1</deviceType>
<friendlyName>...</friendlyName>
<presentationURL>...</presentationURL>
<serialNumber>...</serialNumber>
<modelName>...</modelName>
<modelDescription>...</modelDescription>
<modelNumber>...</modelNumber>
<modelURL>...</modelURL>
<manufacturer>...</manufacturer>
<manufacturerURL>...</manufacturerURL>
<UDN>uuid:...</UDN>
<serviceList>...</serviceList>
<iconList>...</iconList>
</device>
</root>

Memory note: The schema buffer is allocated fresh on every call and the previous allocation is freed first. The caller must not free the returned pointer and must not cache it across calls. Verify available heap before calling in low-memory conditions.


ESP3DNetworkServices owns all discovery services (mDNS and SSDP) and manages their deferred startup. Both are flagged pending at begin() and actually started only once the network interface is confirmed ready.

ESP3DNetworkServices::handle()

called from networkTask

ESP3DNetworkServices::begin()

yes

yes

_mdns_defer_pending = true

_ssdp_defer_pending = true

mDNS deadline

reached?

SSDP deadline

reached?

ESP3DmDNS::begin()

ESP3Dssdp::begin()

ssdp_init() + ssdp_start()

See mdns.md for the companion mDNS service that runs alongside SSDP. For the full network lifecycle, see connection_management.md.


ConditionSSDP stateNotes
SOCKET_CLIENT_SERVICE=OFF (default)EnabledSerial/USB CNC + WiFi remote configuration
SOCKET_CLIENT_SERVICE=ONForbiddensanity_check.cmake hard-blocks this combination
Bluetooth mode activeN/AWiFi stack absent; SSDP does not compile or start

When SOCKET_CLIENT_SERVICE is ON, the WiFi stack is fully committed to the TCP CNC link. Running SSDP multicast on the same interface increases fragmentation risk and can cause EAGAIN errors on the lwIP stack. See esp32_memory_constraints.md for the fragmentation playbook and feature_resource_matrix.md for the full feature compatibility matrix.


The SSDP component performs multiple calloc() allocations during ssdp_start() — one per string field in ssdp_config_t plus a 1401-byte UDP receive buffer. All are freed by ssdp_stop().

AllocationApproximate size
ssdp_task_config_t struct~200 bytes
UDP datagram receive buffer1401 bytes
UUID buffer37 bytes
All string fields combined (typical)~600 bytes
FreeRTOS task stack4096 bytes (configurable via stack_size)
Schema buffer (on demand, per call)~500–800 bytes
Per-send datagram bufferDynamic; freed immediately after sendto()

Key rules:

  • If any allocation fails inside ssdp_start(), err_start becomes ESP_ERR_NO_MEM and the function returns — but partial allocations are not freed by ssdp_start() itself. Always call ssdp_stop() on a failed ssdp_start() to release partial state.
  • The schema buffer is freed and reallocated on every call to get_ssdp_schema_str(). Do not cache the returned pointer across calls.

FunctionReturnsDescription
ssdp_init()esp_err_tCreates the two binary semaphores. Call once before ssdp_start(). Returns ESP_ERR_NO_MEM on allocation failure.
ssdp_start(config)esp_err_tValidates and copies all config fields, allocates runtime state, spawns the FreeRTOS task. Returns ESP_ERR_INVALID_STATE if already running or not initialized.
ssdp_stop()esp_err_tSends BYEBYE, signals the task loop to exit, closes the socket, frees all allocated memory. Safe to call after a partially-failed ssdp_start().
get_ssdp_schema_str()const char*Returns the dynamically built UPnP XML description. Returns NULL if SSDP is not started or allocation fails. Caller must not free the pointer.
MethodDescription
begin()Calls ssdp_init() + ssdp_start() with settings sourced from ESP3DSettings
handle()No-op — protocol is fully driven by the internal FreeRTOS task
end()Calls ssdp_stop()
get_schema()Delegates to get_ssdp_schema_str()
started()Returns the _started flag

DocumentRelationship
mdns.mdPeer discovery service managed alongside SSDP by ESP3DNetworkServices
connection_management.mdNetwork lifecycle that gates SSDP startup
feature_resource_matrix.mdFeature compatibility matrix; WiFi/CNC usage model and net “tickets” design
features.mdHardware / connectivity / services × SKU matrix
esp32_memory_constraints.mdHeap fragmentation playbook; Serial CNC + WiFi remote fragmentation section