# Resident
Sandbox with hardware IO and hot reload for ESP32 devices.
Resident provides a sandboxed Lua runtime that can be loaded with new code over the network at any time. Hardware peripherals are exposed to Lua through a driver interface, so apps can draw to displays, read sensors, and control outputs without touching C++.
## What Resident includes
1. The Resident firmware library for a sandbox on ESP32 devices and custom hardware integration, with optional managed connectivity to load sandbox apps remotely.
2. A default websocket backend at `resident.inanimate.tech/devices/<deviceId>` to relay apps and events during development.
3. Agent skills to create, validate and push sandbox apps. [Install the Resident skills plug-in](tools/agent-plugin/README.md).
Point your agent at [docs/start-building.md](docs/start-building.md) to add the Resident sandbox to your hardware.
## Examples
Working projects live under [examples/](examples/) — the M5StickC Plus2 (`m5stick-demo`, plus `m5stick-voice` and `m5stick-clock`), the Adafruit ESP32-S2 TFT Feather, and a bare ESP-IDF build (`espidf-basic`). Each is buildable as-is; use them as templates for bringing up your own hardware.
### Quick start
`Resident::Sandbox` composes [Courier](https://github.com/inanimate-tech/courier) for connectivity with the Lua runtime. It handles WiFi, WebSocket transport, and message routing automatically — populate `cfg.network` and the sandbox connects on `setup()`.
```cpp
#include <Resident.h>
#include "MyDisplayDriver.h"
#include "MyButtonDriver.h"
MyDisplayDriver display;
MyButtonDriver button{...}; // however your driver takes config
Resident::SandboxConfig makeConfig() {
Resident::SandboxConfig cfg;
cfg.deviceType = "demo";
cfg.systemDisplay = &display;
cfg.extensions = {&display, &button};
// Courier::Config has a constructor with default args, so designated
// initializers (`Courier::Config{ .host = ... }`) don't compile under
// strict ESP-IDF builds. Use direct field assignment.
Courier::Config courier;
courier.host = "resident.inanimate.tech";
cfg.network = courier;
return cfg;
}
Resident::Sandbox sandbox{makeConfig()};
void setup() {
// Optional: override the default WS path on the canonical relay.
sandbox.onTransportsWillConnect([]() {
String path = String("/devices/") + sandbox.getDeviceId();
sandbox.ws().setEndpoint("resident.inanimate.tech", 443, path.c_str());
});
sandbox.setup();
}
void loop() {
sandbox.loop();
}
```
The device connects to WiFi (via a WiFiManager captive portal), opens a WebSocket to your server, and accepts Lua apps as JSON messages.
> Omit `cfg.network` and the sandbox runs standalone with no WiFi pulled in — `sandbox.loop()` ticks Lua at 10 FPS unconditionally, and `isConnected()` returns `false`.
Register reactive callbacks before `setup()` to react to lifecycle events:
```cpp
sandbox.onConnected([]() {
// load a bootstrap Lua app once the WS is up
});
sandbox.onMessageWithChannel("system", [](const char* transport, const char* type, JsonDocument& doc) {
// custom control-plane types; Resident handles the reserved ones itself
});
```
## Writing a Driver
Drivers expose hardware to Lua via a builder API:
```cpp
#include <ResidentDriver.h>
#include <ResidentLuaModule.h>
#include <M5Unified.h>
extern "C" {
#include "lua/lua.h"
#include "lua/lauxlib.h"
}
class IMUDriver : public Resident::Driver {
public:
const char* name() const override { return "imu"; }
void registerModule(Resident::LuaModule& m) override {
m.method<IMUDriver, &IMUDriver::accel>("accel");
}
int accel(lua_State* L) {
M5.Imu.update();
auto d = M5.Imu.getImuData();
lua_pushnumber(L, d.accel.x);
lua_pushnumber(L, d.accel.y);
lua_pushnumber(L, d.accel.z);
return 3;
}
};
```
Then in Lua:
```lua
function on_tick(ctx, dt_ms)
local ax, ay, az = imu.accel()
-- use acceleration data
end
```
For Lua-only extensions that don't expose hardware or emit events, extend `Resident::Extension` directly instead of `Resident::Driver` — the same `registerModule(LuaModule&)` and lifecycle hooks apply.
### Driver lifecycle
- `begin()` — called once by `Sandbox::setup()` in registration order.
Hardware init goes here. Idempotent: a manual early call is safe (the
Sandbox's call becomes a no-op).
- `update()` — called every iteration of `Sandbox::loop()`. Use for
per-tick driver work like polling and debouncing. Runs at full main-loop
rate, distinct from Lua's 10 FPS `on_tick`.
- `registerModule(LuaModule& m)` — called once by `Sandbox::setup()`
to register the driver's Lua-visible global. Use the builder's
`method<>`, `staticMethod`, and `constant` overloads.
- `onAppReset()` — called when a new app is loaded (before compilation).
- `onAppRunning(bool)` — called when an app starts or stops running.
## Message Protocol
Every message carries an envelope `channel` field that steers it onto a plane:
```json
{ "channel": "system", "type": "app", "code": "function on_tick(ctx, dt_ms) ... end" }
{ "channel": "app", "type": "button_press", "data": { "id": 1 } }
```
`channel:"app"` is the data plane — it reaches the Lua `on_event`. `channel:"system"` is the control plane, where Resident handles the reserved types (`app`, `chunk`, `forget`, `framework`, `hello`, `goodbye`) itself and hands anything else to a slot registered with `onMessageWithChannel("system", cb)`. Any other channel name gets its own slot. See [docs/api.md](docs/api.md#channel-routing) for the full picture, including the un-channelled legacy path.
### Sandbox lifecycle
- `init(ctx)` — called once after compilation
- `on_tick(ctx, dt_ms)` — called at 10 FPS with elapsed time
- `on_event(ctx, event)` — called for wire events and driver events, with the payload in `event.data`
The `ctx` table contains: `time_ms` (milliseconds since the app loaded) and `generation_id` (when the load message carried one).
### Time
The Lua `datetime` module is Python's `datetime` — `datetime.now()`, `datetime.date(...)`, `datetime.timedelta{...}`, arithmetic and comparisons between them, `strftime` — in whole seconds, because the Lua is built with 32-bit numbers. Every datetime is local (the zone `setTimezone` resolved, DST applied) or `datetime.UTC`. `datetime.synced()` says whether the wall clock has been set. See [docs/api.md](docs/api.md#datetime-module).
```lua
if datetime.synced() then
local now = datetime.now() -- local once a timezone is set
log.info(now:strftime("%a %H:%M")) -- "Mon 13:05"
local is_weekend = now:weekday() >= 5 -- Monday = 0
local days_left = (datetime.date(2026, 12, 25) - datetime.today()).days
end
```
Elapsed time is the `time` module's MicroPython ticks, `time.ticks_ms()` / `time.ticks_diff()`. Its calendar half (`time.localtime()`, `time.strftime()`, …) is deprecated for `datetime`: it still works, and warns once per app load.
### Timezone
`Sandbox::setTimezone(const char* ianaZone)` — set the sandbox's local timezone for `datetime` (and the deprecated `time.localtime()`). Pass an IANA zone string (e.g. `"Europe/London"`). ezTime performs a UDP lookup to `timezoned.rop.nl` on first sight of a zone and caches the POSIX string in EEPROM. On failure (null / empty / unrecognised zone), the sandbox falls back to UTC.
`Sandbox::hasTimezone() const` — returns `true` after a successful `setTimezone`. Until then, local time is UTC (`datetime.now():tzname()` reads `"UTC"`).
## Building
### PlatformIO
```ini
[env:dev]
platform = espressif32@6.12.0
board = esp32-s3-devkitc-1
framework = arduino
lib_deps =
https://github.com/inanimate-tech/resident.git
https://github.com/inanimate-tech/courier.git
tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.4.2
ropg/ezTime@^0.8.3
fischer-simon/Esp32Lua@^5.4.7
```
### ESP-IDF (Arduino as component)
Add to your `CMakeLists.txt`:
```cmake
set(EXTRA_COMPONENT_DIRS ../vendor)
```
And in `idf_component.yml`:
```yaml
dependencies:
inanimate/resident:
version: "^0.7.0"
```
## License
[MIT](LICENSE)
8e4a130d0dee3ff8f1351aa8aabe9cac24d7faa4
idf.py add-dependency "inanimate/resident^0.10.1"