-
Notifications
You must be signed in to change notification settings - Fork 38
Code Style
These are the formatting rules the tooling enforces in the control repository and the conventions the backend and frontend code follow; stick to them so reviews can focus on behaviour.
| Area | Rule | Source |
|---|---|---|
| Rust edition | 2024, rust-version = "1.90" for the whole workspace |
Cargo.toml |
| Rust toolchain |
1.90.0 with rustfmt, clippy, rust-analyzer
|
rust-toolchain.toml |
| Rust formatting |
cargo fmt; CI runs cargo fmt --check
|
rustfmt.toml, rust.yml
|
| TypeScript formatting | Prettier: double quotes, trailing commas, 2 spaces, semicolons, LF line endings; prettier-plugin-tailwindcss sorts Tailwind classes |
electron/.prettierrc |
| TypeScript lint | ESLint with the JS, typescript-eslint and React recommended sets; react-compiler/react-compiler is an error, no-unused-vars a warning, no-explicit-any off; src/components/ui/ is skipped |
electron/eslint.config.mjs |
| TypeScript compiler |
strict: true; @/ points to electron/src/
|
electron/tsconfig.json |
| Nix formatting | nix fmt |
nix.yml |
rustfmt.toml asks for one use per item (imports_granularity = "Item") in three groups: std, external crates, then the current crate (group_imports = "StdExternalCrate"). Both are unstable rustfmt options, which the pinned stable toolchain skips with a warning. Write imports this way yourself:
use std::cell::RefCell;
use std::time::Duration;
use qitech_framework::machine::ActError;
use qitech_framework::machine::ActResult;
use qitech_lib::units::Length;
use qitech_lib::units::length::millimeter;
use crate::machines::Zone;Longer structs and functions are split with // --- name --- comments (hardware, config, state, measurements, events):
#[derive(Machine)]
pub struct LaserV1 {
// --- hardware ---
device: Rc<RefCell<LaserDevice>>,
// --- config ---
diameter_target: ConfigProperty<Length>,
// --- measurements ---
diameter: Measurement<Length>,
}| Item | Convention | Example |
|---|---|---|
| Software type | <Family>V<n> |
LaserV1, AquapathV1
|
| Schema | qitech_control/schemas/<type in snake_case>.yaml |
laser_v1.yaml, aquapath_v1.yaml
|
| Descriptor |
#[derive(Machine)] loads the schema named after the type and generates SCHEMA and IDENTIFICATION from it |
LaserV1 → laser_v1.yaml
|
| Module |
qitech_control/src/machines/<family>_v<n>.rs, or a folder with mod.rs for larger machines; re-export the type from machines/mod.rs
|
laser_v1.rs, aquapath/
|
| Registration |
.machine::<Type>() in qitech_control/src/main.rs
|
.machine::<LaserV1>() |
Implement MachineDescriptor by hand only when one Rust type serves several schemas: the winder (WinderV1_Regular, WinderV1_7031_Spool in winder_v2/) and the extruder (ExtruderV1, ExtruderV2 in extruder1/) do this with include_str!. Those two folders don't follow the module rule; name new modules after the software type. UI names differ from software names, see QiTech-Machines.
Keep physical values typed from the device to the controller. Use the quantities in qitech_lib::units, a uom system with f64 storage; control doesn't depend on uom directly.
use qitech_lib::units::Length;
use qitech_lib::units::length::millimeter;
let target = Length::new::<millimeter>(1.75);
let raw_mm: f64 = target.get::<millimeter>();The schema declares the unit a value is exposed in, for example diameter: !millimeter in laser_v1.yaml. Convert to a raw f64 only at the edge, for example when a controller does plain arithmetic.
Machine::act(&mut self, dt: Duration) runs every cycle on the machine thread (see Architecture). Keep it short and predictable:
- No blocking: no sleeps, file or network I/O,
.awaitor locks that can wait. Talk to slow devices through non-blocking calls, asLaserV1does withhandle_response()andsend_next_request(). - No allocation-heavy work: prepare buffers, lookups and strings in
build, not every cycle. - Use
dtfor timing, for exampleself.request_timer.saturating_sub(dt)inLaserV1. - Put control logic into controllers that
actcalls, so it stays testable. The extruder'sactcallsScrewSpeedController::update(); reusable controllers live inqitech_control_core/src/controllers.
act returns ActResult, which is Result<(), ActError>. An ActError has a kind and an impact (error.rs):
ActErrorKind |
Use for |
|---|---|
HardwareFault(String) |
A device or bus stopped working. |
ConstraintViolation(..) |
A value broke a schema constraint. |
Custom(String) |
Anything else. |
ActErrorImpact |
Runtime reaction |
|---|---|
Ignore |
The machine keeps running normally. |
Degraded |
The machine keeps running with reduced capability. |
Irrecoverable |
The runtime removes the machine and reports it as removed. |
Helpers return Result<_, ActErrorKind> and act decides the impact, as the winder's puller, traverse and tension arm do. The runtime doesn't log Ignore or Degraded errors, so log them with tracing when someone needs to know. Use Irrecoverable only when the machine can't operate safely; LaserV1 returns it after an I/O break or three failed requests in a row.
Put /// comments on public items: a one-line summary, the details, then # Parameters, # Returns, # Errors, # Panics and # Example (marked ```ignore) where they apply. Use //! for module overviews. qitech_control_core/src/controllers is the model, for example second_degree_motion/jerk_speed_controller.rs and pid_autotuner.rs. In machine code, explain why a value is what it is:
/// ~3 round trips at the device actor's 2s request timeout, so roughly 6s of silence.
const MAX_CONSECUTIVE_ERRORS: u32 = 3;Use the tracing macros with their full path. The code uses three levels:
| Level | Use for | Example |
|---|---|---|
tracing::error! |
Something failed and needs attention. | EtherCAT lost: {reason} |
tracing::warn! |
A recoverable problem. |
laser request failed, a Modbus assignment without a driver |
tracing::info! |
Lifecycle events. | Added Machine: {ident} |
Add structured fields where they help, for example tracing::warn!(attempt = self.consecutive_errors, "laser request failed: {e:?}"). Don't log every cycle. Where logs end up: Logging.
- Avoid duplicate code, but also avoid abstractions nobody needs yet.
- Avoid lifetimes and
asyncunless they're required;actis synchronous. - Borrow instead of taking ownership where you can.
- Implement a trait in the file that defines the struct, and split large
implblocks by topic. - Follow the Rust style guide for everything rustfmt doesn't cover.
-
Validate everything from the backend with Zod. Each machine namespace declares its event schemas with
eventSchema(...), REST responses are parsed (mutationResponseSchema), anduseMachineMutate(schema)validates request bodies. Derive types withz.inferinstead of writing them twice. - Keep the backend's field names (snake_case) in event schemas, for example
laser_state.target_diameter, so the schema mirrors the payload. - Import from
src/through the@/alias, for example@/client/useClient. - Keep live state in Zustand stores and update it immutably, with immer's
producefor nested changes. - The React Compiler rule is an error: follow the Rules of React (hooks at the top level, no mutation of props or state during render).
-
src/components/ui/holds shadcn-generated Radix wrappers (electron/components.json); change them only on purpose. - Components are named exports in PascalCase
.tsxfiles; hooks areuse<Name>.ts.
A machine UI lives in electron/src/machines/<family>/<slug>/, where the slug is family plus generation (winder2, extruder3, laser1):
| File | Example |
|---|---|
<slug>Namespace.ts |
laser1Namespace.ts |
use<Name>.ts |
useLaser1.ts |
<Slug>Page.tsx (tabs) |
Laser1Page.tsx |
<Slug>ControlPage.tsx, <Slug>Graph.tsx, <Slug>Settings.tsx, <Slug>Manual.tsx, <Slug>PresetsPage.tsx
|
Laser1ControlPage.tsx |
Code shared by a family sits one level up, for example machines/winder/TraverseBar.tsx. More on the frontend: Frontend.
QiTech Control · GitHub · Framework wiki · Lib wiki · Report a docs problem
Getting Started
Guides
Machines
Developers
Related