Skip to content

Menu as Protocol: menu.yaml ↔ mqtt_contract.yaml ↔ Portal


Three files — three roles

File Owner Describes
src/menu/menu.yaml your product device menu: parameters, actions, structure
contracts/mqtt_contract.yaml idryer-core list of known meanings: canonical_roles with labels in several languages
frontend-v2/src/contracts/mqtt-api.types.ts generated TypeScript types for the portal

role: — a semantic name for a menu item. The firmware says "I have iheater.heat_temp" rather than "I have item number 35". Internal firmware names can change, role: stays fixed.

The menu is the mirror of the device settings. The firmware publishes every item of menu.yaml, with or without role:. The portal draws each item by its type: value, toggle, action, submenu. role: gives the item a label from the contract in the user's language; without role: the portal shows the name the device sent.

The menu does not build the device card. What the card shows and which operations it starts is described by the card manifest — see Device card: the card manifest. A card action can take the limits and the default of its parameter from a menu item.


1. Firmware build (pio run)

menu.yaml → menu_gen.py checks every role: against canonical_roles in the contract → if a role is unknown, the build fails with an error and a list of valid roles → the generator writes C++ files into src/menu/.

2. Updating TypeScript for the portal (regen.sh)

mqtt_contract.yaml → gen_ts_types.py generates mqtt-api.types.ts and the role labels roles.{lang}.json → the files are copied to the portal.

Run it when the contract changes. Commit the result.

3. Runtime: device ↔ portal

The firmware publishes the menu to the config topic (the product code does it on the get_config command; iDryer products also do it when they come online) → the portal backend stores it → the portal gets it with GET /devices/:id/menu-config → each item is drawn by its type t (val, tog, act, sub), the label is canonical_roles[r].labels[lang], then English, then the name n from the device.

Parameters (min, max, val) come from the menu item itself — the firmware knows the current values.

The portal changes a value with commands/set { "id": <id>, "val": <value> }.


How to add a setting (NVS parameter)

- id: my_param
  type: value
  role: my.param        # optional: a label from the contract
  title: { ru: "ПАРАМЕТР", en: "PARAM" }
  unit: { ru: "°C", en: "°C" }
  vtype: uint16
  min: 0
  max: 100
  step: 1
  bind: my_param        # NVS key (≤ 15 chars)
  persist: true
  scope: global
  default: 50

bind = NVS key. persist: true = the value survives a reboot.

role: is not a free-form field: the value must come from canonical_roles in the contract, otherwise the build fails. See the list in contracts/mqtt_contract.yaml → canonical_roles or in menu.template.yaml. A new role is added to the contract first, then regen.sh.


How to add an operation to the device card

Operations (start, stop, heat, light) are declared as card actions, not as menu items. Their limits can be taken from a menu item:

idryer::card_menu::attach(s_link.card());
s_link.card().action("storage", "STORAGE", onStorage)
    .param("temperature", "target_temperature", MENU_TARGET_TEMP);
s_link.card().action("stop", "IDLE", onStop);

The full description — Device card: the card manifest.


What NOT to do

  • Don't add widget: to menu.yaml. The widget field of canonical_roles is reference data: the portal and the app do not read it.
  • Don't edit mqtt-api.types.ts by hand — it is generated by regen.sh.
  • Don't touch Config.hasXxx flags for new actions — those are only for telemetry (sensors, states).