inanimate/resident

0.10.1

Latest
uploaded 5 hours ago
Sandbox with hardware IO and hot reload for ESP32 devices

Readme

# 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)

Links

Supports all targets

To add this component to your project, run:

idf.py add-dependency "inanimate/resident^0.10.1"

download archive

Stats

  • Archive size
    Archive size ~ 158.32 KB
  • Downloaded in total
    Downloaded in total 36 times
  • Weekly Downloads Weekly Downloads (All Versions)
  • Downloaded this version
    This version: 0 times

Badge

inanimate/resident version: 0.10.1
|