DocumentationComponentsBridges
Write a bridge
When to write a bridge rather than a VMS component, what a bridge library is made of, a complete worked bridge with its test, and how to list it in the bridge firmware and declare it in your vehicle.
A bridge is the place for a world your vehicle has no CAN link to: a serial protocol, a network service, a sensor on a USB port. This page builds one end to end, BoardTemperatureBridge, which reads its board's temperature and publishes it on the OVCS bus. It is small on purpose: every bridge the framework ships, the radio control bridge and the ROS bridge, follows the same recipe with more components.
#Bridge, component or child
Pick the smallest thing that fits:
- A VMS component when the hardware is on a CAN bus the VMS already sees, or on a generic controller's pins. See Add a component.
- A bridge when the data lives outside CAN and needs its own board, or a board shared with other bridges: a radio receiver on a UART, a ROS graph, a lidar on USB.
- A child of a bridge firmware entry when a library only reads the bus and has no per-vehicle wiring of its own. The bus gateway runs that way, listed in an entry's
children.
A bridge never commands an actuator. Commands it produces go to the VMS as CAN frames on the ovcs bus, where the VMS checks their freshness and its control levels decide whether they are in force. Everything a bridge publishes on its own bus stays on its own board: a broadcast is node-local.
#What a bridge is made of
- A Mix project under
bridges/<name>/that depends onovcs_bridge. That one dependency bringsovcs_bus,ovcs_vehicleandcantastic. - A module implementing
OvcsBridge.children/0returns the child specs the bridge firmware supervises; the optionalapply_runtime_config/2runs before any application starts, for a dependency that reads its application environment once at boot. The behaviour's moduledoc documents both. - A configuration callback on your vehicle module, with a
:hostand a:targetarm, returning the bridge's config struct. The bridge reads the active vehicle from:ovcs_vehicle's:moduleenvironment, which the bridge firmware sets at boot.
#A worked bridge
bridges/board_temperature_bridge/mix.exs declares the library and its one dependency:
defmodule BoardTemperatureBridge.MixProject do
use Mix.Project
def project do
[
app: :board_temperature_bridge,
version: "0.1.0",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application, do: [extra_applications: [:logger]]
defp deps do
[{:ovcs_bridge, path: "../../libraries/ovcs_bridge"}]
end
end
lib/board_temperature_bridge.ex holds the config struct, the behaviour your vehicle implements, and children/0:
defmodule BoardTemperatureBridge.Config do
@moduledoc "Per-vehicle configuration for `BoardTemperatureBridge`."
defstruct interval_ms: 1_000, path: "/sys/class/thermal/thermal_zone0/temp"
@type t :: %__MODULE__{interval_ms: pos_integer(), path: String.t()}
end
defmodule BoardTemperatureBridge do
@moduledoc """
Publishes the bridge board's temperature on the OVCS bus as
`:board_temperature`. Vehicles implement
`c:board_temperature_bridge_config/1` to configure it.
"""
@behaviour OvcsBridge
@callback board_temperature_bridge_config(:host | :target) :: BoardTemperatureBridge.Config.t()
# Read at compile time: on a Nerves device Mix.target() returns :host at runtime.
@arm if Mix.target() == :host, do: :host, else: :target
@impl OvcsBridge
def children do
[{BoardTemperatureBridge.Publisher, vehicle().board_temperature_bridge_config(@arm)}]
end
defp vehicle, do: Application.fetch_env!(:ovcs_vehicle, :module)
end
The arm is fixed when the library compiles, because Mix.target() means nothing on the device. That is what lets your vehicle return a dummy driver for ./ovcs run and the real one for the burned image.
lib/board_temperature_bridge/publisher.ex does the work, publishing a bus message as any VMS component does:
defmodule BoardTemperatureBridge.Publisher do
@moduledoc "Reads the board temperature every `interval_ms` and broadcasts it."
use GenServer
alias OvcsBus, as: Bus
def start_link(config), do: GenServer.start_link(__MODULE__, config, name: __MODULE__)
@impl GenServer
def init(config) do
{:ok, _timer} = :timer.send_interval(config.interval_ms, :tick)
{:ok, config}
end
@impl GenServer
def handle_info(:tick, config) do
with {:ok, raw} <- File.read(config.path),
{millidegrees, _} <- Integer.parse(raw) do
Bus.broadcast("messages", %Bus.Message{
name: :board_temperature,
value: millidegrees / 1000,
source: __MODULE__,
unit: Bus.Units.celsius()
})
end
{:noreply, config}
end
end
A reading that fails publishes nothing, so a consumer sees the signal go stale instead of a wrong value.
#Test it
The test drives the publisher against a file standing in for the sensor. test/test_helper.exs starts the bus:
Application.ensure_all_started(:ovcs_bus)
ExUnit.start()
defmodule BoardTemperatureBridge.PublisherTest do
use ExUnit.Case, async: false
test "publishes the board temperature in degrees Celsius" do
path = Path.join(System.tmp_dir!(), "board_temperature")
File.write!(path, "41250\n")
OvcsBus.subscribe("messages")
start_supervised!(
{BoardTemperatureBridge.Publisher,
%BoardTemperatureBridge.Config{interval_ms: 10, path: path}}
)
assert_receive %OvcsBus.Message{name: :board_temperature, value: 41.25, unit: "°C"}
end
end
Run it with mix test --no-start, as CI does for every bridge library: without --no-start, Mix starts Cantastic, which refuses to boot with no CAN configuration.
#Talk to the CAN bus
This bridge only publishes on its own bus. A bridge that sends to the VMS, or reads what the VMS emits, does it with Cantastic on the ovcs network, exactly as a VMS component does (Receiving frames, Emitting frames). The frames go in your vehicle's topology for that bridge firmware, priv/can/bridges/<id>.yml by default (see Bridge firmwares), and can import the shared specs from ovcs_can. A command frame should carry a sequence number that changes with every sample, as the ROS commands do, so the VMS can tell a live command from a stuck one.
#List it in the bridge firmware
No firmware depends on a vehicle as a Mix dependency: vehicles are loaded at boot with Code.prepend_path. So a bridge library can't arrive transitively through your vehicle; it is listed in bridges/firmware/mix.exs, gated to the targets it supports:
{:board_temperature_bridge,
path: "../board_temperature_bridge",
targets: [:host, :ovcs_base_can_system_rpi4, :rpi5],
runtime: Mix.target() != :host},
On the host every bridge matches, so all of them sit in the firmware's dependency tree at once. runtime: false there keeps OTP from starting each one's application, and leaves OvcsBridge.Supervisor to start only the bridges the running entry lists. On a target, only the bridges gated to it are compiled.
#Declare it in your vehicle
Implement the callback on your vehicle's top-level module:
@behaviour BoardTemperatureBridge
@impl BoardTemperatureBridge
def board_temperature_bridge_config(_arm), do: %BoardTemperatureBridge.Config{}
and bundle the bridge in an entry of bridge_firmwares/0, alone or next to others:
"telemetry" => %{
target: :ovcs_base_can_system_rpi4,
bridges: [BoardTemperatureBridge],
default_can_mapping: %{host: "ovcs:vcan0", target: "ovcs:spi0.0"}
}
./ovcs run <vehicle> now starts a <vehicle>-bridge-telemetry BEAM, and ./ovcs build <vehicle> bridge-telemetry builds its image. If your vehicle runs the bus gateway, which collects from every node of the mesh, the signal shows up in its dashboard, on MQTT and at /api/signals?filter[name]=board_temperature.
#Where next
- Bridges: the bridge firmware entry, its targets and its boot sequence.
- ROS bridge: a bridge with a component catalogue, consumers and publishers.
- Add a component: the CAN calls a bridge uses to talk to the VMS.
This page is docs/bridges/write_a_bridge.md in the ovcs repository; edit it there.