DocumentationComponentsVMS

Dashboard pages

The page, block and row model your composers return, every block and row type the dashboards render, and how a row finds its metric or action.

Both dashboards in OVCS are generic: neither the Vue VMS dashboard nor the Flutter infotainment app knows anything about your vehicle. Your composers return a map of pages and blocks, the API serves it as JSON, and the dashboard renders whatever it finds. This guide covers the exact shape of that map, every block and row type each dashboard renders, how a row finds the value it shows, and how a button reaches your code. Everything here is framework behaviour and applies to your vehicle as it is; snippets come from the scaffold template and the reference vehicles, labelled as worked examples.

#Two dashboards, one pattern

VMS dashboardInfotainment dashboard
Composer callbackdashboard_configuration/0 on <Vehicle>.Vms.Composerinfotainment_configuration/0 on <Vehicle>.Infotainment.Composer
BehaviourVmsCore.VehicleInfotainmentCore.Vehicle
APIvms/api, port 4000infotainment/api, port 4001 in development
RendererVue 3 in vms/dashboard/Flutter in infotainment/dashboard/
Layoutthree-column flow of blocksfixed grid, blocks placed by cell
Metric values fromthe VMS bus, via VmsCore.Metricsstatus/0 of the module named in each metric

Both APIs expose the same four routes:

text
GET  /api/vehicle                         vehicle name, colour, refresh interval
GET  /api/vehicle/pages                   pages, sorted by order
GET  /api/vehicle/pages/:page_id/blocks   blocks of one page, sorted by order
POST /api/actions                         calls trigger_action/2 on a module

Both callbacks are optional in their behaviours, but the API calls them on every request: a vehicle without dashboard_configuration/0 has no working VMS dashboard. Your vehicle package covers the rest of each composer.

#The VMS configuration

dashboard_configuration/0 returns a single map under :vehicle. The scaffold template generates this in lib/my_car/vms/composer/dashboard.ex for ./ovcs new my_car; my_car and MyCar stand for your vehicle throughout this guide:

elixir
def dashboard_configuration do
  %{
    vehicle: %{
      name: "My Car",
      main_color: "blue",
      refresh_interval: 100,
      pages: %{
        "status" => Dashboard.DashboardPage.definition(order: 0)
      }
    }
  }
end

The API reads fields with either dot access (the key must exist, or the request raises) or bracket access (optional). The tables below follow that split, from the views in vms/api/lib/vms_api_web/views/api/.

Vehicle keyRequiredEffect
nameyesSidebar heading and browser title (<name> VMS).
main_coloryesTailwind colour for the sidebar and buttons. Only orange, red, blue, indigo, gray, green, amber, rose and teal are safelisted in tailwind.config.js; any other name renders unstyled.
refresh_intervalyesMilliseconds between metric pushes on the websocket, and the time step of every line chart.
pagesyesMap of page id to page. The id is the URL segment and the :page_id in the API.
Page keyRequiredEffect
nameyesSidebar entry and page heading.
orderyesSort position. The first page is served at /, the others at /<page_id>.
iconnoA component name from @heroicons/vue/24/outline ("HomeIcon", "CogIcon", "ArrowPathIcon"). Defaults to GlobeAltIcon.
blocksyesMap of block id to block.

The dashboard appends a Network page after yours; it shows the board's network interfaces and needs no configuration.

Every block carries order (required), name (required), type (required) and full_width (optional). Blocks flow into a three-column grid; full_width: true makes one span all three. DynamicView.vue renders exactly two block types, "table" and "lineChart". The API has a JSON view for those two only, so a block of any other type makes the whole page's blocks request fail.

#Table blocks and rows

A "table" block has a rows list. Each row is a metric (show a value) or an action (send a command), picked by type:.

Metric row keyRequiredEffect
type: :metricyes
moduleyesThe bus source that publishes the value (see below).
keyyesThe message name.
namenoRow label.
unitnoOverrides the unit the publisher attached.
placeholdernoShown while the value is nil. Defaults to —.

RealTimeTable.vue renders a value by its type: true and false become a check or a cross, numbers (including decimals, which arrive as strings) are rounded to two places and followed by the unit, lists are joined with commas, and a string containing a newline is shown as a monospace block. The unit "fraction" is shown as a percentage.

An action row has type: :action and one of three input_type values:

input_typeRendersSends
:buttona button labelled input_namemodule, action, extra_parameters
:numbera number field, a "now" badge and a buttonthe same, plus "value" as a string
:togglea switchmodule, action, extra_parameters
Action row keyRequiredEffect
type: :actionyes
nameyesRow label, and button label when input_name is absent.
input_typeyes:button, :number or :toggle.
moduleyesThe module whose trigger_action/2 is called.
actionyesThe action string passed as its first argument.
input_namenoButton label.
extra_parametersnoMap merged into the request body, for one action shared by several rows.
status_metric_keynoA key on the same module the row subscribes to. A :number row shows it as the current value and seeds the field from it; a :toggle row draws its position from it and needs it to render at all.
hintnoSmall text under the label.
stepnoIncrement of a :number field. Defaults to "0.01".

The OVCS1 steering column page is the worked example of :number rows: three rows share one action and differ only by extra_parameters, from steering_column_page.ex:

elixir
%{
  type: :action,
  name: "Kp",
  hint: "Proportional — strength of immediate correction",
  input_type: :number,
  step: "0.01",
  input_name: "Set",
  module: SteeringColumn,
  action: "set_pid_parameter",
  extra_parameters: %{"parameter" => "kp"},
  status_metric_key: :kp
}

#Line chart blocks

A "lineChart" block plots metrics against time with ECharts. It takes serie_max_size (required: points kept per series; 300 in every reference vehicle) and y_axis, a list of axes. Each axis has min, max and label (all required) and a series list; each series is a name (legend entry) and a metric map with module and key, resolved like a metric row. From the OVCS Mini reference vehicle's dashboard_page.ex:

elixir
"speed-and-rpm" => %{
  order: 2,
  name: "Speed & RPM",
  type: "lineChart",
  serie_max_size: 300,
  y_axis: [
    %{
      min: -10,
      max: 10,
      label: "km/h",
      series: [%{name: "Speed", metric: %{module: OVCS.VehicleMotion, key: :speed}}]
    },
    %{
      min: 0,
      max: 500,
      label: "RPM",
      series: [
        %{
          name: "Wheel RPM",
          metric: %{module: OVCS.VehicleMotion, key: :wheel_rotation_per_minute}
        }
      ]
    }
  ]
}

The chart appends one point per series every refresh_interval, so the visible window is serie_max_size × refresh_interval: 300 × 70 ms is 21 seconds on the Mini. Axes are emitted with only min, max, label and series; other keys, such as the position: "right" some reference pages set, are not forwarded by the API.

#How a row finds its metric

Every component on the VMS publishes values on the internal bus as OvcsBus.Message structs. VmsCore.Metrics subscribes to the "messages" topic and keeps the last value of each message, keyed by source and then name, plus the last unit each publisher gave. A row's module: is matched against source and its key: against name. From vehicle_motion.ex:

elixir
Bus.broadcast("messages", %Bus.Message{
  name: :speed,
  value: speed,
  unit: Units.kilometre_per_hour(),
  source: __MODULE__
})

That message is what %{type: :metric, module: OVCS.VehicleMotion, key: :speed} displays, with km/h appended because the publisher set a unit. Units come from OvcsBus.Units, one function per unit.

source is usually __MODULE__, but it can be any atom. Components started several times publish under their process name: OVCS.InputCurve uses source: process_name, and the OVCS Mini's throttle_input_curve.ex passes that name as module:. When a row shows — forever, the module/key pair doesn't match anything published; VmsCore.Metrics.metrics() in the VMS IEx shell lists every source and name the bus has carried.

On the wire, the dashboard joins the metrics channel on /sockets/dashboard with the vehicle's refresh interval and pushes subscribe for each metric row, series and status_metric_key on the page. The channel converts module and key with String.to_existing_atom/1 and then pushes the filtered values every interval (metrics_channel.ex).

#How an action reaches your code

An action row posts a JSON body to POST /api/actions:

json
{"module": "Elixir.VmsCore.Components.OVCS.SteeringColumn", "action": "set_pid_parameter", "parameter": "kp", "value": "0.12"}

ActionsController converts module to an existing atom and runs :ok = module.trigger_action(action, params), where params is the whole body with string keys. Two rules follow:

  • An action's module must be a real module that defines trigger_action/2. Unlike a metric source, a process name does not work.
  • trigger_action/2 must return :ok. Anything else fails the match and the request returns an error. A :number value arrives as a string; convert it yourself.

The OBD2 reference vehicle's diagnostics.ex shows the usual shape, one clause per action string:

elixir
def trigger_action("clear_dtcs", _params) do
  pulse("clear_dtcs")
  :ok
end

def trigger_action("open_extended_session", _params) do
  GenServer.call(__MODULE__, :open_extended_session)
end

The second clause relies on its handle_call replying :ok. For a parametrised action, match the keys you declared in extra_parameters alongside "value", as SteeringColumn does with %{"parameter" => parameter, "value" => value} in steering_column.ex. The framework's VmsCore.Status defines trigger_action("reset_status", _), which every vehicle can put on a button.

#A complete page

This page combines the scaffold's status table, the reset button from the OVCS Mini dashboard and the Mini's speed chart. Vms is your vehicle's VMS GenServer, which the template publishes :vms_status and :ready_to_drive from; OVCS.VehicleMotion exists only if your composer starts it, so replace that chart's metrics with one of your own components if it doesn't.

elixir
defmodule MyCar.Vms.Composer.Dashboard.DashboardPage do
  alias MyCar.Vms
  alias VmsCore.Components.OVCS
  alias VmsCore.Status

  def definition(order: order) do
    %{
      name: "Status",
      icon: "HomeIcon",
      order: order,
      blocks: %{
        "vms-status" => %{
          order: 0,
          name: "VMS",
          type: "table",
          rows: [
            %{
              type: :action,
              name: "Reset Status",
              input_type: :button,
              module: Status,
              action: "reset_status"
            },
            %{type: :metric, name: "Status", module: Vms, key: :vms_status},
            %{type: :metric, name: "Ready to drive", module: Vms, key: :ready_to_drive}
          ]
        },
        "speed" => %{
          order: 1,
          name: "Speed",
          type: "lineChart",
          full_width: true,
          serie_max_size: 300,
          y_axis: [
            %{
              min: 0,
              max: 50,
              label: "km/h",
              series: [%{name: "Speed", metric: %{module: OVCS.VehicleMotion, key: :speed}}]
            }
          ]
        }
      }
    }
  end
end

Register it in dashboard_configuration/0 under a page id, "status" => Dashboard.DashboardPage.definition(order: 0). The reference vehicles keep one module per page and one per reusable block under vms/composer/dashboard/blocks/, which keeps each page readable; OVCS1's pages are the largest set to copy from.

#Infotainment pages

The infotainment's pages have the same vehicle → pages → blocks shape, on a fixed grid of purpose-built widgets, and read their metrics from a module's status/0 rather than from the bus. Infotainment lists its block types and the layout validator.

#Check what the API serves

Boot your vehicle locally; the reference vehicles work the same way:

sh
./ovcs run ovcs_mini

Then read the layout back from the VMS API. A key missing from your map shows up here as an error rather than as a blank dashboard:

sh
curl -s http://localhost:4000/api/vehicle
curl -s http://localhost:4000/api/vehicle/pages
curl -s http://localhost:4000/api/vehicle/pages/dashboard/blocks

Pages come back as {"type": "page", "id": …, "attributes": {"name", "icon"}} and blocks as {"type": "block", "id": …, "attributes": {"name", "subtype", "fullWidth", …}}, with subtype carrying your block's type. To try an action without the UI, post the body the dashboard would send:

sh
curl -s -X POST http://localhost:4000/api/actions \
  -H 'Content-Type: application/json' \
  -d '{"module": "Elixir.VmsCore.Status", "action": "reset_status"}'

A 201 means trigger_action/2 returned :ok. For the infotainment side, use port 4001 on a vehicle with an infotainment composer (./ovcs run obd2 or ./ovcs run ovcs1), and start the Flutter app in its own terminal as the CLI reference describes. Open the VMS dashboard on the Vite dev server at http://localhost:5173, which hot-reloads; :4000 serves the last prebuilt bundle.

#Next steps

  • Your vehicle package: the composers these callbacks live in, and the rest of the vehicle contract.
  • VMS and Infotainment: the APIs and dashboards that serve these pages.
  • Drivers: the components whose metrics your rows display.
  • Testing with CAN: inject frames on virtual CAN and watch your rows change.
  • Generic controllers: the Generic Controllers page OVCS1 and OVCS Mini carry, and the adoption button on it.

This page is docs/vms/dashboard_pages.md in the ovcs repository; edit it there.