Guides

Connect your roaster

What Roastwarden expects of a roaster on one page, how to pick a transport, and a tested example roaster for each: MQTT, Modbus TCP, TC4 serial and WebSocket.

Roastwarden has no driver per model. It works with any roaster that sends its temperatures over one of its transports, and it can control a roaster that also takes commands for its heater and fan (or burner and air).

  • Logged (monitoring only): the roaster sends bean temperature (BT), and best also environment temperature (ET), about once a second. That is all Roastwarden needs for the live chart, rate of rise, phases, events, alarms that notify you, a profile as a background curve, the roast history, reports and labels. Roastwarden sends it nothing: you roast with the roaster's own controls.
  • Controlled: the roaster also takes commands for its outputs (heater and fan on an electric fluid bed; burner, air, drum speed or damper on a drum), reports each output back in its readings, cuts the heat on an emergency stop, and has safety cutoffs of its own. Then the sliders, STOP, alarm actions and the roast sequence work, and at full control Profile drive, the server's PID loop, autotune and fan assist.

Every roaster starts at control off, logged only, until you raise its control level (roaster kind and control level).

The transports: MQTT (an ESP32 or anything on Wi-Fi), Modbus TCP or RTU (PID controllers, PLCs and many commercial roasters), a TC4-style board on a USB port of the server, a WebSocket, and Phidgets thermocouple boards (reading only). A connection maps a device's own field names, units, topics and command syntax (mapping a device's own names), so a board that already speaks something (Tasmota, ESPHome, a stock TC4) needs no new firmware.

Try it first:

  • No roaster at hand: switch on a simulated roaster under Roasters → Simulator, an electric fluid bed or a gas drum (simulated roasters).
  • A machine Artisan knows: Roasters → Add roaster → A known machine? fills in its connection, registers and outputs (roasters from Artisan's catalogue).
  • Anything else: Roasters → Add roaster, or roast roaster add, then roast roaster map --try until the readings look right, then roast roaster check for the verdict (add a roaster).

The rest of this page is for whoever writes the roaster's firmware: what Roastwarden expects, and four small tested examples, one per transport. Every field and command is in the hardware integration guide.

What Roastwarden wants

Roastwarden judges a roaster with one check (roast roaster check, or the Check step of Add roaster). What the roaster provides decides how far its control level may go:

TierThe roaster providesControl level
LoggerBT (and ET), steadily, 0.5 to 4 times a secondoff
Manual+ the outputs it accepts, each echoed in its readings; emergency stopmanual
Full+ its own safety (below)full

Readings

RoleWhatNeeded
btbean temperature, °Calways
etenvironment, exhaust or inlet temperature, °Cstrongly (the check warns without it)
heater, fanthe % each output runs at now: the command echoedfor each output you declare
heater_appliedthe heater % that reaches the element after your interlocksoptional; the write check uses it
heater_enableheater power on (1) or off (0)optional
drum, damper%when declared
ambient, setpoint, moderoom temperature; the onboard PID's target and modeoptional
a channel's nameany other probe or number (inlet, exhaust)when declared (Extra channels)
  • Rate: 1 Hz is right. The check wants 0.5 to 4 samples a second over the last 30 s, and warns when the gaps are uneven (more than half a gap of jitter): RoR is computed by the server from BT, so send BT on a steady clock and do not compute RoR yourself.
  • Units: °C. A device in °F says so (units: F in its mapping, --units F) and the server converts. The check judges units at idle: with the heater off both probes should read room temperature (5 to 45 °C); 50 to 115 looks like °F.
  • A failed probe: leave the field out (JSON), print -1 (TC4), or send a value the check rejects (Modbus has no "none"). Never send 0 for a missing probe.

Commands

What Roastwarden sends depends on the control level and the outputs declared in the roaster's setup (Roasters → the roaster → Control → Setup):

ControlSends
offnothing at all, not even STOP
manualheater %, fan %, drum %, damper %, heater on/off, emergency stop
full+ setpoint, mode and PID gains (for an onboard PID), feedforward, lease, RoR limit

Every % is 0 to 100, shaped to the output's range and step before it is sent. A roaster with no PID of its own still runs Profile drive at full control: the server's loop sends heater %. Ignore what you do not implement; never fail on it.

Emergency stop means heater off now, fan up to cool, from any state, and it must work without the server's next message. The write check sends it after heater 10 % and expects the heater to read 0 within 5 s.

Its own safety (full tier)

The server cannot see these, so whoever writes the firmware vouches for them before control goes to full (control):

  • heater off above a BT and an ET limit of its own;
  • heater off when a probe fails;
  • no heat without air (a fan floor) or, on a drum, without rotation;
  • a watchdog: no heat when the server has gone quiet (no poll, no connection, or the server's offline on MQTT);
  • presence (MQTT): a retained online status with a last will offline.

Every example below has all five.

What the check reads

roast roaster check <id> (read only, any token) answers ok, warn or fail per line; the verdict is the worst. With the owner key, roast roaster check <id> --write (a cold roaster, control manual or full) sends heater 10 %, waits for its echo, sends emergency stop and waits for the heater to read 0.

LinePasses when
rate0.5–4 samples a second over 30 s, evenly
bt_etBT present and within −10–300 °C; ET present
unitsat idle, the probes read 5–45 °C
outputseach declared output is in the readings
channelseach declared channel is in the readings
capsalways ok: what the roaster says it can do (pid, lease, …)
controlalways ok: the control level and what it allows

Choosing a transport

Your roasterUseWhy
An ESP32 (or anything) on Wi-FiMQTTRoastwarden's own; found by itself when it starts sending; survives Wi-Fi drops
A board on a USB port of the serverTC4 serialno network at all; Artisan's TC4 device reads the same board
A PLC, a PID controller, an industrial gatewayModbus TCP or RTUthe industry's; Artisan reads the same registers
A device that serves a WebSocketWebSocketthe server dials it; your own JSON, mapped
A Phidgets thermocouple board (1048, TMP1101)Phidgetsno firmware to write: Phidgets' network server on the computer it is plugged into (Phidgets boards); it only reads, so controls need a second connection

If you write the firmware, MQTT with Roastwarden's own topics and names is the least work: nothing needs mapping. Each example's sketch is in the source repository's examples/roasters/<name>/ with its roaster.json: the roaster, connection and register map as Add roaster saves them.

All four sketches are for an ESP32 with the Arduino core and the same parts: two MAX31855 thermocouple boards (BT on GPIO 5, ET on 17, clock 18, data 19), a solid state relay for the heater on GPIO 26, run as a one-second slow PWM, and a fan on GPIO 27 through a MOSFET. Change the pins and the safety limits at the top of each sketch. They compile with PlatformIO (ESP32 core 3.3).

The steps, whatever the transport

  1. Flash it and power it with the heater element disconnected the first time.
  2. Add it: Roasters → Add roaster (Roaster → Connection → Check → Control), or roast roaster add (an admin token). It is saved at control off after the Connection step.
  3. Check it: the Check step reads it live, with the mapping inline. Fix the mapping until the verdict is ok.
  4. Control: pick manual or full in the last step (it takes the owner key; or later under Roasters → the roaster → Control → Setup), then run the write check once, cold: Roasters → the roaster → Check → Run the write check, or roast roaster check <id> --write.

Example 1: an ESP32 on MQTT

examples/roasters/mqtt-esp32/mqtt-esp32.ino (PubSubClient, ArduinoJson 7, Adafruit MAX31855).

It speaks Roastwarden's own topics and names, so there is no mapping:

publishes  roaster/mqtt_roaster/telemetry   once a second
           {"beanTemp":182.3,"envTemp":215.0,"heater":45,"heaterApplied":45,"fan":70,"heaterEnable":1}
           roaster/mqtt_roaster/status      retained; last will {"status":"offline"}
           {"status":"online","version":"example-1","caps":[]}
listens    roaster/mqtt_roaster/control/heater           45      (%, may have decimals)
           roaster/mqtt_roaster/control/fan              70
           roaster/mqtt_roaster/control/heater_enable    1 | 0
           roaster/mqtt_roaster/control/emergency_stop   1
           roastwarden/server/status                       the server's presence

"caps": [] says it has no PID of its own (pid would mean it takes setpoint, mode and pid). Its watchdog: no heat while the broker connection or the server is gone.

The broker: the compose bundle's mosquitto takes no anonymous clients, and nor does the server's built-in broker (docker-compose.single.yml, Install). Give the roaster a password in .env (MQTT_DEVICES=mqtt_roaster:its-password, the device id is its user name) and restart the broker (with the built-in one, the server); the ACL lets it use roaster/mqtt_roaster/# only.

Add roaster: the roaster appears by itself under Roasters → Found, not added yet a second after it starts sending; Set up opens Add roaster with it filled in.

  • Roaster: Name, ID mqtt_roaster (what it sends; kept), Kind (Fluid bed, electric here).
  • Connection: Wi-Fi (MQTT), Topic prefix roaster/mqtt_roaster. Save and check.
  • Check: the lines below, live.
  • Control: Manual or Full.

From the command line (it sets up the found roaster in place):

$ roast roaster add mqtt_roaster --transport mqtt --name "DIY roaster (MQTT)" --kind fluid_bed_electric
Set up mqtt_roaster (it had streamed already) with a mqtt connection (936dbd6b-…), kind fluid_bed_electric.
Control is off: Roastwarden only records it; the owner switches control on under Roasters → mqtt_roaster → Setup.
$ roast roaster check mqtt_roaster
mqtt_roaster: ok
  ok   rate     1.00 Hz over the last 30 s, jitter ±0.00 s
  ok   bt_et    BT 20.6 °C, ET 21.0 °C
  ok   units    °C: the probes read room temperature with the heater off
  ok   outputs  Heater, Fan declared and reported
  ok   channels no channels declared
  ok   caps     it says it can do: nothing optional
  ok   control  control off: Roastwarden only records it; the owner switches control on under Roasters → mqtt_roaster → Setup
  reading: bt 20.65, et 21.0, fan 0.0, heater 0.0, heaterApplied 0.0, ror 0.0
$ roast roaster check mqtt_roaster --write          # the owner key, cold, at control manual
mqtt_roaster write check: ok
  2 ms ok   heater 10 %: sent via mqtt (shaped by the roaster's outputs)
  711 ms ok   heater echo: it reports heater 10 %
  714 ms ok   emergency stop: sent via mqtt
  1727 ms ok   heater off: it reports the heater at 0

Your own names and topics instead (a board you cannot reflash): a mapping on the connection, Mapping a device's own names.

Example 2: a Modbus TCP register layout

examples/roasters/modbus-tcp/modbus-tcp.ino (eModbus, Adafruit MAX31855): a Modbus TCP slave on port 502, unit 1, holding registers only (function 3 to read, 6 and 16 to write):

RegisterHoldsTypeRead / writeRole
0BT, °C × 10int16readbt (scale 0.1)
1ET, °C × 10int16readet (scale 0.1)
2heater %uint16read, writeheater
3fan %uint16read, writefan
4heater % after the interlocksuint16readheater_applied
5heater power, 0 / 1uint16read, writeheater_enable
6stop: write 1uint16writeemergency_stop

Tenths in an int16 are what most PID controllers use, and Artisan reads them too (Modbus, function 3, divider 1/10), so the same roaster works in both. Its watchdog: no heat when nobody has read it for 5 s (keep Roastwarden's poll at 1 s). Answer a write with an echo of the request, as the Modbus spec says, also for a register that clears itself (the stop register reads 0): a master may check the echo.

The register map: each register gets a role, so its name is free (mapping a device's own names). In roaster.json it is the register_map list.

Add roaster:

  • Roaster: Name, ID modbus_roaster, Kind.
  • Connection: Controller on the network (Modbus TCP), Address (the ESP32's IP), Port 502; Advanced: Unit ID 1, Poll 1000 ms. Registers: + Add Register per row of the table: Address, Name, Type holding, Data Type, Scale (0.1 for BT and ET), Unit, RW (on for 2, 3, 5, 6), Role. Save and check.
  • Check, Control as above.

From the command line (map --register KIND:ADDRESS:TYPE[*SCALE]:ROLE[:UNIT]; writable follows from a holding register with a command's role):

$ roast roaster add modbus_roaster --transport modbus_tcp --host 192.168.1.60 --port 502 --unit-id 1 \
    --name "DIY roaster (Modbus TCP)" --kind fluid_bed_electric
$ roast roaster map modbus_roaster \
    --register 'holding:0:int16*0.1:bt:°C' --register 'holding:1:int16*0.1:et:°C' \
    --register 'holding:2:uint16:heater:%' --register 'holding:3:uint16:fan:%' \
    --register 'holding:4:uint16:heater_applied:%' --register holding:5:uint16:heater_enable \
    --register holding:6:uint16:emergency_stop
modbus_roaster: 7 registers saved. Next: roast roaster check modbus_roaster
$ roast roaster map modbus_roaster --try
modbus_roaster: the newest Modbus poll (decoded by type, before scale and role)
  [{"address":0,"kind":"holding","name":"bt","type":"int16","value":207.0},{"address":1,…,"value":210.0},…]
$ roast roaster check modbus_roaster
modbus_roaster: ok
  ok   rate     1.00 Hz over the last 30 s, jitter ±0.00 s
  ok   bt_et    BT 20.7 °C, ET 21.0 °C
  ok   units    °C: the probes read room temperature with the heater off
  ok   outputs  Heater, Fan declared and reported
  ok   channels no channels declared
  ok   caps     it says nothing about what it can do: Profile drive runs the server's own PID loop
  ok   control  control off: Roastwarden only records it; the owner switches control on under Roasters → modbus_roaster → Setup
  reading: bt 20.7, emergencyStop 0.0, et 21.0, fan 0.0, heater 0.0, heaterApplied 0.0, ror 0.0

Saving the map restarts the poller, so a check in the first 30 s after it can warn about the rate (the window holds the polls from before): check again. Modbus RTU on RS-485 is the same register map with a serial port, baud and parity instead of an address (hardware integration guide).

Example 3: the TC4 serial lines

examples/roasters/tc4-serial/tc4-serial.ino (Adafruit MAX31855): any Arduino-compatible board on a USB port of the server, answering as stock aArtisan does, so Roastwarden's "stock aArtisan" reply format and Artisan's own TC4 device both read it:

server → board   UNITS;C     CHAN;1200     (once, at open)
                 READ        (every second)
                 OT1;45      heater %
                 IO3;70      fan %
                 PID;OFF  OT2;0   (emergency stop sends PID;OFF, OT1;0, OT2;0, IO3;100)
board → server   READ:   ambient,ET,BT,heater,fan     e.g. 21.00,180.50,165.20,45,70
                 others: a "# …" line, which the server ignores

CHAN;1200 puts ET on logical channel 1 and BT on 2, Artisan's convention: keep the connection's channels at ET 1, BT 2, as this board always prints them. Its watchdog: no heat when no READ came for 5 s.

Add roaster:

  • Roaster: Name, ID tc4_roaster, Kind.
  • Connection: USB thermocouple board (TC4), the serial port (/dev/ttyUSB0, /dev/ttyACM0; in Docker pass it through and add the dialout group), Reply format Stock aArtisan (active channels only); Advanced: Baud 115200, ET 1, BT 2, Fan output IO3. Save and check.
  • Check, Control as above.
$ roast roaster add tc4_roaster --transport serial_tc4 --port /dev/ttyUSB0 --preset stock-aartisan \
    --name "DIY roaster (TC4)" --kind fluid_bed_electric
$ roast roaster map tc4_roaster --try
tc4_roaster serial_tc4 356ead0c-…
  newest: 21.00,21.00,20.65,0,0
  ambientTemp ← active reply layout → ambientTemp 21
  beanTemp ← active reply layout → beanTemp 20.7
  envTemp ← active reply layout → envTemp 21
  fan ← active reply layout → fan 0
  heater ← active reply layout → heater 0
$ roast roaster check tc4_roaster
tc4_roaster: ok
  ok   rate     1.00 Hz over the last 30 s, jitter ±0.00 s
  ok   bt_et    BT 20.7 °C, ET 21.0 °C
  …

A board that prints its own columns or °F takes --column and --units F (mapping a device's own names).

Example 4: a WebSocket server of its own

examples/roasters/websocket/websocket.ino (WebSockets by Markus Sattler, ArduinoJson 7, Adafruit MAX31855): a WebSocket on port 81 that pushes its own JSON once a second; Roastwarden dials it and maps the fields, and its commands go out through the connection's templates:

board → server   {"BT":165.2,"ET":180.5,"heater":45,"fan":70}
server → board   {"set":"heater","value":45}   {"set":"fan","value":70}
                 {"set":"enable","value":1}    {"set":"stop"}

The mapping (roaster.json's ingest and commands):

"ingest": { "fields": { "bt": "BT", "et": "ET", "heater": "heater", "fan": "fan" } },
"commands": {
  "heater": { "payload": "{\"set\":\"heater\",\"value\":{value}}" },
  "fan": { "payload": "{\"set\":\"fan\",\"value\":{value}}" },
  "heater_enable": { "payload": "{\"set\":\"enable\",\"value\":{value}}" },
  "emergency_stop": { "payload": "{\"set\":\"stop\"}" }
}

Its watchdog: no heat while no client is connected.

Add roaster:

  • Roaster: Name, ID ws_roaster, Kind.
  • Connection: WebSocket, WebSocket address ws://192.168.1.61:81/. Save and check.
  • Check: the Mapping editor under the check: a row per field (bt ← BT, et ← ET, heater ← heater, fan ← fan) and the four templates; it shows the newest frame and what each field becomes. Save, and the check turns ok.
  • Control as above.
$ roast roaster add ws_roaster --transport websocket --device-url ws://192.168.1.61:81/ \
    --name "DIY roaster (WebSocket)" --kind fluid_bed_electric
$ roast roaster map ws_roaster --field bt=BT --field et=ET --field heater=heater --field fan=fan --try
ws_roaster websocket 55caf888-…
  newest: {"BT": 20.65, "ET": 21.0, "heater": 0.0, "fan": 0.0}
  bt ← BT = 20.65 → beanTemp 20.65
  et ← ET = 21.0 → envTemp 21
  fan ← fan = 0.0 → fan 0
  heater ← heater = 0.0 → heater 0
  sample {"beanTemp":20.65,"envTemp":21.0,"fan":0.0,"heater":0.0}
Looks right? Run it again without --try to save it.
$ roast roaster map ws_roaster --field bt=BT --field et=ET --field heater=heater --field fan=fan \
    --command 'heater={"set":"heater","value":{value}}' --command 'fan={"set":"fan","value":{value}}' \
    --command 'heater_enable={"set":"enable","value":{value}}' --command 'emergency_stop={"set":"stop"}'
$ roast roaster check ws_roaster
ws_roaster: ok
  ok   rate     1.07 Hz over the last 30 s, jitter ±0.25 s
  …

A device that answers requests instead of pushing (Artisan's WebSocket protocol) takes a poll (--preset artisan-websocket); one that dials the server instead uses its inbound socket, /ws/device/<id>/telemetry, with Roastwarden's own names (hardware integration guide).

Try them without the hardware

The source repository's tools/mock-device/example_roaster.py speaks what each sketch speaks, with a small thermal model behind it (the heater warms, air cools, no heat below 30 % fan):

pip install -r tools/mock-device/requirements.txt
python tools/mock-device/example_roaster.py modbus        # Modbus TCP on :5020
python tools/mock-device/example_roaster.py websocket     # ws://…:8081/
TC4_LINK=/tmp/tc4 python tools/mock-device/example_roaster.py tc4   # a pseudo-terminal at /tmp/tc4
MQTT_BROKER_HOST=… MQTT_PASSWORD=… python tools/mock-device/example_roaster.py mqtt

Add each with its example's steps (the twin's address, /tmp/tc4 as the port), on a server you run for testing (MQTT needs a broker). The outputs above came from these twins.