11 KiB
Mobile Control: architecture and client communication
TL;DR
Mobile Control is a WebSocket-based messaging layer built into Essentials. Devices expose small
"messenger" classes that register JSON message paths (e.g. /device/{key}/startMeeting) with a
central MobileControlSystemController. Clients (typically a React app built on
@pepperdash/mobile-control-react-app-core)
connect over WebSocket with a per-room token, send the same JSON envelope to invoke device methods,
and receive unsolicited state-update messages that a Redux store uses to keep device/room state in
sync in real time.
Server-side architecture
Projects involved
PepperDash.Essentials.MobileControl- theMobileControlSystemControllerdevice, its WebSocket server (WebSocketServer/MobileControlWebsocketServer.cs), room bridges, and touchpanel controller.PepperDash.Essentials.MobileControl.Messengers(namespacePepperDash.Essentials.AppServer.Messengers) - theMessengerBaseclass and the library of built-in messengers for common device interfaces (IHasStartMeetingMessenger,IHasMeetingInfoMessenger,ICommunicationMonitorMessenger,IRoutingMidpointWithFeedbackMessenger, etc.), plus the message envelope types.PepperDash.Essentials.Core.DeviceTypeInterfaces- the public contracts:IMobileControl,IMobileControlMessenger,IMobileControlMessengerWithSubscriptions,IMobileControlRoomMessenger,IMobileControlAction,IMobileControlMessage, and the touchpanel-controller interfaces.
Direct Server vs. Edge Server
Mobile Control supports two (non-exclusive) transport modes, configured on the MobileControlConfig
device:
- Direct Server -
MobileControlWebsocketServerruns an HTTPS WebSocket server directly on the processor hardware. Clients on the same network connect straight to the processor - lowest latency, no external dependency. This is whatmobile-control-react-app-core's local dev flow and the deployedmcUserAppReact app use. - Edge Server (API server / cloud gateway) - the processor instead makes an outbound WebSocket connection to an external Mobile Control server, which relays messages between it and remote clients. Useful when clients can't reach the processor directly (e.g. no local network access).
Both modes can be enabled simultaneously; outgoing messages are queued and sent down whichever transport(s) are active.
The WebSocket endpoint (Direct Server)
MobileControlWebsocketServer (WebSocketServer/MobileControlWebsocketServer.cs) hosts the
Direct Server:
- Path:
/mc/api/ui/join/(_wsPath) - Port:
50000 + <program slot number>by default (e.g. program slot 2 → port50002), or a custom port from config - User app: static files are served from
/user/programX/mcUserApp(_appPath) at the base href/mc/app(_userAppBaseHref) - this is exactly themcUserAppdeployment directory used when deploying a built React app.
Connecting and authentication (tokens)
A client doesn't connect straight to the WebSocket - it first needs a token identifying which room/UI-client slot it's joining:
- Get a token - on the processor console, run:
This prints the Direct Server port, any existing UI-client tokens, and full connect URLs (e.g.mobileinfo:[programSlot]http://[ip]:[port]/mc/app?token=[token]). New tokens can also be added dynamically (mobileadduiclient:[programSlot] [roomKey]) - see the console command's help text for details. - Join a room - the client calls
GET {apiPath}/ui/joinroom?token={token}over HTTPS, which validates the token and returns room data including a generatedclientId. - Open the WebSocket - the client connects to
wss://[processor-ip]:[port]/mc/api/ui/join/{token}?clientId={clientId}.
Each connected client is tracked as a UiClient; disconnecting removes its subscriptions from
every messenger it was subscribed to.
The message envelope
Every message sent in either direction is the same simple JSON envelope
(MobileControlMessage, namespace PepperDash.Essentials.AppServer.Messengers):
{
"type": "/device/zoomRoom1/startMeeting",
"clientId": "abc-123",
"content": { "value": 30 }
}
type- a path identifying the target device/room and action (client → server), or the device/room whose state is being reported (server → client).clientId- which client sent the message, or (server → client) which single client a targeted reply is meant for; omitted/ignored for broadcasts to all subscribers.content- aJTokenpayload. For simple values,MobileControlSimpleContent<T>wraps a singlevalueproperty (as in the example above); for state updates,contentis the device's full serialized state object.
The messenger pattern
Each device that wants to be controllable/observable over Mobile Control gets a small messenger
class deriving from MessengerBase (PepperDash.Essentials.AppServer.Messengers):
public class IHasStartMeetingMessenger : MessengerBase
{
// ...
protected override void RegisterActions()
{
AddAction("/fullStatus", (id, content) => SendFullStatus(id));
AddAction("/startMeeting", (id, content) =>
{
var msg = content.ToObject<MobileControlSimpleContent<uint>>();
_startMeeting.StartMeeting(msg?.Value ?? _startMeeting.DefaultMeetingDurationMin);
});
AddAction("/leaveMeeting", (id, content) => _startMeeting.LeaveMeeting());
}
}
- The messenger is constructed with a base
MessagePath(e.g./device/zoomRoom1) and callsAddAction(subPath, handler)for each supported action, relative to that base path. RegisterWithAppServer(IMobileControl appServerController)registers the messenger'sHandleMessagecallback with the system controller viaIMobileControl.AddAction<T>(...).- When an incoming message's
typestarts with the messenger'sMessagePath,HandleMessagestrips that prefix (leaving e.g./startMeeting) and dispatches to the matching registered action. The sending client is automatically added to that messenger's subscriber list, so it receives future unsolicited feedback from it. - To push state out, messengers call
PostStatusMessage(DeviceStateMessageBase message, clientId)- omittingclientIdbroadcasts to every subscribed client; passing one targets a single client (e.g. replying to a/fullStatusrequest).
How devices opt in
EssentialsDevice.CustomActivate() calls a virtual CreateMobileControlMessengers() hook once all
devices have activated. A device (or a room) overrides this method to look up the IMobileControl
device and construct/register whichever messengers it needs:
protected override void CreateMobileControlMessengers()
{
var mc = DeviceManager.AllDevices.OfType<IMobileControl>().FirstOrDefault();
if (mc == null) return;
var messenger = new IHasStartMeetingMessenger("zoomRoom1-startMeeting", "/device/zoomRoom1", this);
messenger.RegisterWithAppServer(mc);
}
Rooms typically implement IMobileControlRoomMessenger and register a room-level messenger at a
/room/{roomKey} path (via a MobileControlBridgeBase/MobileControlEssentialsRoomBridge), in
addition to any per-device messengers.
Client-side architecture (React)
@pepperdash/mobile-control-react-app-core provides the client building blocks used by apps like
beincourt-pv2-react-app:
Connection lifecycle
The real work happens in a Redux middleware (src/lib/store/middleware/websocketMiddleware.ts);
WebsocketProvider/WebsocketContext are a thin, backward-compatible React Context wrapper around
it that simply dispatches Redux actions (wsConnect, wsSendMessage, wsReconnect, ...).
- On mount,
wsConnect()is dispatched. - The middleware reads
apiPathfrom the app's local config (_config.local.json/_config.default.json) and the connectiontoken(from the URL's?token=query param). - It calls
GET {apiPath}/ui/joinroom?token={token}to validate the token and get room data (including aclientId). - It opens a WebSocket at
{apiPath with ws(s) scheme}/ui/join/{token}?clientId={clientId}. - On specific close codes (e.g.
4000user code changed,4002room combination changed) it stops auto-reconnecting and surfaces an error; otherwise it automatically retries.
Sending messages
const { sendMessage } = useWebsocketContext();
sendMessage('/device/zoomRoom1/startMeeting', { value: 30 });
sendMessage serializes { type, clientId, content } (the same envelope described above) and
sends it over the open WebSocket.
Receiving messages / state hydration
Incoming messages are routed by their type prefix:
| Prefix | Handling |
|---|---|
/system/* |
Internal system messages (user code, touchpanel key, room-combination/device-interface changes, initial sync complete, ...) |
/event/* |
Dispatched to any handlers registered via addEventHandler(eventType, key, callback) |
/room/* |
dispatch(roomsActions.setRoomState(message)) |
/device/* |
dispatch(devicesActions.setDeviceState(message)) |
Device/room state lives in Redux slices keyed by device/room key, so any component can read the latest known state for a given key.
The hook pattern
Each supported device interface has a small hook that combines reading state with calling actions,
e.g. useIHasStartMeeting:
export function useIHasStartMeeting(key: string): IHasStartMeetingReturn | undefined {
const { sendMessage } = useWebsocketContext();
const state = useGetDevice<IHasStartMeetingState>(key); // reads devices[key] from Redux
return useMemo(() => {
if (!state) return undefined;
const path = `/device/${key}`;
return {
state,
startMeeting: (durationMin?: number) => sendMessage(`${path}/startMeeting`, { value: durationMin }),
leaveMeeting: () => sendMessage(`${path}/leaveMeeting`, null),
};
}, [key, sendMessage, state]);
}
Components use these hooks to read live device state and call device actions without needing to know anything about the WebSocket transport underneath.
Full round-trip example: starting a meeting
- UI: user clicks "Start Meeting"; component calls
startMeeting(30)fromuseIHasStartMeeting('zoomRoom1'). - Client → server:
sendMessage('/device/zoomRoom1/startMeeting', { value: 30 })sends{"type":"/device/zoomRoom1/startMeeting","clientId":"abc-123","content":{"value":30}}over the WebSocket. - Server routing: the system controller matches
/device/zoomRoom1against the registeredIHasStartMeetingMessenger'sMessagePath, strips the prefix to get/startMeeting, and invokes its registered action, which callsStartMeeting(30)on the underlying device. - Device fires feedback: the device's meeting-started feedback fires.
- Server → clients: the messenger calls
PostStatusMessage(...), broadcasting{"type":"/device/zoomRoom1","clientId":null,"content":{ ...state }}to every client subscribed to that messenger. - Client receives: the message's
typestarts with/device/, sodispatch(devicesActions.setDeviceState(message))merges the new state into Redux underdevices['zoomRoom1']. - UI updates:
useIHasStartMeeting('zoomRoom1')'sstatereflects the new meeting info on the next render.