Spenser - a developer's companion
1.0.0 - STU1
Spenser - a developer's companion, published by Zeora. This guide is not an authorized publication; it is the continuous build for version 1.0.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/costateixeira/spenser/tree/firmware/key-persistence-ota and changes regularly. See the Directory of published versions
Built from commit d7d58e76. Branch: firmware/key-persistence-ota.
This page documents every interface a Spenser unit exposes: the FHIR ones, the
private HTTP API it uses for its own configuration, and the interfaces that are
not HTTP at all. It is the reference behind the Using Spenser
page, and the source of truth is the CapabilityStatement each unit publishes at
its own /metadata.
A unit is reachable on the local network as http://spenser.local, which it
advertises over mDNS, or at whatever address its router hands it.
Reading a unit is open: its CapabilityStatement, its dispenses, its orders, its
inventory and its dashboard are all available to anyone who can reach it.
Everything that changes a unit needs that unit's key: dispensing, settings,
Wi-Fi, stock, and firmware updates. Those routes answer 401 with an
OperationOutcome when it is missing.
The key is generated on the unit's first boot and printed on its serial console
at every boot. It travels as Authorization: Bearer <key>, as an
X-Spenser-Key header, or as ?key= for a client that cannot set headers.
GET /key returns it for five minutes after a restart, and at any time while
the unit is in setup mode, which is the window its owner has and a passer-by
does not. A unit serving its own access point accepts everything without a key,
because there is no network there to defend.
rest.security.description in each unit's CapabilityStatement says the same
thing, so a client can discover it.
Spenser is a FHIR server and a FHIR client at the same time, and the difference matters when deciding where to send something.
Both roles are declared in its CapabilityStatement.
Following the HL7 Clinical Orders Workflow guidance, a MedicationRequest sitting on a server is an authorization, not a command. Spenser fulfils it only when one of two things is true:
meta.tag
http://terminology.hl7.org/CodeSystem/common-tags#actionable.
See Order made actionable by a tag.requested points at the request in
Task.focus.
See Task asking for an order to be fulfilled,
which makes this plain order
actionable.These are not two modes of the device. Spenser looks for both on every check
and acts on whichever turns up. When an order arrives by both routes at once the
Task takes precedence, and the order is still dispensed exactly once. A unit can
be narrowed to one route with the orderMode setting, which is useful for
demonstrating the difference and for little else.
Who closes the order out depends on which route was used:
| route | what Spenser sets | what it leaves alone |
|---|---|---|
| tag, no Task | the MedicationRequest to completed |
|
| a Task | the Task to completed, with the dispense in Task.output |
the MedicationRequest, which stays active |
The second row is deliberate. With a Task coordinating the work, closing the request is the placer's job, not the filler's, and a completed Task is the placer's cue to do it.
Served by the device itself.
| Interaction | Needs a key | Meaning |
|---|---|---|
GET /metadata |
CapabilityStatement for this unit, declaring both roles, the search parameters, the operation and the security scheme | |
POST /MedicationRequest |
yes | create an order, which dispenses it |
GET /MedicationRequest and /{id} |
the orders this unit accepted | |
POST /$dispense |
yes | dispense in one call, answering with the MedicationDispense |
GET /MedicationDispense and /{id} |
what this unit dispensed or refused | |
GET /InventoryReport |
current stock, one listing per lane | |
POST /InventoryReport |
yes | set stock from a report |
The create behaves like a create. You need not supply an id: the unit
assigns one when you leave it out, which is what a FHIR server is supposed to
do. The answer is 201 with a Location header pointing at the created order,
an ETag, and the created
MedicationRequest as the body.
Prefer: return=minimal and Prefer: return=OperationOutcome are both
honoured. The order is kept, so it can be read back afterwards.
The dispense is a separate resource, at /MedicationDispense/{id}-dispense
when a piece came out, or /MedicationDispense/{id}-declined when the lane was
empty. When what you want is the dispense rather than the order, use the
operation: POST /$dispense takes a MedicationRequest, or Parameters carrying
one, and answers 200 with the
MedicationDispense. An
operation reports itself in the resource it returns, so an empty lane is still a
200, carrying
a declined dispense. R5
removed MedicationDispense.statusReason, so a refusal carries its reason in
notPerformedReason, which is a CodeableReference.
Searches are real searches. Both stores honour _id, status, subject
and _count, and dispenses also honour authorizingPrescription. A reference
matches either in full or by its id alone, so subject=Patient/123 and
subject=123 find the same records. The parameters are declared in the
CapabilityStatement, and the Bundle's self link records what the unit
understood.
The records survive a restart. Orders, dispenses and stock are kept on the unit's filesystem rather than in memory, so rebooting it, or updating it, does not erase what it did. The oldest records are dropped when a store fills.
GET /MedicationDispense is also how you ask a unit what it has been doing.
Besides the dispenses themselves, the Bundle carries one entry with
search.mode set to outcome: an OperationOutcome whose severity,
issue.diagnostics and issue.details.text describe the last check of the
order server, including which routes the unit is accepting and how long ago it
looked.
Errors are always an OperationOutcome with application/fhir+json. There is
no second error format anywhere in the FHIR interface.
Stock is reported as an
InventoryReport, and can be set the same
way, either as a snapshot or as a difference.
A dispense carries recorded, whenPrepared and whenHandedOver, and a
refusal carries recorded, as soon as the unit has reached an NTP server.
Until then those elements are left out rather than guessed: an absent timestamp
means the unit had no clock, never that the event happened at the epoch. The
same applies to InventoryReport.reportedDateTime.
A unit's firmware and its web files are both replaceable over Wi-Fi, which is what makes a remote dispenser supportable. The chip keeps two application slots, so an upload is written to the one that is not running and the unit reboots into it; a new image is marked good only once it has been up for a minute with a network, and the previous one stays available to roll back to.
| Route | Meaning |
|---|---|
GET /ota/status |
version, running slot, how full it is, rollback availability, clock, last result |
POST /ota/firmware |
the firmware image as the request body |
POST /ota/filesystem |
the filesystem image, which carries the web files |
POST /ota/file?path=/index.html |
one file, with no restart |
POST /ota/check |
fetch the release manifest and report what is waiting |
POST /ota/install |
take what the check found |
GET /ota/status |
also reports updateAvailable, availableVersion and how long ago it looked |
POST /ota/rollback |
go back to the previous image |
Builds are published beside this specification, at costateixeira.github.io/spenser/firmware/, which is where a unit looks by default. That page lists what changed in the build it is serving, and carries the binaries and the manifest next to it. A manifest describes a build for the unit:
{ "version": "1.2.0",
"firmware": "https://.../firmware.bin",
"filesystem": "https://.../spiffs.bin" }
GET /setSettings?updateUrl=https://.../manifest.json&updateCheckHours=24
Looking and taking are separate. updateMode=notify, the default, has a check
report what is waiting and stop there: /ota/status then carries
updateAvailable and availableVersion, the dashboard shows a notice reading
"New firmware available" with an Install button, and nothing reboots unasked.
A unit checks hourly by default, and remembers what it found. The notice survives a power cycle, because an update that was waiting before the plug came out is still waiting afterwards, and a notice that disappears on a restart is a notice nobody acts on. It clears itself when a later check finds nothing newer, or once the unit is running the version that was offered.
What an update contains is readable before it is taken. The manifest carries
release notes; the unit keeps them, serves them in /ota/status as
availableNotes and availableNotesUrl, and the dashboard shows them in a
popup when you press Install, with the full list a link away. Installing blind
is not an option a maintainer should be given.
A published version carries the commit it was built from, as 1.2.0+a1b2c3d.
That is what lets a unit tell one build from the next, and settle once it has
installed one. updateMode=install has the unit take it
by itself, which suits a unit nobody can reach. Anything other than the exact
word install counts as notify, so a mistyped setting cannot turn a fleet into
one that updates itself unannounced.
Each unit reports what it is running as software.version in its
CapabilityStatement and as firmwareVersion in its settings, so a fleet can be
told apart.
Point a unit at a server, and on every check it runs two searches and writes back what happened:
GET {server}/Task?status=requested&_include=Task:focus&_count=5
GET {server}/MedicationRequest?_tag=http://terminology.hl7.org/CodeSystem/common-tags|actionable&status=active&intent=instance-order&_count=5
POST {server}/MedicationDispense
PUT {server}/Task/{id}
PUT {server}/MedicationRequest/{id}
The _include matters: Spenser resolves Task.focus from the entries of that
one Bundle rather than fetching each order separately, so a Task whose focus is
not returned with it is reported as a problem and skipped.
_count=5 matters when testing. Only the first five results of each search are
considered per check, so with a longer queue a given order may not be picked up
on a particular check.
Both http:// and https:// work. Certificates are not validated, so treat the
link as you would any other unauthenticated local traffic.
The IG carries these as formal examples:
The smallest useful order, placed on the order server:
{
"resourceType": "MedicationRequest",
"meta": {
"tag": [
{ "system": "http://terminology.hl7.org/CodeSystem/common-tags",
"code": "actionable" }
]
},
"status": "active",
"intent": "instance-order",
"medication": {
"concept": {
"coding": [
{ "system": "http://costateixeira.github.io/spenser/CodeSystem/SpenserMeds",
"code": "chocolate-dark",
"display": "Dark Chocolate" }
]
}
},
"subject": { "reference": "Patient/123" },
"dosageInstruction": [ { "text": "One bite" } ]
}
The same body without meta.tag is the negative case: Spenser must leave it
alone. Drop the tag, add a Task pointing at it, and it becomes actionable again
by the other route.
Medications come from the
SpenserMeds code system: chocolate-dark and
chocolate-milk, one piece per order. R5 puts the code in
medication.concept; a directly posted order may also use the R4
medicationCodeableConcept spelling, which this firmware still accepts.
Everything below is the unit's own administrative API. It is plain JSON or text, it is not FHIR, and it is not meant for exchanging clinical data. It exists to configure a unit, to drive it during a demo, and to serve the built-in dashboard.
| Group | Endpoints |
|---|---|
| Orders | /checkOrders queues a check and returns 202, /orders reports the last check for the dashboard, /forgetOrders clears the list of orders already dealt with |
| Settings | /settings, /setSettings, /resetSettings |
| Updates | /ota/status, /ota/firmware, /ota/filesystem, /ota/file, /ota/check, /ota/rollback |
| The key | /key, within the window described above |
| Stock and hardware | /inventory, /setInventory, /resetInventory, /setServos, /flashServo1, /flashServo2, /setColor, /battery, /reset |
| Wi-Fi and identity | /, /scan, /cfg, /save, /wifi-settings, /wifi-reset, /wifi-reboot-config, /getMac, /setMac, /clearMac |
| Captive portal | /generate_204, /hotspot-detect.html, /fwlink, /ncsi.txt, /connecttest.txt |
/setSettings accepts any of serverUrl, orderMode with values both, tag
or task, orderQuery, taskQuery, pollEnabled, pollSeconds between 5 and
3600, writeBack, giveUpOnStockEmpty, updateUrl, updateCheckHours, and
the servo parameters. It answers with the full settings document, which includes
the firmware version and never includes the key.
A unit checks its order server every pollSeconds, 30 by default, while
pollEnabled is on. /checkOrders forces a check immediately. It returns
before the work is done, because the HTTP work happens in the main loop and
never inside a request handler, so a client should wait for the check to finish
rather than assume it has.
giveUpOnStockEmpty decides what happens to an order that cannot be filled.
Off, the order is reported once as out of stock and left open, so refilling the
lane fulfils it on a later check. On, it is reported once and abandoned, and a
coordinating Task is set to failed.
Anything not matched falls back to a static file from the device filesystem,
which is how the dashboard is served. OPTIONS returns 200 for CORS
preflight.
spenser with an HTTP service on port
80, so http://spenser.local works without knowing its address.Each unit serves a guide written for AI agents at
http://spenser.local/llms.txt. It describes every endpoint, the exact
shapes accepted and produced, worked examples, a test that exercises the whole
loop, and the mistakes that catch people out. Point an agent at that URL and it
can write examples, tests or a client without guessing, against the unit in
front of it rather than against documentation that may have drifted.
It is discoverable from FHIR: the CapabilityStatement at /metadata names it in
implementation.description, so an agent that starts at the FHIR entry point
finds it.
Two things pair well with it. The AGENTS.md file in the source repository
covers the repository itself, how to build and flash, where the routes live, and
how to run the tests. The Bruno collection under bruno/ is an executable
version of this page: it is split into the order server on one side and the
device on the other, and every scenario asserts on FHIR rather than on prose.