Skip to content

Code Style

Christian edited this page Sep 29, 2026 · 3 revisions

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.

Enforced by tooling

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

Rust

Imports

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;

Section comments

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>,
}

Machine naming

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.

Physical units

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.

The act loop

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, .await or locks that can wait. Talk to slow devices through non-blocking calls, as LaserV1 does with handle_response() and send_next_request().
  • No allocation-heavy work: prepare buffers, lookups and strings in build, not every cycle.
  • Use dt for timing, for example self.request_timer.saturating_sub(dt) in LaserV1.
  • Put control logic into controllers that act calls, so it stays testable. The extruder's act calls ScrewSpeedController::update(); reusable controllers live in qitech_control_core/src/controllers.

Errors in act

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.

Doc comments

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;

Logging

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.

General

  • Avoid duplicate code, but also avoid abstractions nobody needs yet.
  • Avoid lifetimes and async unless they're required; act is synchronous.
  • Borrow instead of taking ownership where you can.
  • Implement a trait in the file that defines the struct, and split large impl blocks by topic.
  • Follow the Rust style guide for everything rustfmt doesn't cover.

TypeScript

  • Validate everything from the backend with Zod. Each machine namespace declares its event schemas with eventSchema(...), REST responses are parsed (mutationResponseSchema), and useMachineMutate(schema) validates request bodies. Derive types with z.infer instead 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 produce for 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 .tsx files; hooks are use<Name>.ts.

Machine UI file names

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.

Clone this wiki locally