Introduction
Welcome to IoT/Maker/Harware control of Raspberry Pi using Final! This guide assumes you have already installed and are familiar with Final. See the Final User Guide for installation and how to run and use Final. Note: you must use the Raspberry Pi installation of Final, not the Linux version. Only the Pi version has the hardware and software support for Pi. Only Pi 5 (and later) is supported.
Final has modest runtime and disk requirements. It should run fine on a 4 GB RAM Pi. You can use either the final TUI binary, or final-gui GUI binary for hardware control. Note that only the final runtime supports command line launching of Final programs, so even if you develop on GUI, you will need to use final if you intend to run your controller headless, or from systemd.
Installation
Follow the Final Installation Instructions to install Final on your Pi (5 or later). Use the Raspberry Pi build, not the generic Linux build — only the Pi build includes hardware support.
Getting Ready
Final Features used
Make sure you familiarize yourself with these Final features. We use them in the Pi examples:
- Stacks, and basic stack operations
- Objects
- Compound Object syntax
- DynamicObject
- Paren object constructors
- TypedObject
For more advanced controller designs, it is helpful to be familiar with these features:
- Events and the EventLoop
- EventTimer and EventTicker for creating time-based events in your controller
- EventEscFn for ease of stopping a controller, or selecting options
Enabling Peripherals On Your Pi
Some of the breakout functions of the Pi are not enbled by default in the Pi OS. The pins themselves are available, but the following needs to be done to enable I2C, SPI, and PWM. You make these changes in /boot/firmware/config.txt. BE CAREFUL!
I2C and SPI
Make sure you enable i2c and spi (uncomment.) Only uncomment i2s if you are going to use an I2S audio dongle or hat. If you do, set i2s.reserved true at the top of your Final files, so reset doesn’t clobber it.
The following only enable i2c1 and spi0 (the header defaults).
dtparam=i2c_arm=on
#dtparam=i2s=on
dtparam=spi=on
To enable other numbered i2c or spi ports that Final Pi supports, add lines like these to config.txt.
# I2C -- only include ones you use
dtoverlay=i2c2-pi5
dtoverlay=i2c3-pi5
## SPI -- only include ones you use
dtoverlay=spi1-pi5
dtoverlay=spi5-pi
UARTs
Each UartPeriph that you use must be enabled individually, and conversely, you should not enable the ones whose pins or periphs you plan to use otherwise.
During testing of our UartPeriphs, we connected 0 and 2 together, and enabled them thus. Follow the same numbering for the other supported uarts.
# Final Pi HAL: header UART loopback pair (uart0 GPIO14/15, uart2 GPIO4/5).
dtoverlay=uart0-pi5
dtoverlay=uart2-pi5
PWM
Again, don’t enable any of these if you aren’t using PWM. External PWM dongles are available, if the Pi is too finnicky…
# Final Pi HAL: header PWM on BCM 12/13 (pwm0/pwm1). dtoverlay=pwm-2chan,pin=12,pin2=13,func=4,func2=4
Pi Prototyping Hardware
You should already have a breakout cable and a T-header for a breadboard. You can get this from CanaKit or on Amazon. Several of the examples use the following:
- a red and a green LED (don’t use blue or white, they require too much drive voltage)
- 2x 220 or 330 ohm resistors for the LEDs
- 2 breadboard compatible push buttons.
We also have an example of using:
- a 9250 9-dof accelerometer/gyro/compass
- an SSD1306-class OLED display
- a JD79661 AdaFruit 4-color e-ink display
Conventions Used
We talk exclusively about GPIO pins, not header pins. GPIO is the conceptual pin number, from 0 to 27. These are mapped in kind of haphazard way to the 40-pin header on the Pi. Most Pi boards come with a card showing how the GPIO pins map to the header pins. To avoid any confusion, we never reference the header pins. So just make sure if you follow any of our examples, that you are using the GPIO pin, not the header pin number.
Hello LED!
Well… the LED doesn’t actually say, “Hello!’, but it does blink!
Wire the green LED as follows:
from GPIO pin 27, to a 220R or 330R resistor
from the resistor to the anode of the LED
the cathode (flat edge) of the LED to GND
(Note: it is suggested to always do wiring with the Pi shutdown, and disconnected from power.)
Here is the program we will write. It is also in examples/pi/hello-led.fnl
Run the example file, and enter
hello-led
You can press Esc or any Fn keep to stop the demo (which you should do, so the hardware gets reset.)
: hello-led
// set all pins/periphs to default state+inactive
GpioCtrl.inst.reset^
// set gpio to a named local for clarity/ease of change of pin
gpio27 l.led!
// initialize the led: no pullup/down, output
GpioPin.pullOff l.led.pull!
GpioPin.out l.led.direction!
true l.led.enabled!
clearEscFn // clears the Esc/Fn key latch
"\nPress Esc or any Fn to exit...\n" $
forever
// check for Esc/Fn pressed
escFn? if
==0 if // Esc=0 else Fn#
"Stopping demo...\n" $
// put all pins/periphs to default input
GpioCtrl.inst.reset^
break
then
then
// toggle the LED and wait the delay
l.led.value not l.led.value!
500 delayMs
/forever
;
This simple example has the general steps you will use in any control program, simple or complex. They are:
- Reset the Gpio pins to inputs, and disable all peripherals;
- (optional but highly recommended) create mnemonic names for the pins and peripherals you will use; this makes the code much clearer, and makes changing pin or peripheral numbers simpler;
- initialize every pin and peripheral you will use; after setting the parameters needed for a pin/periph, set
.enabledtotrue; - initialize your own controller program’s state variables;
- establish a way to gracefully stop the program–using the clearEscFn then escFn? is especially well suited;
- start the action:
- for “legacy/poll loop” style, you create a
foreverloop with abreakto exit - we will see later how to stop using an event-based controller
Using the Pi with Final is this simple!
Let’s move on to discuss controllers in more detail, then we will cover pin and peripheral setup and use.
Controllers
“Controller” is a conceptual term that refers to the totality of code that makes up your control program. It includes the pin and peripheral setup, and then your code that accesses the hardware to achieve your end system goal.
There are basically two approaches to writing a controller. We discuss each in turn.
“Legacy” (“Poll Loop”) Controllers
The simplest approach, which we call “legacy” or “poll loop” control, involves creating a single structured routine that executes in a loop. In the loop it checks the states of the hardware, and according to the goals of your system then responds appropriately. For example, if you have a small display screen and two scroll buttons, you frequently poll for the state of the buttons, pressed/not, track the transition from one to another, and scroll your display content accordingly on the down-press detection.
Poll loop controllers can be easy to write… at first… when there are only a couple of devices and the actions are simple. But they soon become complex when there are many devices, and time also factors in, such as checking things on intervals, and so on. And if you have to add manual switch debouncing in.. it gets very hard to reason about and maintain.
Poll loops are fine for simple smoke tests of a device or two, but Final offers a more structured approach, with many benefits…
Event-driven Controller
An event-driven controller in Final takes advantage of the EventLoop built in to Final, and structures a controller as a TypedObject with methods for handling specific kinds of events, such as level changes, and time/interval triggers.
A controller like this has a bit more “ceremony” to set up, but it pays off by greatly simplifying some of the most common situations, which are level-change response (eg. buttons, sensors, etc.), and incorporating time elements: regular “heart beats”, which Final calls tickers, or interval-based events, which final calls timers.
We will revisit this type of controller in more detail in the Writing Controllers section. First let’s dive in to learn all about Pins and Periphs (Peripherals)…
Pins and Periphs
Pi hardware is expressed via numbered GPIO pins, and numbered peripheral types. Final provides support for using all GPIO pins except 0 and 1, which are reserved for the HAT EEPROM protocol, and are not a great choice to try to use yourself.
Pin functionality is exposed via the Final Type GpioPin. Peripheral functionality is exposed via the named types I2cPeriph, SpiPeriph, UartPeriph, and PwmPeriph.
You do not create instances of these types yourself; every available Pin and Periph is declared in a global constant, eg. gpio2, uart4, etc.. You configure and interact with those instances.
The following page contains a map of all the GPIO pins, and the pin mapping of the available peripherals.
Note that Final does not expose the Pulse-width modulation audio. It is a strange and not too useful throwback approach to digital sound, and is typically not used. There are many alternative choices for getting sound from a Pi, such as a USB dongle, or I2S digital hats.
Final also does not drive the I2S digital audio peripherals (ALSA / audio HATs usually own BCM 18–21). A stub i2s peripheral lets you reserve those pins so Reset does not clobber them and Validate blocks Final from taking them — see I2S (reserved stub). Set true i2s.reserved! before GpioCtrl.inst.reset^ when using an ALSA audio HAT.
Pin Map
This table lists all the GpioPins that Final provides, and the Periph instances it provides. Note that if you enable a numbered Periph, you can’t use the correspoding GpioPins or another peripheral that it overlaps with. If you enable an optional feature, such as Uart CTS/RTS then the same restriction applies. “Def(ault) Periph” doesn’t mean it is enabled by default; ALL periphs and pins must be enabled to be used; it just means this is a conventional or highlighted use, eg I2c1 and Uart0.
| GPIO | Def Periph | Alt Periph 1 | Alt Periph 2 |
|---|---|---|---|
| 2 | I2C1 SDA | ||
| 3 | I2C1 SCL | ||
| 4 | I2C2 SDA | UART2 TX | |
| 5 | I2C2 SCL | UART2 RX | |
| 6 | I2C3 SDA | UART2 CTS^ | |
| 7 | SPI0 CE1^ | I2C3 SCL | UART2 RTS^ |
| 8 | SPI0 CE0 | UART3 TX | |
| 9 | SPI0 MISO | UART3 RX | |
| 10 | SPI0 MOSI | UART3 CTS^ | |
| 11 | SPI0 SCLK | UART3 RTS^ | |
| 12 | PWM0 | SPI5 CE0 | |
| 13 | PWM1 | SPI5 MISO | |
| 14 | UART0 TXD | SPI5 MOSI | |
| 15 | UART0 RXD | SPI5 SCLK | |
| 16 | UART0 CTS^ | SPI1 CE2^ | |
| 17 | UART0 RTS^ | SPI1 CE1^ | |
| 18 | SPI1 CE0 | I2S (i2s) @ | |
| 19 | SPI1 MISO | I2S (i2s) @ | |
| 20 | SPI1 MOSI | I2S (i2s) @ | |
| 21 | SPI1 SCLK | I2S (i2s) @ | |
| 22 | |||
| 23 | |||
| 24 | |||
| 25 | |||
| 26 | SPI5 CE1^ | ||
| 27 |
Notes
^ - indicates an optional feature; if not enabled, GPIO pin is available. See the chapter for the peripheral type for details
@ I2S audio (ALSA); Final does not drive it. Use true i2s.reserved! before reset so BCM 18–21 are not restored — see I2S (reserved stub).
GpioPin Basics
Each pin constant, from gpio2 to gpio27 is an instance of the GpioPin Type. You can configure the following:
- internal pullup or pulldown resistors, or none
- these are typically only used on input pins, output pins actively drive both high and low
- input or output (direction)
- for inputs: debouncing interval, or none
- level change event handlers: onLow and onHigh
We already saw an example in Hello LED! of how to set a pin for output. Here is an example of using all the pin features, from several example code files. The hardware is a button connecting the pin to ground when pressed. It uses the internal pullup resistor, to avoid needing an external, one, and uses debouncing and level change events. (Event Handlers are covered later in the manual.)
Any property that requires some kind of mode or set of limited values has corresponding enums defined as constants on the Type, as you see here.
/*
Both buttons get the same init.
( index:i gpio -- ) o( inst:LedBtnPwmCtrl )
*/
: LedBtnPwmCtrl_initBtn
>ts
GpioPin.pullUp t:.pull!
GpioPin.in t:.direction!
GpioPin.debounceButton t:.debounceMs!
true t:.enabled!
// events
dup o:.handleGpioHigh~ swap t:.setOnHigh^
o:.handleGpioLow~ swap t:.setOnLow^
tdrop
;
The default value of .pull is pullOff, and the default value of debounceMs is 0, so you don’t need to set those if the defaults are fine (typical for an output). You should also set pull to pullOff when your input is being driven from an active digital source.
Debouncing
Most mechanical switches and some sensors do not open or close cleanly. Instead, there is a small amount of mechanical chatter that results in short on/off spikes at the time of actuation. If you were counting the number of times a button was pushed, and went solely by the high-speed digital value of the input pin, your count could be wildly off, as it accumulated each chatter noise spike as a valid push.
To correct for this, we use debouncing. Final provides the option to add debouncing to any GPIO input. Note, this capability is restricted to the situation of use of the pin as a GPIO pin, not when the pin is part of a peripheral.
The approach Final takes, is a two-delay approach. At the onset of a change in level, Final waits a few milliseconds, and samples the pin again. If it is still at the same level, it will pass that thru to the value property. It then waits the full debounceMs delay before it will consider a change of the pin in the other direction.
Final provides some helpful named constants for debounce delays that will probably work well as follows:
| GpioPin. | Delay (ms) | When to use |
|---|---|---|
| debounceOff | 0 | disable debouncing, all digital inputs |
| debounceSensor | 5 | an electronic sensor, eg hall effect, photo interrupter |
| debounceButton | 15 | push buttons |
| debounceSwitch | 30 | slider switches |
In practice, you may need to monitor the level change events for spurious multiples, and adjust the time accordingly.
Level Change Events (1)
Many control situations involve doing something when the level of an input changes, either in one direction, or on both edges. For example, a button typically causes its action when the level changes from unpressed to pressed.
To detect level changes in a loop-style controller, you create a variable that holds the previous state of the pin, then in the loop, check for the current state being different. Then you typically set a flag that is picked up later in the loop, or call some action code immediately.
Final provides a second approach, which is to raise an event and call a TypedObject method you provide when the level changes. You can independently set an event handler for the pin going low and going high. We discuss how to use and handle events later in the controller section Event Handlers.
Peripheral Basics
Pi provides a number of peripherals that would be complex or impossible to create via manual toggling/reading of pins. Several instances of each type of peripheral are available to use, with the selection being primarily based on pin assignment.
No peripheral is enabled by default. To use a peripheral, you set any parameters it may need (some like I2C have none), then set .enabled to true.
A peripheral can have some optional pins it uses, based on its setup. Uart can optionally use the RTS/CTS pins. Spi peripherals can have more than 1 CS activated. If you do not activate the optional feature, then the corresponding pins remain available for GPIO use.
In all cases, the defined and enabled optional pins of a peripheral may not be used by a GpioPin or another peripheral. Many of the available peripherals do overlap others, so you need to make sure the pin mapping peripheral choices of your hardware schematic doesn’t cause any overlap. Final does not support dynamic enabling/disabling of peripherals or pins; the schema of your hardware needs to be established during the initialization phase of your controller. (Note: you can dynamically enable pins or peripherals at any point, provided they are still available on the map.)
Final provides the following peripheral instances. Note that the Pi does define some additional choices, but the Final list was chosen as a practical balance. (Some of the excluded peripherals use pins GPIO0/1, which are not supposed to be used.) * indicates the “default” instance, typically highlighted on a pinout card that comes with a Pi.
I2C: i2c1*, i2c2, i2c3
SPI: spi0*, spi1, spi5
UART: uart0, uart2, uart3, uart4*
PWM: pwm0*, pwm1*
Final does not expose the rather puzzling PWM audio channel, nor the I2S high speed digital audio peripherals. You may use those with operating system provided drivers.
The sections that follow provide the API for each type of peripheral.
I2C
I2C is a common two-wire bus for sensors and small displays. The lines are SDA (data) and SCL (clock). Final’s type is I2cPeriph. Instances: i2c1 (usual default), i2c2, i2c3. See the Pin Map.
I2cPeriph has no setup properties. Enable the bus, then call write / read / tx with a 7-bit device address on each call (the address is not stored on the bus object).
Host OS: enable the bus in config (e.g. dtparam=i2c_arm=on or dtoverlay=i2c1-pi5) and be in the i2c group.
true i2c1.enabled!
API
| Property | R/W | Description |
|---|---|---|
| enabled | R/W | set true after wiring is ready; claims SDA/SCL |
| bus | R | bus number (e.g. 1 for i2c1) |
| path | R | device path, e.g. /dev/i2c-1 |
| Method | Stack | Description |
|---|---|---|
| write | ( addr:i data:ByteList -- ) | write bytes to device |
| read | ( addr:i n:i -- data:ByteList ) | read n bytes |
| tx | ( addr:i w:ByteList nRead:i -- data:ByteList ) | write then repeated-start read |
Template
Typical register access (write one register, or write register index then read):
// write reg, value
ByteList.new^ >xs
0x6B x:.append^ // reg
0 x:.append^ // value
0x68 xs> i2c1.write^
// read 1 byte from reg (repeated start)
ByteList.new^ >xs
0x75 x:.append^
0x68 xs> 1 i2c1.tx^ >ys
0 y:@() // value
y:drop
Drivers and demos
Copy/adapt these rather than inventing bus glue from scratch:
| Role | File |
|---|---|
| MPU-9250 + AK8963 driver | modules/pi/drivers/mpu9250 |
| 9-DoF demo (loads the driver) | examples/pi/i2c-9250-9-dof.fnl |
| SSD1306 OLED (driver + demo) | examples/pi/i2c-ssd1306-oled.fnl |
Pattern in those files: a small TypedObject holds bus + addr, with helpers like writeReg / readReg that call i2cN.write^ and i2cN.tx^.
SPI
SPI is a faster, clocked bus used for displays, storage, and similar devices. Final’s type is SpiPeriph. Instances: spi0 (usual default), spi1, spi5. See the Pin Map.
Each SPI device on a shared bus needs its own chip-select (CS / CE). By default a SpiPeriph claims CE0 only. Set numCS before enabling if you need more (e.g. 2 for CE0+CE1). Extra CE lines are optional pins; unused ones stay available as GPIO.
Host OS: enable SPI (e.g. dtparam=spi=on) and be in the spi group (or root).
Set mode / word size / speed / numCS before enabled!.
SpiPeriph.mode0 spi0.mode!
SpiPeriph.bitsPerWord8 spi0.bitsPerWord!
1000000 spi0.maxHz!
1 spi0.numCS!
0 spi0.selCS!
true spi0.enabled!
API
| Property | R/W | Values / default | Description |
|---|---|---|---|
| enabled | R/W | bool | claim the bus (and CE0..numCS-1) |
| path | R | string | CE0 device path, e.g. /dev/spidev0.0 |
| mode | R/W | SpiPeriph.mode0..mode3 (def 0) | SPI mode; set before enable |
| bitsPerWord | R/W | bitsPerWord8 or bitsPerWord16 (def 8) | set before enable |
| maxHz | R/W | > 0 (def 1_000_000) | clock rate; set before enable |
| numCS | R/W | 1..max for that bus (def 1) | how many CE lines; set before enable |
| selCS | R/W | 0..numCS-1 (def 0) | which CE the next transfer uses |
| Method | Stack | Description |
|---|---|---|
| write | ( data:ByteList -- ) | clock out bytes (RX discarded) |
| read | ( n:i -- data:ByteList ) | clock in n bytes (TX zeros) |
| transfer | ( tx:ByteList -- rx:ByteList ) | full-duplex transfer |
Many panels also need plain GPIO for DC, RST, BUSY, and so on. Those are normal GpioPins, not part of SpiPeriph.
Template
// adj pins (example numbers — match your wiring)
GpioPin.out gpio23.direction! // DC
false gpio23.value!
true gpio23.enabled!
GpioPin.out gpio24.direction! // RST
true gpio24.value!
true gpio24.enabled!
SpiPeriph.mode0 spi0.mode!
SpiPeriph.bitsPerWord8 spi0.bitsPerWord!
1000000 spi0.maxHz!
true spi0.enabled!
// command byte (DC low)
false gpio23.value!
ByteList.new^ >xs
0x10 x:.append^
xs> spi0.write^
// data (DC high)
true gpio23.value!
myBytes spi0.write^
Drivers and demos
| Role | File |
|---|---|
| JD79661 e-ink driver | examples/pi/jd79661.fnl |
| Stripe paint demo | examples/pi/spi-jd79661-eink.fnl |
The driver binds SpiPeriph plus DC/RST/BUSY pins (Jd79661.connect^), then uses spi.write^ with DC selecting command vs data. Start from that pair when adding another SPI panel.
UARTs
UARTs provide asynchronous serial I/O over TX/RX. Final’s type is UartPeriph. Instances: uart0, uart2, uart3, uart4 (common default). Optional RS-232 hardware flow control uses CTS/RTS when .flowHW is true. See the Pin Map.
Set transmission properties before enabling. Changing them while active is not allowed.
Properties (set before enable)
| Property | Values / default | Description |
|---|---|---|
| baud | > 0, def 115200 | bit rate |
| dataBits | 5..8, def 8 | data bits |
| stopBits | 1 or 2, def 1 | stop bits |
| parity | UartPeriph.parityNone (def), parityOdd, parityEven | parity |
| flowHW | bool, def false | if true, claims CTS/RTS pins |
| enabled | bool | open the port and start RX background read |
| path | readonly | device path (e.g. /dev/ttyAMA0) |
115200 uart4.baud!
8 uart4.dataBits!
1 uart4.stopBits!
UartPeriph.parityNone uart4.parity!
false uart4.flowHW!
true uart4.enabled!
Write
.write accepts an Int (one byte) or a ByteList:
0x41 uart4.write^ // 'A'
myBytes uart4.write^ // ByteList
Read with UartReader
Reading needs a byte budget, idle timeout, and optional delimiter. Use UartReader from .newReader^, set its properties, then foreach. The loop ends on timeout, max bytes, delimiter, or error. Check the readonly status fields afterward.
| Property | R/W | Description |
|---|---|---|
| maxN | R/W | max bytes this session; 0 = unlimited |
| timeout | R/W | idle ms between bytes (def 100) |
| untilByte | R/W | stop after this byte (0..255), or -1 for none |
| n | R | bytes delivered |
| timedOut | R | true if idle timeout ended the loop |
| hitDelim | R | true if untilByte was received |
| ok | R | false on hard error |
| err | R | error string when not ok |
Example
Loopback-style read until newline (same pattern as the hardware UART tests):
115200 uart0.baud!
true uart0.enabled!
115200 uart2.baud!
true uart2.enabled!
// TX on uart0
ByteList.new^ >xs
"hello\n" foreach i x:.append^ /foreach
xs> uart0.write^
// RX on uart2 until '\n'
uart2.newReader^ >ts
1000 t:.timeout!
0x0A t:.untilByte! // '\n'
ByteList.new^ >xs
t:peek foreach
i x:.append^
/foreach
// xs> holds the line including '\n' if hitDelim
t:.hitDelim@
t:.timedOut@
tdrop
x:drop
There is no UART demo under examples/pi/ yet; enable the matching overlays (e.g. uart0-pi5 / uart2-pi5) before testing on hardware.
Pulse Width Modulation
Pulse WIdth Modulation (PWM) is a technique that involves changing the duty dycle of a pulse stream to vary things like the average power delivered to a load, or to operate a servo.
PWM is provided by the type PwmPeriph. Final exposes two header channels, pwm0 and pwm1 (BCM 12 and 13), enabled with the stock pwm-2chan overlay.
Final provides three distinct operating modes for its PwmPeriphs:
- Raw – you control the pulse interval and on-time manually, in nanoseconds;
- Linear – used to provide a linear variation in duty cycle from 0 to 1 for the signal whose frequency you specify.
- Servo – used for controlling hobby-type servos, that have a defined signal characteristic; you can control the servo on a -1..+1 range, where 0 is the null position; or angle from 0 to 180, where 90 is the null position
Both Linear and Servo modes ultimately render your parameters in raw form, but provide a more convenient and less error prone interface.
Here are the parameters you specify, for each mode. Note, that the modal parameters for a mode may only be set if you have selected that mode. This is to prevent conflicting parameter usage. You set/read the properties for each mode on a subobject with the mode name.
Example property accessors, by mode:
mypwm.raw.periodNs
mpwn.linear.freq
mypwm.servo.byRange
PwmPeriph base parameters
| Parameter | Values | Description |
|---|---|---|
| mode | PwmPeriph.raw, linear, servo | select the mode |
Raw mode
Raw mode is always an available choice, if you want a custom control profile. This mode corresponds to the parameters accepted by the underlying Pi peripheral. The other modes simply layer a more convenient use-case interface upon these raw values.
| Parameter | Values | Description |
|---|---|---|
| periodNs | >0 | the overall pulse width in nanoseconds |
| dutyNs | < periodNs | the width of the On portion |
Linear mode
Linear mode is useful for things like controlling the brightness of LEDs or the power of a heater or other load, using a pulse whose On duty cycle will be the average power delivered into the load. The frequency will typically be on the order of 1 kHz for LEDs, but may be much less for powered loads like heaters. Your hardware and load will determine the appropriate frequency.
| Parameter | Values | Description |
|---|---|---|
| freq | > 0 | the frequency of the pulse in Hz, eg 1000 |
| duty | 0.0 to 1.0 | the on portion of the pulse, def = 0 |
Servo mode
Servo mode is specifically for the common PWM case of controlling hobby servos, linear actuators, and such. They have a very particular pulse profile, whose design goes back many years to early analog electronics and radios. Today’s devices are digital, but the control system has remained the same, with occasional variations. Make sure you initialize the PwmPeriph to correspond to your device’s actual spec. The peripheral will start up with the control pulse in the neutral/zero position for the actuator, usually what you want. If the device should start up at a different value, set that immediately after setting .enabled true.
| Parameter | Values | Description |
|---|---|---|
| freq | > 0 | the frequency, default is 50 (for hobby servos) |
| minUs | > 0 | the minimum pulse width in microseconds, def is 1000 (1 ms) |
| maxUs | > minUs | the maximum pulse width, def is 2000 (2 ms) |
| setMinMax | min, max | setter, for both at once |
| asAngle | 0 to 180 | sets the duty cycle in terms of angle (common for servos), def 90 |
| asRange | -1.0 to 1.0 | sets the duty cycle as a range around 0, def 0 (== 90 degrees) |
asAngle and asRange are convenience properties, so you can set/read values in units of your preference; they both have the same effect on the pulse width.
Creating the TypeDef
An event-driven controller is a TypedObject: fields for pins/periphs and event sources, plus constants, then methods for handlers and init.
Use a paren-object spec and TypedObject.makeType^. Full example: examples/pi/led-btn-ctrl.fnl.
{
"LedBtnCtrl" "name"
{
GpioPin "led"
GpioPin "btnEnable"
GpioPin "btnDisable"
EventTicker "ticker"
EventEscFn "escFn"
Bool "blink"
} "fields"
{
0 "idxEnable"
1 "idxDisable"
2 "ticksPerS"
} "constants"
}
TypedObject.makeType^
Keep a global var holding the live instance so it is not garbage-collected while the EventLoop still has handlers pointing at it. Clear that var in your stop path.
null var: LedBtnCtrl_inst
See also TypedObject in the main guide.
Writing Methods
Controller methods use the usual Final form TypeName#methodName. The instance is on the O stack (o:).
Handlers for the EventLoop take the event and drop it (or pull fields first):
/*
Handle the pin going low.
( ev:Event -- ) o( inst:LedBtnCtrl )
*/
: LedBtnCtrl#handleGpioLow
.index@ nip // which button (from setOnLow index)
LedBtnCtrl.idxEnable ==
o:.blink!
;
Non-handler methods are fine for shared work (#init, #read, and so on). Prefer short methods that do one job; put reusable bits in factor words (next page).
Stack comments matter — keep ( ev:Event -- ) o( inst:YourCtrl ) accurate so help stays useful.
See Objects and methods in the main guide.
Factor Words
Factor repeated code, or merely factored (“clean”) code into plain Final words (not methods); those words will still see the instance on the O stack. Naming convention in the examples: TypeName_helper.
/*
Both buttons get the same init.
( index:i gpio -- ) o( inst:LedBtnCtrl )
*/
: LedBtnCtrl_initBtn
>ts
GpioPin.pullUp t:.pull!
GpioPin.in t:.direction!
GpioPin.debounceButton t:.debounceMs!
o:.handleGpioLow~ swap t:.setOnLow^
true t:.enabled!
tdrop
;
Local stanzas with :s name ... ;s are useful inside #init to split “wire the Pi” from “wire self/events” without polluting the dictionary — see LedBtnCtrl#init in examples/pi/led-btn-ctrl.fnl.
Event Handlers
Controllers can use Final’s EventLoop: you register one of your methods on an Event object, and then Final raises an Event, and your TypedObject method runs. Background on the model: Events and the EventLoop.
For Pi work you mainly use:
| Source | Handler pattern | Covered in |
|---|---|---|
| GPIO level | #handleGpioLow / #handleGpioHigh | Level Change Events (2) |
| Periodic tick | #handleTicker | Timer Events |
| One-shot delay | #handleTimer (name as you like) | Timer Events |
| Tight re-arm loop | #handleFrame (name as you like) + EventAgain | Events |
| Esc / Fn | #handleEscFn | Esc/Fn key Events |
Rules of thumb:
- Form a
MethodRefwith~, pass it when you create the source or callsetOnLow/setOnHigh. - Handlers receive
( ev:Event -- )— dropevor read.index/.paramsthen drop. - Do not block the EventLoop with
forever/ longdelayMsloops; use ticker/timer, orEventAgainfor no-delay re-arm. - On stop,
.close^EventAgain/ tickers / timers andEventEscFn, thenGpioCtrl.inst.reset^.
Worked examples: examples/pi/led-btn-ctrl.fnl (ticker + Esc); examples/pi/i2c-ssd1306-life.fnl (EventAgain + Esc). General ticker/timer demo: examples/events/ticker-timer-demo.fnl.
Level Change Events (2)
Attach handlers on a GpioPin after configuring pull / direction / debounce, before or as you enable.
This is an example of setting a handler to trigger when a pin goes low. The method on the object is called handleGpioLow – the method name is arbitrary, but event handlers customarily start with handle then the name of the event being handled.
// ( mr:MethodRef index:i -- )
o:.handleGpioLow~ 0 t:.setOnLow^
Stack for both setters: ( handler:MethodRef index:i -- ) on the pin. Use index when several pins share one handler (ev.index).
This handler is used for both a “disable” and “enable” blink button; index distinguishes them. The code does a compare to the index of the enable button, then uses that bool results to set the blink field. Note that accessing the .index field still leaves the event on DS; the nip removes it.
// ( ev:Event -- ) o( inst:LedBtnCtrl )
: LedBtnCtrl#handleGpioLow
.index@ nip
LedBtnCtrl.idxEnable ==
o:.blink!
;
Be careful to never leave an event on DS, because they will accumulate, possibly leading to stack overflow, but certainly polluting your stack!
Debouncing is covered in Debouncing. Event basics: Events.
Timer Events
Use EventTicker for a steady beat (blink, poll, heartbeat). Use EventTimer for a one-shot delay or deadline. Details and a desktop demo: Events and examples/events/ticker-timer-demo.fnl.
Create with handler + index, then arm:
o:.handleTicker~ 0 EventTicker.new^ o:.ticker!
500 Interval.millisecond dt::* o:.ticker.eventEvery^
o:.handleTimeout~ 0 EventTimer.new^ o:.timer!
60 Interval.second dt::* o:.timer.eventAfter^
| Type | Arm | Pause | Done |
|---|---|---|---|
| EventTicker | .eventEvery ( Interval -- ) | .stop | .close |
| EventTimer | .eventAfter or .eventAt | .stop | .close |
Handler sketch:
: LedBtnCtrl#handleTicker
drop
o:.blink if
o:.led.value not o:.led.value!
then
;
Always .close^ in your stop path so events do not keep firing after teardown.
Esc and Fn Key Events
Final provides EventEscFn so you can receive an event when the user presses Esc or a Fn key. Handy for development, demos, and a clean shutdown path. Event model: Events.
Several of the Pi examples use the Esc key to exit the program. Here is the typical handler, taken from examples/pi/led-btn-ctrl.fnl The actual key pressed is in the Event.params[0] parameter. Esc is 0, the Fn keys the Fn key number 1-12.
A typical EventEscFn handler code:
/*
Handle an Esc or Fn key press.
We look specifically for Esc.
( ev:Event -- ) o( inst:LedBtnCtrl )
*/
: LedBtnCtrl#handleEscFn
// get key: 0=Esc, else Fn#
>ts 0 t:.params@() tdrop
==0 if
LedBtnCtrl_stop
then
;
Here is the initialization code. Note: you have to call the .start method after creating it.
o:.handleEscFn~ 0 EventEscFn.new^ o:.escFn!
o:.escFn.start^
When your program stops, you should close the EventEscFn object, so it doesn’t continue to send events to zombie code.
: LedBtnCtrl_stop
// stop receiving EscFn events
o:.escFn.close^
// ... more code
Init and Startup
Recommended order (see examples/pi/led-btn-ctrl.fnl and friends):
GpioCtrl.inst.reset^— all pins input, peripherals off.- Assign named aliases:
gpio27 o:.led!,pwm0 o:.pwmLed!, etc. - Configure each pin/periph, then
true … .enabled!. - Create event sources (
EventEscFn,EventTicker, …),.start^/.eventEvery^as needed. - Keep the instance in a global var so it stays alive.
: led-btn
LedBtnCtrl.new^ dup
LedBtnCtrl_inst !
>ts
t:.init^
tdrop
;
Stop path: close event sources, GpioCtrl.inst.reset^, clear the instance var.
For headless / command-line launch (no TUI EventLoop yet), see Command Line Invocation and the runWithStart pattern in examples/events/ticker-timer-demo.fnl.
Command Line Invocation
Interactive work uses the TUI/GUI. For systemd, cron, or a headless box, launch with the final binary (not final-gui).
General scripting forms are in Command line scripts:
final my-controller.fnl
When no TUI owns the EventLoop, start it yourself with EventLoop.inst.runWithStart^ and a start handler. Pattern from examples/events/ticker-timer-demo.fnl:
: setup
EventLoop.running if
// already in TUI/GUI — just run your start word
my-demo
else
myCtrl >ts
true t:.headless!
t:.start~ 0 EventLoop.inst.runWithStart^
then
;
On clean exit from headless mode, close the EventLoop (EventLoop.inst.close^) after you tear down hardware, or the process will keep waiting for events.
Logging
Prefer Final’s logger over ad-hoc $ prints once a controller leaves the bench. Full API: Logging. Example: examples/app-svcs/logging.fnl.
Quick use:
"btn enable" "" logInfo
"duty changed" { o:.duty@ "duty" } >json logDebug
Levels: Off, Fatal, Error, Warn, Info, Debug, Trace. Set level and provider once at startup (logger.init). For costly message builds, gate with logDebug? if ... then.
Multi-threading
Most Pi controllers stay on the main EventLoop thread: GPIO events, tickers, and Esc/Fn all dispatch there. Prefer that model.
If you need background work (long compute, blocking I/O that must not stall handlers), use Final Pips and Pipes. Overview: Threading and Patterns.
Notes for controllers:
- Only the main thread has an EventLoop. A Pip cannot register event handlers; it can send work results back on a Pipe for the main side to handle.
- Do not call long
delayMsinside a handler — useEventTicker/EventTimerinstead (Timer Events). - Hardware setup/teardown (
enabled!,GpioCtrl.inst.reset^) should stay on the main controller path unless you have a clear ownership scheme.
I2S (reserved stub)
When using a digital I2S sound HAT or dongle with Pi, the header I2S peripheral pins on BCM 18–21 are typically owned by the Linux ALSA sound stack (audio HATs). (Final does not drive I2S audio.)
Final exposes a single stub peripheral, i2s (I2sPeriph), so control programs can coexist with ALSA:
- Set
true i2s.reserved!beforeGpioCtrl.inst.reset^. - Reset then skips restoring BCM 18–21 (so ALSA pinmux / ownership is not yanked).
- Validate claims those pins as owner
i2s, so Final cannot take them viagpio18–gpio21or overlapping periphs such asspi1. i2s.reserved is not itself cleared byreset`, so it only needs to be set globaally, once in a session (but it can be set true repeatedly with no harm).s
true i2s.reserved!
GpioCtrl.inst.reset^
// … configure other pins / periphs as usual
There is no .enabled and no audio datapath on I2sPeriph. Only .reserved is exposed.
Conflicts
Reserved i2s overlaps the same BCMs as SPI1 data/CE0 (18–21). Do not enable spi1 (or those GPIO pins) while i2s is reserved.
See also Pins and Periphs and the Pin Map.