11. Services & Interfaces
How the outside world talks to a Psyclone system: services process external requests, interfaces bind them to network ports and protocols.
The model
Two elements cooperate to expose a Psyclone system on the network:
- A service is the thing that handles external requests — PsyProbe's web handler and the console are services.
- An interface is a network listener — a port with a protocol (
HTTP,Telnet,Message,RAW; TCP by default, UDP withip="UDP") — that forwards connections to a service.
Several interfaces can share one port: the protocol is auto-detected from the first bytes of a connection, and if nothing is detected within the timeout, the default="yes" interface is assumed.
Built-in services
Unless disabled, every system implicitly gets PsyProbe and the console, as if the spec contained:
<service name="PsyProbe" root="../html" />
<service name="Console" />
<interface name="PsyProbe" protocol="HTTP" service="PsyProbe" />
<interface name="Console" protocol="Telnet" service="Console" />
Disable either by pointing the protocol at no service:
<interface protocol="HTTP" service="none" />
<interface protocol="Telnet" service="none" />
Custom services
A custom service is the system's front door for something outside it: a browser, a phone app, a sensor, another program. It is declared like a small module, and it owns the subscription declared inside it: its <post>s say what incoming data is posted as, and its <trigger>s say what is sent back out. There is no crank and no function. The service does the network work, so no module ever opens or manages a socket. An <interface> binds the service to a port and protocol.
Message services. Custom Telnet services are not wired yet, and instances is parsed but not acted on. Engine tests: test=httpservice, test=wsservice, test=udpservice.Names, never raw types
An edge client never sets a message type. It names one of the service's posts, exactly as a module's postOutputMessage("Name", msg) does, and the service applies the type declared for that post. On the way out, every message carries the name of the service trigger that fired. The spec alone decides what enters the system, the client never needs the system's numeric type ids, and the type hierarchy can be reorganised without touching any client.
| Entry | Direction | Meaning |
|---|---|---|
PsyPost | client → system | Name of the service <post> to use. Optional when the service declares exactly one post. An unknown name is dropped. Any raw type the client set is overwritten. |
PsyTrigger | system → client | Name of the service <trigger> that matched the outgoing message. |
PsyDevice | client → system | Optional stable device id (UDP and Message). Keeps one identity when the device's address changes. |
PsyServiceConnection | both | Stamped on every incoming message (WebSocket connection or UDP peer). Keep it on a reply to send it back to that client; leave it out to send to every client of the service. |
PsyPeerAddress | client → system | UDP only: the sender's current ip:port, for logging and diagnostics. |
PsyService | client → system | Name of the service the message came in through. |
PsyServiceRequestID | both | HTTP request/reply only: correlates a reply with its waiting request. Echo it on the reply (a pass-through module keeps it automatically). |
1. File hosting
A service with rootdir serves files, optionally under a URL prefix given by subdir. Paths cannot escape the root.
<service name="Site" rootdir="../site" subdir="files" />
<interface name="SiteIf" port="8080" protocol="HTTP" service="Site" />
<!-- http://host:8080/files/index.html -> ../site/index.html -->
For a web UI that sits next to PsyProbe (and can use the PsyProbe API), an <alias> in <psyprobe> is often simpler; see PsyProbe.
2. HTTP ingestion — post it, answer at once
A service with a <post> and no <trigger> is an ingestion service. Each request is posted and answered 200 immediately, so a sender never waits for the system to process its data. Use it for uploads, telemetry and webhooks.
<service name="Telemetry">
<post name="Report" type="Device.Telemetry.Report" />
</service>
<interface name="TelemetryIf" port="8081" protocol="HTTP" service="Telemetry" />
<whiteboard name="Reports">
<trigger name="In" type="Device.Telemetry.Report" />
</whiteboard>
$ curl "http://host:8081/report?battery=71&network=4g"
{ "errorcode":0, "text":"accepted" }
The request becomes a DataMessage with HTTP_OPERATION, REQUEST and URI entries, one string entry per header and per query parameter, and the body (form fields as entries; JSON, XML and text bodies as strings, other content types as binary data). With several posts declared, choose one with a PsyPost query parameter.
3. HTTP request/reply — post it, wait for the answer
A service with a <post> and a <trigger> is a request/reply service. The request is posted with a PsyServiceRequestID; the first trigger message that echoes that id (or, if none does, the oldest waiting request's) becomes the HTTP reply. No reply within the service's timeout (default 5000 ms) returns 504 Gateway Timeout instead of leaving the client hanging.
<service name="Ask" timeout="3000">
<post name="Question" type="Web.Ask.Question" />
<trigger name="Answer" type="Web.Ask.Answer" />
</service>
<interface name="AskIf" port="8082" protocol="HTTP" service="Ask" />
<module name="Answerer">
<crank name="AnswerCrank" function="MyLib::Answer" />
<trigger name="Q" type="Web.Ask.Question" />
<post name="A" type="Web.Ask.Answer" />
</module>
The reply uses the same conventions as a catalog answering /api/query: an optional ResultCode (an HTTP_* status id, default HTTP_OK), an optional MIMETYPE, and one content entry, JSON, XML, HTML, TEXT or BINARY. With none of those, the whole reply message is returned as JSON.
int8 Answer(PsyAPI* api) {
const char* trigger;
while (api->shouldContinue()) {
DataMessage* q = api->waitForNewMessage(100, trigger);
if (!q) continue;
DataMessage* a = new DataMessage();
int64 id = 0;
if (q->getInt("PsyServiceRequestID", id))
a->setInt("PsyServiceRequestID", id); // correlate with the waiting request
const char* text = q->getString("q"); // ?q=... from the URL
a->setString("JSON", utils::StringFormat("{ \"answer\": \"%s\" }", text ? text : "").c_str());
api->postOutputMessage("A", a);
}
return 0;
}
$ curl "http://host:8082/ask?q=hello"
{ "answer": "hello" }
4. Streams — WebSocket and UDP
For independent flows in both directions (a phone streaming audio up while playout, status and events come down) a service has posts and triggers but is not request/reply: anything coming in becomes a post, anything triggered is sent out. One service can be reached over WebSocket and UDP at the same time, and both can use the same port number as its HTTP interface:
<service name="Phone">
<post name="AudioFrame" type="Mimir.Audio.Frame" />
<post name="DeviceEvent" type="Mimir.Device.Event" />
<trigger name="Playout" type="Mimir.Playout.Chunk" />
<trigger name="Status" type="Mimir.Status.Line" />
</service>
<!-- WebSocket arrives as an HTTP upgrade, so it uses the HTTP interface -->
<interface name="PhoneTCP" port="18600" protocol="HTTP" service="Phone" />
<interface name="PhoneUDP" port="18600" ip="UDP" protocol="Message" service="Phone" />
- WebSocket. HTTP and WebSocket share the interface; the upgrade is decided per connection. A binary frame that is a DataMessage is posted as it stands, keeping its timestamps, serials and tags (it still needs
PsyPost). A text frame arrives as aTextentry, any other binary frame as aContententry. Outgoing, a message with aTextentry goes out as a text frame; anything else goes out as the whole DataMessage in one binary frame. - UDP. Each datagram carries one DataMessage (under 64 KB) and becomes a post. Replies leave from the service's own port, which is what NAT and mobile networks let back through to a device. UDP has no connection, so the service keeps a peer per device: the key is
PsyDeviceif the client sends one, otherwise the source address. Every datagram refreshes the peer's address, so a phone that moves from Wi-Fi to 4G is followed and the next reply goes to its new address. - Packed frames. A client may send the packed wire form of a DataMessage (CMSDK
DataMessage::toPacked(), KotlintoPackedBytes()) over UDP,Messageor a WebSocket binary frame: the same content in about a third of the bytes (a phone audio frame with 80 B of Opus: 637 B regular, 253 B packed). The service turns it back into a regular message, so modules see no difference, and answers that client in the packed form until it sends a regular message again. - Routing out. A triggered message that carries
PsyServiceConnectiongoes to that connection or peer only; without it, it goes to every WebSocket connection and UDP peer of the service. A module that simply passes an incoming message through keeps the entry, so replies go back where the request came from. A trigger that echoes thePsyServiceRequestIDof an HTTP request still waiting answers that request instead, so one service can serve HTTP request/reply and streams side by side.
A browser client. A text frame cannot name a post, so it always uses the service's only post: give plain-text clients a single-post service, and use binary DataMessage frames when a client needs several posts.
<service name="Chat">
<post name="Say" type="Chat.Say" />
<trigger name="Heard" type="Chat.Heard" />
</service>
<interface name="ChatIf" port="8083" protocol="HTTP" service="Chat" />
const ws = new WebSocket("ws://host:8083/chat");
ws.onmessage = (e) => console.log("from system:", e.data); // a Text entry comes back as text
ws.onopen = () => ws.send("hello"); // posted as Chat.Say with Text="hello"
A UDP client using the CMSDK Python bindings:
import socket, cmsdk3
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
m = cmsdk3.DataMessage()
m.setString("PsyPost", "AudioFrame") # the post's NAME, never a type
m.setString("PsyDevice", "phone-s25") # stays the same across Wi-Fi/4G changes
m.setInt("Serial", 1)
sock.sendto(m.getBytes(), ("host", 18600))
reply = cmsdk3.DataMessage.fromBytes(sock.recvfrom(65536)[0])
print(reply.getString("PsyTrigger")) # e.g. "Playout"
A Kotlin or Android client uses the CMSDK Kotlin codec (CMSDK/kotlin, package cmlabs.cmsdk), which writes the same bytes as the C++ library:
val m = DataMessage().setString("PsyPost", "AudioFrame").setString("PsyDevice", "phone-s25")
socket.send(DatagramPacket(m.toBytes(), m.toBytes().size, serverAddress, 18600))
val reply = DataMessage.fromBytes(packet.data, 0, packet.length) // reply.getString("PsyTrigger")
The same from Kotlin or Android with the CMSDK Kotlin codec, in the packed form (this is how the Mimir phone app talks to its Link module):
val m = DataMessage().setString("PsyPost", "AudioFrame").setString("PsyDevice", "phone-s25")
.setData("audio", opusFrame)
val bytes = m.toPackedBytes() // ~3x smaller than m.toBytes()
socket.send(DatagramPacket(bytes, bytes.size, InetSocketAddress(host, 18600)))
socket.receive(p)
val reply = DataMessage.fromBytes(p.data, 0, p.length) // packed reply; fromBytes reads both forms
Protocol auto-detection on a shared port
Multiple interfaces can listen on the same port; each connection is sniffed and routed:
<interface name="MyWebInterface" node="Node0" port="1234" protocol="HTTP"
service="MyWebService" timeout="3000" default="yes" />
<interface name="MyTelnet" port="1234" protocol="Telnet" service="MyTelnetService" />
<interface name="MyMsgLink" port="1234" protocol="Message" service="MyMsgService" />
After 3 seconds with no recognisable protocol, the default (here HTTP) interface takes the connection. Interface attributes: name (required), service (required), port (required), node, protocol, timeout, default, plus the SSL verification attributes allowselfsigned / cafile / capath (see the reference).
How clients connect
- Browsers — hit the main port (default 10000) or a dedicated PsyProbe/HTTP interface:
http://hostname:10000/. - Terminals — telnet to a Telnet interface for the console service.
- Devices and apps — connect to a custom service: HTTP for uploads and request/reply, WebSocket or UDP for two-way streams (see above). They name posts and receive trigger names; they never see the system's types.
- Programs — link CMSDK and send DataMessages to a
Messageinterface (TCP or UDP) of a custom service, or run as an external-space component for full module semantics.
<authentication> element (currently a parse-only stub) — lives in the System Guide.