Avlos is a schema-driven code generation framework that automatically creates communication protocol implementations for embedded systems. It generates both firmware (C code) and client library (Python) code from a single YAML specification, ensuring perfect synchronization between device and host.
Key Benefits:
- Single Source of Truth: One YAML file defines the entire API surface
- Zero Boilerplate: No manual protocol wrapper coding required
- Type Safety: Automatic serialization/deserialization with type checking
- Version Control: Protocol hash ensures firmware-client compatibility
- Self-Documenting: API documentation generated from YAML summaries
Avlos Repository: https://github.com/tinymovr/avlos
Tinymovr's entire CAN bus protocol (97 endpoints) is defined in YAML and automatically generated into:
- Firmware: C functions for handling CAN messages (firmware/src/can/can_endpoints.c)
- Python Client: Dynamic Python objects with attribute access (studio/Python/tinymovr/)
- Documentation: RST reference documentation (docs/protocol/reference.rst)
Current Protocol Version: Hash 641680925 (v2.3.x)
Avlos specs are hierarchical trees of remote_attributes. Each attribute can represent:
- Readable value (with
getter_name) - Writable value (with
setter_name) - Callable function (with
caller_name) - Nested namespace (with child
remote_attributes)
Reference: studio/Python/tinymovr/specs/tinymovr_2_3_x.yaml
name: tm
remote_attributes:
- name: Vbus
dtype: float
unit: volt
meta: {dynamic: True}
getter_name: system_get_Vbus
summary: The measured bus voltage.Generated Firmware (firmware/src/can/can_endpoints.h):
// Endpoint ID: 4
uint8_t avlos_Vbus(uint8_t * buffer, uint8_t * buffer_len, Avlos_Command cmd);Generated Python:
voltage = tm.Vbus # Automatically calls avlos_Vbus endpoint- name: position
remote_attributes:
- name: setpoint
dtype: float
unit: tick
getter_name: controller_get_pos_setpoint_user_frame
setter_name: controller_set_pos_setpoint_user_frame
summary: The position setpoint in user frame.Generated Python:
tm.controller.position.setpoint = 5000 # Write via setter
current_setpoint = tm.controller.position.setpoint # Read via getter- name: calibrate
summary: Initiate calibration sequence.
caller_name: controller_calibrate
dtype: void
arguments: []Generated Python:
tm.controller.calibrate() # Calls controller_calibrate() on device- name: state
options: [IDLE, CALIBRATE, CL_CONTROL]
getter_name: controller_get_state
setter_name: controller_set_state
summary: The current controller state.Generated Firmware (firmware/src/tm_enums.h):
typedef enum
{
CONTROLLER_STATE_IDLE = 0,
CONTROLLER_STATE_CALIBRATE = 1,
CONTROLLER_STATE_CL_CONTROL = 2
} controller_state_options;Generated Python:
tm.controller.state = 2 # Set to CL_CONTROL
if tm.controller.state == 0: # Check if IDLE
print("Device is idle")- name: errors
flags: [UNDERVOLTAGE]
meta: {dynamic: True}
getter_name: system_get_errors
summary: Any system errors, as a bitmaskGenerated Firmware:
typedef enum
{
ERRORS_NONE = 0,
ERRORS_UNDERVOLTAGE = (1 << 0)
} errors_flags;Usage:
if tm.errors & (1 << 0): # Check UNDERVOLTAGE bit
print("Undervoltage detected!")name: Identifier for the attribute (used in Python API and C function names)summary: Brief description (appears in documentation and generated comments)
dtype: Data type -float,uint32,int32,uint16,int16,uint8,int8,bool,string,voidunit: Physical unit -tick,ampere,volt,degC,ohm,henry,watt, etc.options: List of enum values (mutually exclusive withflags)flags: List of bitmask flag names (mutually exclusive withoptions)
getter_name: C function name for reading the valuesetter_name: C function name for writing the valuecaller_name: C function name for calling (void functions)arguments: List of argument definitions for callable functions
meta: Dictionary of optional metadatadynamic: True- Value changes frequently (read every time)reload_data: True- Causes device reset/reload after call
- name: controller
remote_attributes:
- name: position
remote_attributes:
- name: setpoint
dtype: float
...Generates Nested Access:
tm.controller.position.setpoint = 1000The code generation process is configured in avlos_config.yaml:
generators:
generator_c:
enabled: true
paths:
output_enums: ./firmware/src/tm_enums.h
output_header: ./firmware/src/can/can_endpoints.h
output_impl: ./firmware/src/can/can_endpoints.c
header_includes:
- src/common.h
- src/tm_enums.h
impl_includes:
- <string.h>
- src/adc/adc.h
- src/system/system.h
- src/sensor/sensors.h
- src/observer/observer.h
- src/motor/motor.h
- src/scheduler/scheduler.h
- src/controller/controller.h
- src/nvm/nvm.h
- src/watchdog/watchdog.h
- src/can/can_endpoints.h
generator_rst:
enabled: true
paths:
output_file: ./docs/protocol/reference.rstLet's add a motor temperature estimation endpoint:
File: studio/Python/tinymovr/specs/tinymovr_2_3_x.yaml
Find the motor section and add:
- name: motor
remote_attributes:
# ... existing attributes ...
- name: temperature
dtype: float
unit: degC
meta: {dynamic: True}
getter_name: motor_get_temperature
summary: Estimated motor temperature from resistance change.cd studio/Python/tinymovr/specs
avlos from file <spec_file>.yamlReplace <spec_file> with the appropriate protocol version spec file (e.g., tinymovr_2_3_x, tinymovr_2_4_x, etc.). Use the spec file that matches the firmware version you're working with.
Output:
Generating C code...
Written: firmware/src/tm_enums.h
Written: firmware/src/can/can_endpoints.h
Written: firmware/src/can/can_endpoints.c
Generating RST documentation...
Written: docs/protocol/reference.rst
Protocol hash: 641680925 -> 789456123 (CHANGED!)
Important: The protocol hash will change with any YAML modification. This breaks compatibility with existing firmware.
Create the getter function in firmware/src/motor/motor.c:
float motor_get_temperature(void)
{
// Estimate temperature from resistance change
// T = T_cal + (R - R_cal) / (R_cal * alpha)
// where alpha = temperature coefficient of copper (0.00393 / °C)
MotorConfig *config = motor_get_config();
float R_cal = config->phase_resistance; // Resistance at calibration
float T_cal = 25.0f; // Assumed calibration temperature (°C)
float alpha = 0.00393f; // Copper temp coefficient
// Measure current resistance (simplified)
float R_current = R_cal; // TODO: Implement actual measurement
float delta_T = (R_current - R_cal) / (R_cal * alpha);
return T_cal + delta_T;
}Add declaration to firmware/src/motor/motor.h:
float motor_get_temperature(void);cd firmware
make release REV=R52Verify:
- No compilation errors
- Binary size hasn't exceeded flash capacity
- New endpoint included in endpoint array (97 -> 98 endpoints)
# Using J-Link (example)
JLinkExe -device PAC5527 -if SWD -speed 4000
> loadbin build/tinymovr_fw.bin 0x00000000
> r
> g
> exitThe Python client automatically generates the new attribute from the YAML spec:
from tinymovr import init_router, create_device
from tinymovr.config import get_bus_config
import can
# Initialize CAN bus
params = get_bus_config(["canine", "slcan_disco"], bitrate=1000000)
init_router(can.Bus, params)
# Create device
tm = create_device(node_id=1)
# Check protocol hash matches
print(f"Protocol hash: {tm.protocol_hash}") # Should be 789456123
# Read new endpoint
temp = tm.motor.temperature
print(f"Motor temperature: {temp:.1f} °C")Add test to studio/Python/tests/test_board.py:
@pytest.mark.hitl_default
def test_motor_temperature(self):
"""Test motor temperature estimation"""
self.try_calibrate()
# Read temperature
temp = self.tm.motor.temperature
# Sanity check: temperature should be reasonable
self.assertGreater(temp, 0.0) # Above absolute zero
self.assertLess(temp, 150.0) # Below typical motor maxRun test:
cd studio/Python
pytest -m hitl_default tests/test_board.py::TestTinymovr::test_motor_temperatureThe protocol hash is a 32-bit checksum computed from the entire YAML spec structure. It ensures firmware and client are using compatible protocol definitions.
Reference: firmware/src/can/can_endpoints.h
static const uint32_t avlos_proto_hash = 641680925;When the Python client connects to a device:
- Read device's
protocol_hashendpoint (endpoint ID 0) - Compare to hash computed from local YAML spec
- If mismatch → Raise
IncompatibleSpecVersionError
Reference: studio/Python/tinymovr/config/config.py
def get_device_spec(hash, logger=None):
try:
return specs["hash_uint32"][hash]
except KeyError:
# Try hash aliases for backward compatibility
for hash_alias in hash_aliases.get(hash, []):
try:
return specs["hash_uint32"][hash_alias]
except KeyError:
pass
return None # No compatible spec foundThe hash changes when any of the following are modified in the YAML:
- Add/remove/rename an attribute
- Change data type (
dtype) - Change unit
- Modify options/flags
- Change nesting structure
- Change order of attributes (endpoint IDs shift)
Breaking Changes (new hash required):
- Adding new endpoints → Endpoint IDs shift → Hash changes
- Changing data types → Serialization incompatible → Hash changes
- Removing endpoints → Missing functionality → Hash changes
Non-Breaking Changes (can use hash aliases):
- Fixing typos in
summary→ Hash changes but protocol compatible - Changing metadata (
meta) → Hash changes but protocol compatible
Hash Aliases (studio/Python/tinymovr/config/config.py):
# Allow old clients to work with new firmware if only cosmetic changes
hash_aliases = {3526126264: [4118115615]}Tinymovr maintains multiple YAML specs for different firmware versions:
studio/Python/tinymovr/specs/
├── tinymovr_1_3_x.yaml # Legacy v1.3.x
├── tinymovr_1_4_x.yaml # Legacy v1.4.x
├── tinymovr_2_0_x.yaml # v2.0.x
├── tinymovr_2_1_x.yaml # v2.1.x
├── tinymovr_2_2_x.yaml # v2.2.x
└── tinymovr_2_3_x.yaml # Current v2.3.x (hash: 641680925)
The Python client automatically selects the correct spec based on device hash:
# Device discovery reads protocol_hash from device
device_hash = 641680925 # Read from device
# Load matching spec
spec = get_device_spec(device_hash)
if spec is None:
raise IncompatibleSpecVersionError(device_hash)
# Deserialize spec into Python object tree
tm = deserialize(spec)Best Practices:
- Never remove endpoints - Mark as deprecated instead
- Append new endpoints - Add to end to avoid shifting IDs
- Create new version - Copy YAML, modify, update hash
- Test with old clients - Ensure graceful degradation
Example: Deprecation Pattern:
- name: old_parameter
dtype: float
getter_name: get_old_parameter
summary: "[DEPRECATED] Use new_parameter instead."Always create a new protocol version (e.g., 2.3.x → 2.4.x) when making breaking changes:
-
API Structure Changes
- Moving endpoints to new namespaces
- Renaming endpoints
- Changing endpoint hierarchy
-
Data Type Changes
- Changing
dtypeof existing endpoint - Changing units
- Changing enum values or flags
- Changing
-
Removing Endpoints
- Deleting functionality (even if deprecated)
Never create new versions for:
- Adding new endpoints (append to end of spec)
- Fixing typos in documentation (
summaryfields) - Adding metadata (
metafields) - Internal implementation changes
-
Copy the previous version file:
cp studio/Python/tinymovr/specs/tinymovr_2_3_x.yaml \ studio/Python/tinymovr/specs/tinymovr_2_4_x.yaml
-
Make breaking changes in the new file
-
Generate code for both versions:
avlos from file <spec_file>.yaml
-
Update firmware to use new generated code
-
Test with Python client - it will automatically detect version via protocol hash
-
Keep old version file for backward compatibility with older firmware
When adding new monitoring/debug endpoints, group them under a descriptive namespace while keeping user-facing operations at the top level for backward compatibility.
Before (v2.3.x):
name: tm
remote_attributes:
- name: save_config
caller_name: nvm_save_config
dtype: void
- name: erase_config
caller_name: nvm_erase_and_reset
dtype: voidAfter (v2.4.x) - Adding wear leveling monitoring:
name: tm
remote_attributes:
# User operations stay at top level (no breaking changes)
- name: save_config
caller_name: nvm_save_config
dtype: void
- name: erase_config
caller_name: nvm_erase_and_reset
dtype: void
# New monitoring endpoints under nvm namespace
- name: nvm
remote_attributes:
- name: num_slots
getter_name: nvm_wl_get_num_slots
dtype: uint8
- name: current_slot
getter_name: nvm_wl_get_current_slot
dtype: uint8
- name: write_count
getter_name: nvm_wl_get_write_count
dtype: uint32Impact:
- User operations unchanged:
tm.save_config(),tm.erase_config()(backward compatible) - New monitoring API:
tm.nvm.num_slots,tm.nvm.write_count, etc. - Protocol hash changes (2.3.x clients won't see new endpoints, but existing code works)
- Tests can detect protocol version via
hasattr(tm, 'nvm')for optional features
- Major version (e.g., 1.x → 2.x): Major firmware rewrite, architecture change
- Minor version (e.g., 2.3.x → 2.4.x): Breaking protocol changes, API reorganization
- Patch version (within x.y.x): Usually not reflected in spec filename, used for firmware-only changes
Enumerations (firmware/src/tm_enums.h)
// Auto-generated from YAML
typedef enum
{
CONTROLLER_STATE_IDLE = 0,
CONTROLLER_STATE_CALIBRATE = 1,
CONTROLLER_STATE_CL_CONTROL = 2
} controller_state_options;
typedef enum
{
MOTOR_ERRORS_NONE = 0,
MOTOR_ERRORS_PHASE_RESISTANCE_OUT_OF_RANGE = (1 << 0),
MOTOR_ERRORS_PHASE_INDUCTANCE_OUT_OF_RANGE = (1 << 1),
// ... more error flags
} motor_errors_flags;Endpoint Array (firmware/src/can/can_endpoints.h)
// Array of 97 function pointers, indexed by endpoint ID
extern uint8_t (*avlos_endpoints[97])(uint8_t * buffer,
uint8_t * buffer_len,
Avlos_Command cmd);Endpoint Functions (firmware/src/can/can_endpoints.c)
uint8_t avlos_Vbus(uint8_t * buffer, uint8_t * buffer_len, Avlos_Command cmd)
{
if (cmd == AVLOS_CMD_READ)
{
float value = system_get_Vbus(); // Call implementation
serialize_float(value, buffer); // Pack into CAN frame
*buffer_len = sizeof(float);
return AVLOS_RET_READ;
}
return AVLOS_RET_NOACTION;
}The Python client creates a nested object structure at runtime:
from avlos.deserializer import deserialize
# Load YAML spec
spec = yaml.safe_load(open("tinymovr_2_3_x.yaml"))
# Deserialize into Python objects
tm = deserialize(spec)
# Object hierarchy mirrors YAML structure
tm.Vbus # Float attribute
tm.controller.state # Enum attribute
tm.controller.position.setpoint # Nested float attribute
tm.controller.calibrate() # Callable methodAttributes with unit in YAML return Pint quantities:
from pint import UnitRegistry
ureg = UnitRegistry()
# Reading returns unit-aware quantity
voltage = tm.Vbus # Returns: 24.0 <Unit('volt')>
# Writing accepts units
tm.controller.current.Iq_setpoint = 2.5 * ureg.ampere
# Or plain floats (assumes base unit)
tm.controller.current.Iq_setpoint = 2.5 # Interpreted as amperesAll protocol changes must be validated with HITL tests:
cd studio/Python
# Run full test suite (requires hardware)
pytest -m hitl_default
# Run specific test
pytest -m hitl_default tests/test_board.py::TestTinymovr::test_position_control
# Run end-of-line comprehensive tests
pytest -m eolHITL tests use studio/Python/tests/tm_test_case.py base class:
import pytest
from tests.tm_test_case import TMTestCase
class TestNewEndpoint(TMTestCase):
@pytest.mark.hitl_default
def test_new_endpoint_read(self):
"""Test reading new endpoint"""
self.try_calibrate() # Calibrate if needed
value = self.tm.new_endpoint
self.assertIsNotNone(value)
@pytest.mark.hitl_default
def test_new_endpoint_write(self):
"""Test writing new endpoint"""
self.try_calibrate()
self.tm.new_endpoint = 123.45
readback = self.tm.new_endpoint
self.assertAlmostEqual(readback, 123.45, delta=0.01)For logic that doesn't require hardware:
# tests/test_simulation.py
import unittest
class TestProtocolHash(unittest.TestCase):
def test_hash_consistency(self):
"""Verify protocol hash computation is deterministic"""
from tinymovr.config import specs
# Load spec twice
spec1 = specs["hash_uint32"][641680925]
spec2 = specs["hash_uint32"][641680925]
# Hashes should match
self.assertEqual(spec1, spec2)CRITICAL RULE: NEVER modify existing YAML spec files. Spec files are immutable once published. Any change to a YAML spec -- including adding meta flags, renaming fields, or changing descriptions -- alters the protocol hash, breaking compatibility with deployed firmware.
To make changes:
- Copy the latest spec to a new version file (e.g.,
tinymovr_2_4_x.yaml->tinymovr_2_6_x.yaml) - Make all edits in the new file only
- Regenerate firmware endpoints from the new spec:
avlos from file <new_spec>.yaml - Update firmware build to reference the new spec version
Existing specs must remain byte-for-byte identical to preserve protocol hash compatibility.
Problem: Modified YAML but didn't run avlos.generate
Symptom: Python client has new attribute, firmware doesn't recognize it
Solution: Always run code generation after YAML changes:
avlos from file <spec_file>.yamlProblem: Modified can_endpoints.c or tm_enums.h manually
Symptom: Changes lost on next code generation
Solution: Never edit generated files. They have warnings at the top:
/*
* This file was automatically generated using Avlos.
* Any changes to this file will be overwritten when
* content is regenerated.
*/Problem: Added endpoint with getter_name but function not implemented
Symptom: Linker error: undefined reference to 'motor_get_temperature'
Solution: Implement the C function before building firmware
Problem: YAML says dtype: uint32 but C function returns float
Symptom: Garbage data, incorrect values, or crashes
Solution: Ensure C function signature matches YAML dtype:
dtype: uint32 → uint32_t function_name(void)
dtype: float → float function_name(void)
dtype: bool → bool function_name(void)Problem: Firmware and Python client have different protocol hashes
Symptom: IncompatibleSpecVersionError on device connection
Solution:
- Flash updated firmware with new hash
- Or revert YAML changes to match firmware
- Or add hash alias for backward compatibility
Avlos supports structures via nested attributes:
- name: pid_gains
remote_attributes:
- name: kp
dtype: float
getter_name: controller_get_kp
setter_name: controller_set_kp
- name: ki
dtype: float
getter_name: controller_get_ki
setter_name: controller_set_kiAccess:
tm.controller.pid_gains.kp = 0.5
tm.controller.pid_gains.ki = 0.01Callables can accept arguments:
- name: set_limits
caller_name: controller_set_limits
dtype: void
arguments:
- name: vel_limit
dtype: float
unit: tick/s
- name: current_limit
dtype: float
unit: ampereGenerated C:
void controller_set_limits(float vel_limit, float current_limit);Python Call:
tm.controller.set_limits(100000, 5.0)Attributes marked meta: {dynamic: True} are read every time:
- name: Vbus
dtype: float
meta: {dynamic: True} # Read from device on every access
getter_name: system_get_VbusStatic attributes (no dynamic flag) are cached:
- name: hw_revision
dtype: uint32
getter_name: system_get_hw_revision # Read once, cached- Repository: https://github.com/tinymovr/avlos
- Documentation: https://github.com/tinymovr/avlos/blob/main/README.md
- avlos_config.yaml - Code generation configuration
- studio/Python/tinymovr/specs/ - YAML specifications
- firmware/src/can/can_endpoints.h - Generated C header
- firmware/src/can/can_endpoints.c - Generated C implementation
- firmware/src/tm_enums.h - Generated enumerations
- studio/Python/tinymovr/config/config.py - Spec loading
- docs/protocol/reference.rst - Generated documentation
- ARCHITECTURE.md - System architecture overview
- CLAUDE.md - Development guidelines for AI agents
- SAFETY.md - Safety-critical constraints
Document Status: Living document, updated as Avlos framework evolves. Current Version: Based on Avlos v0.6.6+ and Tinymovr firmware v2.3.x.