For: AI assistants (Cursor, GitHub Copilot, Codex, etc.)
About: The project, setup and usage is described in README.md
Standards: All coding standards are in CONTRIBUTING.md – follow those rules
ESP-IDF Docs: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/\ Lizard Docs: https://lizard.dev
Lizard is a domain-specific language (DSL) for defining and controlling hardware behavior on ESP32 microcontrollers. It acts as a "lizard brain" for robots – handling time-critical actions, basic safety, and hardware communication while a higher-level system (ROS, RoSys) handles complex logic.
Tech Stack: C++17 on ESP-IDF, with Python tooling for build/flash.
Related Projects:
- RoSys – Robot System framework (Python, uses Lizard)
- NiceGUI – Web UI framework often used with RoSys
The Lizard language controls hardware via serial commands or startup scripts:
# Module creation
led = Output(15) # GPIO output on pin 15
motor = ODriveMotor(can, 0) # ODrive motor on CAN bus
# Method calls
led.on()
led.off()
motor.power(0.5)
# Property access/assignment
motor.position # Read property
motor.speed = 100 # Write property
# Variables
int count = 0
float speed = 3.14
bool active = true
# Rules (checked every cycle)
when button.level == 0 then
led.on()
count = count + 1
end
# Routines (reusable action sequences)
let blink do
int t
t = core.millis
led.on()
await core.millis > t + 500
led.off()
end
# Control commands
!+led = Output(15) # Add to startup script
!-led # Remove from startup
!. # Save startup to flash
!? # Print startup script| Category | Modules |
|---|---|
| Core | Core (always present) |
| I/O | Input, Output, PwmOutput, Analog, AnalogUnit |
| Communication | Serial, SerialBus, Can, Bluetooth, Expander |
| Motor Controllers | LinearMotor, ODriveMotor, ODriveWheels, RmdMotor, RmdPair, StepperMotor, RoboClawMotor, RoboClawWheels, D1Motor, DunkerMotor, DunkerWheels |
| CANopen | CanOpenMaster, CanOpenMotor |
| Sensors | Imu, TemperatureSensor |
| Expanders | Mcp23017 (I2C GPIO expander) |
| Utilities | MotorAxis, Proxy |
lizard/
├── main/ # Core application
│ ├── main.cpp # Entry point (app_main), main loop, UART processing
│ ├── global.cpp/h # Global state (modules, variables, routines, rules)
│ ├── storage.cpp/h # Non-volatile storage for startup scripts
│ ├── parser.h # Generated parser (from language.owl via gen_parser.sh)
│ ├── compilation/ # DSL compilation (expressions, variables, routines, rules)
│ ├── modules/ # Hardware modules (motors, sensors, I/O, CAN, etc.)
│ └── utils/ # Utilities (UART, OTA, timing, string helpers)
├── components/ # ESP-IDF components and submodules
├── docs/ # MkDocs documentation
├── examples/ # Usage examples (ROS integration, OTA, trajectories)
├── build.py # Build script (wraps idf.py)
├── flash.py # Flash script
├── monitor.py # Serial monitor
├── language.owl # Lizard grammar definition (Owl parser generator)
└── gen_parser.sh # Regenerates parser.c from language.owl
| File | Purpose |
|---|---|
main/main.cpp |
app_main() – initialization, main loop |
main/modules/module.cpp |
Module::create() – module factory |
main/global.cpp |
Global state management |
language.owl |
DSL grammar definition |
# Build for ESP32 (default)
python build.py
# Build for ESP32-S3
python build.py esp32s3
# Clean build
python build.py --clean
python build.py esp32s3 --clean# Flash to connected device
python flash.py
# Monitor serial output
python monitor.py
# Or use idf.py directly
idf.py flash monitorAfter modifying language.owl:
./gen_parser.shFollow this pattern (see existing modules in main/modules/):
- Create header (
my_module.h):
#pragma once
#include "module.h"
class MyModule;
using MyModule_ptr = std::shared_ptr<MyModule>;
class MyModule : public Module {
public:
MyModule(const std::string name, /* constructor args */);
void step() override;
void call(const std::string method_name, const std::vector<ConstExpression_ptr> arguments) override;
std::string get_output() const override;
};- Implement (
my_module.cpp):
#include "my_module.h"
MyModule::MyModule(const std::string name, /* args */)
: Module(my_module, name) {
// Initialize properties
this->properties["some_property"] = std::make_shared<NumberVariable>(0.0);
}
void MyModule::step() {
Module::step(); // Handle output_on and broadcast
// Per-cycle logic
}
void MyModule::call(const std::string method_name, const std::vector<ConstExpression_ptr> arguments) {
if (method_name == "my_method") {
Module::expect(arguments, 1, numbery);
// Implementation
} else {
Module::call(method_name, arguments); // Delegate to base
}
}-
Register in module factory (
module.cpp):- Add to
ModuleTypeenum inmodule.h - Add creation logic in
Module::create()inmodule.cpp - Include the header in
module.cpp
- Add to
-
Document in
docs/module_reference.md:
## My Module
Brief description of what the module does.
| Constructor | Description | Arguments |
| --------------------------- | ----------- | ---------------- |
| `my = MyModule(arg1, arg2)` | Description | `type1`, `type2` |
| Properties | Description | Data type |
| -------------- | ----------- | --------- |
| `my.some_prop` | Description | `float` |
| Methods | Description | Arguments |
| ------------------- | ----------- | --------- |
| `my.some_method(x)` | Description | `float` |Don't settle for the first solution. Question assumptions and think deeply about the true nature of the problem before implementing.
We work together as pair programmers, switching seamlessly between driver and navigator:
- Requirements first: Verify requirements are correct before implementing
- Discuss strategy: Present options and trade-offs when uncertain about approach
- Step-by-step for large changes: Break down significant refactorings and get confirmation at each step
- Challenge assumptions: If the user makes wrong assumptions, correct them directly
For significant changes:
- Present the problem and possible approaches
- Discuss trade-offs and implications
- Get confirmation before proceeding with large refactorings
- Work iteratively with feedback at each step
- Prefer simple, straightforward solutions
- Avoid over-engineering
- Remove obsolete code rather than working around it
- Code should be self-explanatory
- Read any file in the codebase
- Run build commands (
python build.py,idf.py build) - Search for patterns and understand code
- Suggest refactorings or improvements
- Fix obvious bugs or typos
- Modifying the grammar (
language.owl) – affects entire DSL - Adding new dependencies or components
- Changing module interfaces (breaking changes)
- Modifying hardware pin assignments or defaults
- Large refactorings spanning multiple files
- Git operations (commit, push, branch)
- Global mutable state without clear justification
- Raw pointers for ownership – use smart pointers (
std::shared_ptr,std::unique_ptr) - Memory leaks – ensure all allocations are freed, prefer RAII patterns
- Heap allocation in ISRs – never allocate memory in interrupt context
- Blocking operations in the main loop or time-critical code paths
- Debug prints – use
echo()for user-facing output, remove before committing - Unnecessary complexity – follow existing patterns in the codebase
- Code duplication – check existing modules for similar functionality
- Unrelated changes – stay focused on the requested task
The main loop runs every 10ms (delay(10) in app_main). Any operation that takes longer will delay all modules, rules, and routines. Avoid:
- Blocking I/O operations
- Long computations
- Waiting for external responses synchronously
When adding a new module, you must:
- Add to
ModuleTypeenum inmodule.h - Add creation case in
Module::create()inmodule.cpp - Include the header in
module.cpp
Forgetting any step causes "unknown module type" errors.
Always use Module::expect() to validate constructor/method arguments:
Module::expect(arguments, 2, identifier, integer); // Exactly 2 args
Module::expect(arguments, -1, numbery, numbery); // Variable count, validate types onlyAlways call parent implementations:
Module::step()– handlesoutput_onandbroadcastflagsModule::call()– handlesmute,unmute,shadow,broadcastmethods
After modifying language.owl, always run ./gen_parser.sh. The parser.c file is generated – never edit it directly.
Use echo() for messages sent to the serial console:
echo("error in module \"%s\": %s", module->name.c_str(), e.what());
echo("warning: Checksum mismatch");Use std::runtime_error for recoverable errors that should be reported:
throw std::runtime_error("module \"" + name + "\" is no serial connection");
throw std::runtime_error("unknown method \"" + this->name + "." + method_name + "\"");The main loop catches exceptions per-module to prevent one failing module from crashing others:
try {
module->step();
} catch (const std::runtime_error &e) {
echo("error in module \"%s\": %s", module->name.c_str(), e.what());
}In a Lizard session:
core.debug = true
This prints parsing details and timing information.
python monitor.py
# or
idf.py monitorIf the device crashes, use core_dumper.py to analyze:
python core_dumper.py- Add temporary
echo()calls (remove before committing) - Check
core.millisfor timing issues - Use
module.unmute()/module.mute()to control output - Enable
module.broadcast()to see all property changes
For low-level debugging, use ESP-IDF logging macros:
#include "esp_log.h"
static const char *TAG = "my_module";
ESP_LOGI(TAG, "Info message");
ESP_LOGW(TAG, "Warning message");
ESP_LOGE(TAG, "Error message");Before claiming a task complete, verify:
- Code compiles without warnings for both ESP32 and ESP32-S3?
- Code follows style guidelines (see CONTRIBUTING.md)?
- No blocking operations in main loop?
- Debug code removed?
- Memory management correct (no leaks, no dangling pointers)?
- New module registered in
Module::create()if applicable? - New module documented in
docs/module_reference.mdif applicable?
- Check existing modules for similar patterns before inventing new ones
- Read the docs at https://lizard.dev for DSL syntax and module reference
- Ask the user by presenting options and trade-offs if strategy is unclear
Purpose: Maximize signal/noise, maintain code quality, and offload maintainers. Act as a single, concise reviewer. Prefer one structured top-level comment with suggested diffs over many line-by-line nits.
Standards Reference: Before starting a review, internalize all coding standards in CONTRIBUTING.md.
- Audience: PR authors and maintainers
- Voice: concise, technical, actionable
- Output format: one summary + grouped findings (BLOCKER, MAJOR, CLEANUP) + suggested diff blocks
- Memory safety: buffer overflows, use-after-free, dangling pointers, memory leaks, null pointer dereferences
- Security/Secrets: leaked credentials/keys, command injection
- Concurrency issues: race conditions, missing synchronization, ISR-unsafe code
- Resource exhaustion: stack overflow, heap fragmentation, unbounded allocations
- Breaking changes: changes that break existing Lizard scripts or module interfaces
- Main loop blocking: operations that would stall the 10ms loop cycle
- Error-handling gaps: unchecked return values, silent failures
- Unnecessary complexity: simpler design meets requirements
- Resource hygiene: unclosed handles, missing RAII, leaked FreeRTOS resources
- Platform issues: hardcoded values without ESP32/ESP32-S3 guards
- Missing registration: new module not added to
Module::create()orModuleTypeenum - Missing documentation: new module not documented in
docs/module_reference.md
- Readability: complex logic without comments; magic numbers
- const correctness: missing
conston parameters or methods - Naming: inconsistent with existing module patterns
Summary → Lead with motivation, explain changes, assess risk
BLOCKER → Critical issues with rationale
MAJOR → Issues to fix pre-merge
CLEANUP → Quick improvements
Suggested diffs → Apply only if trivial and safe
This file complements CONTRIBUTING.md. Maintainers: update this file as conventions evolve.