An opinionated program to abstract over hardware attached to a Raspberry Pi, used to control lighting equipment and other stuff.
This is a low-level, (so far) reliable program that interfaces with hardware and exposes HTTP and TCP interfaces for it. Additionally, events that are generated by the hardware or other sources are pushed to subscribers via the TCP API.
The high-level API is a 16-bit address space of 16-bit values.
Mapped into this address space are virtual devices, which each have exactly one alias and may be part of any number
of groups.
Virtual devices are derived from/mapped to hardware devices on numerical ports, which start at 0
by convention.
Virtual devices also generate events, which are also mapped from hardware devices.
Hardware devices interact with the actual hardware attached to the RPi. Hardware devices run asynchronously to the rest of the program and independently of each other. They use a number of different strategies to interface with the hardware, which are abstracted away.
Events can be subscribed to by address and type:
-
Any change in hardware state generates at least one
Change
event. These are generated by hardware devices when a change in state is detected or initiated. For example: A DHT22 temperature sensor generates aChange
event for both temperature and humidity, if one of those values change. On the other hand, a PCA9685 PWM generator, which is an output device, generatesChange
events once the hardware device successfully set new target values on the device.
The important thing to note is that hardware devices, not virtual devices genrate events. Assuming an update cycle of 5 ms, a PCA9685 would generate at most ~200 events/s per port, even if many more writes were performed on a virtual device mapped to it.
The reason for generatingChange
events from hardware device writes is twofold:- We don't generate as many events.
- The events reflect the actual state of the hardware, always. Writes to virtual devices do not necessarily immediately modify any hardware.
-
MCP23017 GPIO expanders generate
Button
events. These are specific to this type of device, because we use them to interface with many buttons. TheButton
events generated help keep track of button states, such as clicks (down+up) or long presses (which generate an event every second).
The API is exposed via HTTP and TCP, with TCP being the preferred way to interface for hardware control. Only the TCP API provides event subscription functionality.
See dist/example_config.yaml
.
Submarine expects a file named config.yaml
with the program
block in it.
The devices
and virtual_devices
blocks can be split up into multiple files for easier management and then be
imported in the main config file.
Hardware devices are configured according to the config sections given below. All hardware device configurations are aggregated and evaluated before any virtual device is created.
Generally, hardware device configurations look like this:
type: mcp23017 # The device type, see below.
alias: mcp-0 # The alias, must be unique over all hardware devices.
config: # The device-type specific configuration, see below.
...
Virtual devices are mapped into a global 16-bit address space and to one port on one hardware device. Configuration works like this:
address: 1 # Global address, must be unique over all virtual devices.
alias: led0 # Global alias, must be unique over all virtual devices.
groups: ["leds"] # Any number of groups, which can be used to set the value of multiple devices at once.
mapping: # The mapping to a hardware device...
device: mcp-0 # ... by its alias
port: 6 # and port.
read_only: false # Currently unused.
We currently support these hardware devices:
-
pca9685
, which is a PCA9685 PWM generator via I2C (spec). This device is output-only.They are configured like this:
i2c_bus: "" # The i2cdev device, e.g. "/dev/i2c-1", or an empty string to use an I2C mock. i2c_slave_address: 64 # The decimal(!) I2C slave address. use_external_clock: false # Whether to use the external clock source, see spec. inverted: true # Whether to invert the outputs. output_driver_totem_pole: true # Output driver mode, see spec. update_interval_millis: 5 # The update interval. Specifically, the interval at which values will be written # to the device, if changes were made through virtual devices. # This tries to keep a steady rhythm, i.e. if the update takes 1 ms, the next update # will be started 4 ms after that, to keep a 5 ms cycle.
It is noteworthy that multiple PCA9685s on one I2C bus work asynchronously to each other, which can cause weird behaviour. See the
pca9685_sync
device for a way around this. -
pca9685_sync
the synchronized version ofpca9685
. This device groups all instances of itself on the same I2C bus and triggers updates to all of them. This is useful to synchronize multiple PCA9685s. Specifically, using this, the bandwidth of an I2C bus can be utilized better, because the three transactions are done right after each other, in the same order, in one go.Configuration is the same as for
pca9685
devices, and it is recommended to set the sameupdate_interval_millis
for all instances on one bus. -
mcp23017
is the MCP23017 GPIO expander via I2C, see the spec. This device is output-only.The configuration works like this:
i2c_bus: "" # The i2cdev bus, see above. i2c_slave_address: 32 # The decimal(!) slave address.
These devices do not periodically check for updated values and write them out, but rather flush as soon as a new value is written. Note however that a single
set
API request can contain many key-value pairs, which will most likely be set atomically for this device, at least as long as there is only one writer. -
mcp23017_input
is the input variant of the MCP23017. This device is frequently polled to detect changes in its pins. It is advised to adjust the polling interval in such a way that buttons are properly debounced, we use a capacitor for this (but I'm not an expert on those things).Configuration:
i2c_bus: "" # See above. i2c_slave_address: 34 # See above polling_interval_millis: 5 # The polling interval. enable_pullups: true # Whether to activate the (weak) built-in pull-up resistors (for all pins). invert: true # Whether to logically invert values read.
-
dht22
is a DHT22 humidity and temperature sensor, see the spec. This device uses a handwritten bit-banging-via-gpio driver with some error-correction specific to the observed behaviour on a Raspberry Pi.Configuration:
bcm_pin: 26 # The GPIO pin (in BCM numbering) to which the data pin is attached. adjust_priority: true # Whether to adjust thread priority during readout. # Because we bit-bang the protocol, doing this can sometimes improve success rate of # reading. use_experimental_version: true # Whether to use the experimental handwritten driver. # If set to false, a slightly modified version of the dht22_pi crate implementation # will be used. # If set to true, an optimized implementation is used, see src/dht22_lib.rs for more # information. readout_interval_seconds: 2 # The readout interval, plus minus some milliseconds.
The dht22_pi crate, and the Adafruit C implementation that is based off, use a spin-and-count approach to try to calculate the different pulse lengths of the DHT22's protocol. This works on microcontrollers, because they have a fixed clock speed and no preemptive scheduling, but it does not work very well on a multi-core non-realtime OS with clock frequency scaling.
The custom driver implementation also spins and repeatedly polls the GPIO pin, but instead uses a timer to measure pulse lengths (this works surprisingly accurately). Additionally, it compensates for lost and/or blurred pulses, as observed on our hardware configuration. Using this driver we achieve > 95% successful readouts, which is good enough for our case.
-
gpio
represents a single GPIO pin. Currently used for input only.Configuration:
bcm_pin: 24 # The pin number, in BCM numbering. pull: down # The pull direction, either "up" or "down". invert: false # Whether to logically invert values read. readout_interval_milliseconds: 500 # The polling interval in milliseconds.
There are two ways to interact with Submarine: HTTP and plain TCP. While most functionality (except for events) exists for both, it is recommended to use TCP for performance-critical things (such as controlling lighting), and HTTP for diagnostics or other non-time-critical things.
The alloy crate contains all types relevant to the API and an implementation of the TCP transport.
You need a working installation of Rust, see rustup.rs. We use the most recent stable version, which you should have installed by default.
This project is configure to compile for Linux on ARMv8 (64 bit) by default, no matter from where you're compiling. First of all, add the appropriate target to your Rust installation:
rustup target add aarch64-unknown-linux-gnu
# if you also want ARMv7
#rustup target add armv7-unknown-linux-gnueabihf
Then tell cargo how to link for that target, in ~/.cargo/config
:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
# if you also want ARMv7
#[target.armv7-unknown-linux-gnueabihf]
#linker = "arm-linux-gnueabihf-gcc"
Now you actually need to get the linker. On Linux you just need to get the appropriate package (Debian):
sudo apt install gcc-aarch64-linux-gnu
# if you want to build for ARMv7
#sudo apt install gcc-arm-linux-gnueabihf
On Windows you can get pre-built binaries for this from here (8.3.0 worked for me).
Additionally, before building on Windows, invoke env.bat
, which will set AR
to the appropriate value.
The default target is already set to Linux ARMv7 (see .cargo/config
), so cargo build
will produce a debug build for that target.
cargo build --release
produces a release build.
Submarine is a single statically-linked binary which runs on the Raspberry Pi (and should be kept running).
It uses the usual Rust logging facilities, which can be controlled via the RUST_LOG
environment variable.
A good general setting for this might be RUST_LOG="info"
.
To use the new DHT22 implementation, SYS_CAP_NICE capabilities are needed. These can be given to a binary like so:
sudo setcap 'cap_sys_nice=eip' submarine
Unfortunately, this is cleared whenever the binary is replaced.
It has been observed that a current stable Raspbian shows somewhat high variance in network latencies. For this reason it is recommended to run Kaleidoscope, the lighting execution engine, on the same device and communicate via loopback. This still(!) shows high variance, but is generally fast enough to not matter much in practice.