Skip to the content.

rustuya 0.4 — Sans-I/O Redesign Milestones

Splits rustuya into a pure, no_std protocol/state-machine core plus thin I/O drivers, so the same protocol logic runs on a Linux/tokio host (fleet scale) and on an ESP32-class microcontroller (single-device controller).

Developed on 0.4-sansio and promoted to master after the 0.4.0 release. The previous master, including the 0.3.x implementation and Python bindings, is preserved on 0.3-stable. 0.4 is a pure-Rust library — the deliverable is the rustuya-tokio (and later rustuya-embassy) Rust API. Design-decision detail lives in DESIGN.md (IDs S/P/Q/R).


Why

Non-goals / scope guards

Design invariants (hold across every phase)

  1. Core is #![no_std] + alloc. No tokio, sockets, timers, threads, or locks (single-owner, driven by the driver).
  2. now: Instant and rng: &mut impl RngCore are injected. The core never reads the clock or the OS RNG itself.
  3. Addresses use core::net (stable 1.77; MSRV 1.88) — no std::net leak.
  4. Core owns policy, driver owns I/O. “Which bytes/port/timer-duration, whether to reconnect” is the core; “open the socket, write, sleep” is the driver.
  5. Same oracle. The core passes the existing tinytuya_parity fixtures and the tuyamock E2E suite — the regression net against reopening 0.3 fixes.
  6. no_std buildability is the acceptance gate. CI builds the core for riscv32imc-unknown-none-elf / thumbv7em-none-eabi with --no-default-features. If it doesn’t build no_std, the phase isn’t done.
  7. Minimal core dependencies. Prefer a core/alloc built-in over a crate; the only non-trivial deps are RustCrypto and the JSON seam.

Target crate layout

rustuya-core     no_std + alloc   protocol · crypto · Device FSM · Discovery FSM
                                  (now/RNG injected; core::net; no I/O)
rustuya-tokio    std + tokio      thin driver: tokio TCP/UDP, one timer, RNG/clock
                                  injection over the FSMs
rustuya-embassy  no_std           thin driver: embassy-net + embassy-time  (Phase 3)
rustuya          facade           re-exports a driver crate behind a feature flag
                                  (`tokio` today; `embassy`/`sync` add the same way
                                  once those driver crates exist) under namespaced
                                  modules (`rustuya::tokio::*`, ...) — keeps the
                                  well-known crate name useful without merging a
                                  driver into rustuya-core (which would reopen
                                  Cargo's feature-unification hazard for no_std
                                  builds). Publishes last, after its drivers.

Sans-I/O interface (D1 resolved → poll-split)

pub enum DevInput<'a> {
    Connected,
    ConnectFailed,                 // neutral transport failure (D5), not std::io
    Received(&'a [u8]),            // driver read; core reassembles frames (D4)
    Send { cmd, data, cid, t },
    ConnectNow,                    // cancel backoff / revive terminal (P3)
    Closed,
}
impl Device {
    fn handle_input(&mut self, input: DevInput<'_>, now: Instant, rng: &mut impl RngCore);
    fn handle_timeout(&mut self, now: Instant, rng: &mut impl RngCore);
    fn poll_transmit(&mut self) -> Option<Vec<u8>>;    // bytes to send
    fn poll_event(&mut self)    -> Option<Event>;      // app events for listener()
    fn poll_timeout(&self)      -> Option<Instant>;    // THE single next deadline (D2)
}
// Discovery FSM is isomorphic (poll_transmit -> (Vec<u8>, port); poll_event -> Found/Seen).

Driver loop: read poll_timeout() and arm one timer; on socket-read / timer / command, call the matching handle_*; drain poll_transmit() and poll_event() to None. One deadline (no timer ids), no per-event Vec.

Settled interface decisions


M0 — Core skeleton + no_std gate (done)

M1 — Device connection FSM into the core

M2 — Discovery FSM into the core

Acceptance: discovery is a driver over a pure FSM; the decode path is mock-gated.

M3 — ESP32 driver (rustuya-embassy) [0.5+]

Gating principle: every additional layer over the core (embassy, a blocking-std driver, Python bindings, …) waits until the core is proven complete through the tokio driver in real use — the core/driver seam must stop moving first. One driver validating the core beats three drivers chasing it. Where the Embassy-vs-esp-idf choice is made, validated on real hardware.


Risk / landmine register

Definition of done for 0.4