diff --git a/404.html b/404.html index 704a142..34fbfbc 100644 --- a/404.html +++ b/404.html @@ -11,7 +11,7 @@ - + @@ -19,10 +19,7 @@ - - - - + @@ -50,15 +47,12 @@ - - - - - -
Create a new LED "green" at pin 14 and turn it on:
Lizard is a domain-specific language to define hardware behavior. It is intended to run on embedded systems which are connected to motor controllers, sensors etc. @@ -486,10 +481,10 @@
Statements are input via the command-line interface or stored in the startup script on the microcontroller. @@ -500,7 +495,7 @@
Lizard implements the following features to increase machine safety.
All Lizard modules have the following methods in common.
The RMD motor pair module allows to synchronize two RMD motors.
The RoboClaw module serves as building block for more complex modules like RoboClaw motors. It communicates with a Basicmicro RoboClaw motor driver via serial.
Lizard is a domain-specific language to define hardware behavior. It is intended to run on embedded systems which are connected to motor controllers, sensors etc. Most of the time it is used in combination with a higher level engine like ROS or RoSys. You can think of the microcontroller as the machine's lizard brain which ensures basic safety and performs all time-critical actions.
The idea is to not compile and deploy specific C++ code for every new hardware requirement. Instead you simply write your commands in a text-based language which can be changed on the fly.
Lizard consists of individual hardware modules that are either defined in a persistent startup script or interactively via console input. Each module has a name, a number of required or optional constructor arguments and possibly a step function as well as a number of properties.
Furthermore, Lizard allows to define rules, possibly asynchronous routines and variables. A routine is simply a collection of Lizard statements. If a rule's condition evaluates to true, the corresponding routine is started. Conditions can involve variables, module properties and constant expressions.
One core module is automatically defined first. It provides interaction with the microcontroller itself, e.g. reading the system's time or causing a restart.
core
During a main loop Lizard repeatedly performs the following tasks:
Create a new LED \"green\" at pin 14 and turn it on:
green = Output(14)\ngreen.on()\n
Create a button \"b1\" at pin 25 with internal pull-up resistor and read its value:
b1 = Input(25)\nb1.pullup()\nb1.level\n
Clear the persistent storage, configure a button and an LED, write the new startup script to the persistent storage, restart the microcontroller with these two new modules and print the stored configuration:
!-\n!+green = Output(14)\n!+b1 = Input(25)\n!.\ncore.restart()\n!?\n
Create an LED \"red\", a button \"b1\" with pull-up resistor as well as a condition \"c1\" that turns off the LED as soon as the button is pressed:
red = Output(14)\nred.on()\nb1 = Input(25)\nb1.pullup()\nwhen b1.level == 0 then red.off(); end\n
Create a \"green\" LED that shadows a \"red\" LED, i.e. will receive a copy of each command:
green = Output(13)\nred = Output(14)\nred.shadow(green)\n
Create a serial connection as well as a port expander with an LED at pin 15 and turn it on:
serial = Serial(26, 27, 11500, 1)\nexpander = Expander(serial, 32, 33)\nled = expander.Output(15)\nled.on()\n
sudo ./flash.py /dev/<serial device name>
You can launch an interactive shell with ./monitor.py to try out configurations and watch Lizard outputs (see tools for more details). To verify that the communication is working, use one of the following commands to generate some output:
./monitor.py
core.info()\ncore.millis\ncore.print(\"Hello, Lizard!\")\n
See the module reference for other commands.
To try out individual modules, you can get their current properties or unmute them for continuous output, e.g.:
estop = Input(34)\nestop.level\nestop.unmute()\n
Of course you should connect the ESP32 to some hardware you want to control. From basic pins like LEDs (see Output) and buttons (see Input) to communication via CAN and control of stepper motors.
You can create a startup script for rules which should be directly applied after boot of the microcontroller. Simply write the commands into a file like on_startup.lizard and set them with
on_startup.lizard
./configure.py on_startup.lizard /dev/<serial device name>\n
See Tools for more details.
Statements are input via the command-line interface or stored in the startup script on the microcontroller. The following statement types are currently supported.
Expressions
An expression can be a constant value, a variable, a module property, an arithmetic or logical expression as well as various combinations:
true\n42\n3.14\n\"Hello world\"\nled\nbutton.level\n1 + (2 - 3 * 4)**5\n1 == 2 or (x == 4 and button.level == 0)\n
Expressions can be assigned to variables, used in conditions and passed to constructors, method calls or routine calls. Plain expression statements print their result to the command line.
Variables: declaration and assignment
New variables need to be explicitly declared with a data type:
int i\n
They can be immediately initialized:
int i = 1\n
Otherwise they have an initial value of false, 0, 0.0 or \"\", respectively.
false
0
0.0
\"\"
Variables can be assigned a new value of compatible data type:
i = 2\n
Modules: constructors, method calls and property assignments
Constructors are used to create module instances:
led = Output(15)\n
See the module reference for more details about individual modules and their argument lists.
Constructors can also be used with expander modules to instantiate a remote module that can be controlled from the main microcontroller:
led = expander.Output(15)\n
You can call module methods as follows:
led.on()\n
Some module properties are meant to be written to:
motor.ratio = 9\n
Routines: definition and call
Routines have a name and contain a list of actions:
let all_on do\n green.on()\n red.on()\nend\n
They can be called similar to module methods:
all_on()\n
Rules: definition
Rules execute a list of actions when a condition is met:
when button.level == 0 then\n core.print(\"Off!\")\n led.off()\nend\n
In contrast to an if-statement known from other languages a when-condition is checked in every cycle of Lizard's main loop. Whenever the condition is true, the actions are executed.
Note that actions can be asynchronous. If there are still actions running asynchronously, a truthy condition is ignored.
Routines and rules contain a list of actions.
Method and routine calls
Like with a method call statement, you can call methods and routines with an action:
when clicked then\n core.print(\"On!\")\n all_on()\nend\n
Property and variable assignments
Like with the corresponding assignment statements, you can assign properties and variables with an action as well:
when i > 0 then\n i = 0\n core.debug = true\nend\n
Await conditions and routines
In contrast to statements, actions can await conditions, causing the execution of subsequent actions to wait until the condition is met.
int t\nlet blink do\n t = core.millis\n led.on()\n await core.millis > t + 1000\n led.off()\nend\n
Similarly, actions can await asynchronous routines, causing subsequent actions to wait until a routine is completed.
when button.level == 0 then\n core.print(\"Blink...\")\n await blink()\n core.print(\"Done.\")\nend\n
Lizard currently supports five data types:
bool b = true
true
int i = 0
float f = 0.0
str s = \"foo\"
led = Output(15)
Note that identifiers cannot be created via variable declarations, but only via constructors.
Implicit conversion only happens from integers to floating point numbers:
int i = 42\nfloat f = i + 3.14\n
Tabs and spaces are treated as whitespace.
Blank lines are interpreted as no-op and do nothing.
Line comments start with #.
#
Multiple statements or actions are separated with ; or a newline.
;
Lines with a leading ! can indicate one of the following control commands.
!
!+abc
abc
!-abc
!?
!.
!!abc
!\"abc
Note that the commands !+, !- and !? affect the startup script in RAM, which is only written to non-volatile storage with the !. command.
!+
!-
Input from the default command-line interface UART0 is usually interpreted as Lizard code; input from a port expander is usually printed to the command-line on UART0. This behavior can be changed using !! and !\".
!!
!\"
Each line sent via the command-line interface can and should be followed by a checksum. Lizard will omit any lines with incorrect checksums. Any output is as well sent with a checksum.
The 8-bit checksum is computed as the bitwise XOR of all characters excluding the newline character and written as a two-digit hex number (with leading zeros) separated with an @ character, for example:
@
1 + 2
1 + 2@28
The core module provides a property last_message_age, which holds the time in milliseconds since the last input message was received from UART0, parsed and successfully interpreted. It allows formulating rules that stop critical hardware modules when the connection to the host system is lost.
last_message_age
The following example stops a motor when there is no serial communication for 500 ms:
when core.last_message_age > 500 then motor.stop(); end\n
module.mute()
module.unmute()
module.shadow()
module.broadcast()
Shadows are useful if multiple modules should behave exactly the same, e.g. two actuators that should always move synchronously.
The broadcast method is used internally with port expanders.
broadcast
The core module encapsulates various properties and methods that are related to the microcontroller itself. It is automatically created right after the boot sequence.
core.debug
bool
core.millis
int
core.heap
core.restart()
core.version()
core.info()
core.print(...)
core.output(format)
str
core.startup_checksum()
The output format is a string with multiple space-separated elements of the pattern <module>.<property>[:<precision>] or <variable>[:<precision>]. The precision is an optional integer specifying the number of decimal places for a floating point number. For example, the format \"core.millis input.level motor.position:3\" might yield an output like \"92456 1 12.789.
format
<module>.<property>[:<precision>]
<variable>[:<precision>]
precision
\"core.millis input.level motor.position:3\"
\"92456 1 12.789
Lizard can receive messages via Bluetooth Low Energy, and also send messages in return to a connected device. Simply create a Bluetooth module with a device name of your choice.
bluetooth = Bluetooth(device_name)
device_name
bluetooth.send(data)
data
Lizard will offer a service 23014CCC-4677-4864-B4C1-8F772B373FAC and a characteristic 37107598-7030-46D3-B688-E3664C1712F0 that allows writing Lizard statements like on the command line. On a second characteristic 19f91f52-e3b1-4809-9d71-bc16ecd81069 notifications will be emitted when send(data) is executed.
send(data)
The input module is associated with a digital input pin that is be connected to a pushbutton, sensor or other input signal.
input = Input(pin)
pin
input.level
input.change
input.get()
input.pullup()
input.pulldown()
input.pulloff()
The output module is associated with a digital output pin that is connected to an LED, actuator or other output signal.
output = Output(pin)
output.level
output.change
output.on()
output.off()
output.level(value)
value
output.pulse(interval[, duty_cycle])
float
The pulse() method allows pulsing an output with a given interval in seconds and an optional duty cycle between 0 and 1 (0.5 by default). Note that the pulsing frequency is limited by the main loop to around 20 Hz.
pulse()
The MCP23017 allows controlling up to 16 general purpose input or output pins via I2C.
mcp = Mcp23017([port[, sda[, scl[, address[, clk_speed]]]]])
The constructor expects up to five arguments:
port
sda
scl
address
clk_speed
mcp.levels
mcp.inputs
mcp.pullups
The properties levels, inputs and pullups contain binary information for all 16 pins in form of a 16 bit unsigned integer.
levels
inputs
pullups
mcp.levels(value)
mcp.inputs(value)
mcp.pullups(value)
The methods levels(), inputs() and pullups() expect a 16 bit unsigned integer value containing binary information for all 16 pins.
levels()
inputs()
pullups()
Use inputs() to configure input and output pins, e.g. inputs(0xffff) all inputs or inputs(0x0000) all outputs. While levels() will only affect output pins, pullups() will only affect the levels of input pins.
inputs(0xffff)
inputs(0x0000)
Using an MCP23017 port expander module you can not only access individual pins. You can also instantiate the following modules passing the mcp instance as the first argument:
mcp
input = Input(mcp, pin)
output = Output(mcp, pin)
motor = LinearMotor(mcp, move_in, move_out, end_in, end_out)
The pins pin, move_in, move_out, end_in and end_out are numbers from 0 to 15 referring to A0...A7 and B0...B7 on the MCP23017.
move_in
move_out
end_in
end_out
The IMU module provides access to a Bosch BNO055 9-axis absolute orientation sensor. Currently, only reading the accelerometer is implemented.
imu = Imu([port[, sda[, scl[, address[, clk_speed]]]]])
imu.accel_x
imu.accel_y
imu.accel_z
The CAN module allows communicating with peripherals on the specified CAN bus.
can = Can(rx, tx, baud)
can.send(node_id, d0, d1, d2, d3, d4, d5, d6, d7)
can.get_status()
can.start()
can.stop()
can.recover()
The method get_status() prints the following information:
get_status()
state
msgs_to_tx
msgs_to_rx
tx_error_counter
rx_error_counter
tx_failed_count
rx_missed_count
rx_overrun_count
arb_lost_count
bus_error_count
After creating a CAN module, the driver is started automatically. The start() and stop() methods are primarily for debugging purposes.
start()
stop()
The serial module allows communicating with peripherals via the specified connection.
serial = Serial(rx, tx, baud, num)
serial.send(b0, b1, b2, ...)
serial.read()
This module might be used by other modules that communicate with peripherals via serial. You can, however, unmute the serial module to have incoming messages printed to the command line instead of keeping them buffered for other modules.
This module controls a linear actuator via two output pins (move in, move out) and two input pins reading two limit switches (end in, end out).
motor = LinearMotor(move_in, move_out, end_in, end_out)
motor.in
motor.out
motor.in()
motor.out()
motor.stop()
The ODrive motor module controls a motor using an ODrive motor controller.
motor = ODriveMotor(can, can_id)
motor.position
motor.tick_offset
motor.m_per_tick
motor.reversed
motor.zero()
motor.power(torque)
torque
motor.speed(speed)
speed
motor.position(position)
position
motor.limits(speed, current)
motor.off()
The ODrive wheels module combines to ODrive motors and provides odometry and steering for differential wheeled robots.
wheels = ODriveWheels(left_motor, left_motor)
wheels.width
wheels.linear_speed
wheels.angular_speed
wheels.enabled
wheels.power(left, right)
wheels.speed(linear, angular)
linear
angular
wheels.off()
When the wheels are not enabled, power and speed method calls are ignored. This allows disabling the wheels permanently by setting enabled = false in conjunction with calling the off() method. Now the vehicle can be pushed manually with motors turned off, without taking care of every line of code potentially re-activating the motors.
enabled
power
enabled = false
off()
The RMD motor module controls a Gyems RMD motor via CAN.
rmd = RmdMotor(can, motor_id)
rmd.position
rmd.ratio
rmd.torque
rmd.speed
rmd.can_age
rmd.map_distance
rmd.map_speed
rmd.power(torque)
rmd.speed(speed)
rmd.position(pos)
pos
rmd.position(pos, speed)
rmd.stop()
rmd.resume()
rmd.off()
rmd.hold()
rmd.map(leader)
rmd.map(leader, m)
m
rmd.map(leader, m, n)
n
rmd.map(leader, a, b, c, d)
rmd.unmap()
rmd.get_health()
rmd.get_pid()
rmd.get_acceleration()
rmd.set_acceleration()
rmd.clear_errors()
rmd.zero()
The zero command
The zero() method should be used with care! In contrast to other commands it blocks the main loop for up to 200 ms and requires restarting the motor to take effect. Furthermore, multiple writes will affect the chip life, thus it is not recommended to use it frequently.
zero()
Mapping movement to another RMD motor
When mapping the movement of a following motor to a leading motor, the follower uses velocity control to follow the leader. The follower's target speed is always computed such that it catches up within one loop cycle. When the following motor reaches its target position and the computed speed is below 1 degree per second, the follower switches to position control and holds the current position.
The mapping interval (a, b) should not be empty, because the target position of the following motor would be undefined.
a
b
Any method call (except the map() method) will unmap the motor. This avoids extreme position jumps and inconsistencies caused by multiple control loops running at the same time.
map()
rmd = RmdPair(rmd1, rmd2)
rmd.v_max
rmd.a_max
rmd.max_error
rmd.dt
rmd.move(x, y)
x
rmd.move(x, y, v, w)
y
v
w
rmd.clear_moves()
Multiple move commands are scheduled to be executed one after another.
move
claw = RoboClaw(serial, address)
claw.temperature
The temperature property is updated every 1 second.
The RoboClaw motor module controls a motor using a RoboClaw module.
motor = RoboClawMotor(claw, motor_id)
The RoboClaw wheels module combines two RoboClaw motors and provides odometry and steering for differential wheeled robots.
wheels = RoboClawWheels(left_motor, left_motor)
wheels.m_per_tick
When the wheels are not enabled, power and speed method calls are ignored.
The stepper motor module controls a stepper motor via \"step\" and \"direction\" pins. It uses the ESP LED Control API to generate pulses with sufficiently high frequencies and the Pulse Counter API to count steps.
motor = StepperMotor(step, dir[, pu[, cp[, lt[, lc]]]])
The constructor arguments pu (pulse counter unit), pc (pulse counter channel), lt (LED timer) and lc (LED channel) are optional and default to 0. When using multiple stepper motors, they can be set to different values to avoid conflicts.
pu
pc
lt
lc
motor.speed
motor.idle
motor.speed(speed[, acceleration])
motor.position(position, speed[, acceleration])
The optional acceleration argument defaults to 0, which starts and stops pulsing immediately.
The CanOpenMaster module sends periodic SYNC messages to all CANopen nodes. At creation, no messages are sent until sync_interval is set to a value greater than 0.
sync_interval
co_master = CanOpenMaster(can)
co_master.sync_interval
The CanOpenMotor module implements a subset of commands necessary to control a motor implementing DS402. Positional and velocity units are currently undefined and must by manually measured. Once the configuration sequence has finished, current status, position and velocity are queried on every SYNC.
motor = CanOpenMotor(can, node_id)
CAN module
motor.enter_pp_mode(velo)
velo
motor.enter_pv_mode()
motor.set_target_position(pos)
motor.commit_target_position()
motor.set_target_velocity(velo)
motor.set_ctrl_halt(mode)
motor.set_ctrl_enable(mode)
motor.reset_fault()
motor.sdo_read(index)
index
0x00
initialized
last_heartbeat
is_booting
is_preoperational
is_operational
actual_position
position_offset
actual_velocity
status_enabled
status_fault
status_target_reached
ctrl_enable
ctrl_halt
Configuration sequence
After creation of the module, the configuration is stepped through automatically on each heartbeat; once finished, the initialized attribute is set to true. Note that for runtime variables (actual position, velocity, and status bits) to be updated, a CanOpenMaster module must exist and be sending periodic SYNCs.
Target position sequence
Note: The target velocity must be positive regardless of target point direction. The halt bit is cleared when entering pp, though it can be set at any point during moves to effectively apply brakes.
// First time, assuming motor is disabled and not in pp mode\nmotor.set_ctrl_enable(true)\nmotor.enter_pp_mode(<some positive velocity>)\n\n// All further set points only need these\nmotor.set_target_position(<some position>)\nmotor.commit_target_position()\n
Target velocity sequence
Unlike in the profile position mode, here the sign of the velocity does controls the direction. The halt bit is set when entering pv. To start moving, clear it (and set again to stop).
// First time, assuming motor is disabled and not in pv mode\nmotor.set_ctrl_enable(true)\nmotor.enter_pv_mode(<some signed velocity>)\n\n// Further movements only need these\nmotor.set_ctrl_halt(false)\n// await some condition\nmotor.set_ctrl_halt(true)\n
The expander module allows communication with another microcontroller connected via serial.
expander = Expander(serial, boot, enable)
expander.run(command)
command
string
expander.disconnect()
expander.flash()
The disconnect() method might be useful to access the other microcontroller on UART0 via USB while still being physically connected to the main microcontroller.
disconnect()
Note that the expander forwards all other method calls to the remote core module, e.g. expander.info().
expander.info()
-- This module is mainly for internal use with the expander module. --
Proxy modules serve as handles for remote modules running on another microcontroller. Declaring a module x = Proxy() will allow formulating rules like when x.level == 0 then .... It will receive property values from a remote module with the same name x, e.g. an input signal level. Note that the remote module has to have turned on broadcasting: x.broadcast().
x = Proxy()
when x.level == 0 then ...
x.broadcast()
module = Proxy()
Note that the proxy module forwards all method calls to the remote module.
To install Lizard on your ESP32 run
sudo ./flash.py [<device_path>]\n
Note that flashing may require root access (hence the sudo). The command also does not work while the serial interface is busy communicating with another process.
The flash.py script can also upload firmware on a Robot Brain where the microcontroller is connected to the pin header of an NVIDIA Jetson computer.
flash.py
Use the serial monitor to read the current output and interactively send Lizard commands to the microcontroller.
./monitor.py [<device_path>]\n
You can also use an SSH monitor to access a microcontroller via SSH:
./monitor_ssh.sh <user@host>\n
Note that the serial monitor cannot communicate while the serial interface is busy communicating with another process.
Use the configure script to send a new startup script to the microcontroller.
./configure.py <config_file> <device_path>\n
Note that the configure script cannot communicate while the serial interface is busy communicating with another process.
Install Python requirements:
python3 -m pip install -r requirements.txt\n
Download owl, the language parser generator:
./get_owl.sh\n
Install UART drivers: https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers
Get all sub modules:
git submodule update --init --recursive\n
After making changes to the Lizard language definition or its C++ implementation, you can use the compile script to generate a new parser and executing the compilation in an Espressif IDF Docker container.
./compile.sh\n
To upload the compiled firmware you can use the ./flash.py command described above.
./flash.py
In case Lizard terminates with a backtrace printed to the serial terminal, you can use the following script to print corresponding source code lines.
./backtrace.sh <addresses>\n
Note that the script assumes Espressif IDF tools being installed at ~/esp/esp-tools_4.4/ and a compiled ELF file being located at build/lizard.elf.
~/esp/esp-tools_4.4/
build/lizard.elf
To build a new release, tag the commit with a \"v\" prefix, for example \"v0.1.4\". A GitHub action will build the binary and create a new release. After creation you can fill in a description if necessary.
motor.reversed = true\n
rmd = RmdMotor(can, motor_id, ratio)
rmd.temperature
rmd.set_pid(...)
rmd.set_acceleration(...)
Set acceleration
Although get_acceleration() prints only one acceleration per motor, set_acceleration distinguishes the following four parameters:
get_acceleration()
set_acceleration
You can pass 0 to skip parameters, i.e. to keep individual acceleration values unchanged.