REST API and WebSocket interface for programmatic access to PigStick devices.
http://10.0.0.1 or http://pigstick-XXXX.local. All responses are application/json unless otherwise noted.
The PigStick exposes a REST API over HTTP and two WebSocket endpoints for real-time streaming. The API requires no authentication—access is controlled by WiFi connectivity to the device's network.
Content-Type: application/json unless noted (binary waveform data, ZIP exports)POST and PUT use JSON| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/device | Device identity and capabilities |
| GET | /api/v1/status | Full device state snapshot |
| GET | /api/v1/power | Battery status |
| GET | /api/v1/temperature | MCU temperature |
| GET | /api/v1/time | System time and sync status |
| GET | /api/v1/recorder/status | Recorder state and active job |
| GET | /api/v1/recorder/config | Trigger and audio configuration |
| POST | /api/v1/recorder/config | Update recorder configuration |
| POST | /api/v1/recorder/arm | Arm or disarm recorder |
| GET | /api/v1/events | List all event metadata |
| GET | /api/v1/events/{id} | Single event metadata |
| GET | /api/v1/events/{id}/magnetic | Binary magnetic waveform |
| GET | /api/v1/events/{id}/audio | Binary ADPCM audio |
| POST | /api/v1/events/{id}/cursor | Set cursor position on event |
| POST | /api/v1/events/refresh | Rebuild event manifest |
| DELETE | /api/v1/events/{id} | Delete single event |
| DELETE | /api/v1/events | Delete all events |
| GET | /api/v1/jobs | List all jobs |
| POST | /api/v1/jobs | Create new job |
| PUT | /api/v1/jobs/{client}/{job} | Update job metadata |
| POST | /api/v1/jobs/{client}/{job}/activate | Set active job |
| DELETE | /api/v1/jobs/{client}/{job} | Delete job |
| GET | /api/v1/jobs/{client}/{job}/export | Export job as ZIP |
| GET | /api/v1/gps | GPS position and satellites |
| GET | /api/v1/wifi | WiFi mode and connection status |
| GET | /api/v1/wifi/scan | Scan for available networks |
| GET | /api/v1/wifi/networks | List saved networks |
| POST | /api/v1/wifi/networks | Save a WiFi network |
| DELETE | /api/v1/wifi/networks | Delete a saved network |
| GET | /api/v1/system/info | System information |
| POST | /api/v1/system/restart | Restart device |
| WS | /ws/audio | Real-time geophone audio stream |
| WS | /ws/magnetic | Real-time magnetic stream + events |
Returns device identity, firmware version, and hardware capabilities.
{
"device": {
"serial": "PS2609A0110",
"name": "pigstick-0110",
"firmware": "2.0.0",
"uptime": 3621,
"bootCount": 47,
"capabilities": {
"audio": true,
"magnetic": true,
"gps": true,
"battery": true
},
"limits": {
"maxWsClients": 2,
"maxEvents": 103
}
}
}
| Field | Type | Description |
|---|---|---|
| serial | string | Device serial number (PS format) |
| name | string | Device name (matches WiFi AP SSID) |
| firmware | string | Firmware version |
| uptime | number | Seconds since boot |
| bootCount | number | Total boot cycles |
| capabilities | object | Available hardware features |
| limits.maxWsClients | number | Maximum concurrent WebSocket clients |
| limits.maxEvents | number | Maximum stored events before FIFO eviction |
Comprehensive device state snapshot including recorder, GPS, power, and connectivity.
{
"status": {
"timestamp": 1773868800000,
"device": "pigstick-0110",
"serial": "PS2609A0110",
"firmware": "2.0.0",
"app": "2.0.2",
"uptime": 3621,
"storage": {
"eventCount": 12,
"total": 10379264,
"used": 1245184,
"free": 9134080,
"evictionCount": 0
},
"job": {
"client": "Acme Pipeline",
"jobName": "Run-042",
"operator": "J. Smith"
},
"recorder": {
"state": "armed",
"eventCount": 12,
"freeSpace": 9134080,
"captureProgress": null,
"holdoffRemainingMs": null,
"lastTriggerTime": 1773868500000,
"capturesSinceBoot": 3,
"autoArm": false,
"audioEnabled": true
},
"gps": {
"timeSynced": true,
"fix": true,
"fixQuality": 1,
"satellites": 7,
"lat": 30.267153,
"lon": -97.743057,
"altitude": 149.20,
"speedKnots": 0.04,
"speedMps": 0.0206,
"utcEpochMs": 1773868800000
},
"power": {
"voltage": 4.05,
"percent": 82,
"charging": false,
"source": "usb"
},
"temperature": 42.5,
"wifiConnected": false,
"mqttConnected": false,
"iotJobsBusy": false,
"startupBehavior": {
"armOnBoot": false
}
}
}
| Field | Type | Description |
|---|---|---|
| timestamp | number | Current UTC epoch milliseconds |
| job | object|null | Active job info, or null if none |
| recorder.state | string | One of: idle, armed, capturing, writing, holdoff |
| recorder.captureProgress | number|null | 0–100 during capture, null otherwise |
| recorder.holdoffRemainingMs | number|null | Milliseconds remaining in hold-off, null otherwise |
| gps.lat, gps.lon | number | Conditional: only present when fix is true |
| gps.utcEpochMs | number | Conditional: only present when GPS time is valid |
| temperature | number | MCU temperature in °C (omitted if sensor error) |
| ota | object | Conditional: only present during OTA update |
Battery voltage, charge percentage, and power source.
{
"power": {
"voltage": 4.05,
"percent": 82,
"charging": false,
"source": "usb"
}
}
Internal MCU temperature reading.
{
"temperatureC": 42.50,
"temperatureF": 108.50
}
{
"error": "sensorReadFailed"
}
System clock status. Time is synced from GPS satellites.
{
"synced": true,
"time": "2026-03-17T14:30:00"
}
{
"synced": false
}
The event recorder follows a state machine: IDLE → ARMED → CAPTURING → WRITING → HOLDOFF → ARMED. Configuration changes are rejected while a capture is in progress.
Current recorder state, capture statistics, and active job context.
{
"recorder": {
"state": "armed",
"eventCount": 12,
"freeSpace": 9134080,
"captureProgress": null,
"holdoffRemainingMs": null,
"lastTriggerTime": 1773868500000,
"capturesSinceBoot": 3,
"autoArm": false,
"audioEnabled": true,
"activeJob": {
"client": "Acme Pipeline",
"jobName": "Run-042",
"clientDir": "Acme_Pipeline",
"jobDir": "Run-042",
"createdAt": 1773782400,
"notes": ""
}
}
}
Current trigger, audio, and speed estimation configuration.
{
"config": {
"trigger": {
"mode": "zeroCrossing",
"thresholdPercent": 50,
"holdoffMs": 5000,
"preDurationMs": 10000,
"postDurationMs": 10000
},
"audio": {
"enabled": true,
"sampleRate": 8000
},
"speed": {
"intervalWindow": 5,
"segmentLengthFt": 40,
"pigThreshold": 30
},
"autoArm": false,
"activeJob": { ... }
}
}
| Field | Type | Description |
|---|---|---|
| trigger.mode | string | zeroCrossing, threshold, or combined |
| trigger.thresholdPercent | number | 0–100, trigger level for threshold mode |
| trigger.holdoffMs | number | Minimum time between captures (ms) |
| trigger.preDurationMs | number | Pre-trigger buffer duration (ms) |
| trigger.postDurationMs | number | Post-trigger capture duration (ms) |
| audio.enabled | boolean | Whether audio is captured with events |
| speed.segmentLengthFt | number | Pipeline segment length in feet |
Update recorder configuration. All fields are optional—only provided fields are changed.
{
"triggerMode": "threshold",
"threshold": 40,
"holdoffMs": 3000,
"audioEnabled": true,
"autoArm": false
}
| Field | Type | Description |
|---|---|---|
| triggerMode | string | zeroCrossing, threshold, or combined |
| threshold | number | 0–100 percent of full scale |
| holdoffMs | number | Hold-off time in milliseconds |
| preDurationMs | number | Pre-trigger buffer (ms) |
| postDurationMs | number | Post-trigger capture (ms) |
| audioEnabled | boolean | Enable/disable audio capture |
| autoArm | boolean | Arm on boot |
| intervalWindow | number | Speed estimation window |
| segmentLengthFt | number | Segment length in feet |
| pigThreshold | number | Speed detection threshold |
| browserTime | number | Epoch seconds—sets device clock if GPS not synced |
{"status": "ok"}
400 — Invalid JSON or invalid triggerMode value409 — Config rejected (capture in progress){"event":"configChanged"} to all WebSocket clients.
Arm or disarm the event recorder.
{"armed": true}
{
"recorder": {
"state": "armed",
"armed": true
}
}
{"event":"armState","armed":true} to all WebSocket clients.
Events are recorded pig passages containing magnetic waveform data and optional geophone audio. Each event has a numeric ID and is stored on the device's flash filesystem. The device can store up to 103 events; older events are automatically evicted when storage is full.
List all event metadata for the active job.
{
"events": [
{
"id": 1,
"timestamp": 1773868500000,
"triggerSample": 2000,
"magSamples": 4000,
"magRate": 200,
"audioSamples": 160000,
"audioRate": 8000,
"peakMagnetic": 1842,
"triggerMode": 0,
"threshold": 50,
"latitude": 30.267153,
"longitude": -97.743057,
"hasAudio": true,
"magDataSize": 8000,
"audioDataSize": 40000
}
],
"total": 1,
"freeSpace": 9134080
}
| Field | Type | Description |
|---|---|---|
| id | number | Event ID |
| timestamp | number | UTC epoch milliseconds at trigger |
| triggerSample | number | Sample index of trigger point |
| magSamples | number | Total magnetic samples |
| magRate | number | Magnetic sample rate (Hz) |
| audioSamples | number | Total audio samples (0 if no audio) |
| audioRate | number | Audio sample rate (Hz) |
| peakMagnetic | number | Peak magnetic reading (raw ADC) |
| triggerMode | number | 0 = zero crossing, 1 = threshold, 2 = combined |
| hasAudio | boolean | Whether audio data is available |
| magDataSize | number | Magnetic data size in bytes |
| audioDataSize | number | ADPCM audio data size in bytes |
Single event metadata with additional fields.
{
"id": 1,
"timestamp": 1773868500000,
"triggerSample": 2000,
"magSamples": 4000,
"magRate": 200,
"audioSamples": 160000,
"audioRate": 8000,
"peakMagnetic": 1842,
"triggerMode": 0,
"threshold": 50,
"latitude": 30.267153,
"longitude": -97.743057,
"hasAudio": true,
"magDataSize": 8000,
"audioDataSize": 40000,
"deviceSerial": "PS2609A0110",
"agmSiteNumber": "AGM-001",
"cursorSample": 2150
}
| Field | Type | Description |
|---|---|---|
| deviceSerial | string | Serial of device that recorded the event |
| agmSiteNumber | string | User-assigned site identifier |
| cursorSample | number|null | Saved cursor position (sample index), or null |
404 — Event not foundRaw magnetic waveform data as a binary array.
Content-Type: application/octet-stream
int16[] (little-endian, 2 bytes per sample)magSamples from metadata404 — Event not found or file missingGeophone audio data encoded as IMA ADPCM.
Content-Type: application/octet-stream
404 — Event not found or no audio dataSet or clear the cursor position on a recorded event.
{"sample": 2150}
Set to null to clear: {"sample": null}
{"ok": true}
{"event":"cursorUpdated","id":1,"sample":2150} to all WebSocket clients.
Force rebuild of the event manifest from disk. Use after manual file operations.
Same format as GET /api/v1/events.
Delete a single event.
{"status": "ok"}
{"event":"eventDeleted","id":1} to all WebSocket clients.
Delete all events in the active job.
{"status": "ok"}
{"event":"eventsCleared"} to all WebSocket clients.
Events are organized under jobs, grouped by client. One job is active at a time—new events are recorded into the active job. Jobs are stored on-device and persist across reboots.
List all jobs grouped by client, with the active job indicated.
{
"active": "Acme_Pipeline/Run-042",
"clients": [
{
"client": "Acme_Pipeline",
"jobs": [
{
"jobName": "Run-042",
"operator": "J. Smith",
"pipelineSegment": "Segment-A",
"siteNameTemplate": "AGM-{n:3}",
"notes": "",
"createdAt": 1773782400,
"siteNameCounter": 4
}
]
}
]
}
| Field | Type | Description |
|---|---|---|
| active | string|null | Path of active job (Client/Job), or null |
| clients[].client | string | Client directory name |
| jobs[].jobName | string | Job directory name |
| jobs[].operator | string | Operator/technician name |
| jobs[].siteNameTemplate | string | Auto-numbering template (e.g. AGM-{n:3}) |
| jobs[].createdAt | number | Epoch seconds when job was created |
| jobs[].siteNameCounter | number | Next site number for auto-generation |
Create a new job. Becomes the active job by default.
{
"client": "Acme Pipeline",
"jobName": "Run-043",
"operator": "J. Smith",
"pipelineSegment": "Segment-B",
"siteNameTemplate": "AGM-{n:3}",
"notes": "",
"setActive": true
}
| Field | Type | Required | Description |
|---|---|---|---|
| client | string | Yes | Client or company name |
| jobName | string | Yes | Job identifier |
| operator | string | No | Operator name |
| pipelineSegment | string | No | Pipeline segment |
| siteNameTemplate | string | No | Site number template |
| notes | string | No | Job notes |
| siteNameCounter | number | No | Starting site number |
| setActive | boolean | No | Activate immediately (default: true) |
{
"status": "ok",
"clientDir": "Acme_Pipeline",
"jobDir": "Run-043"
}
Update job metadata. All fields are optional—only provided fields are changed.
{
"operator": "A. Jones",
"notes": "Updated notes"
}
{"status": "ok"}
404 — Job not foundSwitch the active job. New events will be recorded into this job.
{"status": "ok"}
404 — Job not foundDelete a job and all its events. Cannot delete the currently active job.
{"status": "ok"}
404 — Job not foundStream the entire job as a ZIP archive.
Content-Type: application/zip
The archive contains:
job.json — Job metadatamanifest.json — Full event manifest{id}.json — Per-event metadata{id}.mag — Binary magnetic waveform (int16[]){id}.audio — Binary ADPCM audio (if available)404 — Job not foundGPS receiver status, satellite count, and position (when fix is available).
{
"gps": {
"timeSynced": true,
"fix": true,
"fixQuality": 1,
"satellites": 7,
"lat": 30.267153,
"lon": -97.743057,
"altitude": 149.20,
"speedKnots": 0.04,
"speedMps": 0.0206,
"utcEpochMs": 1773868800000
}
}
{
"gps": {
"timeSynced": false,
"fix": false,
"fixQuality": 0,
"satellites": 0
}
}
| Field | Type | Description |
|---|---|---|
| timeSynced | boolean | Whether system clock is GPS-synced |
| fix | boolean | Whether GPS has a position fix |
| fixQuality | number | 0 = no fix, 1 = GPS, 2 = DGPS |
| satellites | number | Satellites in use |
| lat | number | Latitude (conditional: fix = true) |
| lon | number | Longitude (conditional: fix = true) |
| altitude | number | Altitude in meters (conditional) |
| speedKnots | number | Speed in knots (conditional) |
| speedMps | number | Speed in m/s (conditional) |
| utcEpochMs | number | GPS UTC time in ms (conditional: time valid) |
WiFi mode, access point info, and station connection status.
{
"wifi": {
"mode": "AP+STA",
"ap": {
"ssid": "pigstick-0110",
"channel": 6,
"clients": 1
},
"sta": {
"connected": true,
"ssid": "FieldOffice-5G",
"rssi": -52,
"ip": "192.168.1.105"
}
}
}
Scan for available WiFi networks. This is a blocking operation that takes a few seconds.
{
"networks": [
{
"ssid": "FieldOffice-5G",
"rssi": -52,
"secure": true
},
{
"ssid": "OpenNetwork",
"rssi": -78,
"secure": false
}
]
}
List saved WiFi networks. Passwords are not returned.
{
"networks": [
{
"ssid": "FieldOffice-5G",
"priority": 0
}
]
}
Save a WiFi network. The device stores up to 5 networks.
{
"ssid": "FieldOffice-5G",
"password": "secretpass"
}
{"success": true}
400 — Missing or empty SSID400 — Maximum networks reached (5)Delete a saved WiFi network.
{"ssid": "FieldOffice-5G"}
{"success": true}
400 — Missing or empty SSID404 — Network not foundFirmware version and storage statistics.
{
"system": {
"firmware": "2.0.0",
"storage": {
"total": 10379264,
"used": 1245184,
"free": 9134080
},
"uptime": 3621
}
}
Restart the device. The restart occurs after a 1-second delay to allow the response to be sent.
{
"restart": {
"scheduled": true,
"delayMs": 1000
}
}
The PigStick provides two WebSocket endpoints for real-time sensor data. Both support up to 2 concurrent clients. Connect using standard WebSocket protocol.
Real-time geophone audio stream.
int16[])| Field | Type | Description |
|---|---|---|
| freq | number | Test tone frequency (Hz) |
| amp | number | Test tone amplitude (0–100%) |
| preamp | number | Hardware pre-amp gain multiplier |
| armed | number | 1.0 = arm, 0.0 = disarm |
| threshold | number | Trigger threshold (0–100%) |
Send as JSON text frame, e.g.: {"preamp": 4.0}
Real-time magnetic sensor stream with JSON event notifications.
Each frame contains batched samples with the following per-sample structure:
struct MagneticSample {
int16_t voltage; // Raw ADC reading
int16_t envelope; // Peak envelope value
}; // 4 bytes per sample
Text frames carry real-time event notifications:
| Event | Description |
|---|---|
trigger | Trigger detected: {"event":"trigger","peak":1842,"time":1773868500000} |
recordingStarted | Capture began: {"event":"recordingStarted","eventId":5} |
recordingCompleted | Event saved: {"event":"recordingCompleted","eventId":5, ...} |
armState | Arm state changed: {"event":"armState","armed":true} |
configChanged | Recorder config updated |
cursorUpdated | Cursor set on event: {"event":"cursorUpdated","id":1,"sample":2150} |
eventDeleted | Event removed: {"event":"eventDeleted","id":1} |
eventsCleared | All events deleted |
jobCreated | New job: {"event":"jobCreated","client":"...","job":"..."} |
jobActivated | Active job switched |
jobUpdated | Job metadata changed |
jobDeleted | Job removed |
| Code | Meaning |
|---|---|
200 | Success |
400 | Bad request (invalid input, missing fields) |
404 | Resource not found (event, job, file) |
409 | Conflict (e.g., config change during capture) |
413 | Payload too large |
500 | Internal server error |
All errors return a JSON object with an error field:
{
"error": "Description of what went wrong"
}
| Error | Endpoint | Cause |
|---|---|---|
Event not found | Events | Invalid event ID |
Job not found | Jobs | Invalid client/job path |
Config rejected (capturing in progress) | Recorder config | Cannot change config while capturing |
Invalid triggerMode | Recorder config | Must be zeroCrossing, threshold, or combined |
Maximum networks reached | WiFi | Already 5 saved networks |
No audio data | Event audio | Event was captured without audio |