# AI Assistant Source: https://docs.coreflux.org/hub/ai-assistant Chat with the built-in Coreflux HUB assistant to get insights from live data, build dashboards, and develop LoT Actions using plain language Animated sequence: opening the AI Assistant from the dock, typing a prompt, and the assistant responding and applying changes in Coreflux HUB ## Your Broker, Now With a Teammate The AI Assistant lives inside the same Coreflux MQTT broker that already carries your sensor, PLC, and cloud traffic. You describe what you want in everyday language—**ask for insights** from the data already flowing through your broker, **build Dashboards** with live widgets and KPIs, or **develop LoT (Language of Things) Actions** that run on the broker—and the assistant uses the broker’s own tools to answer, draft, refine, and deploy. It is not a separate chat app pasted on top; it works with your live system. **Like a colleague who already sits at your control desk** — they can read the same screens you see, suggest the next step, and (when you allow it) wire up the change for you. ## When to Use the AI Assistant * **Ship integrations faster** — Describe connections or logic you need; the assistant drafts and deploys Routes, Actions, and panels where your permissions allow. * **Explore without guesswork** — Ask what topics exist, which Routes are healthy, or how a KPI is calculated before you change anything. * **Hand off safely** — Use read-only mode for operators or auditors who should see answers but not apply changes. ## Run Models On-Premise or in the Cloud Where inference runs is an architecture and compliance choice. The AI Assistant supports **edge (on-premise)** models and **cloud** providers so you can keep prompts and payloads on your network when you need to, or use hosted APIs when policy allows. AI Setup provider list grouped into Cloud (OpenAI selected, Anthropic, Mistral) and Local/Private (Ollama with NO API KEY tag) * **On-premise (edge)** — Run models on hardware you control, for example **Ollama** on the same host or LAN as the broker, so data does not leave your site. * **Cloud providers** — Connect to hosted models such as **OpenAI**, **Anthropic**, or **Mistral** when your organization allows traffic to leave the network. **Local AI prerequisite:** [Install Ollama](https://ollama.com/download) and have it running on the broker host (or a machine your broker can reach) before you pick **Local / Private** in the setup wizard. Pull your model in Ollama first (e.g. `ollama pull llama3.2`) — the name you enter in step 2 must match a model already available in Ollama. The model must support **tool calling** (function calling); the Assistant relies on tools to act on your broker, so models without tool-calling support will not work. Check the model's capabilities on the [Ollama model library](https://ollama.com/search?c=tools) before pulling it. Coreflux connects to your Ollama endpoint; it does not install Ollama or download models for you. Lightweight models may work for a quick test, but often are not capable enough for reliable agent work or good answer quality. If the broker runs in **Docker**, `localhost` inside the container is not the host. With **Docker Compose**, point at the Ollama service (e.g. `http://ollama:11434`). If Ollama runs on the host, use `http://host.docker.internal:11434` (Docker Desktop) or the host's LAN IP. See [AI Routes — Ollama](/latest/lot-language/routes/ai-routes#picking-a-provider) for `BASE_URL` in LoT. The same Assistant panel and modes work whether you point at an edge endpoint or a cloud API—you are not choosing a different product, only where the model runs. *** ## Set up the AI Assistant The first time you open the AI Assistant, a short wizard walks you through three choices—**provider**, **model**, and **API key**—and prepares your broker. You only do this once per broker; after that, clicking the Coreflux icon opens the assistant directly. Click the **Coreflux** icon in the bottom dock. On a fresh broker, the **AI Setup** wizard slides in on the right and asks you to pick where the model will run—a **Cloud** provider (OpenAI, Anthropic, Mistral) or **Local / Private** (Ollama). Pick one and click **Continue**. Coreflux HUB with the AI Setup wizard open on the right showing Cloud providers OpenAI, Anthropic, Mistral and a Local / Private Ollama option, with a Continue button Choose a model from the suggested list, or type the exact model name your provider supports (for example `gpt-5.4-mini`) into the custom field. Click **Continue** when you are ready. AI Setup wizard model step listing suggested models such as gpt-5.5, gpt-5.4-mini, and a custom model field with gpt-5.4-mini typed in Paste the API key from your chosen provider. The key is stored as an **encrypted secret on the broker** and is never sent back to your browser. The wizard also shows a quick summary of what it is about to install so you know what to expect. If you picked **Ollama** in the earlier step, the wizard skips this screen entirely—your local Ollama endpoint does not need a key. AI Setup wizard API key step with an OpenAI API key input, a note that the key is stored as an encrypted secret on the broker, and an install summary card Sign in to the provider you picked in the previous step and follow their docs to create a key: * **OpenAI** — [platform.openai.com/api-keys](https://platform.openai.com/api-keys) * **Anthropic** — [console.anthropic.com](https://console.anthropic.com/) * **Mistral** — [console.mistral.ai](https://console.mistral.ai/) If you picked **Ollama**, no key is required—the broker talks to your local Ollama endpoint directly. Click **Install routes**. The broker provisions everything the assistant needs in a few seconds and then closes the wizard automatically. AI Setup wizard showing a Setting up AI Assistant progress indicator with Setting API key secret status Once the wizard closes, the AI Assistant is live on this broker. From now on, clicking the **Coreflux** icon opens the assistant directly—no more setup. *** ## Open the AI Assistant Use the **Coreflux** icon in the bottom app dock—the first icon on the left. You can peek at the assistant without opening the full side panel, then click when you want the full workspace. Hover the Coreflux icon. A compact **Coreflux AI** bubble appears above the dock with an **Ask anything…** field and **Insight** / **Agent** mode pills so you can send a quick prompt or pick a mode before you commit to the full panel. Coreflux HUB dock with the Coreflux icon highlighted and a compact Coreflux AI popover showing Ask anything placeholder and Insight and Agent toggles Click the Coreflux icon to pin the **AI Assistant** on the right. You get suggested prompts (for example **Create a dashboard** or **Calculate a new KPI**), a **Sources** area for dragging in panels such as the LoT Editor, and the full conversation thread with edit and close controls in the header. Coreflux HUB with the full AI Assistant panel open showing suggested actions, Sources with LoT Editor, and Ask me anything input ## Agent vs Insight mode Pick the mode that matches how much change you want the assistant to make. You can switch at the bottom of the panel at any time. | Mode | What it does | Use it when | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | **Agent** | Can read broker state **and** create or update things—Routes, Actions, Models, panels—subject to your account permissions. | You want the assistant to **build or change** integrations, logic, or dashboards. | | **Insight** | **Read-only**—inspects topics, Routes, payloads, and traces; answers questions; can show tables and charts. | You want **safe exploration**, reporting, or to give users answers **without** deploy rights. | The mode pills sit below the main input so you always know which behavior you have selected before you send a message. ## What you can do These are typical starting points—phrase them in your own words; the assistant will ask follow-ups if something is missing. Ask for live gauges, KPI tiles, or layouts bound to your MQTT topics; the assistant drafts and wires widgets you can refine in the panel. See [Build Your First Dashboard](/latest/quick-start/building-dashboards). Describe triggers, conditions, and outputs in plain language; the assistant proposes LoT you review and deploy without leaving the conversation. Describe the metric or inputs you care about; the assistant drafts logic, confirms details with you, and deploys an Action in Agent mode. Ask what Routes or topics exist, why something errored, or how signals relate—natural in Insight mode when you want answers before any changes. *** ## Example: Build a KPI with Agent mode Below is one real flow: you ask for a KPI, the assistant requests the missing pieces, then deploys an Action and offers a shortcut to the LoT Editor. Stay in **Agent** mode for this path. Type a goal such as **Calculate a new KPI**. The assistant loads context and may offer quick actions that match what you said. AI Assistant showing Agent mode selected and suggested prompts including Calculate a new KPI When the assistant needs a formula, source topic, or rule, it **pauses and asks you directly**—you reply in the thread instead of guessing what to type up front. AI Assistant conversation where the assistant asks the user to provide KPI formula or source data with a Type your answer field After you supply enough detail, the assistant can draft LoT, deploy an Action, and show a status card. Use **Open in LoT Editor** when you want to inspect or tweak the definition yourself. AI Assistant showing deployed CalculateKPI Action with Running status and Open in LoT Editor link When you see **Running** (or equivalent success state) on the deployment card, the new Action is live on the broker—confirm in the **LoT Editor** if you want to read the exact definition. *** ## Example: Explore safely with Insight mode Switch to **Insight** before you send if you only want answers—not changes. You can still get structured output such as tables and follow-up suggestions. AI Assistant in Insight mode responding to 'Please create a list of routes I have on broker' with a table showing Name, Type, Status, Description, and Events columns for Terminal, BrokerAgent, and MyS7Route Try questions like **Create me a list of Routes** or **Which Routes are in a warning state?** The assistant reads broker state and renders the response in the panel without using write tools. ## Built-in safeguards * **Your permissions apply** — The assistant cannot read topics or change Routes beyond what your HUB user is allowed to do. * **Insight mode is read-only** — No deploy, publish, or execute path while Insight is selected. * **Stop at any time** — Long runs can be cancelled from the UI so you are never stuck waiting on a runaway answer. * **Same deployment path as humans** — Anything the assistant deploys still goes through the broker’s normal validation and permission checks. *** ## Best practices Use Insight to map topics, Routes, and health first. Switch to Agent only when you are ready for the assistant to create or update broker objects. Open new Actions or Routes in the **LoT Editor** or **Routes** app to read names, triggers, and bindings before you rely on them in production. ## Next steps Open and refine Actions, Models, and Rules the assistant creates—same editor, full control. Add or monitor Routes the assistant can read and help you configure next. # Connections Manager Source: https://docs.coreflux.org/hub/connections Save multiple broker connections, switch between them without logging out, and secure each one with its own TLS certificates Coreflux HUB login screen with stored connections, Enter buttons, and New Connection ## Why Use the Connections Manager If you work with more than one broker — a local development instance, a staging cluster, a production site — the HUB remembers each one as a saved connection instead of a single remembered URL. Pick a broker from the list to sign in, probe every saved broker for reachability before you commit, and jump between brokers from the account menu without logging out and re-entering details. ## Adding a Connection On the login screen, click **New Connection** in the header (or click a saved card's edit action to modify an existing one). The connection form opens in the same card. Enter a name, the **Protocol** (`mqtt://`, `mqtts://`, `ws://`, or `wss://`), **Host**, and **Port**. For a HUB and broker started together with Docker Compose, use **Host** `broker` rather than `localhost` — the HUB proxies MQTT from inside Docker. Coreflux HUB New Connection form with Local Hub, ws:// localhost port 5000 path mqtt, username root, Save connection, and Connect Add at least one identity: a username and password, or an anonymous identity if the broker allows anonymous access. A connection can hold several identities — see [Identities and Switch User](#identities-and-switch-user) below. Click **Test** to attempt a throwaway connection with the details you entered, without saving anything. Click **Save** to store the connection for later, or **Connect** to save and sign in immediately. ## Saved Connections Each saved connection appears as a card on the login screen, showing its name, broker URL, and a **Reachable** status that the HUB probes automatically in the background — no need to open the connection to see whether that broker is up. * **Favorites** — star a connection to pin it; use the **All / Starred** filter above the list to narrow the view. * **Reorder** — drag a card to change its position in the list. * **Enter** — pick an identity from the card's dropdown and click **Enter**, or press Enter with a card focused, to sign in. * **Edit or delete** — open a card to change its details, or remove it entirely. ## Identities and Switch User A single saved connection can hold more than one identity — for example a day-to-day account and an admin account on the same broker. Mark one identity as the **default** for that connection, and switch between the others from the account menu without leaving the app: open the topbar account menu and choose **Switch user** to reconnect as a different identity on the *current* broker, or **Change Broker** to reconnect to a *different* saved connection (using that connection's default identity). Both close the current session cleanly and reopen it against the new target. ## Securing a Connection with TLS For `mqtts://` and `wss://` connections, open the connection's detail panel to attach TLS material: * A **CA certificate**, if the broker's certificate isn't already trusted by your system. * A **client certificate and private key**, for brokers that require mutual TLS. Server certificate verification is on by default for secure connections; only turn it off for trusted development brokers. This is separate from **route** certificate management (trust stores for OPC UA and similar routes running *on* the broker) — see the route's own Certificate Management section when you configure a [Route](/hub/routes/overview). ## Where Credentials Are Stored * **Desktop app** — passwords and the TLS private key are encrypted at rest using your operating system's secure storage. They are never written to the plain settings file the HUB otherwise syncs to disk. * **Browser app** — credentials are kept for the session and are not written to disk by the HUB; use your browser's own security practices (a trusted device, a locked profile) when saving connections there. The broker's TLS **certificate** (the public part) can still be attached to a saved connection either way, since it isn't sensitive. *** ## Next Steps Install the broker and HUB, and make your first connection. Configure routes, including per-route TLS trust stores. # Container Components Source: https://docs.coreflux.org/hub/dashboards/components/containers Group dashboard components with cards and tabs to structure larger panels in the Coreflux HUB. A titled Zone 1 Status card containing a temperature readout, a status indicator, and a Details button Containers group other components. Use them when a panel grows beyond a simple grid — to cluster a machine's readouts under one title or split a screen into tabs. **Like trays on a workbench.** The bench grid holds the trays in place; each tray keeps one job's tools together so they move as a unit. *** Two containers are available today: `card` for grouping, `tabs` for splitting. ## Card Groups child components under a title. A titled card with one readout inside: A titled Zone 1 Status card containing a temperature readout ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "StatusCard" WITH TYPE "card" SET TITLE "Zone 1 Status" ADD COMPONENT "TempReading" WITH TYPE "value" BIND VALUE TO TOPIC "zone1/temperature" SET UNIT "°C" ``` A card combining a readout, an indicator, and a navigation button — the two extra children are the new parts: A titled Zone 1 Status card with a temperature value, a status indicator, and a Details button ```lot wrap focus={9-17} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "StatusCard" WITH TYPE "card" SET TITLE "Zone 1 Status" ADD COMPONENT "TempReading" WITH TYPE "value" BIND VALUE TO TOPIC "zone1/temperature" SET UNIT "°C" ADD COMPONENT "StatusIndicator" WITH TYPE "indicator" BIND STATE TO TOPIC "zone1/status" SET ON_VALUE "running" WITH COLOR "green" SET ON_VALUE "stopped" WITH COLOR "red" ADD COMPONENT "DetailsButton" WITH TYPE "button" SET LABEL "Details" ON CLICK DO NAVIGATE TO PANEL "Zone1Details" ``` | Property | Type | Description | | -------- | ------ | ------------ | | `TITLE` | String | Card heading | ## Tabs Each tab holds its own components — useful for Overview / Analytics / Settings splits on one screen. In the current HUB build, tab headers show the **inner component name** rather than the `ADD TAB` title. The structure below matches the spec syntax; tab labelling is tracked as a known issue. A tabbed container switching between sub-views on one panel ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "MainTabs" WITH TYPE "tabs" ADD TAB "Overview" ADD COMPONENT "OvTemp" WITH TYPE "value" BIND VALUE TO TOPIC "zone1/temperature" SET UNIT "°C" ADD TAB "Analytics" ADD COMPONENT "OvGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 BIND VALUE TO TOPIC "zone1/temperature" ADD TAB "Settings" ADD COMPONENT "OvText" WITH TYPE "text" SET LABEL "Settings go here" ``` ## Card vs. grid position? Use **grid positioning** (`AT COLUMN/ROW`) when components are independent readouts. Use a **card** when components belong to the same asset and should travel together — a card moves as one unit when you rearrange the panel. See [Mixing layouts with cards](/latest/lot-language/panels/defining-panels#mixing-layouts-with-cards) for a complete panel that combines both. ## Next Steps The readouts you place inside cards and tabs. Full panels that combine containers with live data. # Display Components Source: https://docs.coreflux.org/hub/dashboards/components/display Read-only widgets: gauges, values, indicators, charts, tables, and alerts. A dashboard panel showing several display widgets: a temperature gauge, a pressure readout, a green machine status indicator, a status text line, and a live throughput chart Display components show live data without accepting input. Each entry below opens with tabbed examples — the rendered result above the code that produces it — followed by the property reference. **Like instruments on a car dashboard.** Each one reads a signal and shows it its own way — a dial, a number, a warning light — and none of them lets you steer. *** ## Gauge Circular (radial) or linear gauge for numeric values. A radial gauge bound to a temperature topic: Radial gauge filled about two-thirds with an orange arc, reading 82.0 °C ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ``` A gauge with a label, one decimal place, and two configured thresholds: Radial gauge labeled Production Rate showing the live production rate ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "ProductionGauge" WITH TYPE "gauge" SET STYLE "radial" SET RANGE FROM 0 TO 100 SET UNIT "units/hr" SET LABEL "Production Rate" SET DECIMALS 1 BIND VALUE TO TOPIC "factory/production/rate" ADD THRESHOLD AT 75 WITH COLOR "warning" LABEL "High" ADD THRESHOLD AT 90 WITH COLOR "critical" LABEL "Critical" ``` | Property | Type | Default | Description | | ------------------- | ----------------------- | ---------- | ---------------- | | `STYLE` | `"radial"` / `"linear"` | `"radial"` | Gauge appearance | | `RANGE FROM x TO y` | Numbers | required | Min / max values | | `UNIT` | String | `""` | Unit label | | `LABEL` | String | `""` | Display label | | `DECIMALS` | Integer | `0` | Decimal places | **Bind targets:** `VALUE`, `MIN`, `MAX` ## Value The simplest read-only display: a number or string, with unit and formatting. A pressure readout with two decimals: Numeric value readout showing 3.42 bar ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "PressureDisplay" WITH TYPE "value" BIND VALUE TO TOPIC "pump/pressure" SET UNIT "bar" SET DECIMALS 2 ``` Extracting one field from a JSON MQTT payload — `JSON_PATH` and the label are the new parts: Large numeric value readout showing 1284.50 Wh extracted from a JSON payload ```lot wrap focus={3,5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "EnergyDisplay" WITH TYPE "value" BIND VALUE TO TOPIC "machines/pump1/data" SET JSON_PATH "$.energy_wh" SET UNIT "Wh" SET LABEL "Energy" SET DECIMALS 2 ``` | Property | Type | Default | Description | | ----------- | ------- | ------- | -------------------------------- | | `UNIT` | String | `""` | Unit label | | `LABEL` | String | `""` | Display label | | `DECIMALS` | Integer | `0` | Decimal places | | `JSON_PATH` | String | — | JSONPath to extract from payload | **Bind targets:** `VALUE`, `MAX` If your topic carries a JSON object rather than a plain number, set `JSON_PATH` to pick the field — otherwise the widget shows the raw payload. Inspect the payload first in the [Data Viewer](/hub/mqtt/data-viewer). ## Text / Label Dynamic or static text. `label` is an alias for `text`. A static heading rendered at large size: A large static text line reading Zone 1 Overview ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "SectionTitle" WITH TYPE "label" SET LABEL "Zone 1 Overview" SET SIZE "large" ``` Dynamic text whose content updates live from the broker — the `BIND CONTENT` line is what makes it live: A large text line showing a live status message from the system message topic ```lot wrap focus={2} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "StatusText" WITH TYPE "text" BIND CONTENT TO TOPIC "system/message" SET SIZE "large" ``` | Property | Type | Default | Description | | -------- | ---------------------------------- | ----------- | ----------- | | `SIZE` | `"small"` / `"medium"` / `"large"` | `"medium"` | Font size | | `COLOR` | Color string | `"inherit"` | Text color | **Bind targets:** `CONTENT`, `COLOR`, `TITLE`, `LABEL` ## Indicator Maps incoming values to colors — running/stopped/fault at a glance. A pump-running dot — green when the bound topic reads `true`: A green pump status indicator dot ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "PumpIndicator" WITH TYPE "indicator" BIND STATE TO TOPIC "pump/running" SET ON_VALUE "true" WITH COLOR "success" SET ON_VALUE "false" WITH COLOR "muted" ``` Three states mapped to three colors, rendered large — the extra mapping and size are highlighted: A large green machine status indicator dot for the running state ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "MachineStatus" WITH TYPE "indicator" BIND STATE TO TOPIC "machine/status" SET ON_VALUE "running" WITH COLOR "green" SET ON_VALUE "stopped" WITH COLOR "red" SET ON_VALUE "maintenance" WITH COLOR "yellow" SET SIZE "large" ``` | Property | Type | Default | Description | | ---------- | ---------------------------------- | ---------- | ----------------------------------- | | `ON_VALUE` | `"state" WITH COLOR "color"` | required | One or more value-to-color mappings | | `SIZE` | `"small"` / `"medium"` / `"large"` | `"medium"` | Indicator dot size | **Bind targets:** `STATE` ## Realtime Chart Live streaming chart — it receives **individual data points** from a topic and builds the trend itself. One topic, one trace — every published value becomes a point: A realtime line chart labeled Line Throughput trending as values arrive ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "ThroughputTrend" WITH TYPE "realtime-chart" SET LABEL "Line Throughput" BIND VALUE TO TOPIC "factory/throughput" ``` The same idea with an explicit chart type, a y-axis unit, and a series label — the additions are highlighted: A realtime line chart labeled Message Count Trend building a live trend as points arrive ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "MessageCountTrend" WITH TYPE "realtime-chart" SET LABEL "Message Count Trend" SET CHART_TYPE "line" SET UNIT "msg" BIND VALUE TO TOPIC "kpi/operational/messageCount" SET SERIES_LABEL "Message Count" ``` **Multi-series is not working in the current HUB build.** Adding a second `BIND VALUE TO TOPIC` (each with its own indented `SET SERIES_LABEL`) is the documented syntax for multiple traces, but the chart currently renders a single trace and no legend. Use one chart per signal for now. | Property | Type | Description | | -------------- | ------------------ | ------------------------------------------------------ | | `CHART_TYPE` | `"line"` / `"bar"` | Trace style (the current build renders both as a line) | | `UNIT` | String | Y-axis unit label | | `LABEL` | String | Chart heading | | `SERIES_LABEL` | String | Series name — set **under the BIND, indented** | **Bind target:** `VALUE` ## Image Static, dynamic, or camera-fed image. A static image loaded from a URL: An image component on a dashboard panel rendering a remote picture ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "ProductPhoto" WITH TYPE "image" SET SRC "https://picsum.photos/320/240" SET WIDTH 320 SET HEIGHT 240 ``` A live feed whose source URL is published over MQTT — the `BIND SRC` line replaces the static source: An image component rendering a picture whose URL was published to a camera snapshot topic ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "CameraFeed" WITH TYPE "image" BIND SRC TO TOPIC "camera/1/snapshot" SET WIDTH 320 SET HEIGHT 240 ``` | Property | Type | Description | | ----------------- | ------------------ | ------------------------------ | | `SRC` | String / ENV / URL | Image source path or URL | | `FALLBACK` | String | Fallback image if source fails | | `WIDTH`, `HEIGHT` | Integer | Dimensions in pixels | **Bind targets:** `SRC`, `VISIBLE` ## Table Tabular data from a JSON array topic or a query result. Each array element becomes a row; `COLUMNS` picks and orders the fields: A table with time, barcode, product, and status columns showing recent scans ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "ScanHistory" WITH TYPE "table" BIND DATA TO TOPIC "barcode/history" SET COLUMNS "time, barcode, product, status" SET MAX_ROWS 10 ``` | Property | Type | Description | | ---------- | ------- | ---------------------------- | | `COLUMNS` | String | Comma-separated column names | | `MAX_ROWS` | Integer | Maximum rows to display | **Bind targets:** `DATA` ## List Scrollable list of items from a topic or model. A list widget showing alarm entries from the zone1 alarms topic ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "AlarmLog" WITH TYPE "list" BIND DATA TO TOPIC "zone1/alarms" ``` **Bind targets:** `DATA` ## Alert Banner for critical messages — typically bound to an alarm flag. The banner appears while the alarm flag is true and shows the live message: An alert banner showing a pump pressure critical message ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "AlarmBanner" WITH TYPE "alert" BIND VISIBLE TO TOPIC "pump/alarm_active" BIND CONTENT TO TOPIC "pump/alarm_message" SET VARIANT "critical" ``` In the current HUB build the `critical` variant renders in a neutral/muted style rather than a strong red banner — the message and visibility behaviour work as shown. | Property | Type | Description | | ------------------ | ------------------------------------- | -------------- | | `TYPE` / `VARIANT` | `"critical"` / `"warning"` / `"info"` | Severity style | **Bind targets:** `CONTENT`, `VISIBLE` ## Common mistakes Properties use `SET`, never `WITH`. `WITH UNIT "msg"`, `WITH RANGE 0, 200`, and `WITH DECIMALS 2` are silently ignored — write `SET UNIT "msg"`, `SET RANGE FROM 0 TO 200`, `SET DECIMALS 2`. The topic carries a JSON object — add `SET JSON_PATH "$.field"` to extract the field you want, and confirm the payload shape in the Data Viewer. ## Next Steps Buttons, toggles, sliders, and fields that write back to the broker. Topics, models, queries, transformations, and JSON extraction. # Input & Control Components Source: https://docs.coreflux.org/hub/dashboards/components/inputs Buttons, toggles, sliders, and fields that write back to your broker. A two-column operator panel with Start and Emergency Stop buttons, an Enable Production toggle, a setpoint slider, and device name and pressure fields Input components let operators act: start a line, set a speed, type a value. One rule applies to all of them: **input components don't publish by themselves** — the `ON DO` handler does. **Like a steering wheel, not a speedometer.** Display widgets read signals; these widgets send them — every interaction ends in a publish back to the broker. *** One section per control, each with its rendered result, the code, and then the property reference. ## Button A button that publishes a command when clicked: A Start Production button on a dashboard ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "StartButton" WITH TYPE "button" SET LABEL "Start Production" ON CLICK DO PUBLISH TOPIC "factory/commands/start" WITH 1 ``` A danger-styled emergency stop — the `STYLE` line is the new part: A red danger-styled Emergency Stop button ```lot wrap focus={3} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "StopButton" WITH TYPE "button" SET LABEL "Emergency Stop" SET STYLE "danger" ON CLICK DO PUBLISH TOPIC "factory/commands/stop" WITH 1 ``` | Property | Type | Default | Description | | ---------- | ---------------------------------------- | ----------- | -------------- | | `LABEL` | String | required | Button text | | `STYLE` | `"primary"` / `"secondary"` / `"danger"` | `"primary"` | Visual style | | `DISABLED` | Boolean | `false` | Disable button | **Events:** `ON CLICK DO` ## Toggle / Switch Binary on/off control. `switch` is an alias for `toggle`. The toggle reflects the bound topic live — publish `true` or `false` to it and the switch follows: An Enable Production toggle switched on ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "EnableSwitch" WITH TYPE "switch" SET LABEL "Enable Production" BIND STATE TO TOPIC "factory/production/enabled" ``` | Property | Type | Description | | -------- | ------ | ------------ | | `LABEL` | String | Toggle label | **Bind targets:** `STATE` — **Events:** `ON TOGGLE DO` **Write-back is broken in the current HUB build.** `ON TOGGLE DO PUBLISH ... WITH STATE` fires, but the message payload is the literal text `{{STATE}}` instead of the toggle state (`WITH VALUE` behaves the same). Until this is fixed, use the toggle as a live state display and trigger commands from a [button](#button). ## Slider Range input for numeric values. The slider tracks its bound topic live — the thumb moves as new values arrive: A temperature setpoint slider set partway along its 0 to 200 degree range ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "SetpointSlider" WITH TYPE "slider" SET RANGE FROM 0 TO 200 SET UNIT "°C" BIND VALUE TO TOPIC "control/setpoint" ``` | Property | Type | Description | | ------------------- | ------- | ----------- | | `RANGE FROM x TO y` | Numbers | Min / max | | `UNIT` | String | Unit label | **Bind targets:** `VALUE` — **Events:** `ON CHANGE DO` **Write-back is broken in the current HUB build.** `ON CHANGE DO PUBLISH ... WITH VALUE` sends the literal text `{{VALUE}}` instead of the slider position, and dragging is overridden by the bound topic. Until this is fixed, use the slider as a live numeric display and set values from a [number field](#number-field). ## Text Field / Input Free text entry. A labeled field pre-filled from its topic; typing publishes the new value: A Device Name text field showing pump-station-1 ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "NameInput" WITH TYPE "text-field" SET LABEL "Device Name" SET PLACEHOLDER "Enter name..." BIND VALUE TO TOPIC "config/device/name" ON CHANGE DO PUBLISH TOPIC "config/device/name" WITH VALUE ``` | Property | Type | Description | | ------------- | ------ | ---------------- | | `LABEL` | String | Field label | | `PLACEHOLDER` | String | Placeholder text | **Bind targets:** `VALUE` — **Events:** `ON CHANGE DO` ## Number Field Numeric-only entry. Accepts only numbers and publishes the limit on change: A numeric input field for a maximum pressure limit ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "MaxPressure" WITH TYPE "number-field" SET LABEL "Max Pressure (bar)" SET PLACEHOLDER "0.00" BIND VALUE TO TOPIC "config/pressure/max" ON CHANGE DO PUBLISH TOPIC "config/pressure/max" WITH VALUE ``` | Property | Type | Description | | ------------- | ------ | ---------------- | | `LABEL` | String | Field label | | `PLACEHOLDER` | String | Placeholder text | **Bind targets:** `VALUE` — **Events:** `ON CHANGE DO` ## Common mistakes Text and number fields fire `ON CHANGE` when the value is **committed** — press Enter (or leave the field) after typing. Each keystroke alone does not publish. The toggle reflects its `BIND STATE` topic. If nothing updates that topic, the toggle snaps back to the last bound value — make sure something (device, Action, or a test publish) updates the bound state. ## Next Steps Everything a control can do: publish, navigate, invoke, trigger, and branch. Group controls and readouts into cards and tabs. # Dashboard Manager Source: https://docs.coreflux.org/hub/dashboards/manager Create, open, share, and manage dashboard panels in the Coreflux HUB. Animation showing dashboard widgets updating in real time as MQTT topic values change on the broker ## Opening the Dashboard Manager The **Dashboard Manager** is an app inside the Coreflux HUB. It lists your **panels** (each panel is one dashboard you can open, share, or refine). From here you add panels, refresh the list, and open a panel to see it live. Coreflux HUB Dashboards app showing Panels section with count 1, plus and refresh controls, and a list row for KPI Operational Dashboard with Public label and Published badge ## How Panels Are Defined The Dashboard Manager and the AI Assistant both produce the same thing: a **LoT panel definition** (`DEFINE PANEL …`). The Manager shows that definition next to its live result so you can edit either by hand or by asking the assistant. The **LoT PANEL** reference documents every panel keyword, component, binding, and event. The widget tour, the 60-second first panel, and what dashboards can do. Read and write the `DEFINE PANEL` source behind every dashboard. ## How data updates on a Dashboard Each component is tied to **live data** on your broker—typically a MQTT topic that already carries the signal from your machines, databases, or Routes. When that topic’s value changes, the widget updates. You do not configure polling or manual refresh for normal operation. Coreflux HUB with the Dashboard Manager in the background and a floating panel window titled KPI Operational Dashboard showing a Status readout reading good, a Message Count line chart, and a Message Per Minute gauge reading 190 ## Viewing a Panel You can experience the same panel in more than one layout depending on where you need it. Open the panel as a **floating window** on top of the HUB. That keeps the rest of the HUB—routes, Data Viewer, configuration—one click away while you watch live values or try layout changes. Coreflux HUB with the KPI Operational Dashboard expanded across the workspace, showing Status reading good, a Message Count trend line chart, and a Message Per Minute gauge reading 196 Open the panel in a **dedicated full view** when you want a clean surface for a plant display, a wall-mounted screen, or a link you share with people who only need the dashboard. Clean full-screen view of the KPI Operational Dashboard with no HUB chrome, showing Status reading good, a Message Count line chart, and a Message Per Minute gauge reading 174 on a dark background ## Managing Panels Each row in the panel list shows the panel name, a visibility hint (for example **Public**), its publication state (for example **Published**), and when it was last updated. Clicking the row itself opens the panel; hovering reveals a row of quick actions on the right for sharing, inspecting, and deleting. | Action | How to trigger it | What it does | | -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Open as a window** | Click the panel row | Opens the panel as a floating window on top of the HUB so you can keep working while it stays live | | **Open as a link** | Click the first icon on the right (external link) | Opens the panel in a dedicated shareable view—useful for plant displays, wall screens, or sending a URL to a teammate | | **View source** | Click the second icon on the right (code) | Shows the panel’s underlying definition so you can inspect or copy it | | **Delete dashboard** | Click the last icon on the right (trash) | Removes the panel from the list after confirmation | Coreflux HUB Dashboards app showing the KPI Operational Dashboard row with Public and Published badges and three icons on the right for opening as a link, viewing source code, and deleting the dashboard ## Sharing Panels on TVs and External Displays You can put the same live panel on any screen that runs a browser—control-room wall TVs, lobby KPI boards, shop-floor tablets, or a monitor at a remote site—without installing a separate HMI app or giving everyone full access to the Coreflux HUB. The **shareable panel URL** opens a dedicated view of that dashboard so viewers see only the panel, not routes, configuration, or other HUB apps. From the **Dashboard Manager**, hover the panel row and click the **external link** icon (first action on the right). That opens the panel in a dedicated tab or window; copy the URL from the address bar and paste it into a browser on a TV stick, kiosk PC, tablet, or any display you want to keep on the loop. **Same live stream as inside the HUB.** The shareable URL reflects broker data the same way as the floating window—widgets update when topics change, without manual refresh for normal operation. Typical uses: * **Control-room and plant walls** — Full-screen dashboards that stay readable from a distance * **Lobby and office KPI screens** — Management views without exposing operator tools * **Shop-floor tablets** — Lightweight devices that only need one panel * **Stakeholders and contractors** — A single URL when someone needs read-only visibility Coreflux HUB Dashboard Manager highlighting the external link action on a panel row to open a shareable dashboard URL Deleting a panel from the list removes that dashboard permanently after confirmation. ## Best Practices Use names that tell you who it is for (for example “Line 3 — Shift Lead”) so the list stays scannable. Ship a minimal panel with the three signals people ask for most, then add depth once it is in daily use. Use pumps, valves, and tanks where operators already think in those terms; use charts where trends matter more than mimic layout. Check labels, ranges, and safety-related controls before handing the panel URL to operators — they will rely on this screen. *** ## Next Steps Browse topics and payloads so you know what to bind each widget to. Ask the assistant to suggest layouts, bindings, or improvements to a panel. # Dashboards in the HUB Source: https://docs.coreflux.org/hub/dashboards/overview Live panels bound to your broker data — written in the Dashboard Manager or generated by the AI Assistant. Coreflux dashboard panel titled Operational KPI Dashboard showing a Message Count gauge at 135 msg, a rising Message Count Trend line chart, and an Error Rate readout of 1.25 percent Dashboards in Coreflux are **panels**: live screens assembled from components — gauges, charts, controls — each wired to data flowing through your broker. You write them in the **Coreflux HUB** Dashboard Manager, where the panel definition and its live result sit side by side, or you ask the **AI Assistant** to build one for you and refine it from there. **Like a control room sketched in plain text.** You describe a gauge, point it at a signal, and it shows that signal live from the moment you save — no wiring diagram, no deploy step. ## What you can build * **KPI walls** — large readouts and trends for supervisors * **Line and area overviews** — at-a-glance status for a process or zone * **Machine detail** — gauges, indicators, and controls for a single asset * **Operator stations** — buttons, toggles, and setpoints where people work the process * **Shift and mobile views** — simplified layouts for tablets and shared screens *** Panels are authored as **LoT (Language of Things)** definitions — the syntax is documented under [LoT PANEL](/latest/lot-language/panels/defining-panels), which versions with your broker. The rest of this page is a quick tour of what the HUB renders; every widget has a full reference linked at the end. ## Your first panel in 60 seconds Open **Dashboards** in the HUB, click **Create new panel**, and write the definition below. The panel renders live as you save — point a gauge at a topic and the needle starts moving: The Sensor Monitor panel rendering a single radial gauge bound to the temperature topic ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL SensorMonitor WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Sensor Monitor" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ``` Every part of a definition is covered step by step in [Defining a Panel](/latest/lot-language/panels/defining-panels). Describe what you want in plain language: > "Build me a panel with a radial temperature gauge on `sensor/temperature`, range 0–100 °C, with a warning threshold at 75." The assistant creates the panel for you. Open it in the Dashboard Manager to see the definition it wrote — and adjust anything directly. See [AI Assistant](/hub/ai-assistant). Include `WITH VISIBILITY PUBLIC` **and** `WITH STATE PUBLISHED` in every panel definition — panels saved without them may not appear in the Dashboard Manager list. See [Defining a Panel](/latest/lot-language/panels/defining-panels). ## Tour of the main widgets A quick taste of each family — every widget has a full reference page. ### Readouts A value with context: range, unit, and thresholds. A radial temperature gauge reading 82.0 °C ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ``` The big number — unit, decimals, wall-readable. A numeric readout labeled Error Rate showing 1.25 percent ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "ErrorRate" WITH TYPE "value" SET LABEL "Error Rate" SET UNIT "%" SET DECIMALS 2 BIND VALUE TO TOPIC "kpi/operational/errorRate" ``` State at a glance — values mapped to colors. A green machine status indicator dot for the running state ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "MachineStatus" WITH TYPE "indicator" BIND STATE TO TOPIC "machine/status" SET ON_VALUE "running" WITH COLOR "green" SET ON_VALUE "stopped" WITH COLOR "red" ``` A banner that appears when a condition is true. An alert banner showing a pump pressure critical message ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "AlarmBanner" WITH TYPE "alert" BIND VISIBLE TO TOPIC "pump/alarm_active" BIND CONTENT TO TOPIC "pump/alarm_message" SET VARIANT "critical" ``` ### Charts and tables Builds a live trend from individual data points on a topic. A realtime line chart trending as message count points arrive ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "MessageCountTrend" WITH TYPE "realtime-chart" SET LABEL "Message Count Trend" SET CHART_TYPE "line" SET UNIT "msg" BIND VALUE TO TOPIC "kpi/operational/messageCount" SET SERIES_LABEL "Message Count" ``` Rows from a JSON array topic or query result. A table with time, barcode, product, and status columns showing recent scans ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "ScanHistory" WITH TYPE "table" BIND DATA TO TOPIC "barcode/history" SET COLUMNS "time, barcode, product, status" ``` ### Controls On/off switch that tracks the live state of its bound topic. A pump enable toggle switched on ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "PumpControl" WITH TYPE "toggle" SET LABEL "Pump Enable" BIND STATE TO TOPIC "zone1/pump/state" ``` Click to act — publish a command to any topic, or navigate to another panel. A Start Production button on a dashboard ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "StartButton" WITH TYPE "button" SET LABEL "Start Production" ON CLICK DO PUBLISH TOPIC "factory/commands/start" WITH 1 ``` Numeric value tracked live on a range. A conveyor speed slider set to 65 percent ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "SpeedSlider" WITH TYPE "slider" SET RANGE FROM 0 TO 100 SET UNIT "%" BIND VALUE TO TOPIC "zone1/conveyor/speed" ``` ## What dashboards can do * **Live bindings** — bind to MQTT topics, LoT Model fields, or database queries; widgets update the moment values change. See [Data Binding](/latest/lot-language/panels/data-binding). * **Interactive controls** — buttons and entry fields that publish commands, invoke Actions, and trigger Routes. See [Actions & Events](/latest/lot-language/panels/actions-events). * **Multi-page navigation** — buttons and cards that jump between panels with `NAVIGATE`, for overview-to-detail drill-downs. See [Defining a Panel](/latest/lot-language/panels/defining-panels#navigation). * **Share on any screen** — a panel URL runs full-screen on wall TVs, tablets, and kiosks, no app install. See [Dashboards in the HUB](/hub/dashboards/overview). ## Next Steps A 10-minute guided walk from topic to published panel. Every widget, every property, every bind target. # Common Patterns Source: https://docs.coreflux.org/hub/dashboards/patterns Ready-to-adapt panel recipes for the most frequent dashboard jobs. A KPI dashboard with a Message Count gauge, a rising Message Count Trend line chart, and an Error Rate readout Each recipe is a complete, working panel. Copy it, swap the topics for yours, and adjust ranges and labels. **Like starter dough.** Each recipe already works as-is — you don't build from scratch, you season: your topics, your ranges, your labels. *** Five recipes, ordered from the smallest useful panel to data-layer integrations. ## Minimal monitoring panel **Goal:** one signal, one status — the smallest useful dashboard. A dashboard with a temperature gauge reading 82 °C and a green running status indicator ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL SensorMonitor WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Sensor Monitor" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" AT COLUMN 1 ROW 1 SET RANGE FROM 0 TO 150 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD THRESHOLD AT 100 WITH COLOR "warning" ADD THRESHOLD AT 130 WITH COLOR "critical" ADD COMPONENT "StatusIndicator" WITH TYPE "indicator" AT COLUMN 2 ROW 1 BIND STATE TO TOPIC "machine/status" SET ON_VALUE "running" WITH COLOR "green" SET ON_VALUE "stopped" WITH COLOR "red" SET SIZE "large" ``` **Adapt:** your topic names, the gauge range, the threshold levels. ## Live KPI dashboard **Goal:** a wall screen with a headline number, its trend, and a secondary KPI. A KPI dashboard with a Message Count gauge at 135 msg, a rising Message Count Trend line chart, and an Error Rate readout of 1.25 percent ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL KpiDashboard WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Operational KPI Dashboard" WITH LAYOUT "grid" COLUMNS 3 ADD COMPONENT "MessageCountGauge" WITH TYPE "gauge" SET LABEL "Message Count" SET RANGE FROM 0 TO 200 SET UNIT "msg" BIND VALUE TO TOPIC "kpi/operational/messageCount" ADD COMPONENT "MessageCountTrend" WITH TYPE "realtime-chart" SET LABEL "Message Count Trend" SET CHART_TYPE "line" SET UNIT "msg" BIND VALUE TO TOPIC "kpi/operational/messageCount" SET SERIES_LABEL "Message Count" ADD COMPONENT "ErrorRate" WITH TYPE "value" SET LABEL "Error Rate" SET UNIT "%" SET DECIMALS 2 BIND VALUE TO TOPIC "kpi/operational/errorRate" ``` **Adapt:** share it on a TV via the panel URL — see [Dashboards in the HUB](/hub/dashboards/overview). ## Control panel **Goal:** drive the line from the dashboard — buttons publish commands, a number field sets the speed, and a slider tracks the live feedback. A two-column operator panel with Start and Stop Conveyor buttons, a speed setpoint number field, and a conveyor speed slider ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Zone1Control WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Zone 1 Control" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "StartButton" WITH TYPE "button" AT COLUMN 1 ROW 1 SET LABEL "Start Conveyor" ON CLICK DO PUBLISH TOPIC "zone1/conveyor/cmd/start" WITH 1 ADD COMPONENT "StopButton" WITH TYPE "button" AT COLUMN 2 ROW 1 SET LABEL "Stop Conveyor" SET STYLE "danger" ON CLICK DO PUBLISH TOPIC "zone1/conveyor/cmd/stop" WITH 1 ADD COMPONENT "SpeedSetpoint" WITH TYPE "number-field" AT COLUMN 1 ROW 2 SET LABEL "Speed Setpoint (%)" SET PLACEHOLDER "0" ON CHANGE DO PUBLISH TOPIC "zone1/conveyor/cmd/speed" WITH VALUE ADD COMPONENT "LiveSpeed" WITH TYPE "slider" AT COLUMN 2 ROW 2 SET RANGE FROM 0 TO 100 SET UNIT "%" BIND VALUE TO TOPIC "zone1/conveyor/speed" ``` **Adapt:** the command topics (`cmd/...`) and the feedback topic (`speed`) — keeping them separate means the slider reflects what the line is actually doing, not just the last setpoint sent. ## Panel backed by a LoT Model **Goal:** when a [Model](/latest/lot-language/models/overview) already aggregates your data layer, bind to its fields instead of raw topics. **Known issue (current HUB build).** `BIND … TO MODEL` bindings do not receive data yet, and `SET MODEL … WITH VALUE` write-back from panel handlers does not update model fields — so this recipe cannot be shown rendering live values and the setpoint field cannot write back. The syntax below follows the spec; in the meantime, bind widgets to the model's source topics (or its output topic with `JSON_PATH`) and publish setpoints to those topics directly. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL PumpDashboard WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Pump System" WITH LAYOUT "grid" COLUMNS 3 ADD COMPONENT "FlowGauge" WITH TYPE "gauge" BIND VALUE TO MODEL "PumpSystem.flow_rate" SET UNIT "L/min" SET RANGE FROM 0 TO 500 ADD COMPONENT "PressureDisplay" WITH TYPE "value" BIND VALUE TO MODEL "PumpSystem.pressure" SET UNIT "bar" SET DECIMALS 2 ADD COMPONENT "PumpStatus" WITH TYPE "indicator" BIND STATE TO MODEL "PumpSystem.running" SET ON_VALUE "true" WITH COLOR "success" SET ON_VALUE "false" WITH COLOR "muted" SET SIZE "large" ADD COMPONENT "FlowSetpoint" WITH TYPE "number-field" SET LABEL "Flow Setpoint (L/min)" SET PLACEHOLDER "0" BIND VALUE TO MODEL "PumpSystem.setpoint" ``` ## Next Steps Walk through one of these panels end to end with live data. Publish, share, and put any of these on a wall screen. # LoT Editor Source: https://docs.coreflux.org/hub/lot-editor Author, deploy, and inspect LoT and Python as notebooks and projects in the Coreflux HUB Coreflux HUB LoT Editor showing the Sample notebook with a live document preview of a sensor gauge reading 60, status normal, and a live trend chart, a SampleDashboard panel cell below, PROJECTS with Sample LIVE, BROKER RUNTIME Project Sample, a Deployed chip, main branch, and Deploy All ## Why Use the LoT Editor? The LoT Editor brings the **LoT Notebooks** experience from VS Code into the Coreflux HUB. You author LoT and Python in notebook cells, deploy any cell on its own, and see per-cell whether your copy matches what the broker is running — with the broker's live entity list right beside your code, without leaving the browser. This makes the HUB a single place to develop, manage, and operate your Coreflux solutions. **This is parity, not a replacement.** Editing LoT from the [VS Code LoT Notebooks extension](/lot-notebooks/usage) remains fully supported, and you never have to choose between the two. The broker is the source of truth: either editor deploys to it, and both read the same project. `Import .lotnb`, `Push to broker`, and `Pull from broker` are what keep the two workflows one. **Like a lab notebook wired to the machine it describes.** The prose, tables, and code live in one document — and each block of code is the thing that is actually running. ## When to Use It * **Deploying LoT without another tool installed** — Any HUB install, Docker or desktop app, can author and deploy * **Checking what is really on the broker** — **Broker runtime** is the authoritative list, next to your code * **Iterating on one definition** — Run a single cell instead of redeploying a whole project * **Auditing drift** — Per-cell badges and a broker diff show exactly which definitions have moved out of sync * **Documenting a solution where it lives** — Markdown cells render inline, so the topic conventions and device maps sit beside the code that implements them * **Putting live visuals next to the LoT that drives them** — Drag Library components into a markdown cell and watch topics such as `demo/sensor/value` update in the notebook * **Picking up someone else's project** — Open it from the broker and read the notebook top to bottom * **Publishing or recovering a project** — Push a local draft, Pull or Inspect one already on the broker, or restore an earlier commit * **Removing without surprises** — A local delete, an entity remove, and a project remove from the broker are three different operations *** ## Opening the Editor Select **LoT Editor** in the HUB dock — the notebook-and-pen icon, after **Data Viewer** and **Connections**. An orange dot under the icon marks the editor as the active app. Animated walkthrough of clicking the LoT Editor icon in the Coreflux HUB dock to open the Sample notebook The window header carries the current project and notebook plus the controls that act on the whole notebook: Coreflux HUB LoT Editor window above the dock, Sample notebook.lotnb in the breadcrumb, 12 not running chip, orange Deploy All, Projects listing Sample as local, Library Components 28, LoT Editor dock icon active | Control | What it does | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Breadcrumb** (`Sample › notebook.lotnb`) | The open project and file. Click either part to rename it | | **`Deployed`** | Every comparable cell in the open project matches the broker. Drift shows as an orange count such as `N differs` or `N not running` — on a live project, click it to Push | | **Branch chip** (`main`) | Git branch of the open project on the broker. Missing git is silence — there is no `Local workspace` chip | | **`Deploy All`** | Deploys every LoT and Python cell in the **open notebook**. The split control also offers **Deploy project**, which stores and loads the whole project | | **Action Console** (pulse) | Opens the right-hand **Action Console** rail for running and tracing Actions — see [Action Console](#action-console-traces-and-run-once) | | **Remove entities** | Removes the entities *this notebook* defines from the broker | | **Undo** / **Redo** | Step cell changes back and forward | | **Attach notebook to AI chat** | Hands the open notebook to the HUB's AI assistant as context | | **`…`** | `Import .lotnb file` and `Export open notebook as .lotnb` | ## A Notebook Is Both Document and Deployment A notebook is an ordered list of cells, and the cell type dropdown decides what each one is: | Cell type | Contents | Deployed? | | ------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- | | **LoT** | One complete definition — `DEFINE ACTION`, `DEFINE MODEL`, `DEFINE ROUTE`, `DEFINE RULE` | Yes, to the broker | | **Python** | A Python script your Actions call, named in the cell's **Script name** field | Yes, to the broker | | **Markdown** | Headings, prose, lists, tables, **live visual components**, and links to project documents | Visuals are published as panels; prose is documentation | Markdown cells render in place between the executable ones. That is not documentation-only: you can drop a gauge, chart, or card from **Library → Components** into a markdown cell and it stays live, bound to MQTT. The Sample notebook uses this for a **live document preview** — widgets on `demo/sensor/value` sit in the notebook itself, so you deploy the cells above and watch the gauge, status, and trend update without leaving the editor. **Adding visuals in the editor is a first-class authoring step**, not a Dashboards-only feature. Drag from Library, or write the component in the markdown cell — the notebook is allowed to *be* the live view. See [Library](#library-visual-components-and-documents). To add a cell, use the **`+ Code`** and **`+ Markdown`** buttons that appear in the gutter between cells — so a new cell always lands exactly where you point at it. ## Working With Cells Every cell has its own toolbar, left to right. Hover the cell to reveal the right-hand controls — **Move up** / **Move down**, **Duplicate**, and **Delete** stay hidden until then. Coreflux HUB LoT Editor SummarizeF2Hvac cell hovered, green DEPLOYED badge, pulse Run or trace, move duplicate and delete controls, DEFINE ACTION on uns/porto/hq/f2/hvac/temp, and the strip SummarizeF2Hvac Not yet run Tracing with Open in Action Console | Control | What it does | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | **Drag handle** | Drag to reorder the cell | | **Run cell** (`Shift`+`Enter`) | Deploys just this cell to the broker | | **Cell type** | Switch between `LoT`, `Python`, and `Markdown` | | **Sync badge** | This cell's state against the broker — see below | | **Run or trace** (pulse, Action cells only) | Pin this Action in the [Action Console](#action-console-traces-and-run-once). While a capture is armed it reads as tracing | | **Compare with broker** | Shown when the cell is not `DEPLOYED` — see [Comparing a cell with the broker](#comparing-a-cell-with-the-broker). Appears on hover | | **Move up** / **Move down** | Reorder without dragging. Appear on hover | | **Duplicate** | Copy the cell and its contents. Appears on hover | | **Delete cell** | Remove the cell from the notebook. Appears on hover | Action cells also carry a strip under the code. Here it reads **`SummarizeF2Hvac · Not yet run · Tracing`** — expand it for Traces and Run, or choose **Open in Action Console** to pin the same panel in the right-hand rail. **Tracing** (green) means a five-minute capture is already armed for that Action. Deleting a cell asks **"Delete this cell?"** and warns that the cell and its contents are removed from the notebook and that it cannot be undone. It changes the notebook only — the entity that cell deployed stays on the broker until you remove it there. See [Removal](#removal-four-different-operations). ## Action Console: Traces and Run Once **Run cell** deploys the definition. The console underneath — or pinned from the header pulse — is where you watch and trigger execution: **Traces** records every run for five minutes, and **Run once** fires the Action with a topic and payload you choose. Every Action cell has the same console in two places — expanded under the cell, or pinned in the header pulse rail. Both have **Traces** and **Run**. Coreflux HUB LoT Editor SampleProcessor console on the Traces tab showing no messages yet, timeline playback controls, and copy that traces record every execution for five minutes | Control | What it does | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | **Traces** tab | A five-minute capture of every execution, including runs triggered elsewhere. Playback sits on the timeline; a single run's result stays on **Run** | | **Run** tab | Topic and payload (or JSON parameters for callable Actions), then **Run once** | | **`Use`** | Fill the payload from the last message on that topic, or from the last run | | **Run once** | Live run — (re)deploys the version in the cell, then executes it. There is no sandbox: anything the Action publishes is published | | **Open in Action Console** | Pin this Action in the right-hand rail so you can switch Actions without leaving the strip | Coreflux HUB LoT Editor SampleProcessor console on the Run tab with topic demo/sensor/value, payload 60, Use dropdown, and Run once play button **Run once is a live run.** It (re)deploys the Action from the cell and executes it on the broker. Downstream topics, databases, and devices see the same publishes they would from a real trigger. ## Is This Cell Running on the Broker? Each LoT and Python cell reports its own state against the broker, which is the fastest way to tell whether the code you are reading is the code that is running: | Badge | Meaning | What to do | | ----------------- | ------------------------------------------------------- | ------------------------------------------ | | **`DEPLOYED`** | The cell matches an entity running on the broker | Nothing | | **`DIFFERS`** | The entity is running but the source here has changed | Run the cell to update it | | **`NOT RUNNING`** | Nothing of that name is running on the broker yet | Run the cell to deploy it | | **`RUNNING`** | Something is running that this project does not declare | Adopt it, or remove it from Broker runtime | Coreflux HUB LoT Editor showing notebook cells badged NOT RUNNING with Compare with broker controls, and a header chip reading a not-running count `NOT RUNNING` and `DIFFERS` are how the editor answers "why is my Action missing from [LoT Flow](/hub/lot-flow)?" — the graph is drawn from Broker runtime, so a cell that has never been deployed has nothing to draw. ### Comparing a cell with the broker Whenever a cell is not `DEPLOYED`, a **Compare with broker (Diff)** control appears on its toolbar. It replaces the editor with a line-by-line comparison of the running definition against your local copy, headed `BROKER · ⟷ LOCAL` with an added/removed line count: Coreflux HUB LoT Editor showing SampleMqttBridge in diff mode, header BROKER · SampleMqttBridge vs LOCAL, CLIENT_ID changed from bridge-client to bridge, PROJECTS Sample LIVE, orange 1 differs chip and main branch For a `NOT RUNNING` cell the comparison is against nothing — every line shows as an addition, which is a quick way to review a whole definition before it goes live. ## Deploying Two granularities, and the difference matters when a project is large: * **Run cell** deploys that one definition. Use it while iterating — you get the broker's response for that entity alone. * **Deploy All** deploys every LoT and Python cell in the **open notebook**. Use it while iterating. * **Deploy project** (the other half of the split button) stores the whole project and loads it, replacing the broker's entity set. It always confirms first. Either way the cell's badge flips to `DEPLOYED` once the broker confirms it, and the entity appears under **Broker runtime**. Here is a complete Model as it appears in a cell — the fields the Action fills in are the ones worth reading first: ```lot wrap focus={2-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SampleTableRow COLLAPSED ADD DOUBLE "value" ADD STRING "status" ADD STRING "timestamp" ``` Running a cell writes to the broker immediately. There is no staging step and no separate save — the definition in the cell replaces whatever the broker was running under that name. *** ## The Sidebar: Projects and Broker Runtime There is **one Projects list**, and **Broker runtime** sits under it as its own section — always present, even when nothing is running. Coreflux HUB LoT Editor sidebar with PROJECTS Sample LIVE, notebook.lotnb, Entities 12, Models SampleReading and SampleTableRow; BROKER RUNTIME Project Sample with type counts; CONFIGURATION Variables Secrets Internal Rules 35; LIBRARY Components 28 Resources 0; header Deployed and main | Section | What it holds | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Projects** | Every project you can open — on the broker, local-only drafts, and the one currently **LIVE**. `+ New` creates a draft. Hover a name for placement (`On broker · live`, `On broker · idle`, `Local draft`). The row plug is **Activate** or **Deactivate** | | **Broker runtime** | What is actually running. The group reads **`Project: Sample`** (or whatever project is live), or **`Standalone`** when none is. Click the name to open the inspector table | | **Configuration** | `Variables`, `Secrets`, and `Internal Rules` — the broker's locked built-in permission rules | | **Library** | Visual components, documents, and images — see [Library](#library-visual-components-and-documents) | Editing a notebook changes your local copy. Only deploying, Push, Activate, or Deploy project writes to the broker. A colleague on another machine sees the same Broker runtime and the same on-broker projects, not your unpublished local edits — which is why you Push and Pull. ### Broker runtime Expanding **Broker runtime** groups everything running by type — `Actions`, `Models`, `Rules`, `Routes`, `Python`, `Themes`, `Panels` — each with a count and names beneath. Click **`Project: {name}`** or **Standalone** to open the inspector: Coreflux HUB Broker runtime inspector listing Sample entities plus a BrokerAgent route with empty Project and Notebook columns, SampleReset ACTION selected showing DEFINE ACTION SampleReset with Open notebook, Copy to project, and Remove The inspector is where mixed ownership is obvious: Sample-owned rows point at `notebook.lotnb`; a standalone route such as `BrokerAgent` leaves **Project** and **Notebook** blank. Select a row to read the running source and use **Open notebook**, **Copy to project**, or **Remove**. A project's own **Entities** folder (under the project in **Projects**) is what that project's notebooks *declare*. Broker runtime is what is *running*. In the Sample notebook those counts can disagree — **Routes (1)** under the project versus **Routes (2)** on the broker — because a leftover such as `BrokerAgent` is running but not declared in the notebook. An idle project's declarations can match the running set and still read `NOT RUNNING` on cells — Activate that project before treating those badges as a deploy to-do. **Routes and Python scripts deploy from the editor too.** Routes still have their own [Routes Manager](/hub/routes/overview) for connection status and templates. Two counts are easy to misread: * **`Rules`** under a project counts rules that project owns. Locked built-ins live under **Configuration → Internal Rules**, not here. * **`Python`** counts *scripts*. [LoT Flow](/hub/lot-flow)'s Python group counts the individual *functions* those scripts expose. ## Library: Visual Components and Documents **Library** is the bottom of the LoT Editor sidebar. It is how you put *views* and *files* into a project — not a second editor. Coreflux HUB LoT Editor sidebar Library with visual component types grouped as containers, data viz, input, geo, and media, Resources Documents showing site-notes.md and Add file, Images count 1, SampleProcessor traces in the notebook ### Components **Components** is the palette of live widgets you can drop onto a **markdown** cell. Each row is a type (Gauge, Chart, Button, Map, …) with its group on the right. Drag a row onto a markdown cell to embed a viewer bound to an MQTT topic. The Sample notebook's live preview is this capability in use: a card of gauge, status, and trend sitting in the document. Coreflux HUB LoT Editor markdown cell titled Live document preview with a Sensor overview card showing a gauge at 65.0, status normal, and a live trend chart Types you can drop today, by group: | Group | Components | | -------------------- | ------------------------------------------------------------------------------------ | | **Display** | Gauge, Indicator, Value, Text, Header | | **Containers** | Card, Frame | | **Data viz** | Chart, Realtime Chart, Table, List | | **Input** | Button, Switch, Toggle, Slider, Input, Text Field, Number Field, Select, Date Picker | | **Geo / spatial** | Map, Markers, Trace | | **Media / graphics** | SVG, Image, Video, Camera, Audio, Barcode | Widgets in the notebook publish as panels when the document is saved, so they are the same component types Dashboards uses — authored next to the LoT that feeds them. ### Resources: documents and images **Resources** nests **Documents** and **Images**. These are files in the project, not a separate scrapbook you have to remember to Push. Coreflux HUB LoT Editor Library Resources with Documents expanded to site-notes.md and Add file, Images collapsed with count 1 | Item | What it holds | How you add it | What drop onto markdown does | | ------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------- | | **Documents** | `.txt`, `.md`, and `.pdf` files in the **open project** | **Add file…**, or project / folder **Import file…** | Inserts a relative markdown link | | **Images** | Project images (PNG, JPEG, SVG, GIF, WebP, and similar) | **Add file…** under Images, **Import file…**, or drag an image onto a project | Inserts a relative markdown image | Click a document or image in Library to open it in the main pane (it replaces the notebook until you go back). Hover **Remove** on a Library leaf to delete that file from the list. Right-click a project or folder for **Import file…** when you want the file to live under a specific folder rather than the Library picker. Dropping a Library image onto a project copies it into that project. ## Standalone Deployments vs Projects The broker can run LoT **with** a live project, or **without** one. Broker runtime tells you which: | Mode | Broker runtime label | What it means | | ---------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Live project** | **`Project: Sample`** (the live project's name) | At most one project is **LIVE**. Deployments from that project auto-commit. **Activate** / **Deactivate** on the project row choose which stored project is active | | **Standalone** | **`Standalone`** | No project is loaded. Entities can still be running — you deployed cells with nothing live, or leftovers that no project owns. Auto-commit is off | Standalone is a normal state: a scratchpad, the view after **Deactivate**, or entities deployed outside a project (another editor, an older session, or a cell run with nothing LIVE). How the two interact: * **Run cell / Deploy All** with nothing LIVE writes straight into standalone runtime. The toolbar stays a direct action. * **Deploy All** from an idle project on the broker asks you to **Activate** first — it does not silently load that project. * **Deploy All** from a local-only draft while another project is LIVE is refused (the toast points you at **Push**). Deploying those cells would write into the other live project. * **Activate** loads a stored project's declarations. If the broker publishes entity ownership, only that project's entities stop; standalone leftovers stay. Without ownership, the confirm says the current project's entities are replaced. * **Deactivate** unloads the live project and **removes its entities from running**. Auto-commit stops until you Activate again. Broker runtime then reads **Standalone** (plus anything that was never owned by that project). The confirm is explicit: *Deactivating **BMS** unloads it from the broker and **removes its entities from running**.* * **Adopt into project** (Broker runtime context menu) writes selected running entities into a notebook and Pushes — that is how you turn a standalone set into a project. ## Moving Work Between Projects and the Broker Right-click a project under **Projects** for operations on that row. A **LIVE** project and an idle on-broker project do not offer the same items (idle has **Inspect** and **Activate**; live has **Deactivate** and the full create/import set). Context menu for a project in the Coreflux HUB LoT Editor with New notebook, Push project to broker, Pull project from broker, Version history, Inspect, Activate, Deactivate, Rename, Delete local copy, and Remove project from broker | Item | What it does | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`New notebook`** / **`New folder`** | Add a `.lotnb` file or a folder inside the project | | **`Push to broker`** | Send the whole project — every notebook and folder — to the broker. This is what links a local project to a broker project and puts it under version control | | **`Pull from broker`** | Replace your local copy with the broker's. If you have unsaved local changes, a **"Pull from broker?"** prompt warns you first | | **`Version history`** | Open the broker's git history for this project | | **`Import .lotnb`** / **`Import file…`** | Bring a notebook or other file from outside into this project | | **`Inspect`** | Download the broker's copy of an idle on-broker project into the editor (does not Activate) | | **`Activate`** / **`Deactivate`** | Load or unload the stored project as the live one — see [Standalone vs projects](#standalone-deployments-vs-projects) | | **`Rename`** | Rename the local project | | **`Delete local copy`** | Delete the HUB notebooks only | | **`Remove project from broker`** | Permanently delete the project from the broker | Click **`Project: {name}`** to open the inspector table (status, type, name, owning project, notebook). A row with empty **Project** / **Notebook** is a standalone leftover — see [Standalone vs projects](#standalone-deployments-vs-projects). Right-click **Broker runtime** for operations on the running set: Context menu on Broker runtime in the Coreflux HUB LoT Editor with Adopt into project, Open in LoT Flow, Download all entities to notebook, and Remove all entities from broker | Item | What it does | | ---------------------------------------- | ----------------------------------------------------------------------- | | **`Adopt into project`** | Write the selected (or all) running entities into a notebook, then Push | | **`Open in LoT Flow`** | Open the live graph of this runtime | | **`Download all entities to notebook…`** | Copy running definitions into a `.lotnb` file | | **`Remove all entities from broker…`** | Undeploy the running set. Internal Rules stay | ## Version History Broker projects are git repositories, and the editor exposes enough of that to recover from mistakes without a terminal: Coreflux HUB version history dialog headed Sample with main at f5394a5, RECENT COMMITS showing f5394a5 Initial project commit with Restore, and a Branch / tag / commit field with Checkout, Pull, and Push The dialog reports the project's branch and current commit, lists recent commits with their messages, and shows any uncommitted changes on the broker. From here you can: * **Restore a commit** — checks that commit out on the broker and reloads the workspace if the project is active * **Checkout a branch, tag, or commit** — type a ref and check it out * **Pull** and **Push** — synchronise with the broker's configured git remote **Entity changes auto-commit while the project is active.** You do not commit by hand — deploying an entity produces a commit like `Update route: BacnetPortoHq`, which is why the history reads as a log of deployments. Restoring a commit can discard uncommitted changes on the broker. Check the **Uncommitted changes** list in the same dialog before restoring. ## Pulling Entities Back Into a Notebook Deployment is not one-way. Anything running on the broker can be brought back into a notebook, which is how you adopt LoT that was deployed by other means. Right-click an entity **type group** under **Broker runtime**: Context menu on the Models entity type group in the Coreflux HUB LoT Editor showing Download all models to notebook… and Remove all models… in red Every type group carries its own pair, and the `Entities` group itself carries **`Download all to notebook…`** and **`Remove all from broker…`** for the whole set. Right-clicking a **single entity** offers **`Open in code editor`**, **`Download to notebook…`**, and **`Remove from broker…`**. Any download asks **"Choose notebook"** — pick an existing `.lotnb` file in the project, or create a new one — and the definitions arrive as cells you can then edit and redeploy. ## Removal: Four Different Operations Deleting locally and removing from the broker are different operations with different consequences, and the HUB guards them differently on purpose. Read the dialog: it tells you which one you are in. | Trigger | Dialog | What it destroys | Guard | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------- | | Workspace project → **`Delete local copy`** | **"Delete project?"** — local notebooks and folders only | Your local copy only | Plain confirm | | Single entity → **`Remove from broker…`** | **"Remove from broker?"** — *Remove action "SummarizeF2Hvac" from the broker. This cannot be undone.* | One running entity | Plain confirm | | `Entities` → **`Remove all from broker…`** | **"Remove all from broker?"** — *Remove 3 action(s), 3 model(s), 1 route(s), 1 python script(s), 0 theme(s), and 0 panel(s) from the broker. Project-owned rules are also cleared; built-in and operator custom rules are preserved.* | Everything loaded on the broker | Plain confirm, but it enumerates live counts — read them | | Broker project → **`Remove from broker…`** | **"Remove project from broker"** — *This permanently deletes **BMS** from the broker — all its files **and its entire git history**. This cannot be undone.* | The project, its files, and every commit | **Type-to-confirm** — the button stays disabled until you type the project name | **Deleting a workspace project does not touch the broker, and removing a project from the broker does not touch your workspace.** A local delete leaves every entity running and every commit intact; a broker removal leaves your notebooks on disk but destroys the project's git history. Neither is a substitute for the other, and only the broker removal is guarded by type-to-confirm. Coreflux HUB dialog titled Remove project from broker in red, warning that this permanently deletes BMS from the broker including all its files and its entire git history and cannot be undone, with a Type BMS to confirm field and a greyed-out Remove permanently button beside Cancel The bulk-remove wording also settles a question the sidebar raises: **project-owned rules are cleared, while built-in and operator custom rules are preserved.** That is why the `Rules` count under **Broker runtime** can be small while **Configuration → Internal Rules** lists the broker's locked built-ins separately. A `.lotnb` file is the safety net worth having before any removal. **`Download all to notebook…`** on the `Entities` group captures everything currently deployed, and **`Export open notebook as .lotnb`** in the header `…` menu writes it out as a file. ## Editing the Same Project From VS Code The in-HUB editor and VS Code share the same broker-held project and git history: a project pushed from the HUB opens in the VS Code extension and vice versa, and `.lotnb` files move between them directly: * **`Import .lotnb`** on a project, or **`Import .lotnb file`** in the header `…` menu, brings a notebook from outside into that project * **`Export open notebook as .lotnb`** takes the open notebook back out * **`Push to broker`** and **`Pull from broker`** move the whole project, which is how two people using two editors stay on one project Whichever tool deployed an entity, it appears the same way under **Broker runtime** and is drawn the same way in [LoT Flow](/hub/lot-flow). *** ## Next Steps See the entities you just deployed as a live dependency graph. Edit the same broker-held project from VS Code, then Push and Pull to stay in sync. # LoT Flow Source: https://docs.coreflux.org/hub/lot-flow See the Actions, Models, Routes, and topics deployed on your broker as a live dependency graph Coreflux HUB LoT Flow with the Settings tab open on the left showing Canvas options: Legend off, Minimap off, Topic update flash on, Edge style Bezier, Spacing Compact, Group large branches on, Collapse over 1, and Collapse all / Expand all buttons; the canvas shows write topics feeding the BacnetPortoHq BACNET route, collapsed topic-group nodes such as uns/porto/hq/f2/hvac with a 4/4 count and uns/porto/hq/f2/energy with 2/2, three Action nodes SummarizeF2Energy, SummarizeF2Lighting, and SummarizeF2Hvac badged ON TOPIC, and Model nodes F2EnergyModel, F2LightingModel, and F2HvacModel; a freshness indicator, Reset layout, and Open LoT Editor sit top right of the canvas ## Why Use LoT Flow? Once you have more than a handful of Actions, Models, and Routes on a broker, the question "what happens when this topic changes?" stops having an obvious answer. Tracing it means opening each definition and following topic names by hand. LoT Flow reads the LoT entities deployed on your broker and draws how they connect — what triggers what, and which topics each one reads and writes — so you can follow the data flow instead of reconstructing it from source. LoT Flow carries a **`Beta`** badge in its window header. It is available for everyday use, but its controls and layout may still change between releases. **Like a wiring diagram that draws itself.** You never maintain the diagram — it is rebuilt from whatever is actually running on the broker. ## When to Use It * **Understanding an inherited project** — See the whole entity graph before changing any of it * **Answering "what does this topic affect?"** — Follow trigger edges from a topic to the Actions it fires * **Confirming a deployment** — Check that a newly deployed Action appears, and that it is wired to the topics you intended * **Finding orphans** — Spot entities that nothing triggers and topics that nothing consumes * **Reviewing an integration** — See which topics a Route publishes into your namespace and which it writes back out * **Debugging execution order** — Follow trigger and event edges to understand what runs before what *** ## LoT Flow Reads the Broker, Not Your Workspace This is the single most important thing to know about LoT Flow: **the graph is built entirely from entities deployed on the connected broker.** The LoT Editor's sidebar is split into two halves that are easy to conflate: | Sidebar section | What it holds | | --------------- | ---------------------------------------------------------------------------------------- | | **Workspace** | Notebook files local to your browser profile. Editing them changes nothing on the broker | | **Broker** | `Projects`, `Entities`, `Configuration`, and `Library` — shared server state | A notebook cell you have written but not yet deployed is marked **`NOT RUNNING`** in the editor, and it will **not** appear in LoT Flow. Deploy it — with **Deploy All**, or by running the individual cell — and it appears. Cells already live on the broker are marked **`DEPLOYED`**. If an entity you just wrote is missing from LoT Flow, check that it was deployed. **Broker runtime** in the LoT Editor is the authoritative list of what is actually running on the broker, and the graph matches that list. Because the graph reflects the broker rather than any one editor, it does not matter which tool produced the LoT. Entities deployed from the [LoT Editor in the HUB](/hub/lot-editor) and entities deployed from VS Code appear the same way. *** ## Accessing LoT Flow Select **LoT Flow** in the HUB dock. The window header carries a **`Beta`** badge. The canvas top-right cluster is the layout chrome: | Control | What it does | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Freshness** (`3m ago`) | How long ago the graph was last built from the broker | | **Reset layout** | Auto-arranges every node from scratch — use it after collapsing topics, after dragging nodes out of place, or whenever the graph looks tangled. Tooltip: *Auto-arrange LoT Flow from scratch* | | **Open LoT Editor** | Opens the editor so you can change the entities this graph is drawn from | Zoom, pan, **Fit View**, and **Download image** sit at the bottom-left of the canvas. Fit the view after Reset layout or after changing grouping — a freshly laid-out graph is often off-centre. ## The Entity Rail The **Entities** tab groups everything deployed, with a count per group and a **Find entity** search for jumping straight to one by name: | Group | Contents | | ----------- | ------------------------------------------------------------ | | **Actions** | Event-driven logic, each badged with its trigger type | | **Models** | Data schemas that map topic values into structured documents | | **Routes** | Connections to external systems, badged with the route type | | **Python** | The individual Python functions your Actions call | Selecting an entity focuses it on the canvas and opens its detail panel. The **Python** group lists the individual *functions* invoked from Actions, so its count is usually higher than the number of Python *scripts* deployed. A single script exposing four functions appears as one entity under **Broker runtime** in the LoT Editor and as four nodes here. ## Reading the Graph The canvas draws your entities, the topics that wire them together, and the Python they call: | Node | Meaning | | --------------- | -------------------------------------------------------------------- | | **Action** | Event-driven logic, badged with its trigger (for example `ON TOPIC`) | | **Model** | A data schema, badged `Model` | | **Route** | An external connection, badged with its type and a health indicator | | **Python** | A Python function, badged with the script it belongs to | | **Topic** | An MQTT topic, showing its current value | | **Topic group** | Many topics sharing a prefix, collapsed into one node with a count | Edges are colour-coded by relationship — solid lines are live data flow, muted dashed lines are structure. The vocabulary covers `publish`/`sub`, `trigger`, `write`, `event`, `python call`, `dependency` (a value read but not triggered on), `internal state`, `extends`, and grouping links. Turn on the **Legend** from the Settings tab while you are still learning it. LoT **Rules** are not drawn on the canvas. Rules govern access control rather than moving data, so the graph focuses on Actions, Models, Routes, and Python. Rules deployed on your broker still appear under **Broker runtime** in the LoT Editor. ### How a single Action becomes a subgraph The Action below reads four topics, calls two Python functions, and publishes a Model to a status topic: ```lot wrap focus={7-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SummarizeF2Hvac ON TOPIC "uns/porto/hq/f2/hvac/temp" DO SET "temp" WITH (GET TOPIC "uns/porto/hq/f2/hvac/temp" AS DOUBLE) SET "humidity" WITH (GET TOPIC "uns/porto/hq/f2/hvac/humidity" AS DOUBLE) SET "occupied" WITH (GET TOPIC "uns/porto/hq/f2/hvac/occupied" AS BOOL) SET "setpoint" WITH (GET TOPIC "uns/porto/hq/f2/hvac/setpoint" AS DOUBLE) CALL PYTHON "BuildingInsights.comfort_score" WITH ({temp}, {humidity}, {setpoint}) RETURN AS {score} CALL PYTHON "BuildingInsights.comfort_label" WITH ({score}) RETURN AS {label} PUBLISH MODEL F2HvacModel TO "uns/porto/hq/f2/hvac/status" WITH temp = {temp} humidity = {humidity} occupied = {occupied} setpoint = {setpoint} comfort_score = {score} comfort_label = {label} timestamp = TIMESTAMP "ISO" ``` On the canvas that becomes one Action node with a trigger edge from `temp`, dependency edges from the three topics it reads, `python call` edges to both `BuildingInsights` functions, and an edge to the Model that publishes the result. You can read the same relationships from the other direction in the [UNS Viewer](/hub/mqtt/uns-viewer), where each topic reports the Actions and Routes that produce and consume it. ## Inspecting an Entity Select any node, or any entity in the rail, to open its detail panel. Coreflux HUB LoT Flow with SummarizeF2Hvac selected in the Entities rail, which lists Actions 3, Models 3, Routes 1, and Python 4; the right-hand detail panel shows the SummarizeF2Hvac Action with a Receiving data indicator, Trigger ON TOPIC, Enable Trace, Inputs 4 and Outputs 1 for hvac/, a Publish a test value control targeting uns/porto/hq/f2/hvac/temp, and a LoT Code block beginning DEFINE ACTION SummarizeF2Hvac; the canvas behind shows topic groups such as uns/porto/hq/f2/hvac Depending on the entity type, the panel reports: * **Its trigger** and a live status such as `Receiving data` * **Inputs and outputs** — the topics it consumes and produces, with counts * **The deployed LoT source**, syntax-highlighted, so you can read what is actually running * **Route status** for Route entities, including connection health * **Trace** — capture this Action's executions to see it run. Traces auto-expire after five minutes * **Publish a test value** — send a value to one of the entity's topics to exercise it **Trace answers "did it actually run?"** Rendering a graph proves an Action is deployed and wired up; a trace proves it fired. ## Keeping Large Graphs Readable The **Settings** tab is the Canvas menu — grouping, look, and collapse live here. Layout itself is the **Reset layout** control at the top-right of the canvas, not in this list. | Option | What it does | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Legend** | Puts a key for node and edge types on the canvas | | **Minimap** | An overview map. Leave it off on a wide graph — it parks in the bottom-right and can cover real nodes | | **Topic update flash** | Pulses a topic node when a new value arrives | | **Edge style** | Smooth step, Bezier, Step, or Straight. This is a persisted preference, not a fixed default | | **Spacing** | Compact, Comfortable, or Spacious. Also persisted | | **Group large branches** | Fold sibling topics that share a prefix into one topic-group node with a count | | **Collapse over** | How many children a branch needs before it groups. `1` groups every sibling set, which is the most readable hero view on a busy namespace. The field accepts `1`–`50` | | **Collapse all** / **Expand all** | Collapse or expand every grouped branch at once | After you change grouping, click **Reset layout** (top-right of the canvas) so the remaining nodes are placed again, then **Fit View**. Collapsing without resetting leaves the old positions and a lot of empty space. Topic grouping is the most effective control on a busy namespace: **Collapse over** `1` folds each floor/device prefix — `…/f2/hvac/` `4/4`, `…/f2/energy/` `2/2` — into one node. The relationships stay on the graph; only the leaf clutter goes. *** ## Next Steps Explore the topic namespace those entities read from and write to. Create and deploy the entities that appear in this graph, cell by cell. # Connected Clients Source: https://docs.coreflux.org/hub/mqtt/connected-clients View active MQTT client connections and their status in the Coreflux HUB Coreflux HUB Connected Clients view listing two active MQTT v4 connections, coreflux-controller-a4d3f1ef at 127.0.0.1:32946 with 20m duration and 31.31 MB traffic and mqtt-explorer-05b06873 at 127.0.0.1:14107 with 40m duration and 36.23 MB traffic, and an empty detail panel on the right prompting to select a client to view details ## Why View Connected Clients? You need to know which clients are connected to your broker—devices, applications, or the Coreflux HUB itself. The Connected Clients view shows all active MQTT connections with detailed connection info, traffic statistics, session data, and last activity so you can verify connectivity and troubleshoot disconnections. ## When to Use It * **Verifying device connections** — Confirm that sensors or gateways are connected * **Troubleshooting** — Check if a client has disconnected or failed to connect * **Monitoring** — See how many clients are using the broker and their traffic * **Debugging** — Inspect session state, pending messages, and last activity timestamps *** ## Connected Clients Interface The Connected Clients view has two panels: a client list on the left and a detail panel on the right. Select a client to view its connection information. Coreflux HUB Connected Clients detail view for the selected coreflux-controller-a4d3f1ef client showing Connection Information with protocol version MQTT v4, client IP 127.0.0.1, source port 32946, full endpoint 127.0.0.1:32946, connected timestamp and 20m duration; Traffic Statistics with 9.39 KB received and 31.17 MB sent; and Session Information with session ID, created timestamp, 0s expiry interval, and 0 pending messages *** ## Client List (Left Panel) The left panel lists all clients currently connected to the broker. For each client you see: | Field | Description | | ----------------------- | --------------------------------------------------------------------------- | | **Client ID** | Unique identifier for the connection (e.g., `coreflux-controller-0e1e882c`) | | **Endpoint** | Client IP and port (e.g., `127.0.0.1:62983`) | | **Connection Duration** | How long the client has been connected (e.g., `40m`) | | **Total Traffic** | Estimated bytes received and sent combined (e.g., `15.16 MB`) | | **Protocol** | MQTT version (e.g., `MQTT v4`) | Select a client to view its detailed information in the right panel. ## Client Details (Right Panel) When you select a client from the list, the right panel shows four sections: ### Connection Information | Field | Description | | -------------------- | ----------------------------------- | | **Protocol Version** | MQTT version (e.g., MQTT v4) | | **Source Port** | Client's source port | | **Connected** | Timestamp when the client connected | | **Client IP** | Client IP address | | **Full Endpoint** | IP:port (e.g., `127.0.0.1:62983`) | | **Duration** | Connection duration (e.g., `40m`) | ### Traffic Statistics | Direction | Fields | | ------------ | ------------------------ | | **Received** | Bytes, Packets, Messages | | **Sent** | Bytes, Packets, Messages | Use these to see how much data the client has exchanged with the broker. ### Session Information | Field | Description | | -------------------- | --------------------------------------------------- | | **Session ID** | Session identifier (typically matches Client ID) | | **Created** | Timestamp when the session was created | | **Expiry Interval** | Session expiry (e.g., `0s` for no expiry) | | **Pending Messages** | Number of messages awaiting delivery to this client | | **Status** | Connection status (e.g., Connected) | ### Last Activity | Field | Description | | ------------------------ | ----------------------------------------------------- | | **Last Packet Received** | Timestamp of the last packet received from the client | | **Last Packet Sent** | Timestamp of the last packet sent to the client | | **Last Non-KeepAlive** | Timestamp of the last non-keepalive packet | ## Refreshing the List The view updates as clients connect and disconnect. Use the refresh control if you need to force an update of the displayed list. The detail panel shows an "Updated" timestamp indicating when the data was last refreshed. *** ## Next Steps Explore topics and publish messages. Understand MQTT client connections and sessions. # Data Viewer Source: https://docs.coreflux.org/hub/mqtt/data-viewer Subscribe to topics, view the topic tree, and publish messages from the Coreflux HUB Coreflux HUB Data Viewer showing the Publish panel on the left with topic input, QoS selector, Retain toggle, and payload field; the Topic Tree expanded to $SYS/Coreflux/Resources with MemoryInfoTotal selected; and a live chart of Resources/MemoryInfoTotal values streaming on the right ## Why Use the Data Viewer? You need to see what data is flowing through your broker—which topics are active, what payloads look like, and how to send test messages. The built-in Data Viewer in the Coreflux HUB lets you subscribe to topics, browse the topic tree, and publish messages without installing an external MQTT client. **An MQTT data viewer built into your Coreflux.** Designed to feel as intuitive as the popular MQTT Explorer desktop app — but with zero installation, right inside the HUB. ## When to Use It * **Viewing device data** — Subscribe to sensor or device topics to see live readings (temperature, status, etc.) as they arrive * **Exploring your topic structure** — Browse the Topic Tree to understand which topics exist and how they are organized * **Verifying Actions** — Subscribe to output topics to confirm your LoT Actions are publishing correctly * **Debugging** — Inspect payloads and topic structure in real time * **Testing** — Publish messages to trigger Actions, test Routes, or simulate device data * **Monitoring broker activity** — Watch `$SYS` topics to check broker health, connected clients, and message throughput *** ## Accessing the Data Viewer 1. In the HUB top navigation, select **MQTT**, then **Data Viewer**. 2. The Topic Tree appears on the left, showing topics as they receive messages. 3. Select a topic to view its payloads in the right panel. Animated walkthrough showing how to open the Data Viewer from the Coreflux HUB top navigation ## Topic Tree The Topic Tree shows the hierarchical structure of topics. Topics appear as messages are received. Expand branches to navigate and select a topic to view its payload history in the payload viewer. Coreflux HUB Data Viewer with $SYS/Coreflux expanded showing Actions, Command, Comms, Config, Log, Models, Python, Resources, Routes, Rules, Visus, Assets, License, and Version subtopics; Routes expanded to BrokerAgent, SiemensS7Route, and Terminal; SiemensS7Route selected with its LoT route definition displayed in the payload viewer and a live message timeline above ## Exploring Live Data The Data Viewer is laid out as three vertical panels: **Publish** on the left, the **Topic Tree** in the middle, and a **Detail area** on the right where you build a personal view of the topics you care about right now. You can drag the divider between the tree and the detail area, and collapse the Publish panel into a thin rail to give yourself more room. **Like an instrument panel for your broker.** Instead of reading rows of raw messages, you pin the topics that matter to the right side and watch them update — values, charts, lamps, even maps — in real time. ### Previewing a topic Get a feel for what is happening on the broker without committing to a layout. Click any topic in the tree and its latest payload appears in the Detail area as a quick preview. Each row in the tree carries a small **type icon** that tells you at a glance what kind of value the topic currently holds: | Icon | Meaning | | ------------------ | --------------- | | Glowing orange dot | Boolean `true` | | Empty dot | Boolean `false` | | `#` | Number | | `{}` | JSON object | | `[]` | JSON array | | `"` | Plain string | Rows briefly **flash orange** whenever a new message arrives, so you can see broker activity without staring at every value. To narrow the list, type any part of a topic path into the **Filter** box at the top of the tree — the filter matches anywhere in the path, and the usual display cap is lifted while you are filtering. For broader monitoring, you can also subscribe with a wildcard pattern such as `sensors/#`, and every matching topic appears in the tree as messages come in. ### Tracking what matters Once a topic is worth watching, **pin it**: double-click the topic, or drag it from the tree onto the Detail area. A card appears for it, and the card shape adapts to the payload so you don't have to mentally decode raw values: * **Numbers** render as a large value with a **sparkline** of recent history — perfect for sensor readings like temperature or RPM. * **Booleans** render as a glowing **lamp** — green when `true`, dim when `false`. * **JSON and text** render with colored keys and an expandable tree, so nested objects stay readable. * **Geolocation** payloads (latitude/longitude) render as a small **map** with the current point and a trail of recent positions, plus the raw payload underneath. Cards can be reordered by dragging them up or down, copied to the clipboard with one click, and removed with the **X** in their corner. Add more topics any time from the dashed **+ Add topic** button at the bottom of the cards, which opens a searchable list of every known topic. Coreflux HUB Data Viewer with the Topic Tree on the left expanded to kpi/operational showing messageCount, messagesPerMinute, and status with their inline values; on the right a tracked card for operational/messageCount displays the value 113 with a sparkline trail, a timeline above shows 197 message marks, and an + Add topic button sits below the card ### Replaying history with the timeline As soon as you are tracking topics with more than one message of history, a **timeline** appears at the top of the Detail area. Every tracked topic gets its own color, and every message it has received is drawn as a colored mark on a horizontal track. From there you can: * **Click or drag** anywhere on the track to scrub to a moment in time — every card below jumps to the values they had then. * Step message-by-message with **First / Previous / Next / Last**. * Hit **Play** to replay the message history like a video, with cards animating through every value. * Tap **Live** to snap back to the latest message and resume real-time updates (the label turns green when you are caught up). * Open the gear icon to change the **history buffer** per topic, from 50 up to 2 000 messages — larger buffers give you more scrub range. While the viewer is focused, the arrow keys step through messages, **Home** jumps to the first, and **End** jumps back to live. Coreflux HUB Data Viewer timeline showing two topic chips for operational/messa... with 225 messages each in orange and purple, First/Previous/Play/Next/Last playback controls, a green Live indicator, the position counter 450 / 450 with timestamp 15:38:31.796, and a horizontal track with orange and purple message marks spanning from 15:27:30.810 to 15:38:31.796 ### Drilling into payloads When a payload is too big to read inline, click its preview in the tree to open the **payload inspector** — a floating window that parses the full payload and lets you expand JSON to any depth. If the payload contains coordinates, the inspector also draws a small map at the top. For payloads where you only care about one number, drag a single value out of the inspector (or out of a card's expanded JSON tree) onto the Detail area. That value becomes its own **extraction card** — a small card that tracks just that one field, sparkline included if it's numeric. It's the fastest way to follow `temperature` from a 30-field sensor payload without the surrounding noise. Coreflux HUB Data Viewer payload inspector floating over the Topic Tree, displaying the $SYS/Coreflux/Config payload with fields such as Name, Port 1883, TlsPort 8884, WebsocketPortTls 443, AnonymousLogin true, and several certificate paths, each with its key highlighted and value formatted by type ### Drag-and-drop cheatsheet Most of the viewer is built around dragging. While you are dragging, valid drop zones light up so you can't miss them. | Drag this | Onto this | Result | | --------------------------- | -------------------------------- | ------------------------------------------------ | | A topic in the tree | The Detail area | Adds a tracked card for that topic | | A topic in the tree | The timeline's topic chips | Adds the topic to the timeline only | | A topic in the tree | The Publish panel's Topic input | Pre-fills the publish topic | | A topic in the tree | The Publish panel's Payload area | Pastes the topic's current value | | A JSON value from a payload | The Detail area | Creates an extraction card for that single field | | A JSON value from a payload | The Publish panel's Payload area | Pastes just that value | ## Publishing Messages To publish a test message: 1. Select or enter the target topic. 2. Enter the payload (plain text or JSON). 3. Click **Publish** to send the message. Use this to trigger Actions, test route configurations, or simulate device data. Coreflux HUB Data Viewer showing a published message to $SYS/Coreflux/Command with payload -h, a Message sent success toast, and the sent message displayed in the payload viewer *** ## Next Steps See which clients are connected to your broker. Learn how MQTT topics and wildcards work. # MQTT Overview Source: https://docs.coreflux.org/hub/mqtt/overview Browse topics, publish messages, and see who's connected — all from your Coreflux HUB Coreflux HUB Data Viewer showing the publish panel on the left, the topic tree expanded to $SYS/Coreflux/Resources, and the Resources/MemoryInfoTotal topic plotted live on the right ## Your MQTT Dashboard Welcome to your built-in MQTT toolbox. From here you can browse every topic on your broker, publish test messages, and check which clients are online — all without leaving the Coreflux HUB. Two widgets are ready for you: **Data Viewer** for topics and messages, and **Connected Clients** for monitoring connections. **No need to install external tools** — the Data Viewer is built right into the Coreflux HUB. ## Pick the Right Widget | Widget | You want to... | | --------------------- | --------------------------------------------------------- | | **Data Viewer** | Browse topics, inspect payloads, or publish test messages | | **Connected Clients** | See who's connected and check connection health | *** ## Next Steps Subscribe to topics, browse the topic tree, and publish messages. View active MQTT client connections and their status. # UNS Viewer Source: https://docs.coreflux.org/hub/mqtt/uns-viewer Explore your broker's topic namespace as a live hierarchy graph in the Coreflux HUB Coreflux HUB UNS Viewer with the Namespace rail on the left showing Root filter, Include $ topics, Expand depth, and Collapse all / Expand all controls plus Canvas options for Legend, Minimap, Edge style and Spacing; the canvas graphs a namespace from Root through uns, porto, and hq into floors f1 and f2, with hvac, energy, and lighting branches carrying descendant counts and leaf topics showing live values including setpoint 21.50, occupied true, humidity 39.20, temp 22.55, power_kw 9.19, and energy_kwh 160.12; a 34 topics badge sits top right ## Why Use the UNS Viewer? You have hundreds or thousands of topics on your broker and you need to understand their *shape* — how the namespace is organised, which levels exist, and where a given device or metric sits in the hierarchy. Reading that structure from a flat list of topic strings is slow and error-prone. The UNS Viewer draws your broker's namespace as a hierarchy graph, so you can see the structure at a glance instead of reconstructing it in your head. The UNS Viewer carries a **`BETA`** badge in its window header. It is available for everyday use, but its controls and layout may still change between releases. **Like a map of your factory instead of a list of street names.** A topic list tells you what exists; the namespace graph shows you how it all fits together. ## When to Use It * **Understanding an unfamiliar broker** — Open the namespace and read the hierarchy top-down instead of guessing at topic conventions * **Reviewing your namespace design** — Check that a new site, line, or device was published under the level you intended * **Finding where a metric lives** — Search for a name and see which branch of the hierarchy it belongs to * **Tracing a topic back to your logic** — Each leaf reports which Actions and Routes produce or consume it * **Comparing several topics at once** — Select multiple topics and watch their values and history side by side * **Spotting activity** — Live pulses show which parts of the namespace are actually receiving traffic *** ## Accessing the UNS Viewer Select **UNS Viewer** in the HUB dock. The window opens with the namespace already built from the topics the HUB has seen, and a `N topics` badge in the top-right corner reports how many leaf topics are currently in scope. ## Reading the Graph The canvas draws two kinds of node: | Node | Shape | Meaning | | ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------- | | **Namespace node** | Square | A level in the hierarchy. Carries a count of everything beneath it, and a chevron to expand or collapse its children | | **Leaf topic** | Rounded | An actual MQTT topic. Shows its current value inline | Colour and motion carry meaning too, and the **Legend** toggle puts a key on the canvas while you are learning the vocabulary: * **Live value** — a green pulse means a message just arrived on that topic * **Activity pulse** — a green glow on a namespace node shows aggregated traffic somewhere below it * **Search match** — an amber ring marks topics matching your current search Only leaf topics carry payloads. Selecting a namespace node reports `No message at this time`, because the values live on its children. ## Navigating the Namespace The **NAMESPACE** section of the left rail controls how much of the tree is drawn: | Control | What it does | | --------------------------------- | -------------------------------------------------------------------------------------------------- | | **Root filter** | An MQTT filter that scopes the whole view, `#` by default. The `N topics` badge follows the filter | | **Include \$ topics** | Brings broker-internal `$SYS` and `$Visu` topics into the graph, which are hidden by default | | **Expand depth** | How many levels are expanded when the graph is built. Defaults to `3` | | **Collapse all** / **Expand all** | Collapse the whole tree, or expand every level at once | The **Root filter** applies when you press **Enter** — typing a filter alone does not redraw the graph. On a large namespace, narrowing the root filter to a subtree such as `uns/porto/hq/f1/#` is the fastest way to keep the graph readable. The **Search topics** box highlights matching topics in place with an amber ring rather than filtering the graph, so you keep the surrounding structure as context while you locate something. Coreflux HUB UNS Viewer with the Root filter set to uns/porto/hq/f1/#, an 8 topics badge, Expand depth 7, and the Legend and Minimap switches turned on; the canvas shows only the f1 floor with hvac, energy, and lighting branches and live values setpoint 21.50, humidity 48.60, temp 22.63, power_kw 6.59, energy_kwh 160.47, enabled true, and level 13, with a legend reading Live value, Activity / update pulse, and Search match at the bottom left and a minimap at the bottom right ### Canvas options The **CANVAS** section changes how the graph is drawn without changing what it contains: | Option | Choices | | -------------- | ----------------------------------------------------------------- | | **Legend** | Show or hide the node and edge key | | **Minimap** | Show or hide an overview map for staying oriented in large graphs | | **Edge style** | Smooth step, Bezier, Step, or Straight | | **Spacing** | Compact, Comfortable, or Spacious | Zoom, pan, and a **fit-view** control sit at the bottom-left of the canvas. Fit the view after expanding or filtering — a freshly laid-out graph is often off-centre. *** ## Inspecting a Topic Select a leaf topic to open the inspector on the right. It reports the topic's breadcrumb path, its live value, its history, and how it relates to the LoT running on your broker. Coreflux HUB UNS Viewer with the topic inspector open for uns/porto/hq/f2/hvac/temp, showing a breadcrumb of uns, porto, hq, f2, hvac, temp, First/Previous/Play/Next/Last playback controls, a green Live toggle, a timestamp of 23:07:36.790 with position 250 / 250, an orange message histogram running from 23:03:27.788 to 23:07:36.790, a value card reading 20.61 with a sparkline, and three cross-reference rows reading Triggers action SummarizeF2Hvac, Referenced by action SummarizeF2Hvac, and Produced by route BacnetPortoHq The inspector gives you: * **A value card** with the current value and a **sparkline** of recent history * **A message histogram** with the start and end timestamps of the buffered history * **Playback controls** — step to first, previous, next, or last message, or play the history back * **A `Live` toggle** that snaps to the latest message and resumes real-time updates * **LoT cross-references** — which Actions the topic triggers, which reference it, and which Route produces it Those cross-references are the bridge between your namespace and your logic. A topic written by a Route and consumed by an Action shows all three relationships, and a topic that nothing uses reports `Not referenced by any action or route.` Cross-references answer "is anything actually using this topic?" — useful when cleaning up a namespace, or when confirming that a newly deployed Action really is wired to the topic you intended. ### Comparing several topics Turn on **multi-select** in the top-right corner to keep more than one topic selected. Each selection becomes a removable chip carrying its own message count, the inspector header counts them, and the histogram merges every selected topic onto one timeline so you can line their behaviour up against each other. Press `Esc` to leave multi-select. With multi-select off, a plain click replaces the current selection, while `Shift`, `Ctrl`, or `⌘` click adds to it. *** ## Namespace Best Practices The UNS Viewer makes the consequences of namespace design visible, and the shape it draws is only as good as the topics you publish: * **Keep the hierarchy consistent** — model your physical or organisational structure, so the graph reads like your plant * **Use lowercase, stable, descriptive segment names** — and avoid renaming a level once it is in use * **Publish state as retained messages** so late subscribers, including this viewer, get the current value * **Prefer descriptive leaf metrics** over opaque blobs that have to be parsed downstream * **Narrow the root filter** on large namespaces rather than fighting a graph with thousands of nodes The viewer builds its tree from the topics the HUB has observed. A topic that has never published, and holds no retained value, cannot appear in the graph. *** ## Next Steps See the Actions, Models, and Routes behind those topics as a dependency graph. Read individual payloads, track topics as cards, and publish test messages. # Coreflux HUB Overview Source: https://docs.coreflux.org/hub/overview Manage your Coreflux broker—explore MQTT topics, edit LoT code, configure routes, and monitor system health from the browser or the standalone desktop app Coreflux HUB home screen with central artwork, bottom app dock with the home icon selected, and status details in the header ## Why Use the Coreflux HUB? You need a single place to manage your broker without installing extra tools or connecting via the command line. The Coreflux HUB gives you an interface to explore topics, create and edit LoT (Language of Things) definitions, configure routes, and monitor system status—all from one dashboard. It is available as a browser app (its own container, via Docker Compose) or as a standalone desktop application for Windows and Linux. The Coreflux HUB is a **separate container** from the broker — it is not bundled in the broker image. Run it with **Docker** or **Docker Compose** (open `http://localhost:3000`) or as a **standalone desktop application** for Windows x64, Linux x64, and Linux ARM64. See [Installation](/latest/quick-start/installation) for Docker commands, Compose profiles, the HUB desktop app, and the login flow. **Like a control panel for your broker.** The HUB puts Data Viewer, LoT Editor, route management, and system tools in one place so you can deploy and debug without extra tooling. ## When to Use the HUB * **Manage everything from the HUB** — Configure your broker, deploy logic, and set up data routes without installing desktop tools * **Go from idea to deployment in minutes** — Write and deploy Actions, Models, and Rules directly in the built-in editor * **Monitor your system in real time** — See device connections, data flow, and system health at a glance * **Diagnose issues fast** — Inspect live MQTT messages and run broker commands from one interface ## Prerequisites Install the HUB (and the broker, if you do not already have one) from [Installation](/latest/quick-start/installation). After `docker compose --profile full up`, open `http://localhost:3000`, click **New Connection**, and set **Host** to `broker` (**Protocol** `mqtt://`, **Port** `1883`). The desktop app is on the **HUB desktop** tab of that page. ## How the HUB Is Organized The HUB is built around five apps. Each app groups related tools so you can switch between tasks without leaving the browser. Explore topics, publish messages, and view connected clients. Create and edit Actions, Models, and Rules in the browser. Build live panels with gauges, charts, and controls bound to broker data. Add, configure, and monitor routes with templates or LoT code. Broker configuration, user management, trace logs, and command console. ### MQTT Browse live data flowing through your broker. The MQTT app includes a **Data Viewer** for subscribing to topics and publishing test messages, and a **Connected Clients** view to see which devices are online. Coreflux HUB MQTT app showing Data Viewer with topic tree, publish panel, and Connected Clients list with client details ### LoT Editor Write and deploy logic directly in the HUB. The LoT Editor is a notebook editor: cells hold LoT, Python, or Markdown, each cell deploys on its own, and the sidebar lists everything currently running on the broker — no external tools required. Coreflux HUB LoT Editor showing the Sample notebook with a live sensor preview, Projects with Sample LIVE, and Broker runtime listing Project Sample ### Dashboards Design operator-facing screens from a component library—gauges, charts, industrial symbols, and controls—and bind each widget to live data on your broker. The **Dashboard Manager** lists your panels; open one to preview or use it full screen for walls and shared links. Coreflux HUB panel view titled S7 DB1 Dashboard with an orange gauge reading 100.0 and a ScanCounterTrend live line chart on a dark background ### Routes Connect your broker to databases, PLCs, and external systems. The Routes app shows each route as a card with a live **status indicator**, and lets you configure routes visually or in LoT code. Coreflux HUB Routes app showing multiple route cards in a row with type labels, green status dots, and toolbar icons on each card ### System Administer the broker itself. The System app provides **Broker Configuration**, **User Management**, a **Command Console** for direct broker commands, and **Broker Logs** for real-time diagnostics. Coreflux HUB System app showing System Config with General, Network, and Binding fields, plus the overlay app menu listing Data Viewer, LoT Editor, Routes, and system tools *** ## Next Steps Run the HUB with Docker, Compose, or the desktop app — then connect from New Connection. Save multiple brokers, secure them with TLS, and switch between them. Subscribe to topics and publish messages from the browser. # Route Configuration Source: https://docs.coreflux.org/hub/routes/configuration Edit route configuration, events, mappings, and tags in Visual or LoT mode Coreflux HUB Route Configuration panel showing Visual Mode and LoT Mode toggle with configuration, events, and mappings tabs ## Why Configure Routes in the Coreflux HUB? You need to adjust route settings—connection parameters, events, mappings, and tags—without editing files or publishing MQTT commands. The Route Configuration in the HUB lets you edit routes with a form-based Visual Mode or switch to LoT Mode to modify the raw LoT code by hand. ## When to Use It * **Updating connection settings** — Change IP, port, credentials, or other config fields * **Adding events or mappings** — Extend a route with new events, mappings, or tags * **Switching modes** — Use Visual Mode for guided editing or LoT Mode for full control For an overview of how to add routes, select route types, and use templates, see [Routes Overview](/hub/routes/overview). *** ## Visual Mode vs LoT Mode | Mode | Best For | | --------------- | ----------------------------------------------------------------- | | **Visual Mode** | Form-based editing with add/remove fields, modals, and validation | | **LoT Mode** | Direct LoT code editing for power users who prefer hand-editing | Toggle between modes using the **Visual Mode** / **LoT Mode** toggle at the top of the route configuration panel. Changes in either mode are applied when you click **Save Changes**. ### LoT Mode Switch to **LoT Mode** to view and edit the full route definition as LoT code. This gives you direct control over the syntax and is useful for copying configurations or making bulk changes. Coreflux HUB Route Configuration LoT source editor for SiemensS7Route showing syntax-highlighted DEFINE ROUTE SiemensS7Route WITH TYPE SIEMENS_S7 with an S7_CONFIG block (IP, CPU_TYPE, RACK, SLOT), a MAPPING TestPLC block with SOURCE_TOPIC, EVERY, and a Real TAG with ADDRESS, ADDRESS_TYPE, and DATA_TYPE, plus Cancel and Save Changes buttons in the bottom bar ### Renaming Elements You can rename routes, events, mappings, and tags directly from the configuration panel. Click the **pencil icon** next to any element name to turn it into an editable text field, type the new name, and press **Enter** to confirm. A **Renamed** badge appears briefly to confirm the change. This works the same way for all element types: * **Route name** — Displayed at the top of the configuration panel * **Event name** — Displayed in the Events tab header for each event * **Mapping name** — Displayed in the Mappings tab header for each mapping * **Tag name** — Displayed in the tag header within a mapping Renaming takes effect when you click **Save Changes**. If you cancel without saving, the original names are restored. *** ## Configuration Tab The **Configuration** tab shows the route's connection and protocol settings (e.g., `S7_CONFIG`, `SQL_CONFIG`, `MODBUS_CONFIG`). Each field displays its name, whether it is required or optional, its data type, and a description. Coreflux HUB Route Configuration tab for Route: SiemensS7Route with Configuration, Events, and Mappings tabs; Configuration is selected showing the S7 Configuration section with four fields — IP required string set to 127.0.0.1, CPU_TYPE string set to S71200, RACK int set to 0, and SLOT int set to 1 — plus an + Add Field button and Cancel and Save Changes buttons at the bottom * **View fields** — See all configuration fields with their types and descriptions * **Edit field** — Change values in the input fields * **Add field** — Click **+ Add Field** to add optional fields from a list of available parameters * **Delete field** — Use the delete icon on a field to remove it ### Adding a Configuration Field Click **+ Add Field** to open the **Add Configuration Field** modal. This shows all optional fields available for the route type, along with their data types and descriptions. Coreflux HUB Add Configuration Field modal prompting to select an optional field to add to the route configuration, listing PORT int for the TCP port for S7 connection (default 102), CONNECTION_TYPE string with four options PG, OP, S7BASIC, and S7_BASIC (default PG), RETRY_ATTEMPTS int for number of retry attempts (default 3), RETRY_TIME_SECONDS int for seconds to wait between retries (default 5), REFRESH_TIME_MS int for refresh time in milliseconds (default 1000), and a partially visible READ_TIMEOUT int field *** ## Events Tab The **Events** tab manages event definitions for the route. Events define how the route reacts to MQTT messages—typically by reading from or writing to the connected device or service. Coreflux HUB Route Configuration Events tab for Route: SiemensS7Route showing Event ONDEMANDREAD with 3 fields — SOURCE_TOPIC string set to s7sim/event/read, DESTINATION_TOPIC string set to s7sim/event/read/result, and QUERY string containing a JSON S7 operation with operation, address, data_type, and count placeholders — plus + Add Event and + Add Field controls and Cancel and Save Changes buttons * **Add Event** — Click **+ Add Event** to create a new event with a name and fields * **Rename event** — Click the pencil icon next to the event name to rename it * **Edit event** — Expand an event to view and edit its fields (e.g., `SOURCE_TOPIC`, `DESTINATION_TOPIC`, `QUERY`) * **Add field** — Use **+ Add Field** within an event to add optional fields ### Adding an Event Field Click **+ Add Field** inside an event to open the **Add Event Field** modal. This shows the optional fields available for the event. Coreflux HUB Add Event Field modal prompting to select an optional field to add to the event, showing a single option DESTINATION_TOPIC string described as MQTT topic to publish S7 results *** ## Mappings Tab The **Mappings** tab manages mapping definitions and tags. Mappings define periodic data polling from a connected device—reading tags at a set interval and publishing the data to MQTT topics. Coreflux HUB Route Configuration Mappings tab for Route: SiemensS7Route showing Mapping TESTPLC with Mapping Configuration (SOURCE_TOPIC string set to plc, EVERY string set to 1 SECOND) and a Tags section containing the Real tag with ADDRESS string set to DB1.DBD28, ADDRESS_TYPE string set to DATABLOCK, and DATA_TYPE string set to REAL, plus + Add Mapping, + Add Field, + Add Tag, and + Field controls and Cancel and Save Changes buttons * **Add Mapping** — Click **+ Add Mapping** to create a new mapping with a name, mapping fields, and tags * **Rename mapping** — Click the pencil icon next to the mapping name to rename it * **Edit mapping** — Expand a mapping to edit its configuration (e.g., `EVERY`, `SOURCE_TOPIC`) and tags * **Add Tag** — Use **+ Add Tag** within a mapping to add tags with address, data type, and other properties ### Adding a New Mapping Click **+ Add Mapping** to open the **Add New Mapping** modal. Enter a mapping name, then use **+ Add Field** and **+ Add Tag** to build the mapping. Coreflux HUB Add New Mapping modal titled Create a new mapping with optional fields and tags, showing a Mapping Name input with an e.g., MyMapping placeholder, a Mapping Fields (0) section with an + Add Field button, a Tags (0) section with an + Add Tag button and a No tags added yet placeholder, plus Cancel and Create Mapping buttons ### Adding a Mapping Field Click **+ Add Field** inside a mapping to open the **Add Mapping Field** modal. This shows the optional fields available for the mapping, such as `DESTINATION_TOPIC` and `QUERY`. Coreflux HUB Add Mapping Field modal prompting to select an optional field to add to the mapping, showing DESTINATION_TOPIC string described as MQTT topic to publish polled data (legacy mode) and QUERY string described as S7 read operation in JSON format (legacy mode) #### Tags Tags are defined inside mappings. Each tag represents a data point to read from the connected device. You can add, edit, rename, and remove tags and their properties (address, address type, data type, scaling, unit, etc.) from the Mappings tab. To rename a tag, click the pencil icon next to the tag name. #### Saving and Canceling * **Save Changes** — Applies all edits to the broker * **Cancel** — Discards unsaved changes and closes the configuration panel *** ## Next Steps Create new routes with templates or LoT code. View and export route-specific logs and status. # Routes Monitoring Source: https://docs.coreflux.org/hub/routes/monitoring View real-time route status and logs — monitor connection health, message counts, and export logs Coreflux HUB route card titled MyS7Route with a green status dot in the top-right corner and a row of action icons at the bottom — a gear for Configuration, a monitor icon currently hovered showing a Route Monitor tooltip, a globe for Documentation, and a trash can for Delete ## Why Monitor Routes? You need to know whether a route is running correctly — whether it is connected, how many messages it has processed, and whether there are errors. The Monitor window gives you live status metrics and real-time logs filtered to a single route, so you can troubleshoot without searching through broker-wide output. ## When to Use It * **Verifying connectivity** — Confirm that a route is connected to its target system after creation or configuration changes * **Tracking activity** — Check message counts and error rates to validate that events and mappings are executing * **Troubleshooting connections** — See why a route failed to connect using status details and log entries * **Exporting for analysis** — Download filtered logs as JSON, CSV, or LOG for offline review *** ## Accessing the Monitor Open the Routes Manager, click on a route card, and select **Monitor**. The Monitor window opens with two tabs: **Status** and **Logs**. Coreflux HUB Routes Manager showing how to click a route card and select Monitor to open the monitoring window *** ## Status Tab The Status tab displays live metrics from the broker for the selected route. Coreflux HUB Monitor: SiemensS7Route window with the SIEMENS_S7 type badge, Connection Connected, Health Green, and Uptime 00:30:40 in the header; Status tab selected showing an Overview with Connection Connected Since 30m ago, Health Green with Uptime 00:30:40, Messages 1,835 with 1,835 ok and 0 failed, and Errors 0 with 0 consecutive; an Activity Timeline with Last Activity and Last Success 6s ago on 4/16/2026 and Last Error None; and an Advanced Raw JSON panel displaying the SiemensS7Route status payload with name, type, connection, health, errors, messages, lastActivity, lastSuccess, uptime, connectedSince, and timestamp fields | Field | Description | | -------------- | ---------------------------------------------------------- | | **Connection** | Whether the route is currently connected to its target | | **Health** | Health indicator (Green, Yellow, Red) based on error rates | | **Messages** | Total messages processed, with success and failure counts | | **Errors** | Total and consecutive error counts | The **Activity Timeline** shows timestamps for last activity, last success, and last error. The **Advanced: Raw JSON** section shows the full status payload from the broker, including uptime and connection timestamps. *** ## Logs Tab The Logs tab shows real-time log entries filtered to the selected route. Coreflux HUB Monitor: SiemensS7Route window with Logs tab selected showing three INFO log entries from 16:40:49 for Category Event, Name ProcessCloudCLICommand, Outcome Success, Description Processing MQTT command -addTraceLog with topics CtrlFlux/SiemensS7Route/Fatal, /Error, and /Warning; plus ALL, Fatal, Error, WARN, and INFO 3 filter buttons, a JSON format selector, an export download icon, a 3 / 3 entry counter in the bottom left, and a red Clear Logs icon in the bottom right ### Filter by Level Use the filter buttons to show only certain log levels: | Filter | Description | | --------- | --------------------------- | | **ALL** | Show all log entries | | **FATAL** | Fatal errors only | | **ERROR** | Errors only | | **WARN** | Warnings only | | **INFO** | Informational messages only | ### Log Entry Fields Each log entry displays: | Field | Description | | --------------- | ------------------------------------ | | **Timestamp** | When the event occurred | | **Log level** | FATAL, ERROR, WARN, or INFO | | **Category** | Event category (e.g., Action, Event) | | **Name** | Event or action name | | **Outcome** | Success or failure | | **Description** | Detailed message or structured data | ### Export Use the export dropdown to choose a format: * **JSON** — Structured export for parsing or integration * **CSV** — Spreadsheet-friendly format * **LOG** — Plain text log file Click **Export** to download the filtered logs. Coreflux HUB Monitor: SiemensS7Route Logs tab displaying the three INFO ProcessCloudCLICommand entries and a Logs Exported toast in the top-right corner confirming Exported 3 logs as JSON, with level filters, JSON format selector, and export controls still visible ### Clear Logs Use **Clear Logs** to remove displayed entries from the view. This does not affect the broker — it only clears the HUB display. *** ## Next Steps Edit route settings if status or logs show connection or configuration issues. View broker-wide logs for system-level troubleshooting. # Routes Overview Source: https://docs.coreflux.org/hub/routes/overview Add, configure, and monitor Routes in the Coreflux HUB—view status, edit configuration, and inspect route logs Coreflux HUB Routes section showing route cards with connection status indicators, sort and filter controls, and route management options ## Why Use the Routes Section? You need to manage Routes from the browser—add new routes, edit configuration, check connection status, and troubleshoot with logs. The Routes section of the Coreflux HUB lets you configure routes with a visual editor or LoT code, view connected and disconnected routes, and inspect route-specific logs. ## When to Use It * **Adding routes** — Create routes from templates (PostgreSQL, MongoDB, Modbus, etc.) or write LoT code directly * **Configuring routes** — Edit configuration, events, mappings, and tags in Visual or LoT mode * **Monitoring** — See which routes are connected and which are not, and view real-time status metrics * **Troubleshooting** — View route logs, filter by level, and export for analysis *** ## Routes Tools Edit routes, view status (connected/not connected), and delete routes. View real-time status metrics and logs filtered by route, export as JSON/CSV/LOG. *** ## Route List and Status The Routes Manager displays all defined routes as cards. Each route card shows the route name, type (e.g., `SIEMENS_S7`), and a **status indicator** dot in the top-right corner. The dot color tells you the current connection state at a glance: | Color | Status | | ---------- | -------------- | | **Green** | Connected | | **Red** | Not connected | | **Gray** | Unknown status | | **Yellow** | Reconnecting | Coreflux HUB route card titled SiemensS7Route with the SIEMENS_S7 type badge, a green status dot in the top-right corner indicating a connected route, and four action icons along the bottom for configuration, monitor, documentation, and delete Use the controls at the top to **sort** routes by name and **filter** by type. The **Delete All** button removes all routes, and the **Add a route +** button opens the route creation options. ## Route Card Actions Click on a route card to open its context menu with three options: Coreflux HUB SiemensS7Route card with the SIEMENS_S7 type badge and a row of four action icons at the bottom — a gear for Configuration, a monitor for Monitor, a globe for Documentation, and a trash can for Delete | Action | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Configuration** | Opens the [Routes Configuration](/hub/routes/configuration) panel to edit settings, events, mappings, and tags | | **Monitor** | Opens the [Routes Monitoring](/hub/routes/monitoring) window with a **Status** tab (connection, health, messages, errors) and a **Logs** tab (real-time route logs) | | **Documentation** | Opens the Coreflux documentation for the route type directly inside the HUB | ### Route Documentation The Documentation option opens the Coreflux docs page for the route type in an embedded viewer inside the HUB. This gives you quick access to syntax reference, configuration parameters, and examples without leaving the interface. Coreflux HUB embedded Docs window titled SiemensS7Route loading the Siemens S7 Route page from docs.coreflux.org with Routes navigation on the left, Siemens S7 Overview and Basic Syntax content in the center, and an On this page outline on the right, plus Refresh and Open in Browser controls *** ## Adding a Route Click **Add a route +** in the Routes Manager. The **Create a New Route** modal appears with two options: * **Start from Scratch** — Opens the LoT code editor where you write your route definition from scratch * **Start from Template** — Opens the template list where you pick a route type and configure it visually Coreflux HUB Create a New Route modal overlaid on the Route Manager asking How would you like to get started with two options — Start from Scratch to write a route definition in LoT code from scratch and Start from Template to choose from available route templates and customize with a visual or code editor | Method | Best For | | ----------------------- | ------------------------------------------------------------------- | | **Start from Scratch** | Custom routes, full control over syntax, copying from documentation | | **Start from Template** | Quick setup for common route types with a visual configuration form | ### Start from Scratch Select **Start from Scratch** to open the **Create Route with Code Editor** modal. Write your route definition in LoT language and click **Create Route** to deploy. Coreflux HUB Create Route with Code Editor modal prompting to write your route definition in LoT language, showing an empty numbered code editor with Enter route definition here placeholder, Collapse All and fullscreen controls at the top, and Cancel and Create Route buttons at the bottom Example: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MyPostgres WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "localhost" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "admin" WITH PASSWORD "your_password" ADD EVENT StoreSensor WITH SOURCE_TOPIC "sensors/#" WITH QUERY "INSERT INTO readings (topic, value) VALUES ('{topic}', '{value.json}')" ``` ### Start from Template Select **Start from Template** to open the templates list. Choose a route type to configure it visually. 1. Select **Start from Template** from the Create a New Route modal. 2. The **Templates** list opens with available route types. Coreflux HUB Templates modal titled Choose a route type to get started with a Search by type field and a scrollable list of route templates including PostgreSQL, MariaDB, MySQL, SQL Server, MongoDB, and OpenSearch, each with a description of its database route purpose 3. Use **Search by type...** to filter (e.g., "PostgreSQL", "Modbus"). 4. Select a template. The route creation view opens with a configuration form. When you select a template, a configuration tab opens with **Visual Mode** and **LoT Mode**. Visual Mode displays all configuration fields and events as a form. LoT Mode shows the raw LoT code. Coreflux HUB Create PostgreSQL Route view for Route NewPostgreSQL_1776353175451 with Configuration and Events tabs, Configuration Fields panel showing SQL Configuration with five fields — SERVER required string set to localhost, PORT int set to 5432, DATABASE required string set to test_db, USERNAME required string set to admin, and PASSWORD required string hidden — plus an + Add Field button and Cancel and Create Route buttons at the bottom * **Configuration** — Fill in the connection fields (server, port, database, username, password). Required fields are marked with a badge. Use **+ Add Field** to include optional parameters. * **Events** — Define events for the route (e.g., queries triggered by MQTT messages). * **LoT Mode** — Switch to LoT Mode to view or edit the generated LoT code directly. 5. Fill in the configuration fields and add events as needed. 6. Click **Create Route** to deploy. Click **Cancel** to discard. Templates are available for all supported route types. See the [Routes Overview](/latest/lot-language/routes/overview) for the full list of available types and their documentation. *** ## Next Steps Edit routes with Visual or LoT mode. Learn route syntax and available route types. # Welcome Source: https://docs.coreflux.org/latest/index Get, centralize, process, and send IoT data - all from one MQTT Broker with built-in LoT (Language of Things) # Welcome to Coreflux Tired of stitching together MQTT brokers, message queues, transformation scripts, and database connectors? Coreflux handles it all in one place. **Get** data from devices, **centralize** it in your broker, **process** it with simple scripts, and **send** it wherever it needs to go. **"You can do a LoT"** - The complete IoT data pipeline for developers who want to ship faster. ## Quick Navigation Go from zero to your first LoT Action in under 5 minutes. Install Coreflux with Docker, Docker Compose, or a native binary. Manage your broker from the browser—explore topics, edit LoT, configure routes. Learn the Language of Things - Actions, Models, Rules, and Routes. Download the MQTT Broker from the Coreflux website — use **Start Free**. ## What is LoT? Think of LoT as **SQL for real-time IoT data**. **Just like SQL lets you query databases without writing low-level code**, LoT lets you process MQTT messages without deploying external services. Write simple scripts, get instant results. Instead of deploying separate microservices to process, transform, and route your IoT data, write simple scripts that run inside the broker. No containers to orchestrate. No message queues to manage. Just logic that executes the moment data arrives. Learn the fundamentals of the Language of Things and how it works. React to events and publish data. "When temperature exceeds 80°, send an alert." Transform and structure data. Convert raw bytes to clean JSON with context. Connect systems. Stream data to PostgreSQL, MongoDB, REST APIs, and more. ## Why Developers Choose Coreflux No microservices, containers, or orchestration for basic automation. One binary, full functionality. Logic executes on publish, not on poll. Sub-millisecond response times. Everything runs inside the MQTT broker. No Redis, no Kafka, no Node.js. Write automation in plain English-like syntax anyone can understand. *** Ready to see what this looks like? Here's a working example you can deploy in seconds. ## See It In Action **The problem:** You want to see your broker run logic at the edge without external hardware or sensors. **The solution:** A temperature simulator action that publishes readings every 5 seconds—deploy it and see data on a topic right away: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TemperatureSimulator ON EVERY 5 SECONDS DO PUBLISH TOPIC "sensors/zone1/temperature" WITH "{\"temperature\": 72}" ``` This action runs every 5 seconds and publishes a JSON temperature reading to `sensors/zone1/temperature`. Subscribe to that topic with MQTT Explorer or any MQTT client to see output immediately after deployment. *** ## Resources Explore examples and community projects. Join the community for support and discussions. Download Coreflux and start your free trial. *** ## Next Steps Follow the 5-minute tutorial to create a working LoT action. A beginner’s guide to building IIoT solutions with LoT, by yourself or with the help of AI. # Callable ACTIONs Source: https://docs.coreflux.org/latest/lot-language/actions/callable-actions Reuse LoT logic across ACTIONs with callable utilities—shared calculations, validations, and on-demand scripts invoked via CALL ACTION. ## Overview Most ACTIONs start automatically—on a schedule, when an MQTT message arrives, or at broker startup. A **callable ACTION** has no trigger. It runs when another ACTION invokes it with `CALL ACTION`, or when you publish `-runAction` to `$SYS/Coreflux/Command`. Callable ACTIONs let you extract logic into named, reusable blocks: unit conversions, threshold checks, aggregations, or short on-demand scripts (refresh a cache, recalculate totals) that run at a specific point in a workflow—not on their own timer or topic. **Think of callable ACTIONs as functions inside the broker.** Triggered ACTIONs decide *when* something happens; callables hold *how* to compute, validate, or transform—and run only when called. *** ## When to Use Callable ACTIONs Before splitting logic out, understand where callables fit: | Use Callable ACTIONs For | Use Triggered ACTIONs For | | ------------------------------------------------------- | ---------------------------------------------------------- | | Shared calculations used in multiple places | Reacting to MQTT messages (`ON TOPIC`, `ON CHANGE`) | | Validations and transformations with typed inputs | Scheduled polling and reports (`ON EVERY`, `ON TIMESTAMP`) | | On-demand steps inside a workflow (cache refresh, prep) | One-time bootstrap at startup (`ON START`) | | Keeping main ACTION bodies short and readable | Logic that must run on its own when an event occurs | If an ACTION must run by itself when data arrives or on a clock, give it a trigger. Use a callable when the logic is shared or should run only as part of another ACTION's flow. To invoke a callable from MQTT with JSON `INPUT` values, see [Broker Commands — run an Action on demand](/latest/mqtt-broker/commands#run-an-action-on-demand). *** ## Defining a Callable ACTION A callable is a normal `DEFINE ACTION` **without** an `ON …` clause. Optionally declare `INPUT` parameters and `RETURN` one or more `OUTPUT` values. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION [INPUT AS ...] DO -- logic [RETURN OUTPUT ...] ``` ### Supported INPUT Types | Type | Typical use | | -------- | ------------------------------ | | `STRING` | IDs, labels, status text | | `INT` | Counts, discrete setpoints | | `DOUBLE` | Sensor readings, analog values | | `BOOL` | Flags and enable/disable state | ### Parameterless Callable Use when the block has no inputs—common for on-demand scripts that read from topics or update cached state: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION RefreshDashboardCache DO KEEP TOPIC "cache/dashboard/summary" WITH (GET TOPIC "data/live/summary") PUBLISH TOPIC "cache/dashboard/updated" WITH TIMESTAMP "UTC" ``` ### Callable with Inputs and Return Value ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ConvertCelsiusToFahrenheit INPUT celsius AS DOUBLE DO SET "fahrenheit" WITH ({celsius} * 9 / 5 + 32) RETURN OUTPUT fahrenheit ``` *** ## CALL ACTION Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL ACTION WITH = , ... RETURN [, , ...] ``` * `WITH` argument names must match the callee's `INPUT` names. * `RETURN` variables are local to the caller and receive `OUTPUT` values in declaration order. ## Parameter Passing Pass one or more values with `WITH`. Each name must match an `INPUT` on the callable: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL ACTION CalculateAverage WITH value1 = {temp1}, value2 = {temp2} RETURN result ``` Define the callable with a matching `INPUT` for each parameter: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CalculateAverage INPUT value1 AS DOUBLE INPUT value2 AS DOUBLE DO SET "avg" WITH (({value1} + {value2}) / 2) RETURN OUTPUT avg ``` ## Return Value Handling The `RETURN` clause on the caller captures values from the callable's `OUTPUT` declarations. ### Single Return Value Declare one `OUTPUT` on the callable and one variable on the caller: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ConvertCelsiusToFahrenheit INPUT celsius AS DOUBLE DO SET "fahrenheit" WITH ({celsius} * 9 / 5 + 32) RETURN OUTPUT fahrenheit ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL ACTION ConvertCelsiusToFahrenheit WITH celsius = {temp_c} RETURN temp_f ``` ### Multiple Return Values Declare one `OUTPUT` per value. On the caller, list return variables in the **same order** as the `OUTPUT` lines: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AnalyzeValue INPUT value AS DOUBLE INPUT threshold AS DOUBLE DO SET "is_above" WITH ({value} > {threshold}) SET "difference" WITH ({value} - {threshold}) SET "percentage" WITH ({value} / {threshold} * 100) RETURN OUTPUT is_above OUTPUT difference OUTPUT percentage ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL ACTION AnalyzeValue WITH value = {reading}, threshold = {limit} RETURN above, diff, pct ``` The first `OUTPUT` (`is_above`) maps to `above`, the second to `diff`, and the third to `pct`. *** ## Usage Examples Call a shared conversion from a topic-triggered ACTION instead of repeating the formula inline: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessTemperatureReading ON TOPIC "sensors/+/celsius" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp_c" WITH PAYLOAD AS DOUBLE CALL ACTION ConvertCelsiusToFahrenheit WITH celsius = {temp_c} RETURN temp_f PUBLISH TOPIC "sensors/" + {sensor_id} + "/fahrenheit" WITH {temp_f} ``` A parameterless callable runs only when a triggered ACTION calls it—not on its own schedule or topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION HourlyDashboardReport ON EVERY 1 HOUR DO CALL ACTION RefreshDashboardCache SET "summary" WITH (GET TOPIC "cache/dashboard/summary") PUBLISH TOPIC "reports/dashboard/hourly" WITH {summary} ``` Chain callables in a triggered ACTION so the main body stays focused on orchestration: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessTankLevel ON TOPIC "tanks/+/level" DO SET "tank_id" WITH TOPIC POSITION 2 SET "level" WITH PAYLOAD AS DOUBLE SET "capacity" WITH (GET TOPIC "config/" + {tank_id} + "/capacity" AS DOUBLE) CALL ACTION CalculatePercentage WITH value = {level}, total = {capacity} RETURN fill_pct CALL ACTION AnalyzeReading WITH current = {level}, threshold = ({capacity} * 0.9) RETURN above, diff, pct_of_max PUBLISH TOPIC "processed/" + {tank_id} + "/fill_percent" WITH {fill_pct} PUBLISH TOPIC "processed/" + {tank_id} + "/over_threshold" WITH {above} ``` *** ## Best Practices Each callable should do one clear thing—convert units, validate a range, compute an average. If a block grows large or handles unrelated steps, split it into separate callables and compose them with `CALL ACTION`. The moment the same expression appears in two ACTIONs, consider a callable. Shared rules (thresholds, conversions, KPI formulas) belong in one place so fixes propagate to every caller on deploy. Use PascalCase, function-like names: `ConvertCelsiusToFahrenheit`, `CalculateAverage`, `ValidateReading`. See [Naming conventions](/latest/lot-language/usage-patterns/naming-conventions). Triggered ACTIONs should read as workflows: read data, call utilities, publish results. Push calculations and validations into callables so topic and schedule handlers stay easy to scan and maintain. *** ## Next Steps Review all available LoT operations. Time-based, topic-based, and initialization triggers. # ACTION Triggers Source: https://docs.coreflux.org/latest/lot-language/actions/events Define when actions execute using time-based schedules (ON EVERY, ON TIMESTAMP), topic-based events, or manual invocation ## Overview Every Action needs a trigger—the event that starts its execution. Choosing the right trigger type determines how your automation responds to real-world conditions. **Think of triggers like different ways to start your car.** A time-based trigger is like an auto-start that warms up your car every morning at 7 AM. A topic-based trigger is like pushing the start button when you get in. A callable action is like asking someone else to start it for you. ## When to Use Each Type | Trigger Type | Use When | Example | | -------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------- | | **Time-Based** (`ON EVERY`, `ON TIMESTAMP`) | You need a clock-driven schedule (interval or wall-clock) | Heartbeats, polling, shift windows, one-shot cutovers | | **Topic-Based** (`ON TOPIC`, `ON CHANGE`) | You need to react when MQTT data arrives | Sensor processing, commands; `ON CHANGE` when only new values matter | | **Initialization** (`ON START`) | You need to run logic once when the broker starts | Bootstrap UNS structure, set default config values | | **Client lifecycle** (`ON CONNECT`, `ON DISCONNECT`, `ON SUBSCRIBE`) | You need to react to MQTT session events | Connection audit, session dashboards, subscription tracking | | **Callable** (no trigger) | You need reusable logic invoked by other actions | Utility functions, shared calculations | *** ## Time-Based Triggers LoT supports several time-based trigger forms. All of them use the `ON` keyword and run the action's `DO` block automatically—no MQTT message is required to fire them. Together they cover recurring intervals, calendar rules (a specific weekday, Monday–Friday, or Saturday–Sunday), daily wall-clock times, and single one-shot executions. ### Quick reference | Syntax | Pattern | Recurs? | | -------------------------------------- | -------------------------------------------- | ------------------- | | `ON EVERY` *N* *UNIT* | Every *N* seconds, minutes, hours, and so on | Yes — indefinitely | | `ON EVERY` *DAY* `AT` `"HH:mm"` | Named weekday at a fixed local time | Yes — every week | | `ON EVERY WEEKDAY AT` `"HH:mm"` | Monday–Friday at a fixed local time | Yes — every weekday | | `ON EVERY WEEKEND AT` `"HH:mm"` | Saturday–Sunday at a fixed local time | Yes — every weekend | | `ON TIMESTAMP` `"HH:mm:ss"` | Every day at a fixed local time | Yes — every day | | `ON TIMESTAMP` `"dd-MM-yyyy HH:mm:ss"` | Exact date and time | No — one-shot | **Time zone:** For `HH:mm` and `HH:mm:ss` in `ON EVERY … AT` and in daily `ON TIMESTAMP`, values are interpreted in the **local time of the machine running the broker**, then converted to UTC internally. The action is scheduled for the correct wall-clock time across daylight saving changes. The one-shot form `dd-MM-yyyy HH:mm:ss` is interpreted as **UTC**, as described below. **Structured payloads:** `PUBLISH TOPIC` does not support inline JSON object literals. For multi-field records, define a **COLLAPSED** model and use `PUBLISH MODEL … TO … WITH` (see [Publishing Models](/latest/lot-language/models/publish-model)). The timed-action examples below use that pattern. ### 1. `ON EVERY` *N* *UNIT* — fixed interval Fires repeatedly every *N* units of time. Common for monitoring, polling, and heartbeats. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ON EVERY DO -- body ``` #### Supported units | Keyword(s) | Example | | -------------------- | --------------------- | | `MILLISECOND` / `MS` | `ON EVERY 500 MS` | | `SECOND` / `SECONDS` | `ON EVERY 30 SECONDS` | | `MINUTE` / `MINUTES` | `ON EVERY 5 MINUTES` | | `HOUR` / `HOURS` | `ON EVERY 1 HOUR` | | `DAY` / `DAYS` | `ON EVERY 1 DAY` | | `WEEK` / `WEEKS` | `ON EVERY 2 WEEKS` | | `MONTH` / `MONTHS` | `ON EVERY 1 MONTH` | | `YEAR` / `YEARS` | `ON EVERY 1 YEAR` | Singular and plural unit names are interchangeable (for example, `1 SECOND` and `1 SECONDS`). `ON EVERY 1 DAY` fires every 24 hours from the last run; for the same clock time every calendar day, use daily `ON TIMESTAMP` instead. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SystemHeartbeat ON EVERY 5 SECONDS DO PUBLISH TOPIC "system/heartbeat" WITH "alive" PUBLISH TOPIC "system/heartbeat/timestamp" WITH TIMESTAMP "UTC" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PollMachineTemperature ON EVERY 30 SECONDS DO SET "temp" WITH GET TOPIC "machine/sensor/temperature" IF {temp} > 85 THEN PUBLISH TOPIC "alerts/temperature/high" WITH {temp} PUBLISH TOPIC "machine/temperature/last_check" WITH TIMESTAMP "UTC" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION FastSensorSample ON EVERY 500 MS DO PUBLISH TOPIC "sensors/vibration/sample" WITH GET TOPIC "sensors/vibration/raw" ``` ### 2. `ON EVERY` *DAY* `AT` — weekly, specific day Fires once per week on the named weekday at the given **local** time. Use `"HH:mm"` or `"HH:mm:ss"`; seconds are optional. | Keyword | Fires on | | ------------------- | ---------------------- | | `MONDAY` … `SUNDAY` | That weekday each week | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ON EVERY MONDAY AT "08:00:00" DO -- body ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ProductionWeeklyReport COLLAPSED ADD STRING "week" ADD INT "total_parts" ADD INT "total_rejects" DEFINE ACTION WeeklyProductionReport ON EVERY MONDAY AT "08:00:00" DO SET "week_total" WITH GET TOPIC "factory/line1/parts_produced" SET "week_rejects" WITH GET TOPIC "factory/line1/rejects" PUBLISH MODEL ProductionWeeklyReport TO "reports/weekly/production" WITH week = TIMESTAMP "UTC" total_parts = {week_total} total_rejects = {week_rejects} PUBLISH TOPIC "factory/line1/parts_produced" WITH 0 PUBLISH TOPIC "factory/line1/rejects" WITH 0 ``` ### 3. `ON EVERY WEEKDAY AT` — Monday–Friday Fires Monday through Friday at the same **local** time—one action instead of five separate daily schedules. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ShiftMorningBriefing COLLAPSED ADD STRING "line_status" ADD STRING "pending_orders" ADD STRING "shift_start" DEFINE ACTION ShiftStartBriefing ON EVERY WEEKDAY AT "07:30" DO SET "line_status" WITH GET TOPIC "factory/line1/status" SET "pending_orders" WITH GET TOPIC "erp/orders/pending" PUBLISH MODEL ShiftMorningBriefing TO "shift/briefing/morning" WITH line_status = {line_status} pending_orders = {pending_orders} shift_start = TIMESTAMP "UTC" ``` ### 4. `ON EVERY WEEKEND AT` — Saturday and Sunday Fires on both weekend days at the specified **local** time—useful for maintenance windows or weekend-only reporting. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION WeekendMaintenanceWindow ON EVERY WEEKEND AT "06:00:00" DO PUBLISH TOPIC "maintenance/window/open" WITH "true" PUBLISH TOPIC "maintenance/window/start" WITH TIMESTAMP "UTC" PUBLISH TOPIC "notifications/maintenance" WITH "Weekend maintenance window is now open" ``` ### 5. `ON TIMESTAMP` — daily fixed time Fires **every day** at the given **local** wall-clock time. Unlike `ON EVERY 1 DAY`, this tracks clock time, not elapsed time since the broker or last run started. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ON TIMESTAMP "08:00:00" DO -- body ``` `"HH:mm"` without seconds is accepted. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION DailyCounterReset ON TIMESTAMP "00:00:00" DO PUBLISH TOPIC "factory/line1/parts_produced" WITH 0 PUBLISH TOPIC "factory/line1/rejects" WITH 0 PUBLISH TOPIC "factory/line1/day_start" WITH TIMESTAMP "UTC" ``` ### 6. `ON TIMESTAMP` — one-shot, exact date and time Fires **once** at the exact instant given, then never again (the scheduler moves the next run far into the future). Format is strictly **`dd-MM-yyyy HH:mm:ss`** (day–month–year). The timestamp is treated as **UTC**. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SystemCutoverNotice ON TIMESTAMP "01-04-2026 06:00:00" DO PUBLISH TOPIC "system/cutover/notice" WITH "New firmware version going live in 1 hour" PUBLISH TOPIC "notifications/all" WITH "Scheduled maintenance begins at 07:00 UTC" ``` ### Combination patterns Real systems often split schedules across several actions—one trigger per concern. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL DailyTemperatureReport COLLAPSED ADD DOUBLE "max" ADD DOUBLE "min" ADD STRING "date" DEFINE ACTION SensorPoll ON EVERY 10 SECONDS DO SET "value" WITH GET TOPIC "sensors/temperature/raw" PUBLISH TOPIC "sensors/temperature/current" WITH {value} DEFINE ACTION DailySensorSummary ON TIMESTAMP "00:00:00" DO SET "max" WITH GET TOPIC "sensors/temperature/max" SET "min" WITH GET TOPIC "sensors/temperature/min" PUBLISH MODEL DailyTemperatureReport TO "reports/daily/temperature" WITH max = {max} min = {min} date = TIMESTAMP "UTC" PUBLISH TOPIC "sensors/temperature/max" WITH 0 PUBLISH TOPIC "sensors/temperature/min" WITH 0 ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION WeekdayProductionStart ON EVERY WEEKDAY AT "06:00" DO PUBLISH TOPIC "factory/line1/mode" WITH "production" PUBLISH TOPIC "factory/line1/shift_start" WITH TIMESTAMP "UTC" DEFINE ACTION WeekendMaintenance ON EVERY WEEKEND AT "07:00" DO PUBLISH TOPIC "factory/line1/mode" WITH "maintenance" PUBLISH TOPIC "notifications/maintenance/start" WITH TIMESTAMP "UTC" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL DashboardThroughput COLLAPSED ADD INT "parts" ADD DOUBLE "rate_per_min" ADD INT "updated" DEFINE ACTION WeeklyKpiReset ON EVERY MONDAY AT "00:00:00" DO PUBLISH TOPIC "kpi/weekly/parts_produced" WITH 0 PUBLISH TOPIC "kpi/weekly/reset_time" WITH TIMESTAMP "UTC" DEFINE ACTION DailyKpiSnapshot ON TIMESTAMP "23:59:00" DO SET "daily_parts" WITH GET TOPIC "factory/line1/parts_produced" PUBLISH TOPIC "kpi/daily/snapshot" WITH {daily_parts} PUBLISH TOPIC "kpi/daily/snapshot_time" WITH TIMESTAMP "UTC" DEFINE ACTION LiveThroughput ON EVERY 1 MINUTE DO SET "parts" WITH GET TOPIC "factory/line1/parts_produced" SET "rate" WITH GET TOPIC "factory/line1/production_rate" PUBLISH MODEL DashboardThroughput TO "dashboard/live/throughput" WITH parts = {parts} rate_per_min = {rate} updated = TIMESTAMP "UNIX" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CutoverActivation ON TIMESTAMP "01-07-2026 06:00:00" DO PUBLISH TOPIC "system/version" WITH "2.0" PUBLISH TOPIC "system/cutover/complete" WITH TIMESTAMP "UTC" DEFINE ACTION V2Heartbeat ON EVERY 30 SECONDS DO SET "ver" WITH GET TOPIC "system/version" IF {ver} EQUALS "2.0" THEN PUBLISH TOPIC "system/v2/heartbeat" WITH "running" ``` ### Error handling If a time string does not match an expected format, the broker logs an error similar to: `Invalid DATE_TIME format: . Expected formats:` * `dd-MM-yyyy HH:mm:ss` (one-time) * `HH:mm:ss` / `HH:mm` (daily) * Weekly forms using a day name with `AT` and a time string Errors are also published (retained) to: `$SYS/Coreflux/Actions//Error` Subscribe to `$SYS/Coreflux/Actions/+/Error` to observe failures from any action. ### At a glance — valid time triggers ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ON EVERY 500 MS DO ON EVERY 10 SECONDS DO ON EVERY 5 MINUTES DO ON EVERY 1 HOUR DO ON EVERY 1 DAY DO ON EVERY 1 WEEK DO ON EVERY 1 MONTH DO ON EVERY MONDAY AT "08:00:00" DO ON EVERY WEEKDAY AT "07:30" DO ON EVERY WEEKEND AT "06:00" DO ON TIMESTAMP "07:00" DO ON TIMESTAMP "00:00:00" DO ON TIMESTAMP "25-12-2026 09:00:00" DO ``` ### More examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SystemHeartbeat ON EVERY 5 SECONDS DO PUBLISH TOPIC "system/status" WITH "online" PUBLISH TOPIC "system/timestamp" WITH TIMESTAMP "UTC" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION IncrementCounter ON EVERY 10 SECONDS DO IF GET TOPIC "counter/value" == EMPTY THEN PUBLISH TOPIC "counter/value" WITH 0 ELSE PUBLISH TOPIC "counter/value" WITH (GET TOPIC "counter/value" + 1) ``` *** ## Topic-Based Triggers MQTT-driven actions use a **topic trigger** — the condition that tells the broker *when* to run the action body. Both triggers share the same topic patterns and wildcards; the difference is whether repeated identical payloads should be ignored. | Trigger | When the action runs | | --------------- | ------------------------------------------------------------------------------------------------------------------ | | **`ON TOPIC`** | On **every** message that matches the topic pattern | | **`ON CHANGE`** | When the message matches the pattern **and** the payload is **different** from the last message on that same topic | ### `ON TOPIC` — react to every message Use `ON TOPIC` when each publish matters: commands, alarms, counters, or any flow where duplicate payloads still need processing. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessTemperature ON TOPIC "sensors/+/temperature" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp" WITH PAYLOAD AS DOUBLE PUBLISH TOPIC "processed/" + {sensor_id} + "/temperature" WITH {temp} ``` **Key characteristics:** * Any client (or route) that publishes to a matching topic runs the action. * Publishing the same value twice runs the action **twice**. * Wildcard `+` matches one topic level (same rules as [MQTT wildcards](/latest/mqtt-broker/protocol#wildcards)). ### `ON CHANGE` — react only when the value changes Use `ON CHANGE` for telemetry, status fields, or setpoints that are published often but only need logic when the reading actually changes. This reduces CPU load, database writes, and downstream noise. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessTemperatureOnChange ON CHANGE "sensors/+/temperature" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp" WITH PAYLOAD AS DOUBLE PUBLISH TOPIC "processed/" + {sensor_id} + "/temperature" WITH {temp} ``` **Key characteristics:** * The **first** message on a given topic always runs the action (there is no previous value yet). * Later messages run the action only if the **payload bytes** are not identical to the last message on that **exact** topic name. * Example: `sensors/line1/temperature` and `sensors/line2/temperature` are tracked separately. * Wildcard patterns work the same as `ON TOPIC`; change is evaluated per real topic, not per pattern. **What counts as “changed”?** Comparison is on the **raw message body** (bytes), not on parsed numbers or JSON. `25` and `25.0` are different payloads. `{"temp":25}` and `{"temp": 25}` (different spacing) are different payloads. If you need semantic equality (for example, treat `25` and `25.0` as the same), handle that inside an `ON TOPIC` action with your own logic. ### Choosing between `ON TOPIC` and `ON CHANGE` | Situation | Recommended trigger | | --------------------------------------------------------------------------- | ---------------------------------------------- | | Commands, events, “do something” signals | `ON TOPIC` | | Alarms that must fire on every notification | `ON TOPIC` | | High-frequency sensor values (temperature, level, status) | `ON CHANGE` | | Writing to a database or historian only when value moves | `ON CHANGE` | | Forwarding data only when it differs | `ON CHANGE` | | Same pattern, but some actions need every message and others only on change | Use **both** (two actions on the same pattern) | You can deploy two actions on the same topic pattern: one with `ON TOPIC` and one with `ON CHANGE`. The broker runs `ON TOPIC` every time and skips the `ON CHANGE` action when the payload is unchanged. ### Side-by-side example Two devices publish temperature every second. Only `22.5` → `23.1` should trigger heavy processing. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION LogEveryReading ON TOPIC "sensors/+/temperature" DO PUBLISH TOPIC "logs/all" WITH PAYLOAD ``` On value change only: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessWhenDifferent ON CHANGE "sensors/+/temperature" DO SET "temp" WITH PAYLOAD AS DOUBLE PUBLISH TOPIC "analytics/updated" WITH {temp} ``` | Publish to | Payload | `LogEveryReading` | `ProcessWhenDifferent` | | ----------------------- | ------- | ----------------- | ---------------------- | | `sensors/a/temperature` | `22.5` | Runs | Runs (first value) | | `sensors/a/temperature` | `22.5` | Runs | Skipped (unchanged) | | `sensors/a/temperature` | `23.1` | Runs | Runs (changed) | Inside the action body, **`PAYLOAD`** is the content of the message that fired the trigger, and **`TOPIC POSITION N`** is segment *N* of the actual topic (1-based), useful with `+`. ### Wildcard Patterns LoT supports MQTT wildcards for flexible topic matching: The `+` wildcard matches exactly one topic level: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SensorRouter ON TOPIC "sensors/+/temperature" DO // POSITION 1="sensors", POSITION 2=wildcard (sensor_id), POSITION 3="temperature" SET "sensor_id" WITH TOPIC POSITION 2 PUBLISH TOPIC "processed/" + {sensor_id} + "/temp" WITH PAYLOAD ``` **Matches:** `sensors/room1/temperature`, `sensors/room2/temperature` **Does NOT match:** `sensors/building1/floor2/temperature` (too many levels) Use multiple single-level wildcards for deeper hierarchies: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION FactoryMonitor ON TOPIC "factory/+/+/status" DO // POSITION 1="factory", POSITION 2=line_id, POSITION 3=machine_id, POSITION 4="status" SET "line_id" WITH TOPIC POSITION 2 SET "machine_id" WITH TOPIC POSITION 3 PUBLISH TOPIC "monitoring/" + {line_id} + "/" + {machine_id} WITH PAYLOAD ``` **Matches:** `factory/line1/machine1/status`, `factory/line2/press3/status` **Does NOT match:** `factory/line1/status` (missing one level), `factory/status` (missing two levels) *** ## Initialization Triggers Use `ON START` to execute logic exactly once when the broker starts **or when the action is first deployed on a live broker**. This is the right place to bootstrap a Unified Namespace (UNS) structure, publish retained default configurations, and log startup events. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION InitUNSRoot ON START DO KEEP TOPIC "config/uns_root" WITH "factory/lisbon" KEEP TOPIC "factory/info/name" WITH "Lisbon Plant" KEEP TOPIC "factory/info/timezone" WITH "Europe/Lisbon" PUBLISH TOPIC "system/log" WITH "UNS root initialised" ``` **Key characteristics:** * Runs once when the broker starts, and again when you first deploy (or redeploy) the action while the broker is already up — you do not need a restart to seed bootstrap topics * Cold-start behaviour is unchanged: every `ON START` action still runs once after the MQTT broker is ready. Deploying before the broker is ready defers until startup so the body is not double-run * A failing `ON START` body is logged but does not fail the deploy * `KEEP TOPIC` publishes with the retain flag so the value is delivered to any future subscriber immediately on connection * Use it to set default configuration values and retained state that must survive restarts * There is no `ON STOP` equivalent — `ON START` is the correct place for setup logic Action `PUBLISH` and `KEEP` are ordinary MQTT publishes: the payload is on the topic and any client subscribed to that topic receives it. What they skip is the **publish ACL** that applies to connected MQTT users — a restrictive rule on that topic does not stop the action from publishing, so bootstrap topics from `ON START` (and other actions) are not silently dropped. Clients still need subscribe permission to receive those messages, and anything a client publishes itself remains fully subject to rules. *** ## Client Lifecycle Triggers Use **`ON CONNECT`**, **`ON DISCONNECT`**, and **`ON SUBSCRIBE`** when automation must react to MQTT session events instead of publishes. Typical uses include audit trails, connection dashboards, onboarding retained topics when a UI client connects, or tracking which topics clients subscribe to. | Trigger | When the action runs | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | **`ON CONNECT DO`** | After a client passes authentication and the broker accepts the MQTT CONNECT packet | | **`ON DISCONNECT DO`** | When the broker detects a client disconnection (clean, unclean, or session takeover) | | **`ON SUBSCRIBE ["pattern"] DO`** | After the broker accepts a subscription (RBAC check passed); optional pattern limits which subscriptions fire the action | If no action in the broker uses `ON CONNECT`, `ON DISCONNECT`, or `ON SUBSCRIBE`, the broker does not dispatch those lifecycle hooks — there is no overhead when the triggers are unused. ### `ON CONNECT DO` Runs once after a client successfully authenticates and the broker accepts the MQTT CONNECT packet. ```lot wrap focus={2} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AuditConnect ON CONNECT DO PUBLISH TOPIC "audit/connect/" + CLIENTID WITH USER + "@" + ENDPOINT KEEP TOPIC "sessions/" + CLIENTID + "/clean" WITH CLEANSESSION ``` **Key characteristics:** * Fires for every successful client connection, including routes and HUB clients. * Session context variables (`CLIENTID`, `USER`, `ENDPOINT`, `CLEANSESSION`) reflect the connecting client. * `USER IN GROUP`, `USER EQUALS`, and `USER HAS` evaluate against the session user, the same way they do for `ON TOPIC`. ### `ON DISCONNECT DO` Runs when the broker detects a client disconnection — clean disconnect, unclean disconnect, or session takeover. ```lot wrap focus={2} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AuditDisconnect ON DISCONNECT DO PUBLISH TOPIC "audit/disconnect/" + CLIENTID WITH DISCONNECTREASON ``` **Key characteristics:** * `DISCONNECTREASON` carries the disconnect type or MQTT reason code for the session that ended. * `CLIENTID`, `USER`, and `ENDPOINT` reflect the disconnecting session when available from the connect handshake. ### `ON SUBSCRIBE DO` Runs after the broker accepts a subscription and RBAC allows it. Use a topic pattern to limit which subscriptions fire the action; omit the pattern to run on **every** allowed subscription. ```lot wrap focus={2} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TrackSensorSubscriptions ON SUBSCRIBE "sensors/#" DO PUBLISH TOPIC "audit/subscriptions" WITH CLIENTID + " -> " + SUBSCRIBETOPIC + " qos=" + SUBSCRIBEQOS ``` Track every allowed subscription: ```lot wrap focus={2} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TrackAllSubscriptions ON SUBSCRIBE DO KEEP TOPIC "audit/last/subscribe" WITH SUBSCRIBETOPIC ``` **Key characteristics:** * Supports the same MQTT wildcards as `ON TOPIC`: `+` (single level) and `#` (multi-level). * `SUBSCRIBETOPIC` is the topic filter the client subscribed to; `SUBSCRIBEQOS` is the requested QoS (`0`, `1`, or `2`). * Lifecycle subscribe dispatch runs for all allowed subscriptions, including Visu and system topics, unless you filter them in the action body. ### Session context variables Available inside lifecycle-triggered actions (and still available on `ON TOPIC` / `ON CHANGE` for the **publisher**): | Variable | `ON CONNECT` | `ON DISCONNECT` | `ON SUBSCRIBE` | `ON TOPIC` / `ON CHANGE` | | ------------------ | -------------------------- | ----------------------------- | ---------------------------- | ------------------------ | | `CLIENTID` | Connecting client | Disconnecting client | Subscribing client | Publishing client | | `USER` | Authenticated username | Session username | Session username | Publisher username | | `ENDPOINT` | Client address (`ip:port`) | Last known address | Address from connect | Empty | | `CLEANSESSION` | `"true"` / `"false"` | Value from connect | Value from connect | Empty | | `DISCONNECTREASON` | Empty | Disconnect type / MQTT reason | Empty | Empty | | `SUBSCRIBETOPIC` | Empty | Empty | Subscribed topic filter | Empty | | `SUBSCRIBEQOS` | `-1` | `-1` | QoS level (`0`, `1`, or `2`) | `-1` | ### Not the same as Visu `ON CHANGE` In **dashboard / Visu (LoTV)** panels, `ON CHANGE DO` on a slider or text field is a **UI event** in the browser (user edits a control). That is separate from broker **`ON CHANGE "topic" DO`**, which reacts to **MQTT publishes** inside the broker. Lifecycle triggers (`ON CONNECT`, `ON DISCONNECT`, `ON SUBSCRIBE`) are also broker-side only. The broker still publishes `$SYS/Coreflux/Comms/Logins` on connect for observability, but LoT lifecycle actions give you programmatic automation with session context variables. | Context | Syntax | What fires it | | ----------------- | ----------------------------- | -------------------------------------- | | Broker LoT action | `ON CHANGE "sensors/temp" DO` | MQTT message with changed payload | | Broker LoT action | `ON CONNECT DO` | MQTT client connects | | Broker LoT action | `ON DISCONNECT DO` | MQTT client disconnects | | Broker LoT action | `ON SUBSCRIBE "pattern" DO` | Client subscribes to a matching filter | | Visu component | `ON CHANGE DO PUBLISH …` | User changing a control in the UI | *** ## Callable Actions Actions **without** a trigger clause (`ON TOPIC`, `ON EVERY`, `ON START`, `ON CONNECT`, and so on) are **callable**—they run only when another ACTION calls them with `CALL ACTION`. Use callables for reusable logic (conversions, validation, shared math) that several trigger-based Actions need. They accept typed `INPUT` parameters and can `RETURN` one or more `OUTPUT` values. **Full guide:** [Callable ACTIONs](./callable-actions)—reusable logic, on-demand scripts, and `CALL ACTION` syntax. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CalculateAverage INPUT value1 AS DOUBLE INPUT value2 AS DOUBLE DO SET "avg" WITH (({value1} + {value2}) / 2) RETURN OUTPUT avg DEFINE ACTION ProcessSensorPair ON TOPIC "sensors/pair/data" DO SET "temp1" WITH (GET JSON "sensor1" IN PAYLOAD AS DOUBLE) SET "temp2" WITH (GET JSON "sensor2" IN PAYLOAD AS DOUBLE) CALL ACTION CalculateAverage WITH value1 = {temp1}, value2 = {temp2} RETURN result PUBLISH TOPIC "sensors/average" WITH {result} ``` *** ## Comparing Trigger Types | Aspect | Time-Based | Topic-Based | ON START | Client lifecycle | Callable | | ------------------ | -------------------------------- | --------------- | ------------------------------ | --------------------------------------- | ------------------ | | **Trigger** | Clock interval or schedule | MQTT message | Broker startup or first deploy | Connect, disconnect, subscribe | Manual command | | **Frequency** | Interval, daily, weekly, or once | Event-driven | Once | Per session event | On-demand | | **Payload Access** | No | Yes (`PAYLOAD`) | No | Session context (`CLIENTID`, `USER`, …) | Via parameters | | **Best For** | Monitoring, reporting | Data processing | Initialization | Audit, session automation | Reusable utilities | *** ## Next Steps Reusable logic, on-demand scripts, and `CALL ACTION`. Learn about SET, GET, PUBLISH, and conditional logic. # ACTION Operations Reference Source: https://docs.coreflux.org/latest/lot-language/actions/operations Complete reference for LoT operations: variables, arithmetic, strings, RANDOM, transformations, and control flow ## Overview Complete reference for all LoT operations available within actions. These operations allow you to manipulate variables, perform calculations, transform strings, read and write MQTT topics, and implement conditional logic. **Quick Reference:** Use the sidebar to jump to specific operations. *** ## Variable Operations SET, GET TOPIC, GET JSON, and GET PROTO are the core operations for working with data in actions. They allow you to store values, read from MQTT topics, extract fields from JSON payloads, and decode protobuf fields. ### SET Creates or assigns a value to an internal variable: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "variable_name" WITH ``` Variables exist only during action execution and are referenced using `{variable_name}`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "threshold" WITH 100 SET "status" WITH "active" SET "enabled" WITH TRUE ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "current_temp" WITH (GET TOPIC "sensors/temperature") // For topic "sensors/temp001/data": POSITION 2 gets the sensor ID SET "sensor_id" WITH TOPIC POSITION 2 ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "total" WITH (GET TOPIC "count/a" + GET TOPIC "count/b") SET "average" WITH ({sum} / {count}) ``` Variable names must be enclosed in double quotes when using SET, but referenced with curly braces: `SET "myVar" WITH 10` then use `{myVar}`. ### GET TOPIC Retrieves the last known value from a topic. If the topic has no value, the broker returns a default based on the requested type. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET TOPIC "topic/path" GET TOPIC "topic/path" AS ``` #### Default Values for Missing Topics | Type | Default Value | | -------- | ------------------- | | `STRING` | `""` (empty string) | | `DOUBLE` | `0.0` | | `INT` | `0` | | `BOOL` | `FALSE` | #### Type Casting ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET TOPIC "sensors/temp" AS DOUBLE GET TOPIC "config/count" AS INT GET TOPIC "flags/enabled" AS BOOL ``` #### Wildcard Inheritance When an action is triggered by a wildcard topic pattern, GET TOPIC inherits the matched wildcard values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessWithContext ON TOPIC "sensors/+/temperature" DO // If triggered by "sensors/temp001/temperature" // The + wildcard matches "temp001" // This GET TOPIC uses the same wildcard value: SET "humidity" WITH (GET TOPIC "sensors/+/humidity" AS DOUBLE) // Reads from "sensors/temp001/humidity" ``` #### Example Usage ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Read and process SET "last_temp" WITH (GET TOPIC "sensors/temperature" AS DOUBLE) // Check before using (EMPTY check) IF GET TOPIC "config/threshold" == EMPTY THEN SET "threshold" WITH 80 ELSE SET "threshold" WITH (GET TOPIC "config/threshold" AS DOUBLE) // Combine multiple topic values SET "total" WITH (GET TOPIC "counters/a" AS INT + GET TOPIC "counters/b" AS INT) ``` ### GET JSON Extracts fields from JSON payloads with support for nested paths and arrays. For narrative patterns, missing-field workflows, and end-to-end `PUBLISH MODEL` examples, see [Working with JSON](/latest/lot-language/usage-patterns/working-with-json). #### Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET JSON "field_name" IN PAYLOAD AS ``` #### Supported Types | Type | Use For | Example | | -------- | ----------------- | --------------------------------------------- | | `STRING` | Text values | `GET JSON "name" IN PAYLOAD AS STRING` | | `DOUBLE` | Decimal numbers | `GET JSON "temperature" IN PAYLOAD AS DOUBLE` | | `INT` | Whole numbers | `GET JSON "count" IN PAYLOAD AS INT` | | `BOOL` | True/false values | `GET JSON "enabled" IN PAYLOAD AS BOOL` | #### Nested Path Extraction Use dot notation to access nested objects and bracket notation for arrays: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Nested object access SET "nested" WITH (GET JSON "data.sensors.temperature" IN PAYLOAD AS DOUBLE) SET "deep" WITH (GET JSON "metadata.config.threshold" IN PAYLOAD AS INT) // Array access (0-indexed) SET "first_reading" WITH (GET JSON "readings[0].value" IN PAYLOAD AS DOUBLE) SET "second_sensor" WITH (GET JSON "data.sensors[1].id" IN PAYLOAD AS STRING) ``` For a payload like: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "data": { "sensors": [ { "id": "temp001", "value": 23.5 }, { "id": "temp002", "value": 24.1 } ] }, "metadata": { "config": { "threshold": 80 } } } ``` #### Error Handling If a field doesn't exist, the result depends on the requested type: | Type | Missing Field Result | | -------- | -------------------- | | `STRING` | `""` (empty string) | | `DOUBLE` | `0.0` | | `INT` | `0` | | `BOOL` | `FALSE` | Use EMPTY check for conditional handling: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "value" WITH (GET JSON "optional_field" IN PAYLOAD AS DOUBLE) IF {value} == EMPTY THEN SET "value" WITH 100 // Default fallback ``` #### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessComplexJSON ON TOPIC "sensors/data" DO // Top-level fields SET "sensor_id" WITH (GET JSON "id" IN PAYLOAD AS STRING) SET "timestamp" WITH (GET JSON "timestamp" IN PAYLOAD AS STRING) // Nested fields SET "temp" WITH (GET JSON "readings.temperature" IN PAYLOAD AS DOUBLE) SET "humidity" WITH (GET JSON "readings.humidity" IN PAYLOAD AS DOUBLE) // Array access SET "latest" WITH (GET JSON "history[0].value" IN PAYLOAD AS DOUBLE) PUBLISH TOPIC "processed/" + {sensor_id} + "/temp" WITH {temp} PUBLISH TOPIC "processed/" + {sensor_id} + "/humidity" WITH {humidity} ``` ### GET PROTO Extracts fields from **binary protobuf** MQTT payloads. `GET PROTOBUF` is the same verb. You need a matching `DEFINE MODEL` with `WITH FORMAT PROTOBUF` (or `BOTH`) and `PROTO_TAG` values that follow the vendor `.proto`. Full models, type mapping, and pitfalls: [Protobuf Models](/latest/lot-language/models/protobuf). #### Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET PROTO "field_name" IN PAYLOAD AS USING MODEL "ModelName" ``` | Source | Bytes come from | | ------------------- | -------------------------------------- | | `IN PAYLOAD` | The MQTT payload that fired the Action | | `IN GET TOPIC "…"` | Last payload stored for that topic | | `IN` a LoT variable | A Base64 string in that variable | `USING MODEL` names the model that describes the payload. Supported `AS` types: `INT`, `DOUBLE`, `STRING`, `BOOL`, `BYTES`. A missing or mismatched field returns `null` (not a type default). Declare the parent message (unused arms stay as `ADD OBJECT` so wire numbers do not shift): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL DeviceCommand WITH FORMAT PROTOBUF COLLAPSED ADD INT "command_type" PROTO_TAG 1 ADD OBJECT "header" PROTO_TAG 2 ``` Then read a field from the triggering payload: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION OnPlcCommand ON TOPIC "plant/+/+/command" DO SET "cmd" WITH (GET PROTO "command_type" IN PAYLOAD AS INT USING MODEL "DeviceCommand") KEEP TOPIC "plant/pending_command" WITH {cmd} ``` *** ## Environment Variables & Secrets **Version Requirement:** `GET ENV`, `GET SECRET`, `KEEP ENV`, `KEEP SECRET`, `DELETE ENV`, and `DELETE SECRET` are available from **Coreflux Broker v1.9.3** and above. LoT Actions can read, write, and delete environment variables and encrypted secrets at runtime. Environment variables store non-sensitive configuration (hostnames, ports, URLs). Secrets store sensitive credentials encrypted with AES-256-GCM. Use environment variables for configuration that can safely appear in logs (hostnames, ports, feature flags). Use secrets for anything sensitive (passwords, API keys, tokens). ### GET ENV Reads an environment variable. The broker checks the managed `.env` file first, then falls back to process, user, and machine environment variables. Returns an empty string if not found. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET ENV "NAME" ``` #### Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Read connection settings SET "host" WITH (GET ENV "DB_HOST") SET "port" WITH (GET ENV "DB_PORT") SET "apiUrl" WITH (GET ENV "API_BASE_URL") -- Use in conditional logic IF GET ENV "FEATURE_FLAG" == "enabled" THEN PUBLISH TOPIC "features/new" WITH "active" ``` ### KEEP ENV Persists a new environment variable to the `.env` file on disk: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP ENV "NAME" WITH ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Save a timestamp KEEP ENV "LAST_RUN" WITH TIMESTAMP "UTC" -- Save a computed value KEEP ENV "DEVICE_COUNT" WITH {count} ``` ### DELETE ENV Removes an environment variable from the managed `.env` file: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DELETE ENV "NAME" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DELETE ENV "OLD_CONFIG" ``` ### GET SECRET Reads an encrypted secret from `secrets.json`, decrypting it at runtime. Returns an empty string if the secret does not exist. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET SECRET "NAME" ``` #### Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Read credentials SET "dbPass" WITH (GET SECRET "DB_PASSWORD") SET "apiKey" WITH (GET SECRET "API_KEY") -- Use in conditional logic IF GET SECRET "LICENSE_KEY" == EMPTY THEN PUBLISH TOPIC "system/warnings" WITH "No license key configured" ``` ### KEEP SECRET Encrypts and persists a new secret to `secrets.json`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP SECRET "NAME" WITH "value" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP SECRET "API_KEY" WITH "sk-abc123" ``` ### DELETE SECRET Removes a secret from `secrets.json`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DELETE SECRET "NAME" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DELETE SECRET "OLD_API_KEY" ``` ### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SecureDataForwarder ON TOPIC "sensors/+/data" DO SET "sensor_id" WITH TOPIC POSITION 2 -- Read configuration from environment SET "apiUrl" WITH (GET ENV "API_BASE_URL") SET "region" WITH (GET ENV "DEPLOY_REGION") -- Read credentials from secrets SET "apiKey" WITH (GET SECRET "API_KEY") IF {apiUrl} == EMPTY THEN PUBLISH TOPIC "system/errors" WITH "API_BASE_URL not configured" ELSE -- Forward sensor data with region tag PUBLISH TOPIC "forwarded/" + {region} + "/" + {sensor_id} WITH PAYLOAD PUBLISH TOPIC "forwarded/" + {sensor_id} + "/timestamp" WITH TIMESTAMP "UTC" -- Track last forwarded sensor KEEP ENV "LAST_FORWARDED_SENSOR" WITH {sensor_id} ``` For full details on environment variable resolution order, encryption key management, and deployment configuration, see [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env). *** ## Arithmetic Operators LoT supports standard arithmetic operations that work with numeric types (INT, DOUBLE) and can be combined with GET TOPIC, PAYLOAD, and variables. ### Supported Operators | Operator | Symbol | Description | | -------------- | ------ | ------------------------------- | | Addition | `+` | Adds two values together | | Subtraction | `-` | Subtracts right value from left | | Multiplication | `*` | Multiplies two values | | Division | `/` | Divides left value by right | | Modulo | `%` | Returns remainder of division | | Unary Negative | `-` | Negates a value | ### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} + - * / % - ``` ### Basic Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Simple arithmetic with literals SET "sum" WITH (10 + 5) SET "difference" WITH (100 - 25) SET "product" WITH (6 * 7) SET "quotient" WITH (100 / 4) SET "remainder" WITH (17 % 5) SET "negative" WITH (-42) ``` ### Using with Variables ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "a" WITH 100 SET "b" WITH 25 SET "result" WITH ({a} + {b}) SET "average" WITH (({a} + {b}) / 2) ``` ### Using with GET TOPIC ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Add values from two different topics SET "total" WITH (GET TOPIC "sensors/count/a" AS INT + GET TOPIC "sensors/count/b" AS INT) // Calculate percentage SET "percentage" WITH (GET TOPIC "current/value" AS DOUBLE / GET TOPIC "max/value" AS DOUBLE * 100) // Temperature conversion (Celsius to Fahrenheit) SET "fahrenheit" WITH (GET TOPIC "sensors/temp/celsius" AS DOUBLE * 9 / 5 + 32) ``` ### Using with PAYLOAD ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CalculateWithPayload ON TOPIC "input/value" DO SET "doubled" WITH (PAYLOAD AS DOUBLE * 2) SET "incremented" WITH (PAYLOAD AS INT + 1) PUBLISH TOPIC "output/doubled" WITH {doubled} ``` ### Operator Precedence Operations follow standard mathematical precedence: 1. Parentheses `()` - highest priority 2. Unary negation `-` 3. Multiplication `*`, Division `/`, Modulo `%` 4. Addition `+`, Subtraction `-` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Without parentheses: 2 + 3 * 4 = 14 (multiplication first) SET "result1" WITH (2 + 3 * 4) // With parentheses: (2 + 3) * 4 = 20 SET "result2" WITH ((2 + 3) * 4) ``` ### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TemperatureProcessor ON TOPIC "sensors/+/temperature" DO // POSITION 1="sensors", POSITION 2=sensor_id (wildcard), POSITION 3="temperature" SET "sensor_id" WITH TOPIC POSITION 2 SET "celsius" WITH PAYLOAD AS DOUBLE // Convert to Fahrenheit SET "fahrenheit" WITH ({celsius} * 9 / 5 + 32) // Calculate deviation from setpoint SET "setpoint" WITH (GET TOPIC "config/setpoint" AS DOUBLE) SET "deviation" WITH ({celsius} - {setpoint}) SET "deviation_percent" WITH ({deviation} / {setpoint} * 100) PUBLISH TOPIC "processed/" + {sensor_id} + "/fahrenheit" WITH {fahrenheit} PUBLISH TOPIC "processed/" + {sensor_id} + "/deviation" WITH {deviation_percent} ``` *** ## String Concatenation The `+` operator concatenates strings, allowing dynamic construction of topics, messages, and values. ### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} + ``` ### Basic Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "greeting" WITH ("Hello, " + "World!") SET "fullname" WITH ({firstname} + " " + {lastname}) ``` ### Dynamic Topic Construction ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Build topic paths dynamically PUBLISH TOPIC "devices/" + {device_id} + "/status" WITH "online" PUBLISH TOPIC "sensors/" + {sensor_type} + "/" + {location} + "/reading" WITH {value} // Using with KEEP KEEP TOPIC "cache/" + {sensor_id} + "/last_reading" WITH PAYLOAD ``` ### Combining Strings with Other Types ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // String + Number (number is converted to string) SET "message" WITH ("Temperature is: " + {temp}) PUBLISH TOPIC "alerts/temperature" WITH ("High temp: " + {celsius} + "°C") ``` ### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION DynamicPublisher ON TOPIC "input/+/+/data" DO // POSITION 1="input", POSITION 2=region, POSITION 3=device, POSITION 4="data" SET "region" WITH TOPIC POSITION 2 SET "device" WITH TOPIC POSITION 3 SET "timestamp" WITH TIMESTAMP "UTC" // Build dynamic output topic SET "output_topic" WITH ("processed/" + {region} + "/" + {device}) // Build descriptive message SET "message" WITH ("Device " + {device} + " in " + {region} + " reported at " + {timestamp}) PUBLISH TOPIC {output_topic} WITH {message} ``` *** ## RANDOM Function Generates random values of various types including numeric ranges and unique identifiers. ### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Random integer in range (inclusive) RANDOM BETWEEN AND RANDOM INT BETWEEN AND // Random double/float in range RANDOM DOUBLE BETWEEN AND // Unique identifiers RANDOM UUID RANDOM GUID RANDOM ULID ``` ### Random Integers ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Random integer between 1 and 100 SET "dice_roll" WITH (RANDOM BETWEEN 1 AND 6) SET "random_percent" WITH (RANDOM INT BETWEEN 0 AND 100) // Using expressions for range SET "random_in_range" WITH (RANDOM INT BETWEEN {min_value} AND {max_value}) ``` ### Random Doubles ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Random double between 0.0 and 1.0 SET "probability" WITH (RANDOM DOUBLE BETWEEN 0.0 AND 1.0) // Random temperature for simulation SET "simulated_temp" WITH (RANDOM DOUBLE BETWEEN 18.5 AND 25.5) ``` ### Unique Identifiers ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Generate UUID (e.g., "550e8400-e29b-41d4-a716-446655440000") SET "transaction_id" WITH (RANDOM UUID) // Generate GUID (same format as UUID) SET "session_id" WITH (RANDOM GUID) // Generate ULID (e.g., "01ARZ3NDEKTSV4RRFFQ69G5FAV") // ULIDs are sortable by time SET "event_id" WITH (RANDOM ULID) ``` ### Use Cases Generate test data for sensors: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SimulateSensor ON EVERY 5 SECONDS DO SET "temperature" WITH (RANDOM DOUBLE BETWEEN 20.0 AND 30.0) SET "humidity" WITH (RANDOM INT BETWEEN 40 AND 80) SET "pressure" WITH (RANDOM DOUBLE BETWEEN 1000.0 AND 1020.0) PUBLISH TOPIC "simulated/sensor/temperature" WITH {temperature} PUBLISH TOPIC "simulated/sensor/humidity" WITH {humidity} PUBLISH TOPIC "simulated/sensor/pressure" WITH {pressure} ``` Generate unique record identifiers: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CreateRecord ON TOPIC "records/create" DO SET "record_id" WITH (RANDOM UUID) SET "timestamp" WITH TIMESTAMP "UTC" PUBLISH TOPIC "records/" + {record_id} WITH PAYLOAD PUBLISH TOPIC "records/index/latest" WITH {record_id} ``` Distribute work across workers: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION RandomRouter ON TOPIC "incoming/requests" DO SET "worker" WITH (RANDOM INT BETWEEN 1 AND 4) PUBLISH TOPIC "workers/" + {worker} + "/queue" WITH PAYLOAD ``` *** ## String Transformations LoT provides string transformation operations for filtering, replacing, and parsing text data. ### FILTER Extracts content from a string using a regular expression pattern. #### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} FILTER USING REGEX "" ``` #### Supported Sources * `PAYLOAD` - The incoming message payload * `GET TOPIC "path"` - Value from an MQTT topic * `{variable}` - A previously set variable * `"string literal"` - A hardcoded string #### Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Extract all digits from payload SET "numbers" WITH FILTER PAYLOAD USING REGEX "[0-9]+" // Extract from a topic value SET "extracted" WITH FILTER (GET TOPIC "data/raw") USING REGEX "[a-zA-Z]+" // Extract email pattern SET "email" WITH FILTER PAYLOAD USING REGEX "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" // Extract numbers from mixed content SET "value" WITH FILTER "Temperature: 23.5°C" USING REGEX "[0-9]+\\.?[0-9]*" ``` #### Common Regex Patterns | Pattern | Description | Example Match | | ------------------ | ------------------- | ---------------- | | `[0-9]+` | One or more digits | "123", "4567" | | `[a-zA-Z]+` | One or more letters | "Hello", "World" | | `[0-9]+\\.?[0-9]*` | Decimal numbers | "23.5", "100" | | `[^\\s]+` | Non-whitespace | "word" | | `\\w+` | Word characters | "test\_123" | ### REPLACE Replaces occurrences of a pattern with a new value. #### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} REPLACE "" WITH "" IN ``` #### Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Replace in payload SET "cleaned" WITH REPLACE "old" WITH "new" IN PAYLOAD // Replace in topic value SET "modified" WITH REPLACE "error" WITH "warning" IN (GET TOPIC "logs/latest") // Replace in variable SET "formatted" WITH REPLACE " " WITH "_" IN {sensor_name} // Remove characters by replacing with empty string SET "digits_only" WITH REPLACE "[^0-9]" WITH "" IN {mixed_content} ``` #### Multiple Replacements ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "step1" WITH REPLACE "," WITH ";" IN PAYLOAD SET "step2" WITH REPLACE " " WITH "_" IN {step1} SET "final" WITH REPLACE "'" WITH "" IN {step2} ``` ### SPLIT CSV Parses CSV (Comma-Separated Values) data from the payload into a structured format. #### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SPLIT PAYLOAD CSV [SEPARATOR ""] [HEADERS PRESENT|NONE] ``` #### Parameters | Parameter | Description | Default | | ----------------- | ------------------------------- | ----------- | | `SEPARATOR` | The delimiter character | `,` (comma) | | `HEADERS PRESENT` | First row contains column names | - | | `HEADERS NONE` | No header row | - | #### Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessCSVData ON TOPIC "import/csv" DO // Payload: "name,value,unit\nTemperature,23.5,C\nHumidity,65,percent" SET "parsed" WITH SPLIT PAYLOAD CSV SEPARATOR "," HEADERS PRESENT PUBLISH TOPIC "import/parsed" WITH {parsed} ``` SPLIT CSV currently only supports PAYLOAD as input. Support for GET TOPIC and variables is planned for future releases. *** ## Topic Operations ### PUBLISH vs KEEP — When to Use Which | Operation | Retained | Typical use | | --------------- | -------- | --------------------------------------------------------------------------- | | `PUBLISH TOPIC` | No | Fire to current subscribers, drive routes, dashboards, and external systems | | `KEEP TOPIC` | Yes | Broker-side state shared between actions — readable with `GET TOPIC` | Use `KEEP TOPIC` for inter-action state (counters, mode flags, last-known values). Use `PUBLISH TOPIC` when routes, UIs, or other subscribers must react to the message. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP TOPIC "state/mode" WITH "running" KEEP TOPIC "state/lastTemp" WITH {temp} SET "mode" WITH (GET TOPIC "state/mode" AS STRING) PUBLISH TOPIC "sensors/data" WITH {temp} PUBLISH TOPIC "alerts/zone1" WITH "CRITICAL" ``` Do **not** use `KEEP TOPIC` to drive route events — that path is unreliable. Use `PUBLISH TOPIC` when a route or external consumer must react to the message. ### PUBLISH TOPIC Sends a **non-retained** message to an MQTT topic. Current subscribers receive it immediately; the message can trigger other actions or routes listening on that topic. #### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} PUBLISH TOPIC "" WITH ``` #### Value Types You can publish various value types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // String PUBLISH TOPIC "system/status" WITH "online" // Number PUBLISH TOPIC "sensors/reading" WITH 23.5 // Variable PUBLISH TOPIC "output/value" WITH {calculated_result} // Payload passthrough (string path — may corrupt non-UTF-8 data) PUBLISH TOPIC "forwarded/data" WITH PAYLOAD // Timestamp PUBLISH TOPIC "events/timestamp" WITH TIMESTAMP "UTC" ``` #### Binary passthrough — `PAYLOAD AS BYTES` Use `AS BYTES` when the incoming MQTT payload must be forwarded or retained bit-for-bit — for example protobuf messages, Modbus frames, or image chunks. Untyped `WITH PAYLOAD` still uses the string publish path and may corrupt non-UTF-8 data. To **decode or encode** protobuf fields rather than forward the blob, use [Protobuf Models](/latest/lot-language/models/protobuf) (`GET PROTO` / `PUBLISH MODEL`). ```lot wrap focus={3} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ForwardBinary ON TOPIC "binary/in" DO PUBLISH TOPIC "binary/out" WITH PAYLOAD AS BYTES ``` `KEEP TOPIC` and `SET` accept the same binary form, so you can store raw frames for later reads with `GET TOPIC`: ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION KeepBinaryState ON TOPIC "binary/in" DO KEEP TOPIC "state/raw" WITH PAYLOAD AS BYTES SET "frame" WITH PAYLOAD AS BYTES ``` **Binary path limitations** * Do **not** chain string transforms (`TRIM`, `FILTER`, `REPLACE`, `SPLIT`) after `PAYLOAD AS BYTES` on publish or keep — the parser rejects that combination. * Empty payloads are not published or stored on the binary path. **Cannot Use Inline JSON Objects** `PUBLISH TOPIC` does **not** support inline JSON object literals. This will NOT work: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // ❌ WRONG - This does not work PUBLISH TOPIC "output/data" WITH {"temperature": 23.5, "humidity": 65} ``` **Why?** `PUBLISH TOPIC` is designed for simple scalar values (strings, numbers, timestamps, variables). It doesn't have the type system or schema validation needed for structured JSON data. **Solution:** For structured JSON output, use [Models](/latest/lot-language/models/overview) with `PUBLISH MODEL`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // ✅ CORRECT - Define a model and publish it DEFINE MODEL SensorData COLLAPSED ADD DOUBLE "temperature" ADD DOUBLE "humidity" DEFINE ACTION PublishStructuredData ON TOPIC "sensors/input" DO PUBLISH MODEL SensorData TO "output/data" WITH temperature = 23.5 humidity = 65 ``` Models provide type safety, schema validation, and proper JSON serialization. See [Publishing Models](/latest/lot-language/models/publish-model) for complete guidance. #### Dynamic Topics Build topic paths dynamically using string concatenation: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} PUBLISH TOPIC "devices/" + {device_id} + "/status" WITH "active" PUBLISH TOPIC "sensors/" + {sensor_type} + "/" + {location} + "/reading" WITH {value} ``` #### Multiple Publishes Actions can publish to multiple topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION MultiPublish ON TOPIC "input/data" DO PUBLISH TOPIC "output/raw" WITH PAYLOAD PUBLISH TOPIC "output/timestamp" WITH TIMESTAMP "UTC" PUBLISH TOPIC "output/source" WITH "sensor_001" ``` **Triggering Other Actions:** When you publish to a topic, any action with `ON TOPIC` matching that path will execute. This enables chaining actions together for complex workflows. ### KEEP TOPIC Stores a retained value on a topic that persists between action executions. Unlike `SET` (which exists only during one execution), `KEEP` values survive restarts and are readable with `GET TOPIC` from other actions or routes. **KEEP vs PUBLISH:** `PUBLISH TOPIC` is non-retained and drives routes and external subscribers. `KEEP TOPIC` is retained and is the right choice for broker-side state you read back with `GET TOPIC`. #### When to Use KEEP | Use Case | Example | | ------------------------------ | ------------------------------------------------- | | Cache last known value | `KEEP TOPIC "cache/last_temp" WITH {temperature}` | | Store state between runs | `KEEP TOPIC "state/counter" WITH {count}` | | Save intermediate calculations | `KEEP TOPIC "calc/running_avg" WITH {avg}` | #### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP TOPIC "" WITH KEEP TOPIC "" WITH PAYLOAD AS BYTES ``` Retain string or variable state, including dynamic topic paths: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP TOPIC "state/mode" WITH "running" KEEP TOPIC "device/" + {id} + "/config" WITH {config} ``` #### Example: Persistent Counter ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PersistentCounter ON EVERY 10 SECONDS DO SET "current" WITH (GET TOPIC "state/counter") IF {current} == EMPTY THEN KEEP TOPIC "state/counter" WITH 1 ELSE KEEP TOPIC "state/counter" WITH ({current} + 1) PUBLISH TOPIC "stats/count" WITH (GET TOPIC "state/counter") ``` #### KEEP vs PUBLISH Comparison | Operation | Retained | Readable with `GET TOPIC` | Typical use | | --------------- | -------- | ------------------------- | ---------------------------------------- | | `PUBLISH TOPIC` | No | Yes (until overwritten) | Routes, dashboards, external subscribers | | `KEEP TOPIC` | Yes | Yes | Shared state between actions | *** ## Control Flow ### IF ... THEN ... ELSE Execute different logic based on conditions. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF THEN ELSE ``` #### Comparison Operators | Operator | Description | Example | | ---------------- | ---------------- | ------------------------ | | `EQUALS` or `==` | Equality | `IF {value} EQUALS 100` | | `!=` | Not equal | `IF {status} != "error"` | | `>` | Greater than | `IF {temp} > 80` | | `<` | Less than | `IF {count} < 10` | | `>=` | Greater or equal | `IF {level} >= 50` | | `<=` | Less or equal | `IF {pressure} <= 100` | #### Basic Conditionals ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF {temperature} > 80 THEN PUBLISH TOPIC "alerts/high_temp" WITH {temperature} ELSE PUBLISH TOPIC "status/normal" WITH "OK" ``` #### Nested Conditionals For multiple conditions, nest IF statements: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF {temperature} > 90 THEN PUBLISH TOPIC "alerts/critical" WITH "CRITICAL: " + {temperature} ELSE IF {temperature} > 80 THEN PUBLISH TOPIC "alerts/warning" WITH "WARNING: " + {temperature} ELSE IF {temperature} > 70 THEN PUBLISH TOPIC "alerts/info" WITH "Elevated: " + {temperature} ELSE PUBLISH TOPIC "status/normal" WITH "OK" ``` #### Checking for Empty Topics Use `EMPTY` to check if a topic has no value: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF GET TOPIC "data/value" == EMPTY THEN PUBLISH TOPIC "data/value" WITH 0 ELSE PUBLISH TOPIC "data/value" WITH (GET TOPIC "data/value" + 1) ``` #### Combining with Expressions ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Check calculated values SET "average" WITH (({temp1} + {temp2}) / 2) IF {average} > {threshold} THEN PUBLISH TOPIC "alerts/average_high" WITH {average} // String comparisons IF {status} EQUALS "active" THEN PUBLISH TOPIC "devices/active_count" WITH (GET TOPIC "devices/active_count" + 1) ``` *** ## Timestamps Add timestamps to your messages for tracking, logging, and debugging. LoT supports two timestamp formats. ### Supported Formats | Format | Output | Use Case | | ------------------ | ---------------------- | ----------------------------------------------------- | | `TIMESTAMP "UTC"` | `2025-10-25T14:30:15Z` | Human-readable logs, APIs, ISO 8601 compliance | | `TIMESTAMP "UNIX"` | `1729864215` | Databases, calculations, cross-platform compatibility | ### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} TIMESTAMP "UTC" // ISO 8601 format TIMESTAMP "UNIX" // Unix epoch (seconds since 1970-01-01) ``` ### Using with PUBLISH and KEEP ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Add timestamp to published messages PUBLISH TOPIC "events/created" WITH TIMESTAMP "UTC" PUBLISH TOPIC "sensors/reading/time" WITH TIMESTAMP "UNIX" // Store timestamp for later reference KEEP TOPIC "cache/last_update" WITH TIMESTAMP "UNIX" ``` ### Using with SET ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Store timestamp in a variable SET "event_time" WITH TIMESTAMP "UTC" SET "process_start" WITH TIMESTAMP "UNIX" // Use in string concatenation SET "log_message" WITH ("Event occurred at " + TIMESTAMP "UTC") PUBLISH TOPIC "logs/events" WITH {log_message} ``` ### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AuditLogger ON TOPIC "actions/+/executed" DO SET "action_name" WITH TOPIC POSITION 2 SET "utc_time" WITH TIMESTAMP "UTC" SET "unix_time" WITH TIMESTAMP "UNIX" // Human-readable log PUBLISH TOPIC "audit/" + {action_name} + "/log" WITH ("Action " + {action_name} + " executed at " + {utc_time}) // Machine-readable timestamp for database storage PUBLISH TOPIC "audit/" + {action_name} + "/timestamp" WITH {unix_time} ``` *** ## Callable Actions LoT supports callable actions - actions that can be invoked by other actions with input parameters and return values. This enables modular, reusable logic blocks. For reusable logic, on-demand scripts, and composition patterns, see [Callable ACTIONs](./callable-actions). ### Defining a Callable Action ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ActionName INPUT param1 AS INPUT param2 AS DO RETURN OUTPUT variable1 OUTPUT variable2 ``` #### Supported Input Types * `STRING` * `INT` * `DOUBLE` * `BOOL` * `JSON` ### Calling an Action ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL ACTION ActionName WITH param1 = , param2 = RETURN var1, var2 ``` ### Basic Example Create a reusable calculation: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CalculateAverage INPUT value1 AS DOUBLE INPUT value2 AS DOUBLE DO SET "sum" WITH ({value1} + {value2}) SET "avg" WITH ({sum} / 2) RETURN OUTPUT avg ``` Use the callable action: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessSensorPair ON TOPIC "sensors/pair/data" DO SET "temp1" WITH (GET JSON "sensor1" IN PAYLOAD AS DOUBLE) SET "temp2" WITH (GET JSON "sensor2" IN PAYLOAD AS DOUBLE) CALL ACTION CalculateAverage WITH value1 = {temp1}, value2 = {temp2} RETURN result PUBLISH TOPIC "sensors/average" WITH {result} ``` ### Multiple Outputs ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AnalyzeValue INPUT value AS DOUBLE INPUT threshold AS DOUBLE DO SET "is_above" WITH ({value} > {threshold}) SET "difference" WITH ({value} - {threshold}) SET "percentage" WITH ({value} / {threshold} * 100) RETURN OUTPUT is_above OUTPUT difference OUTPUT percentage ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CheckThreshold ON TOPIC "sensors/+/value" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "current_value" WITH PAYLOAD AS DOUBLE SET "threshold" WITH (GET TOPIC "config/threshold" AS DOUBLE) CALL ACTION AnalyzeValue WITH value = {current_value}, threshold = {threshold} RETURN above, diff, pct PUBLISH TOPIC "analysis/" + {sensor_id} + "/above_threshold" WITH {above} PUBLISH TOPIC "analysis/" + {sensor_id} + "/difference" WITH {diff} PUBLISH TOPIC "analysis/" + {sensor_id} + "/percentage" WITH {pct} ``` ### Utility Action Library Build reusable utility actions for common operations: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Reusable temperature conversion DEFINE ACTION ConvertCelsiusToFahrenheit INPUT celsius AS DOUBLE DO SET "fahrenheit" WITH ({celsius} * 9 / 5 + 32) RETURN OUTPUT fahrenheit // Reusable percentage calculation DEFINE ACTION CalculatePercentage INPUT value AS DOUBLE INPUT total AS DOUBLE DO SET "pct" WITH ({value} / {total} * 100) RETURN OUTPUT pct // Main processing action using utilities DEFINE ACTION ProcessTemperatureReading ON TOPIC "sensors/+/celsius" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp_c" WITH PAYLOAD AS DOUBLE SET "max_temp" WITH (GET TOPIC "config/max_temperature" AS DOUBLE) CALL ACTION ConvertCelsiusToFahrenheit WITH celsius = {temp_c} RETURN temp_f CALL ACTION CalculatePercentage WITH value = {temp_c}, total = {max_temp} RETURN capacity PUBLISH TOPIC "sensors/" + {sensor_id} + "/fahrenheit" WITH {temp_f} PUBLISH TOPIC "sensors/" + {sensor_id} + "/capacity_percent" WITH {capacity} ``` *** ## TRIGGER Statement The TRIGGER statement allows actions to programmatically trigger route events or mappings. This is useful for initiating data pipeline operations or triggering external integrations. ### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} TRIGGER "" TO FOR ``` ### Components | Component | Description | | -------------- | --------------------------------------------------- | | `route_name` | The name of the route to trigger | | `data` | The data to send (PAYLOAD, variable, or expression) | | `mapping_name` | The specific mapping or event within the route | ### Basic Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Trigger a route with payload data TRIGGER "EmailRoute" TO PAYLOAD FOR "SendAlert" // Trigger with a variable SET "message" WITH ("Alert: " + {sensor_id} + " exceeded threshold") TRIGGER "NotificationRoute" TO {message} FOR "EmailMapping" // Trigger with a literal value TRIGGER "DatabaseRoute" TO "backup_requested" FOR "BackupEvent" ``` ### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AlertHandler ON TOPIC "alerts/+/critical" DO SET "alert_type" WITH TOPIC POSITION 2 SET "alert_data" WITH PAYLOAD SET "timestamp" WITH TIMESTAMP "UTC" // Build alert message SET "message" WITH ("Critical alert [" + {alert_type} + "] at " + {timestamp}) // Trigger email notification route TRIGGER "EmailNotification" TO {message} FOR "CriticalAlertEmail" // Also trigger database storage TRIGGER "DatabaseStorage" TO {alert_data} FOR "StoreAlert" PUBLISH TOPIC "alerts/processed" WITH {alert_type} ``` ### Use Cases ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SendDailyReport ON EVERY 24 HOURS DO SET "report_data" WITH (GET TOPIC "reports/daily/summary") TRIGGER "EmailRoute" TO {report_data} FOR "DailyReportEmail" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ArchiveOldData ON EVERY 1 HOUR DO SET "cutoff_time" WITH TIMESTAMP "UNIX" TRIGGER "DatabaseRoute" TO {cutoff_time} FOR "ArchiveEvent" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION NotifyExternalSystem ON TOPIC "events/important" DO TRIGGER "RestAPIRoute" TO PAYLOAD FOR "WebhookEvent" ``` *** ## CALL MCP CALL MCP allows actions to invoke tools provided by MCP (Model Context Protocol) routes. This enables integration with AI assistants and external tools. ### Prerequisites * An MCP route must be defined and active * The route must expose the tool you want to call ### Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL MCP "RouteName.tool_name" WITH (arg1 = , arg2 = , ...) RETURN AS {variable_name} ``` ### Example MCP Route ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FileSystem WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "npx" WITH SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-filesystem, /data" WITH CLIENT_NAME "CorefluxBroker" ``` ### Basic Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Call an MCP tool with arguments CALL MCP "FileSystem.read_file" WITH (path = "/data/config.json") RETURN AS {file_content} // Call without arguments CALL MCP "MyMCPRoute.list_resources" RETURN AS {resources} // Call with multiple arguments CALL MCP "MyMCPRoute.echo" WITH (message = "Hello", count = 3) RETURN AS {response} ``` ### Complete Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ReadDeviceConfig ON TOPIC "devices/+/request_config" DO SET "device_id" WITH TOPIC POSITION 2 SET "config_path" WITH "/configs/" + {device_id} + ".json" CALL MCP "FileSystem.read_file" WITH (path = {config_path}) RETURN AS {config_data} IF {config_data} == EMPTY THEN PUBLISH TOPIC "devices/" + {device_id} + "/config/error" WITH "Config not found" ELSE PUBLISH TOPIC "devices/" + {device_id} + "/config" WITH {config_data} ``` *** ## Complete Example This example combines the key operations covered on this page—SET, GET TOPIC, GET JSON, arithmetic, IF/THEN, PUBLISH, and KEEP—in a realistic sensor processing action. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ComprehensiveProcessor ON TOPIC "sensors/+/data" DO // Extract sensor ID from topic path (POSITION 2 = the wildcard) SET "sensor_id" WITH TOPIC POSITION 2 // Parse JSON payload SET "temperature" WITH (GET JSON "temp" IN PAYLOAD AS DOUBLE) // Convert to Fahrenheit SET "fahrenheit" WITH ({temperature} * 9 / 5 + 32) // Conditional alerting IF {temperature} > 80 THEN PUBLISH TOPIC "alerts/" + {sensor_id} WITH "High temp: " + {temperature} // Publish processed data PUBLISH TOPIC "processed/" + {sensor_id} + "/temperature" WITH {temperature} PUBLISH TOPIC "processed/" + {sensor_id} + "/fahrenheit" WITH {fahrenheit} PUBLISH TOPIC "processed/" + {sensor_id} + "/timestamp" WITH TIMESTAMP "UTC" // Cache for later reference KEEP TOPIC "cache/" + {sensor_id} + "/last_reading" WITH PAYLOAD ``` ### What This Action Does 1. **Triggers** on any message to `sensors/+/data` topics 2. **Extracts** the sensor ID from the topic path 3. **Parses** the JSON payload to get temperature value 4. **Converts** Celsius to Fahrenheit using arithmetic 5. **Alerts** if temperature exceeds threshold 6. **Publishes** formatted data to processed topics 7. **Caches** the raw payload for future reference *** ## Quick Reference | Operation | Category | Description | | ----------------------------------------- | -------------- | ----------------------------------------------------------- | | `+, -, *, /, %` | Arithmetic | Mathematical operations | | `+ (strings)` | String | Concatenation | | `RANDOM BETWEEN` | Generation | Random numbers | | `RANDOM UUID/GUID/ULID` | Generation | Unique identifiers | | `FILTER ... USING REGEX` | Transformation | Extract with regex | | `REPLACE ... WITH ... IN` | Transformation | String replacement | | `SPLIT ... CSV` | Transformation | Parse CSV data | | `GET ENV` | Configuration | Read environment variable | | `GET SECRET` | Configuration | Read encrypted secret | | `KEEP ENV` | Configuration | Persist environment variable | | `KEEP SECRET` | Configuration | Persist encrypted secret | | `DELETE ENV` | Configuration | Remove environment variable | | `DELETE SECRET` | Configuration | Remove secret | | `CALL PYTHON` | Integration | Execute Python functions | | `CALL MCP` | Integration | Invoke MCP tools | | `CALL ACTION` | Control | Call other actions | | `TRIGGER` | Control | Trigger route events | | `PUBLISH TOPIC` | Topic | Non-retained publish to routes, dashboards, and subscribers | | `PUBLISH TOPIC ... WITH PAYLOAD AS BYTES` | Topic | Forward binary payloads bit-for-bit | | `KEEP TOPIC` | Topic | Retained broker-side state readable with `GET TOPIC` | | `KEEP TOPIC ... WITH PAYLOAD AS BYTES` | Topic | Retain binary payloads without string conversion | *** ## Next Steps Extend actions with Python functions for complex logic. Run and deploy these operations from VS Code with LoT Notebooks. # ACTION Overview Source: https://docs.coreflux.org/latest/lot-language/actions/overview Learn how LoT Actions automate MQTT data processing with event-driven logic that runs inside the broker ## Overview Actions are executable logic blocks that run directly inside the Coreflux MQTT broker. They react to events—incoming messages, time intervals, or manual triggers—and perform operations like data transformation, routing, and alerting. **Think of Actions like a mail sorting machine.** When a letter (message) arrives, the machine reads the address (topic), decides where it should go (routing logic), and sends it to the right mailbox (publishes to a topic). All of this happens instantly, without needing a person to manually sort each letter. ## Action Capabilities | Capability | Description | | -------------------------- | -------------------------------------------------------------------------- | | **Event-Driven Execution** | Trigger on MQTT messages with wildcard support for flexible topic matching | | **Time-Based Scheduling** | Execute at regular intervals—seconds to weeks—for monitoring and reporting | | **Data Transformation** | Extract JSON fields, perform calculations, and restructure payloads inline | | **Conditional Logic** | Route data, set thresholds, and make decisions with IF/THEN/ELSE | *** ## When to Use Each Pattern | Pattern | Use When | | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | **Time-Based** (`ON EVERY`) | Periodic tasks: heartbeats, polling, scheduled reports | | **Topic-Based** (`ON TOPIC`, `ON CHANGE`) | Event-driven processing: every message (`ON TOPIC`) or only when the payload changes (`ON CHANGE`) | | **Client lifecycle** (`ON CONNECT`, `ON DISCONNECT`, `ON SUBSCRIBE`) | Session events: audit connections, track subscriptions, react when clients join or leave | | **Callable** (no trigger) | Reusable utilities invoked by other actions or commands | | **Python-Extended** (`CALL PYTHON`) | Complex calculations, external libraries, data validation | *** ## In This Section Start here to learn the DEFINE ACTION structure and build your first action. Choose the right trigger type for your use case: time-based, topic-based, or callable. Master SET, GET, PUBLISH, conditionals, and all available LoT operations. Extend actions with Python when you need complex logic or external libraries. *** ## Action Types LoT supports three action patterns: ### Time-Based Actions Execute at regular intervals using `ON EVERY`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SystemHeartbeat ON EVERY 5 SECONDS DO PUBLISH TOPIC "system/status" WITH "online" PUBLISH TOPIC "system/timestamp" WITH TIMESTAMP "UTC" ``` **Key characteristics:** * Runs on a schedule regardless of incoming data * Useful for monitoring, polling, and periodic reports * Supported units: MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS * Calendar-aware scheduling: `ON EVERY WEEKDAY AT "HH:MM:SS"` and `ON EVERY WEEKEND AT "HH:MM:SS"` ### Topic-Based Actions Execute when MQTT messages arrive using `ON TOPIC` (every matching message) or `ON CHANGE` (only when the payload differs from the last message on that topic): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TemperatureProcessor ON TOPIC "sensors/+/temperature" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE) IF {temp} > 80 THEN PUBLISH TOPIC "alerts/high_temp/" + {sensor_id} WITH PAYLOAD ELSE PUBLISH TOPIC "processed/" + {sensor_id} WITH PAYLOAD ``` Trigger only when the message payload changes from the last published value with `ON CHANGE`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TemperatureOnChange ON CHANGE "sensors/+/temperature" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp" WITH PAYLOAD AS DOUBLE PUBLISH TOPIC "analytics/" + {sensor_id} WITH {temp} ``` **Key characteristics:** * `ON TOPIC` runs on every matching publish; `ON CHANGE` skips identical payloads on the same topic * Supports `+` (single-level) and `#` (multi-level) [MQTT wildcards](/latest/mqtt-broker/protocol#wildcards) * Access payload data via `PAYLOAD` and `GET JSON` * Extract topic segments with `TOPIC POSITION` See [Action Triggers](./events#topic-based-triggers) for when to choose each trigger and a side-by-side comparison. ### Client Lifecycle Actions Execute when MQTT clients connect, disconnect, or subscribe using `ON CONNECT`, `ON DISCONNECT`, or `ON SUBSCRIBE`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AuditConnect ON CONNECT DO PUBLISH TOPIC "audit/connect/" + CLIENTID WITH USER + "@" + ENDPOINT DEFINE ACTION TrackSubscriptions ON SUBSCRIBE "sensors/#" DO PUBLISH TOPIC "audit/subscriptions" WITH CLIENTID + " -> " + SUBSCRIBETOPIC ``` **Key characteristics:** * Runs on session events instead of publishes — useful for audit trails and connection dashboards * Access session context via `CLIENTID`, `USER`, `ENDPOINT`, `CLEANSESSION`, `DISCONNECTREASON`, `SUBSCRIBETOPIC`, and `SUBSCRIBEQOS` * `ON SUBSCRIBE` supports optional MQTT wildcard patterns, same semantics as `ON TOPIC` * The broker only evaluates lifecycle hooks when at least one action defines them See [Client Lifecycle Triggers](./events#client-lifecycle-triggers) for examples and the session context variable reference. ### Callable Actions Actions without triggers are invoked manually or by other actions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CalculateAverage INPUT value1 AS DOUBLE INPUT value2 AS DOUBLE DO SET "avg" WITH (({value1} + {value2}) / 2) RETURN OUTPUT avg ``` **Key characteristics:** * No automatic trigger—must be invoked explicitly * Can accept input parameters and return values * Invoked via `CALL ACTION` from other ACTIONs * Ideal for reusable utility functions Learn more in [Callable ACTIONs](./callable-actions)—reusable logic, on-demand scripts, and `CALL ACTION`. *** ## Core Operations Actions use these operations to process data: SET, GET TOPIC, GET JSON, PUBLISH, and IF/THEN are the core operations for working with data in actions. See the [Operations Reference](./operations) for complete details. Create and use variables within an action: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "sensor_id" WITH TOPIC POSITION 2 SET "temperature" WITH (GET TOPIC "sensors/temp" AS DOUBLE) SET "humidity" WITH (GET JSON "humidity" IN PAYLOAD AS DOUBLE) PUBLISH TOPIC "output/" + {sensor_id} WITH {temperature} ``` Read from and write to MQTT topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Read from a topic SET "last_value" WITH (GET TOPIC "sensors/temperature" AS DOUBLE) // Publish to subscribers PUBLISH TOPIC "output/data" WITH {temperature} // Store internally (not broadcast) KEEP TOPIC "cache/last_reading" WITH PAYLOAD ``` Parse JSON payloads and extract fields: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // For payload: {"temp": 23.5, "humidity": 65} SET "temp" WITH (GET JSON "temp" IN PAYLOAD AS DOUBLE) SET "humidity" WITH (GET JSON "humidity" IN PAYLOAD AS INT) // Nested paths SET "city" WITH (GET JSON "location.city" IN PAYLOAD AS STRING) ``` Implement branching logic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF {temperature} > 80 THEN PUBLISH TOPIC "alerts/high" WITH {temperature} ELSE IF {temperature} < 20 THEN PUBLISH TOPIC "alerts/low" WITH {temperature} ELSE PUBLISH TOPIC "status/normal" WITH "OK" ``` *** ## Example: Complete Sensor Processor This example combines topic matching, JSON extraction, conditional routing, and state updates in a realistic sensor processing action. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SensorDataProcessor ON TOPIC "sensors/+/raw" DO // Extract sensor ID from topic path // Topic: sensors/temp001/raw → POSITION 2 = "temp001" SET "sensor_id" WITH TOPIC POSITION 2 // Parse JSON payload SET "temp_value" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) SET "humidity" WITH (GET JSON "humidity" IN PAYLOAD AS DOUBLE) // Conditional routing based on thresholds IF {temp_value} > 80 THEN PUBLISH TOPIC "alerts/high_temp/" + {sensor_id} WITH PAYLOAD ELSE IF {temp_value} < 10 THEN PUBLISH TOPIC "alerts/low_temp/" + {sensor_id} WITH PAYLOAD ELSE PUBLISH TOPIC "sensors/normal/" + {sensor_id} WITH PAYLOAD // Always update the processed output PUBLISH TOPIC "sensors/processed/" + {sensor_id} WITH PAYLOAD // Record the last update timestamp PUBLISH TOPIC "sensors/" + {sensor_id} + "/last_update" WITH TIMESTAMP "UTC" ``` **What this does:** 1. Triggers when any message arrives at `sensors/*/raw` 2. Extracts the sensor ID from the topic path 3. Parses temperature and humidity from the JSON payload 4. Routes alerts based on temperature thresholds 5. Publishes processed data and timestamps *** ## Actions vs. Models Actions and Models serve different purposes in LoT: | Aspect | Actions | Models | | -------------- | --------------------------------- | ---------------------------- | | **Purpose** | Process and route data | Structure and format data | | **Execution** | Event-driven logic | Schema definition | | **Output** | Any MQTT operation | Structured JSON | | **Complexity** | Conditionals, loops, Python | Field mapping, calculations | | **Best For** | Routing, alerting, transformation | Data aggregation, formatting | Use them together: Actions process and route data, then publish it using a Model to produce structured JSON output. *** ## Next Steps Learn the complete DEFINE ACTION syntax and components. Master time-based, topic-based, lifecycle, and ON START triggers. Reusable logic, on-demand scripts, and `CALL ACTION`. # ACTION Python Integration Source: https://docs.coreflux.org/latest/lot-language/actions/python-integration Extend LoT actions with Python functions for complex calculations and data processing ## Overview While LoT handles most IoT automation elegantly, some tasks need the full power of a programming language. Python integration lets you call Python functions directly from your LoT actions—no external services, no network calls, no containers. Your Python code runs inside the broker alongside your actions. **Think of Python as a specialist you can call for help.** Your LoT action handles the routine work (reading sensors, routing messages, checking thresholds), but when it encounters something complex—statistical analysis, regex parsing, or calling an external library—it hands the task to Python and gets the result back instantly. *** ## When to Use Python vs. LoT Before diving into syntax, understand when Python makes sense: | Use Python For | Use LoT For | | -------------------------------------- | ------------------------------------ | | Complex calculations (statistics, ML) | Simple math (+, -, \*, /) | | Regex and advanced string parsing | String concatenation | | Data validation with complex rules | Basic conditionals (IF/THEN) | | External library calls (numpy, pandas) | MQTT operations (PUBLISH, GET TOPIC) | | JSON transformation and restructuring | Field extraction (GET JSON) | If you can do it in LoT, do it in LoT. Python adds overhead—use it when LoT's built-in operations aren't enough. *** ## Writing Python Scripts Every Python script in LoT must declare its name on the **first line** using a `# Script Name:` comment. That name is what you use in `CALL PYTHON "ScriptName.function_name"`—the part before the dot must match the header exactly. ```python wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Script Name: Calculator def add(a, b): return a + b def multiply(a, b): return a * b ``` The first line must be `# Script Name: YourScriptName`. Without this header, the broker does not register the script and `CALL PYTHON` cannot find it. | Header | LoT call | | ------------------------------ | ----------------------------------------------- | | `# Script Name: Calculator` | `CALL PYTHON "Calculator.add"` | | `# Script Name: DataProcessor` | `CALL PYTHON "DataProcessor.calculate_average"` | Define one or more functions below the header. Each function is callable from LoT by name. *** ## CALL PYTHON Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL PYTHON "ScriptName.function_name" WITH (parameter1, parameter2, ...) RETURN AS {variable_name} ``` ## Parameter Passing ### Single Parameter ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL PYTHON "Processor.validate" WITH (PAYLOAD) RETURN AS {result} ``` ### Multiple Parameters ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "min_val" WITH (GET TOPIC "config/min") SET "max_val" WITH (GET TOPIC "config/max") CALL PYTHON "Validator.check_range" WITH (PAYLOAD, {min_val}, {max_val}) RETURN AS {result} ``` ## Return Value Handling Python functions can return simple values or complex JSON objects. The `RETURN AS` clause captures the result into a LoT variable. ### Simple Values For basic calculations, return a single value: **Python script:** ```python wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Script Name: Calculator def add_numbers(a, b): return float(a) + float(b) ``` **LoT action calling the function:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL PYTHON "Calculator.add_numbers" WITH (5, 3) RETURN AS {result} PUBLISH TOPIC "math/result" WITH {result} ``` ### JSON Objects For richer data, return a dictionary—it becomes JSON that LoT can parse: **Python script returning a dictionary:** ```python wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Script Name: Analyzer def analyze_data(value): return { "original": float(value), "doubled": float(value) * 2, "squared": float(value) ** 2 } ``` **LoT action extracting fields from the result:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL PYTHON "Analyzer.analyze_data" WITH (5) RETURN AS {result} PUBLISH TOPIC "analysis/full" WITH {result} PUBLISH TOPIC "analysis/doubled" WITH (GET JSON "doubled" IN {result} AS DOUBLE) ``` ## Error Handling ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL PYTHON "Calculator.safe_division" WITH (10, 2) RETURN AS {result} SET "success" WITH (GET JSON "success" IN {result} AS BOOL) IF {success} EQUALS TRUE THEN PUBLISH TOPIC "calculation/result" WITH (GET JSON "result" IN {result} AS DOUBLE) ELSE PUBLISH TOPIC "calculation/error" WITH (GET JSON "error" IN {result} AS STRING) ``` ## Best Practices Put `# Script Name: YourScriptName` on line 1 of every Python script. The name must match the script name in `CALL PYTHON "YourScriptName.function_name"`. If calls fail with an unknown script, check spelling and that the header is the very first line. Target execution times under 100ms. Long-running Python functions block your action and can delay message processing. For heavy computations, consider offloading to an external service. Wrap Python logic in try/except blocks and return structured error responses. This lets your LoT action gracefully handle failures instead of crashing. Return dictionaries, lists, strings, numbers, and booleans. Custom objects and classes won't serialize properly. When in doubt, convert to a dictionary. Python functions receive parameters as strings by default. Always convert types explicitly (e.g., `float(value)`, `int(count)`) and validate ranges before processing. *** ## Next Steps Review all available LoT operations. Return to the Actions overview for pattern guidance. # ACTION Syntax Reference Source: https://docs.coreflux.org/latest/lot-language/actions/syntax Complete syntax reference for defining LoT actions - event-driven logic executed by the Coreflux MQTT broker Actions are executable logic blocks triggered by events such as timed intervals, topic updates, or explicit calls. They are the primary way to implement automation in LoT. Actions run inside the MQTT broker itself, eliminating the need for external middleware or application servers. *** ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ON DO ``` ### Components A unique identifier for the action. Use descriptive names that indicate the action's purpose (e.g., `SensorDataProcessor`, `HeartbeatMonitor`). Specifies when the action executes. Can be time-based (`ON EVERY`, `ON TIMESTAMP`), topic-based (`ON TOPIC`, `ON CHANGE`), initialization (`ON START`), client lifecycle (`ON CONNECT`, `ON DISCONNECT`, `ON SUBSCRIBE`), or omitted for callable actions. One or more LoT statements that execute when the action triggers. These can include publishing, variable assignment, conditionals, and Python calls. ## Action Structure Every action follows this general structure: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ActionName ON DO // Variable assignment SET "variable" WITH // Conditional logic IF THEN // Statements ELSE // Alternative statements // Topic operations PUBLISH TOPIC "output/topic" WITH KEEP TOPIC "internal/topic" WITH ``` ## Trigger Types Execute at regular intervals: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PeriodicTask ON EVERY 5 SECONDS DO PUBLISH TOPIC "system/heartbeat" WITH "alive" ``` Supported units: `SECOND(S)`, `MINUTE(S)`, `HOUR(S)`, `DAY(S)`, `WEEK(S)` Execute when a topic receives data. Use `ON TOPIC` for every message, or `ON CHANGE` to run only when the payload differs from the last message on that topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION MessageProcessor ON TOPIC "sensors/temperature" DO PUBLISH TOPIC "processed/temp" WITH PAYLOAD ``` Detection of values changed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION OnValueChange ON CHANGE "sensors/+/temperature" DO PUBLISH TOPIC "analytics/updated" WITH PAYLOAD ``` Supports wildcards: `+` (single level), `#` (multi-level). See [Action Triggers](./events#topic-based-triggers) for details. Execute when MQTT clients connect, disconnect, or subscribe: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AuditConnect ON CONNECT DO PUBLISH TOPIC "audit/connect/" + CLIENTID WITH USER + "@" + ENDPOINT DEFINE ACTION AuditDisconnect ON DISCONNECT DO PUBLISH TOPIC "audit/disconnect/" + CLIENTID WITH DISCONNECTREASON DEFINE ACTION TrackSubscriptions ON SUBSCRIBE "sensors/#" DO PUBLISH TOPIC "audit/sub" WITH SUBSCRIBETOPIC + " qos=" + SUBSCRIBEQOS ``` Omit the pattern on `ON SUBSCRIBE` to run on every allowed subscription. See [Client Lifecycle Triggers](./events#client-lifecycle-triggers). No trigger - called manually or by other actions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION UtilityFunction DO KEEP TOPIC "cache/data" WITH (GET TOPIC "source/data") ``` Invoked via `CALL ACTION` from other ACTIONs. See [Callable ACTIONs](./callable-actions). ## Compatible Keywords Keywords that can be used within action logic: ### Variable Operations | Keyword | Description | | ----------- | -------------------------------------------------------------------------------------------------------------------------------- | | `SET` | Create or assign a variable | | `GET TOPIC` | Retrieve data from a topic | | `GET JSON` | Extract fields from JSON payloads — see [Working with JSON](/latest/lot-language/usage-patterns/working-with-json) | | `GET PROTO` | Extract fields from protobuf payloads (`GET PROTOBUF` is the same) — see [Protobuf Models](/latest/lot-language/models/protobuf) | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // For topic "sensors/temp001/data": POSITION 1="sensors", POSITION 2="temp001", POSITION 3="data" SET "sensor_id" WITH TOPIC POSITION 2 SET "temperature" WITH (GET TOPIC "sensors/temp" AS DOUBLE) SET "humidity" WITH (GET JSON "humidity" IN PAYLOAD AS DOUBLE) ``` ### Topic Operations | Keyword | Description | | --------------- | ----------------------------------------------------------- | | `PUBLISH TOPIC` | Non-retained publish to subscribers, routes, and dashboards | | `KEEP TOPIC` | Retained broker-side state, readable with `GET TOPIC` | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} PUBLISH TOPIC "output/data" WITH {temperature} PUBLISH TOPIC "devices/" + {device_id} + "/status" WITH "online" KEEP TOPIC "cache/last_reading" WITH PAYLOAD ``` ### Control Flow | Keyword | Description | | ---------------------- | --------------------- | | `IF ... THEN ... ELSE` | Conditional execution | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF {temperature} > 80 THEN PUBLISH TOPIC "alerts/high_temp" WITH {temperature} ELSE PUBLISH TOPIC "status/normal" WITH "OK" ``` ### Entities | Entity | Description | | ------------------ | --------------------------------------------------------------------------------- | | `PAYLOAD` | The data that triggered the action; use `PAYLOAD AS BYTES` for binary passthrough | | `CLIENTID` | MQTT client ID for the session or publisher that fired the trigger | | `USER` | Authenticated username for the session or publisher | | `ENDPOINT` | Client address (`ip:port`) on connect and lifecycle events | | `CLEANSESSION` | `"true"` or `"false"` from the client's CONNECT packet | | `DISCONNECTREASON` | Disconnect type or MQTT reason on `ON DISCONNECT` | | `SUBSCRIBETOPIC` | Topic filter from an `ON SUBSCRIBE` event | | `SUBSCRIBEQOS` | Requested QoS (`0`, `1`, or `2`) from an `ON SUBSCRIBE` event | | `TOPIC POSITION n` | Extract segment from topic path | | `TIMESTAMP` | Generate timestamps (UTC, UNIX) | | `EMPTY` | Check if a topic has no data | ### Python Integration | Keyword | Description | | ------------- | ------------------------ | | `CALL PYTHON` | Execute Python functions | **Extend LoT with Python:** Use `CALL PYTHON` to execute complex calculations, data validation, or integrate external libraries. See the [Python Integration guide](/latest/lot-language/actions/python-integration) for complete syntax and examples. *** ## Type Casting Use `AS` to cast values when performing comparisons or calculations: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} GET TOPIC "sensors/value" AS DOUBLE GET TOPIC "config/enabled" AS BOOL GET TOPIC "settings/count" AS INT GET JSON "temperature" IN PAYLOAD AS DOUBLE ``` Supported types: `STRING`, `INT`, `DOUBLE`, `BOOL`, `TIMESTAMP`, `BYTES` For binary MQTT payloads you want to **forward unchanged** (protobuf, Modbus frames, image chunks), use `PAYLOAD AS BYTES` with `PUBLISH TOPIC` or `KEEP TOPIC`. See [Binary passthrough](/latest/lot-language/actions/operations#binary-passthrough--payload-as-bytes). To **read or write protobuf fields**, use `GET PROTO` and `PUBLISH MODEL` — see [Protobuf Models](/latest/lot-language/models/protobuf). *** ## Complete Example **Putting It All Together:** This example demonstrates topic extraction, JSON parsing, conditional logic, and publishing - the core patterns you'll use in most actions. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SensorDataProcessor ON TOPIC "sensors/+/raw" DO // Extract sensor ID from topic (position 2 = the wildcard) // Topic: sensors/temp001/raw → POSITION 1="sensors", POSITION 2="temp001", POSITION 3="raw" SET "sensor_id" WITH TOPIC POSITION 2 // Get the temperature value SET "temp_value" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) // Check thresholds IF {temp_value} > 80 THEN PUBLISH TOPIC "alerts/high_temp/" + {sensor_id} WITH PAYLOAD ELSE PUBLISH TOPIC "sensors/processed/" + {sensor_id} WITH PAYLOAD // Always update the last reading timestamp PUBLISH TOPIC "sensors/" + {sensor_id} + "/last_update" WITH TIMESTAMP "UTC" ``` *** ## Next Steps Learn about time-based, topic-based, and client lifecycle triggers in detail. Master GET, SET, PUBLISH, and conditional logic. # Introduction to LoT Source: https://docs.coreflux.org/latest/lot-language/introduction Learn what LoT (Language of Things) is and see it in action with simple examples ## What is LoT? LoT (Language of Things) is a human-readable language for IoT automation. It uses near-English syntax to define logic, data structures, and integrations—all executed directly within the Coreflux MQTT broker. Everything in LoT is built by defining four types of entities — **Actions**, **Models**, **Routes**, and **Rules**. Each one handles a different part of your automation, and inside them you use commands like `PUBLISH`, `SET`, and `GET` to make things happen. Think of LoT as SQL for real-time MQTT data. Just as SQL transforms database records, LoT transforms live MQTT streams. **Like building with LEGO blocks.** You pick the right block for the job — Actions for logic, Models for data structure, Routes for connections, Rules for security — and snap them together into a complete system. ## The Four Building Blocks Everything in LoT is built from four core constructs, each created with the `DEFINE` keyword: | Construct | Purpose | Trigger Types | | ----------- | ------------------------------------- | ---------------------------------- | | **Actions** | Execute logic when events occur | Time, Topic, System events, Manual | | **Models** | Structure MQTT data into JSON schemas | Topic values, Action calls | | **Routes** | Connect to external systems | Topic patterns | | **Rules** | Control who can publish/subscribe | Client identity, Topic patterns | Event-driven logic triggered by time or MQTT topics. Data schemas that structure MQTT messages into JSON. Access control for topics and operations. Connect to databases, brokers, and external services. *** ## How They Work Together A typical LoT solution combines all four building blocks. Each definition below uses valid LoT syntax — the same patterns you will see in the [Models](/latest/lot-language/models/overview), [Actions](/latest/lot-language/actions/overview), [Routes](/latest/lot-language/routes/overview), and [Rules](/latest/lot-language/rules/overview) references. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- 1. MODEL: When raw temperature updates, publish structured JSON per sensor DEFINE MODEL SensorReading WITH TOPIC "sensors/+/formatted" ADD STRING "sensor_id" WITH TOPIC "sensors/+/id" ADD DOUBLE "temperature" WITH TOPIC "sensors/+/raw" AS TRIGGER ADD STRING "unit" WITH "celsius" ADD STRING "timestamp" WITH TIMESTAMP "UTC" -- 2. ACTION: React to formatted readings and raise alerts DEFINE ACTION TemperatureAlert ON TOPIC "sensors/+/formatted" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) IF {temp} > 80 THEN PUBLISH TOPIC "alerts/" + {sensor_id} + "/high_temp" WITH "Temperature above threshold" -- 3. ROUTE: Persist sensor traffic to PostgreSQL DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorData WITH SOURCE_TOPIC "sensors/#" WITH QUERY "INSERT INTO sensor_readings (topic, payload) VALUES ('{topic}', '{value.json}')" -- 4. RULE: Only supervisors (or root) may publish to alert topics DEFINE RULE RestrictAlertPublish WITH PRIORITY 100 FOR Publish TO TOPIC "alerts/#" IF USER IN GROUP "supervisors" OR USER IS "root" THEN ALLOW ELSE DENY ``` | Step | What Happens | | ---- | ------------------------------------------------------------------------------------------------------------ | | 1 | When `sensors/temp001/raw` updates, the Model publishes JSON to `sensors/temp001/formatted` | | 2 | The Action reads `temperature` from that JSON and publishes to `alerts/temp001/high_temp` when it exceeds 80 | | 3 | The Route inserts every message under `sensors/#` (raw, formatted, and related topics) into PostgreSQL | | 4 | The Rule denies publish to `alerts/#` unless the client is in `supervisors` or is `root` | *** ## What You'll Learn on This Page Below you'll find practical examples showing LoT in action - from simple heartbeats to data transformation. Each example demonstrates a core capability you can implement immediately. *** ## Hello World Three examples showing what LoT can do: ```lot Time-Based Action wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION Heartbeat ON EVERY 5 SECONDS DO PUBLISH TOPIC "system/alive" WITH "ok" ``` ```lot Topic-Based Action wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION Echo ON TOPIC "input/message" DO PUBLISH TOPIC "output/message" WITH PAYLOAD ``` ```lot Data Model wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorData WITH TOPIC "sensors/formatted" ADD STRING "id" WITH "TEMP001" ADD DOUBLE "value" WITH TOPIC "sensors/raw" AS TRIGGER ``` | Example | Trigger | What it does | | -------------- | -------------------------- | ---------------------------------------------- | | **Heartbeat** | Every 5 seconds | Publishes "ok" to indicate the system is alive | | **Echo** | Message on `input/message` | Forwards the payload to another topic | | **SensorData** | Value on `sensors/raw` | Formats raw sensor data into structured JSON | *** ## Next Steps Naming, topic design, reusable logic, and pitfalls to avoid. Learn how Actions automate MQTT data processing. # MODEL Examples Source: https://docs.coreflux.org/latest/lot-language/models/examples Practical examples of LoT models for common IoT data structuring scenarios. ## Examples Index Find the right pattern for your use case: | Example | Pattern | Best For | | ----------------------------------------------------------------- | ---------------- | ---------------------------------- | | [Simple Sensor Reading](#simple-sensor-reading) | Basic | Single source, static metadata | | [Multi-Source Equipment](#multi-source-equipment-status) | Basic | Combining multiple topics | | [Production Record](#production-record-with-calculations) | Calculated | Derived metrics | | [Multi-Instance Sensor](#multi-instance-sensor-model) | Wildcard | Multiple similar devices | | [Factory Hierarchy](#factory-hierarchy-model) | Wildcard | Multi-level topic structures | | [Alarm Record](#alarm-record-from-json) | Action-Published | JSON processing, conditional logic | | [Production Event](#production-event-with-calculations) | Action-Published | Complex calculations | | [Quality Check](#quality-check-with-validation) | Action-Published | Pass/fail determination | | [Alert Family](#alert-model-family) | Inheritance | Related data types | | [Sensor with Metadata](#sensor-with-metadata) | Nested Objects | Grouped configuration | | [Protobuf command and telemetry](#protobuf-command-and-telemetry) | Protobuf (beta) | Binary gateway `.proto` on MQTT | *** ## Basic Model Examples ### Simple Sensor Reading A straightforward model that formats raw sensor data with static metadata. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Input A[sensors/raw/temperature] end subgraph Model B[SensorReading] end subgraph Output C[sensors/formatted/temperature] end A -->|triggers| B B -->|publishes| C ``` This model adds context (sensor ID, unit, status) to a raw temperature value: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading WITH TOPIC "sensors/formatted/temperature" ADD STRING "sensor_id" WITH "TEMP001" ADD DOUBLE "value" WITH TOPIC "sensors/raw/temperature" AS TRIGGER ADD STRING "unit" WITH "celsius" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ADD STRING "status" WITH "ACTIVE" ``` When a value arrives at `sensors/raw/temperature`, the model publishes: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "sensor_id": "TEMP001", "value": 25.5, "unit": "celsius", "timestamp": "2025-10-25T14:30:15Z", "status": "ACTIVE" } ``` *** ### Multi-Source Equipment Status Combine data from multiple topics into a unified equipment status report. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Inputs A[equipment/current/id] B[equipment/current/status] C[equipment/current/runtime] D[equipment/current/efficiency] end subgraph Model E[EquipmentStatus] end subgraph Output F[equipment/status/formatted] end A --> E B -->|triggers| E C --> E D --> E E -->|publishes| F ``` Each field pulls from a different source topic. The status topic acts as the trigger: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL EquipmentStatus WITH TOPIC "equipment/status/formatted" ADD STRING "equipment_id" WITH TOPIC "equipment/current/id" ADD STRING "status" WITH TOPIC "equipment/current/status" AS TRIGGER ADD INT "runtime_hours" WITH TOPIC "equipment/current/runtime" ADD DOUBLE "efficiency" WITH TOPIC "equipment/current/efficiency" ADD BOOL "maintenance_required" WITH TOPIC "equipment/current/maintenance_flag" ADD STRING "last_update" WITH TIMESTAMP "UTC" ADD STRING "location" WITH "Factory Floor A" ``` When the status topic updates, the model publishes a combined report to `equipment/status/formatted`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "equipment_id": "CNC-4500", "status": "RUNNING", "runtime_hours": 1823, "efficiency": 92.4, "maintenance_required": false, "last_update": "2025-10-25T14:30:15Z", "location": "Factory Floor A" } ``` *** ### Production Record with Calculations Automatically calculate derived values like efficiency percentages. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Inputs A[quantity] B[target] end subgraph Calculation C["efficiency = quantity / target × 100"] end subgraph Output D[ProductionRecord] end A --> C B --> C C --> D ``` The `efficiency_percent` field is calculated inline from other topic values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ProductionRecord WITH TOPIC "production/records/completed" ADD STRING "batch_id" WITH TOPIC "production/current/batch_id" ADD STRING "product_code" WITH TOPIC "production/current/product_code" ADD INT "quantity_produced" WITH TOPIC "production/current/quantity" AS TRIGGER ADD INT "quantity_target" WITH TOPIC "production/current/target" ADD DOUBLE "efficiency_percent" WITH (GET TOPIC "production/current/quantity" AS DOUBLE / GET TOPIC "production/current/target" AS DOUBLE * 100) ADD STRING "operator" WITH TOPIC "production/current/operator" ADD STRING "completion_time" WITH TIMESTAMP "UTC" ``` Example output when 95 of 100 units are produced: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "batch_id": "BATCH_2025_001", "product_code": "WIDGET_A", "quantity_produced": 95, "quantity_target": 100, "efficiency_percent": 95.0, "operator": "John Smith", "completion_time": "2025-10-25T14:30:15Z" } ``` *** ## Wildcard Model Examples ### Multi-Instance Sensor Model Handle multiple sensors with a single model definition using the `+` wildcard. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart TB subgraph Inputs A[sensors/temp001/value] B[sensors/pressure002/value] C[sensors/humidity003/value] end subgraph SingleModel D["GenericSensorData
sensors/+/formatted"] end subgraph Outputs E[sensors/temp001/formatted] F[sensors/pressure002/formatted] G[sensors/humidity003/formatted] end A -->|triggers| D B -->|triggers| D C -->|triggers| D D --> E D --> F D --> G ``` The `+` wildcard matches any single level in the topic hierarchy: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL GenericSensorData WITH TOPIC "sensors/+/formatted" ADD STRING "sensor_id" WITH TOPIC "sensors/+/id" ADD DOUBLE "value" WITH TOPIC "sensors/+/value" AS TRIGGER ADD STRING "unit" WITH TOPIC "sensors/+/unit" ADD STRING "location" WITH TOPIC "sensors/+/location" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` When `sensors/temp001/value` receives data, the model publishes to `sensors/temp001/formatted`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "sensor_id": "TEMP001", "value": 23.7, "unit": "celsius", "location": "Building A - Room 101", "timestamp": "2025-10-25T14:30:15Z" } ``` Each sensor instance (temp001, pressure002, etc.) gets its own output topic automatically. The wildcard maintains topic context through the model. *** ### Factory Hierarchy Model Handle multi-level topic hierarchies with multiple wildcards. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart TB subgraph Factory A["factory/line1/machine1/status"] B["factory/line1/machine2/status"] C["factory/line2/machine1/status"] end subgraph Model D["MachineData
factory/+/+/data/formatted"] end subgraph Outputs E["factory/line1/machine1/data/formatted"] F["factory/line1/machine2/data/formatted"] G["factory/line2/machine1/data/formatted"] end A -->|triggers| D B -->|triggers| D C -->|triggers| D D --> E D --> F D --> G ``` Multiple `+` wildcards capture different levels of the hierarchy: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MachineData WITH TOPIC "factory/+/+/data/formatted" ADD STRING "line_id" WITH TOPIC "factory/+/id" ADD STRING "machine_id" WITH TOPIC "factory/+/+/id" ADD STRING "status" WITH TOPIC "factory/+/+/status" AS TRIGGER ADD DOUBLE "output_rate" WITH TOPIC "factory/+/+/output_rate" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` When `factory/line1/machine1/status` updates, the model publishes to `factory/line1/machine1/data/formatted`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "line_id": "LINE_01", "machine_id": "MACH_01", "status": "RUNNING", "output_rate": 145.8, "timestamp": "2025-10-25T14:30:15Z" } ``` *** ## Models Published by Actions Examples Use this pattern when you need **full control** over publishing logic—conditional decisions, complex calculations, JSON extraction, or custom routing. The action determines when and where to publish. ### Alarm Record from JSON Process incoming JSON payloads into structured alarm records. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Input A["alarms/+/json_input
{type, value, threshold, ...}"] end subgraph Action B[ProcessAlarmJSON] end subgraph Output C["alarms/structured/{equipment_id}
AlarmRecord model"] end A -->|triggers| B B -->|PUBLISH MODEL| C ``` First, define the model schema with `COLLAPSED` (no automatic trigger): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL AlarmRecord COLLAPSED ADD STRING "alarm_id" ADD STRING "equipment_id" ADD STRING "alarm_type" ADD DOUBLE "trigger_value" ADD DOUBLE "threshold" ADD STRING "severity" ADD STRING "message" ADD STRING "timestamp" ADD BOOL "auto_generated" ``` Then create an action that extracts JSON fields and publishes the model: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessAlarmJSON ON TOPIC "alarms/+/json_input" DO SET "equipment_id" WITH TOPIC POSITION 2 PUBLISH MODEL AlarmRecord TO "alarms/structured/" + {equipment_id} WITH alarm_id = (RANDOM UUID) equipment_id = {equipment_id} alarm_type = (GET JSON "type" IN PAYLOAD AS STRING) trigger_value = (GET JSON "value" IN PAYLOAD AS DOUBLE) threshold = (GET JSON "threshold" IN PAYLOAD AS DOUBLE) severity = (GET JSON "severity" IN PAYLOAD AS STRING) message = (GET JSON "message" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" auto_generated = TRUE ``` When a JSON payload arrives at `alarms/pump003/json_input`, the action publishes to `alarms/structured/pump003`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "alarm_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "equipment_id": "pump003", "alarm_type": "OVERHEAT", "trigger_value": 95.3, "threshold": 85.0, "severity": "CRITICAL", "message": "Motor temperature exceeded safe limit", "timestamp": "2025-10-25T14:30:15Z", "auto_generated": true } ``` *** ### Production Event with Calculations Create production records with computed metrics like efficiency. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Input A["production/+/events
{parts, cycle_time, target, ...}"] end subgraph Action B["ProcessProductionEvent
Calculate efficiency"] end subgraph Output C["production/events/structured/{line}
ProductionEvent model"] end A -->|triggers| B B -->|PUBLISH MODEL| C ``` Define the schema for production events: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ProductionEvent COLLAPSED ADD STRING "event_id" ADD STRING "line_id" ADD STRING "event_type" ADD INT "parts_count" ADD DOUBLE "cycle_time" ADD DOUBLE "efficiency_percent" ADD STRING "operator" ADD STRING "timestamp" ``` The action performs calculations before publishing: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessProductionEvent ON TOPIC "production/+/events" DO SET "line_id" WITH TOPIC POSITION 2 SET "parts" WITH (GET JSON "parts_produced" IN PAYLOAD AS INT) SET "cycle" WITH (GET JSON "cycle_time" IN PAYLOAD AS DOUBLE) SET "target" WITH (GET JSON "target_cycle_time" IN PAYLOAD AS DOUBLE) SET "eff" WITH ({target} / {cycle} * 100) PUBLISH MODEL ProductionEvent TO "production/events/structured/" + {line_id} WITH event_id = (RANDOM UUID) line_id = {line_id} event_type = (GET JSON "event_type" IN PAYLOAD AS STRING) parts_count = {parts} cycle_time = {cycle} efficiency_percent = {eff} operator = (GET JSON "operator" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" ``` When a production event arrives at `production/line2/events` with a target cycle time of 10s and an actual cycle time of 8.5s, the action publishes to `production/events/structured/line2`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "line_id": "line2", "event_type": "BATCH_COMPLETE", "parts_count": 250, "cycle_time": 8.5, "efficiency_percent": 117.6, "operator": "Maria Garcia", "timestamp": "2025-10-25T14:30:15Z" } ``` *** ### Quality Check with Validation Determine pass/fail status based on measurement tolerances. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Input A["quality/+/measurement
{value, target, tolerance}"] end subgraph Action B["ValidateQuality
Check tolerance range"] end subgraph Outputs C["quality/passed/{id}"] D["quality/failed/{id}"] end A -->|triggers| B B -->|if within tolerance| C B -->|if outside tolerance| D ``` Complex conditional logic (like tolerance checking) is cleaner in an action than inline in a model. The action can also route to different topics based on the result. Define the quality reading schema: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL QualityReading COLLAPSED ADD STRING "inspection_id" ADD STRING "part_id" ADD DOUBLE "measurement" ADD DOUBLE "target" ADD DOUBLE "tolerance" ADD STRING "result" ADD STRING "timestamp" ``` The action checks if the measurement is within tolerance and routes accordingly: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ValidateQuality ON TOPIC "quality/+/measurement" DO SET "part_id" WITH TOPIC POSITION 2 SET "value" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE) SET "target" WITH (GET JSON "target" IN PAYLOAD AS DOUBLE) SET "tolerance" WITH (GET JSON "tolerance" IN PAYLOAD AS DOUBLE) SET "min_allowed" WITH ({target} - {tolerance}) SET "max_allowed" WITH ({target} + {tolerance}) IF {value} >= {min_allowed} AND {value} <= {max_allowed} THEN PUBLISH MODEL QualityReading TO "quality/passed/" + {part_id} WITH inspection_id = (RANDOM UUID) part_id = {part_id} measurement = {value} target = {target} tolerance = {tolerance} result = "PASS" timestamp = TIMESTAMP "UTC" ELSE PUBLISH MODEL QualityReading TO "quality/failed/" + {part_id} WITH inspection_id = (RANDOM UUID) part_id = {part_id} measurement = {value} target = {target} tolerance = {tolerance} result = "FAIL" timestamp = TIMESTAMP "UTC" ``` When a measurement of 10.02 mm arrives for a part with a target of 10.00 mm and tolerance of 0.05 mm, it passes and publishes to `quality/passed/PART_A1`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "inspection_id": "b7e8f9a0-1234-5678-90ab-cdef01234567", "part_id": "PART_A1", "measurement": 10.02, "target": 10.0, "tolerance": 0.05, "result": "PASS", "timestamp": "2025-10-25T14:30:15Z" } ``` *** ## Model Inheritance Examples For complete inheritance patterns and syntax, see [Model Inheritance](./model-inheritance). ### Alert Model Family Create specialized alert types from a common base model. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart TB subgraph BaseModel A["BaseAlert
alert_id, source, timestamp,
severity, message, acknowledged"] end subgraph SpecializedModels B["TemperatureAlert
+ temperature_value
+ threshold_exceeded
+ sensor_location"] C["PressureAlert
+ pressure_value
+ max_safe_pressure
+ system_affected"] D["MaintenanceAlert
+ equipment_id
+ hours_since_service
+ maintenance_type"] end A --> B A --> C A --> D ``` First, define the base model with common fields: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BaseAlert COLLAPSED ADD STRING "alert_id" ADD STRING "source_system" ADD STRING "timestamp" ADD STRING "severity" ADD STRING "message" ADD BOOL "acknowledged" ``` Then create specialized models that inherit and extend it: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TemperatureAlert FROM BaseAlert ADD DOUBLE "temperature_value" ADD DOUBLE "threshold_exceeded" ADD STRING "sensor_location" ADD STRING "unit" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL PressureAlert FROM BaseAlert ADD DOUBLE "pressure_value" ADD DOUBLE "max_safe_pressure" ADD STRING "pressure_unit" ADD STRING "system_affected" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MaintenanceAlert FROM BaseAlert ADD STRING "equipment_id" ADD STRING "maintenance_type" ADD INT "hours_since_service" ADD INT "recommended_interval" ``` Use the specialized model in an action: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION GenerateTemperatureAlert ON TOPIC "sensors/temperature/+/alert" DO SET "sensor_id" WITH TOPIC POSITION 3 PUBLISH MODEL TemperatureAlert TO "alerts/temperature/" + {sensor_id} WITH alert_id = (RANDOM UUID) source_system = "TemperatureMonitor" timestamp = TIMESTAMP "UTC" severity = "HIGH" message = "Temperature threshold exceeded" acknowledged = FALSE temperature_value = (GET JSON "value" IN PAYLOAD AS DOUBLE) threshold_exceeded = (GET JSON "threshold" IN PAYLOAD AS DOUBLE) sensor_location = (GET JSON "location" IN PAYLOAD AS STRING) unit = "celsius" ``` When an alert triggers for sensor `TH_042`, the action publishes to `alerts/temperature/TH_042` with both inherited and specialized fields: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "alert_id": "c3d4e5f6-7890-1234-abcd-ef5678901234", "source_system": "TemperatureMonitor", "timestamp": "2025-10-25T14:30:15Z", "severity": "HIGH", "message": "Temperature threshold exceeded", "acknowledged": false, "temperature_value": 92.7, "threshold_exceeded": 85.0, "sensor_location": "Boiler Room B", "unit": "celsius" } ``` *** ## Nested Object Examples ### Sensor with Metadata Group related configuration and context fields into nested sub-objects using the `OBJECT` type and indentation. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Inputs A[sensors/+/value] B[sensors/+/id] C[sensors/+/location] D[sensors/+/floor] E[sensors/+/building] F[sensors/+/unit] end subgraph Model G["SensorWithMetadata
sensors/+/formatted"] end subgraph Output H["sensors/{id}/formatted
{value, metadata: {location, ...}}"] end A -->|triggers| G B --> G C --> G D --> G E --> G F --> G G -->|publishes| H ``` The `metadata` field uses `OBJECT` to group location details into a nested object. Fields indented under `ADD OBJECT` become its properties. When indentation returns to the parent level, fields belong to the top-level model again: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorWithMetadata WITH TOPIC "sensors/+/formatted" ADD STRING "sensor_id" WITH TOPIC "sensors/+/id" ADD DOUBLE "value" WITH TOPIC "sensors/+/value" AS TRIGGER ADD OBJECT "metadata" ADD STRING "unit" WITH TOPIC "sensors/+/unit" ADD STRING "location" WITH TOPIC "sensors/+/location" ADD INT "floor" WITH TOPIC "sensors/+/floor" ADD STRING "building" WITH TOPIC "sensors/+/building" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` When `sensors/temp001/value` receives data, the model publishes to `sensors/temp001/formatted`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "sensor_id": "TEMP001", "value": 25.5, "metadata": { "unit": "celsius", "location": "Room 101", "floor": 3, "building": "Building A" }, "timestamp": "2025-10-25T14:30:15Z" } ``` The indentation rule applies everywhere `OBJECT` is used — in basic models, wildcard models, and `COLLAPSED` templates published from actions. *** ## Protobuf Examples Dynamic protobuf encoding and decoding in LoT is **beta**. Field numbers must match your vendor `.proto` — see [Protobuf Models](./protobuf) for `PROTO_TAG`, type mapping, and pitfalls. ### Protobuf command and telemetry Decode a binary command from a gateway, keep the command type on a topic, then pack JSON tag values back into protobuf telemetry the gateway already understands. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Input A["plant/+/+/command
protobuf bytes"] B["plant/+/+/tags
JSON tags"] end subgraph Actions C[OnPlcCommand] D[PublishTelemetry] end subgraph Output E["plant/.../pending_command"] F["plant/.../telemetry
protobuf bytes"] end A -->|GET PROTO| C C --> E B --> D D -->|PUBLISH MODEL| F ``` Declare every command arm on the parent so unused submessages do not shift wire numbers. `ADD OBJECT` holds a slot open; you only need `GET PROTO` for the fields you read: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL DeviceCommand WITH FORMAT PROTOBUF COLLAPSED ADD INT "command_type" PROTO_TAG 1 ADD OBJECT "header" PROTO_TAG 2 ADD OBJECT "write_setpoint" PROTO_TAG 3 ADD OBJECT "ack_alarm" PROTO_TAG 4 ADD OBJECT "reserved_slot" PROTO_TAG 5 ADD OBJECT "read_snapshot" PROTO_TAG 6 ``` When protobuf bytes arrive on `plant/line1/plc01/command`, the action reads `command_type` and stores it for later: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION OnPlcCommand ON TOPIC "plant/+/+/command" DO SET "line" WITH TOPIC POSITION 2 SET "device_id" WITH TOPIC POSITION 3 SET "cmd" WITH (GET PROTO "command_type" IN PAYLOAD AS INT USING MODEL "DeviceCommand") KEEP TOPIC "plant/" + {line} + "/" + {device_id} + "/pending_command" WITH {cmd} ``` Nested messages you fill on publish use `ADD MODEL`, not indented `ADD OBJECT`. Tag the sparse fields explicitly: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MessageHeader WITH FORMAT PROTOBUF COLLAPSED ADD STRING "device_id" PROTO_TAG 1 ADD STRING "timestamp" PROTO_TAG 2 DEFINE MODEL ProcessValues WITH FORMAT PROTOBUF COLLAPSED ADD DOUBLE "temperature" PROTO_TAG 1 ADD DOUBLE "pressure" PROTO_TAG 2 DEFINE MODEL TelemetryMessage WITH FORMAT PROTOBUF COLLAPSED ADD INT "message_type" PROTO_TAG 1 ADD MODEL MessageHeader "header" PROTO_TAG 2 ADD MODEL ProcessValues "process_values" PROTO_TAG 3 ``` When JSON tags arrive on `plant/line1/plc01/tags`, the action publishes **raw protobuf bytes** to `plant/line1/plc01/telemetry`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PublishTelemetry ON TOPIC "plant/+/+/tags" DO SET "line" WITH TOPIC POSITION 2 SET "device_id" WITH TOPIC POSITION 3 SET "temp" WITH (GET JSON "Temperature" IN PAYLOAD AS DOUBLE) SET "pressure" WITH (GET JSON "Pressure" IN PAYLOAD AS DOUBLE) SET "ts" WITH TIMESTAMP "UNIX" PUBLISH MODEL TelemetryMessage TO "plant/" + {line} + "/" + {device_id} + "/telemetry" WITH message_type = 2 header.device_id = {device_id} header.timestamp = {ts} process_values.temperature = {temp} process_values.pressure = {pressure} ``` Example JSON on `plant/line1/plc01/tags` that this action encodes: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Temperature": 72.4, "Pressure": 1.18 } ``` The destination topic carries binary protobuf, not JSON. A payload that matches this schema has field **1** = `2` (message type), field **2** = the header (`device_id` + Unix timestamp), and field **3** = process values (`temperature` + `pressure`). Decode a captured payload with `protoc --decode_raw` to confirm those field numbers. Trigger-based models (`WITH TOPIC` + `AS TRIGGER`) still auto-publish JSON. Use `COLLAPSED` plus `PUBLISH MODEL` whenever the edge must see protobuf bytes. Full type mapping and `GET PROTO` sources: [Protobuf Models](./protobuf). *** ## Choosing the Right Pattern **Use when:** Data flows continuously and formatting is straightforward. **Best for:** Sensor readings, status updates, simple aggregation. **Trigger:** Automatic when source topic updates. **Use when:** You need derived metrics computed from source values. **Best for:** Percentages, totals, averages, unit conversions. **Note:** Keep calculations simple; use actions for complex logic. **Use when:** You have multiple instances of the same data structure. **Best for:** Fleets of sensors, multi-line factories, device groups. **Benefit:** One model definition handles all instances. **Use when:** You need control over timing, routing, or complex logic. **Best for:** JSON extraction, conditional publishing, pass/fail routing, complex calculations. **Benefit:** Full action power with structured output. **Use when:** You have related data types sharing common fields. **Best for:** Alert families, equipment types, event categories. **Benefit:** DRY principle—define common fields once. **Use when:** Your JSON output needs grouped or hierarchical data. **Best for:** Metadata grouping, configuration objects, multi-level status reports. **Benefit:** Clean nested JSON without external formatting. Indentation controls structure. **Use when:** A gateway or SCADA system already speaks a `.proto` on MQTT. **Best for:** Decoding binary commands with `GET PROTO` and emitting telemetry bytes with `PUBLISH MODEL`. **Benefit:** Match vendor field numbers without a separate transformation service. See [Protobuf Models](./protobuf). *** ## Next Steps Connect your broker to external systems and databases. Master the action syntax for publishing models. # MODEL Inheritance Source: https://docs.coreflux.org/latest/lot-language/models/model-inheritance Create model families with shared fields using the FROM keyword for code reuse and consistency ## Why Use Model Inheritance? When your system has related data types—different kinds of alerts, equipment, or events—you often repeat the same fields across multiple models. Model inheritance lets you define common fields once in a **base model**, then extend it with specialized fields for each variant. **Like a family tree for data.** A "TemperatureAlert" is a child of "Alert"—it inherits all the standard alert properties (ID, timestamp, severity) but adds its own temperature-specific fields. Change the parent, and all children get the update. ### When to Use Inheritance | Scenario | Example | | ------------------------ | ------------------------------------------------------ | | **Alert systems** | Base alert + temperature, pressure, maintenance alerts | | **Equipment monitoring** | Base equipment + pumps, conveyors, motors | | **Event tracking** | Base event + production, quality, safety events | | **Sensor networks** | Base sensor + temperature, pressure, flow sensors | *** ## Inheritance Syntax The `FROM` keyword creates a child model that inherits all fields from a parent: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ChildModel FROM ParentModel ADD STRING "specialized_field" ADD DOUBLE "another_field" ``` The child model automatically includes every field from the parent, plus any additional fields you define. ### How It Works ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Parent model with common fields DEFINE MODEL BaseAlert COLLAPSED ADD STRING "alert_id" ADD STRING "timestamp" ADD STRING "severity" ADD STRING "message" ADD BOOL "acknowledged" // Child model inherits all parent fields + adds specialized ones DEFINE MODEL TemperatureAlert FROM BaseAlert ADD DOUBLE "temperature_value" ADD DOUBLE "threshold_exceeded" ADD STRING "sensor_location" ``` **Result:** `TemperatureAlert` has 8 fields total: * 5 inherited from `BaseAlert` (alert\_id, timestamp, severity, message, acknowledged) * 3 specialized fields (temperature\_value, threshold\_exceeded, sensor\_location) Base models are typically defined with `COLLAPSED` since they're meant to be extended, not used directly. The child models inherit this behavior. *** ## Designing Base Models A good base model contains fields that **every variant needs**. Put yourself in the position of someone processing alerts, equipment data, or events—what fields do they always expect? ### Base Model Best Practices ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BaseEquipment COLLAPSED // Identification - always needed ADD STRING "equipment_id" ADD STRING "equipment_name" // Context - helps locate and categorize ADD STRING "location" ADD STRING "status" // Operational data - common across equipment ADD INT "runtime_hours" ADD STRING "last_maintenance" // Metadata - for tracking and auditing ADD STRING "operator" ADD STRING "timestamp" ``` Don't include type-specific fields in base models. If only some variants need a field, it belongs in the child model. ### What to Include in Base Models | Field Category | Examples | Why | | --------------- | -------------------------------- | ------------------------------------- | | **Identifiers** | `id`, `name`, `code` | Every record needs to be identifiable | | **Timestamps** | `timestamp`, `created_at` | Essential for ordering and debugging | | **Status** | `status`, `severity`, `state` | Common across most data types | | **Context** | `location`, `source`, `operator` | Helps downstream processing | | **Flags** | `acknowledged`, `processed` | Common workflow states | *** ## Creating Specialized Models Child models add fields specific to their domain. Keep these focused—only add what that particular variant needs. ### Alert Family Example Starting from a common base, create specialized alert types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BaseAlert COLLAPSED ADD STRING "alert_id" ADD STRING "source_system" ADD STRING "timestamp" ADD STRING "severity" ADD STRING "message" ADD BOOL "acknowledged" ``` Temperature alerts add measurement data and thresholds: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TemperatureAlert FROM BaseAlert ADD DOUBLE "temperature_value" ADD DOUBLE "threshold_exceeded" ADD STRING "sensor_location" ADD STRING "unit" ``` Pressure alerts track system-level concerns: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL PressureAlert FROM BaseAlert ADD DOUBLE "pressure_value" ADD DOUBLE "max_safe_pressure" ADD STRING "pressure_unit" ADD STRING "system_affected" ``` Maintenance alerts focus on service schedules: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MaintenanceAlert FROM BaseAlert ADD STRING "equipment_id" ADD STRING "maintenance_type" ADD INT "hours_since_service" ADD INT "recommended_interval" ADD STRING "priority_level" ``` *** ## Publishing Inherited Models Inherited models work exactly like regular COLLAPSED models—use `PUBLISH MODEL` from an Action and provide values for all fields (inherited + specialized): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION GenerateTemperatureAlert ON TOPIC "sensors/temperature/+/alert" DO SET "sensor_id" WITH TOPIC POSITION 3 PUBLISH MODEL TemperatureAlert TO "alerts/temperature/" + {sensor_id} WITH // Inherited fields from BaseAlert alert_id = (RANDOM UUID) source_system = "TemperatureMonitor" timestamp = TIMESTAMP "UTC" severity = "HIGH" message = "Temperature threshold exceeded" acknowledged = FALSE // Specialized fields for TemperatureAlert temperature_value = (GET JSON "value" IN PAYLOAD AS DOUBLE) threshold_exceeded = (GET JSON "threshold" IN PAYLOAD AS DOUBLE) sensor_location = (GET JSON "location" IN PAYLOAD AS STRING) unit = "celsius" ``` ### Polymorphic Action Pattern One Action can handle multiple model types by checking the incoming data and publishing the appropriate variant: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PolymorphicAlertGenerator ON TOPIC "sensors/+/+/alert" DO SET "sensor_type" WITH TOPIC POSITION 2 SET "sensor_id" WITH TOPIC POSITION 3 IF {sensor_type} EQUALS "temperature" THEN PUBLISH MODEL TemperatureAlert TO "alerts/temperature/" + {sensor_id} WITH alert_id = (RANDOM UUID) source_system = "SensorNetwork" timestamp = TIMESTAMP "UTC" severity = (GET JSON "severity" IN PAYLOAD AS STRING) message = "Temperature alert triggered" acknowledged = FALSE temperature_value = (GET JSON "value" IN PAYLOAD AS DOUBLE) threshold_exceeded = (GET JSON "threshold" IN PAYLOAD AS DOUBLE) sensor_location = (GET JSON "location" IN PAYLOAD AS STRING) unit = "celsius" ELSE IF {sensor_type} EQUALS "pressure" THEN PUBLISH MODEL PressureAlert TO "alerts/pressure/" + {sensor_id} WITH alert_id = (RANDOM UUID) source_system = "SensorNetwork" timestamp = TIMESTAMP "UTC" severity = (GET JSON "severity" IN PAYLOAD AS STRING) message = "Pressure alert triggered" acknowledged = FALSE pressure_value = (GET JSON "value" IN PAYLOAD AS DOUBLE) max_safe_pressure = (GET JSON "max_pressure" IN PAYLOAD AS DOUBLE) pressure_unit = "PSI" system_affected = (GET JSON "system" IN PAYLOAD AS STRING) ``` All alert types share the same base structure, so downstream systems can process common fields (like `severity` and `acknowledged`) without knowing the specific alert type. *** ## Complete Examples A complete equipment monitoring system with specialized equipment types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Base model with common equipment fields DEFINE MODEL BaseEquipment COLLAPSED ADD STRING "equipment_id" ADD STRING "equipment_name" ADD STRING "location" ADD STRING "status" ADD INT "runtime_hours" ADD STRING "last_maintenance" ADD STRING "operator" ADD STRING "timestamp" // Pump-specific model DEFINE MODEL PumpEquipment FROM BaseEquipment ADD DOUBLE "flow_rate" ADD DOUBLE "pressure_output" ADD INT "rpm" ADD DOUBLE "power_consumption" ADD STRING "pump_type" // Conveyor-specific model DEFINE MODEL ConveyorEquipment FROM BaseEquipment ADD DOUBLE "belt_speed" ADD INT "items_per_minute" ADD DOUBLE "belt_tension" ADD BOOL "emergency_stop_active" ADD STRING "direction" ``` **Action to publish equipment reports:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION EquipmentReporter ON TOPIC "equipment/+/+/status_update" DO SET "equipment_type" WITH TOPIC POSITION 2 SET "equipment_id" WITH TOPIC POSITION 3 IF {equipment_type} EQUALS "pump" THEN PUBLISH MODEL PumpEquipment TO "equipment/reports/pump/" + {equipment_id} WITH equipment_id = {equipment_id} equipment_name = (GET JSON "name" IN PAYLOAD AS STRING) location = (GET JSON "location" IN PAYLOAD AS STRING) status = (GET JSON "status" IN PAYLOAD AS STRING) runtime_hours = (GET JSON "runtime" IN PAYLOAD AS INT) last_maintenance = (GET JSON "last_service" IN PAYLOAD AS STRING) operator = (GET JSON "operator" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" flow_rate = (GET JSON "flow_rate" IN PAYLOAD AS DOUBLE) pressure_output = (GET JSON "pressure" IN PAYLOAD AS DOUBLE) rpm = (GET JSON "rpm" IN PAYLOAD AS INT) power_consumption = (GET JSON "power" IN PAYLOAD AS DOUBLE) pump_type = "Centrifugal" ELSE IF {equipment_type} EQUALS "conveyor" THEN PUBLISH MODEL ConveyorEquipment TO "equipment/reports/conveyor/" + {equipment_id} WITH equipment_id = {equipment_id} equipment_name = (GET JSON "name" IN PAYLOAD AS STRING) location = (GET JSON "location" IN PAYLOAD AS STRING) status = (GET JSON "status" IN PAYLOAD AS STRING) runtime_hours = (GET JSON "runtime" IN PAYLOAD AS INT) last_maintenance = (GET JSON "last_service" IN PAYLOAD AS STRING) operator = (GET JSON "operator" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" belt_speed = (GET JSON "speed" IN PAYLOAD AS DOUBLE) items_per_minute = (GET JSON "throughput" IN PAYLOAD AS INT) belt_tension = (GET JSON "tension" IN PAYLOAD AS DOUBLE) emergency_stop_active = (GET JSON "estop" IN PAYLOAD AS BOOL) direction = (GET JSON "direction" IN PAYLOAD AS STRING) ``` An event system for production, maintenance, and quality events: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Base event structure DEFINE MODEL BaseEvent COLLAPSED ADD STRING "event_id" ADD STRING "event_category" ADD STRING "source" ADD STRING "timestamp" ADD STRING "severity" ADD STRING "description" // Production-specific events DEFINE MODEL ProductionEvent FROM BaseEvent ADD STRING "line_id" ADD STRING "batch_id" ADD INT "parts_affected" ADD STRING "operator" ADD DOUBLE "production_impact" // Quality-specific events DEFINE MODEL QualityEvent FROM BaseEvent ADD STRING "inspection_station" ADD STRING "product_batch" ADD INT "items_tested" ADD INT "items_failed" ADD STRING "failure_reason" // Maintenance-specific events DEFINE MODEL MaintenanceEvent FROM BaseEvent ADD STRING "equipment_id" ADD STRING "maintenance_type" ADD INT "downtime_minutes" ADD STRING "technician" ADD DOUBLE "cost_estimate" ``` A sensor hierarchy for different measurement types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Base sensor structure DEFINE MODEL BaseSensor COLLAPSED ADD STRING "sensor_id" ADD STRING "sensor_name" ADD STRING "location" ADD STRING "status" ADD STRING "last_calibration" ADD BOOL "calibration_valid" ADD STRING "timestamp" ADD INT "reading_count" // Temperature sensor DEFINE MODEL TemperatureSensor FROM BaseSensor ADD DOUBLE "temperature" ADD STRING "temperature_unit" ADD DOUBLE "min_range" ADD DOUBLE "max_range" ADD DOUBLE "accuracy" // Pressure sensor DEFINE MODEL PressureSensor FROM BaseSensor ADD DOUBLE "pressure" ADD STRING "pressure_unit" ADD DOUBLE "max_working_pressure" ADD STRING "connection_type" ADD BOOL "overpressure_protection" // Flow sensor DEFINE MODEL FlowSensor FROM BaseSensor ADD DOUBLE "flow_rate" ADD STRING "flow_unit" ADD DOUBLE "pipe_diameter" ADD STRING "fluid_type" ADD DOUBLE "viscosity" ``` *** ## Best Practices Only include fields that **every** child model needs. If only some variants use a field, it belongs in the child model, not the base. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Good - base has universal fields only DEFINE MODEL BaseAlert COLLAPSED ADD STRING "alert_id" ADD STRING "timestamp" ADD STRING "severity" // Bad - 'temperature_unit' doesn't apply to all alerts DEFINE MODEL BaseAlert COLLAPSED ADD STRING "alert_id" ADD STRING "timestamp" ADD STRING "severity" ADD STRING "temperature_unit" // Too specific! ``` Name child models to clearly indicate their specialization: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Good - clear inheritance relationship DEFINE MODEL TemperatureAlert FROM BaseAlert DEFINE MODEL PressureAlert FROM BaseAlert // Bad - unclear relationship DEFINE MODEL TempData FROM BaseAlert DEFINE MODEL PressData FROM BaseAlert ``` When you have multiple inheritance levels, keep the structure clear: ``` BaseEntity └── BaseEquipment ├── PumpEquipment ├── ConveyorEquipment └── MotorEquipment ``` When using `PUBLISH MODEL`, you must provide values for both inherited and specialized fields: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} PUBLISH MODEL TemperatureAlert TO "alerts/temp" WITH // Don't forget inherited fields! alert_id = (RANDOM UUID) timestamp = TIMESTAMP "UTC" severity = "HIGH" // Plus specialized fields temperature_value = 85.5 threshold_exceeded = 80.0 ``` *** ## Limitations **Current limitations of model inheritance:** * **Single inheritance only** — A model can only inherit from one parent (no multiple inheritance) * **No field override** — Child models cannot redefine fields from the parent with different types * **COLLAPSED required** — Base models should use `COLLAPSED` since they're templates, not triggered models *** ## Next Steps Master the PUBLISH MODEL syntax for dynamic data creation. See more inheritance examples in context. # MODEL Overview Source: https://docs.coreflux.org/latest/lot-language/models/overview Learn how LoT models transform, structure, and format MQTT data into consistent JSON schemas ## What are Models? Models in LoT are like database schemas for MQTT data. They define the structure of your data, ensure consistency across your system, and provide automatic JSON formatting. Models transform raw MQTT messages into structured, typed data - eliminating the need for external data transformation layers. ## Model Capabilities Define fields with specific types (STRING, INT, DOUBLE, BOOL, ARRAY) to create consistent data schemas. Automatically explode incoming JSON payloads into individual topics for easy field access. Combine data from multiple MQTT topics into a single structured output. Perform inline calculations and conditional logic to derive new values. *** ## How Models Work Models do more than format data. They aggregate values from multiple MQTT topics, scale across device fleets with wildcards, compute derived metrics inline, and apply conditional logic — all within the broker, with zero external code. Here's how the core flow works. ### Receiving JSON Payloads When a device sends a full telemetry payload, you often need to access individual fields independently — for threshold alerts, dashboard widgets, or other models. All models automatically handle this by "exploding" incoming JSON payloads into individual topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorData WITH TOPIC "sensors/device1/data" ``` **When JSON arrives at** `sensors/device1/data`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "temperature": 23.5, "humidity": 65, "status": "active" } ``` **LoT automatically creates:** * `sensors/device1/data/temperature` → `23.5` * `sensors/device1/data/humidity` → `65` * `sensors/device1/data/status` → `"active"` This "explosion" behavior allows you to subscribe to individual fields or use them in other models and actions. ### Producing Structured Output When downstream systems — databases, dashboards, REST APIs — expect a specific JSON schema, a model guarantees that format on every publish. When a model triggers (either automatically or via action), it publishes structured JSON combining all its fields: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL EquipmentStatus WITH TOPIC "equipment/formatted" ADD STRING "equipment_id" WITH TOPIC "equipment/id" ADD STRING "status" WITH TOPIC "equipment/status" AS TRIGGER ADD INT "runtime_hours" WITH TOPIC "equipment/runtime" ADD STRING "last_update" WITH TIMESTAMP "UTC" ``` **Produces:** ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "equipment_id": "PUMP001", "status": "RUNNING", "runtime_hours": 1250, "last_update": "2025-10-25T14:30:15Z" } ``` ### Aggregating Multiple Sources When you need a unified equipment dashboard from scattered PLC topics, or a combined production report from sensors across different lines, a single model pulls live values from many independent topics and merges them into one structured output — replacing external aggregation scripts or middleware entirely. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Inputs A[equipment/current/id] B[equipment/current/status] C[equipment/current/runtime] D[equipment/current/efficiency] end subgraph Model E[EquipmentStatus] end subgraph Output F[equipment/status/formatted] end A --> E B -->|triggers| E C --> E D --> E E -->|publishes| F ``` Each field draws from a different source topic, but the model waits for the trigger field (`status`) to update before publishing a combined snapshot: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL EquipmentStatus WITH TOPIC "equipment/status/formatted" ADD STRING "equipment_id" WITH TOPIC "equipment/current/id" ADD STRING "status" WITH TOPIC "equipment/current/status" AS TRIGGER ADD INT "runtime_hours" WITH TOPIC "equipment/current/runtime" ADD DOUBLE "efficiency" WITH TOPIC "equipment/current/efficiency" ADD BOOL "maintenance_required" WITH TOPIC "equipment/current/maintenance_flag" ADD STRING "last_update" WITH TIMESTAMP "UTC" ``` ### Building Nested Objects with Indentation When your API or database expects grouped fields — such as a `metadata` object inside a sensor reading, or a `status` block with nested `maintenance` details — use the `OBJECT` type to produce nested JSON directly. Fields indented one level deeper than `ADD OBJECT` become properties of that sub-object — the same way `ADD` fields under `DEFINE MODEL` become top-level properties. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorWithMetadata WITH TOPIC "sensors/+/formatted" ADD STRING "sensor_id" WITH TOPIC "sensors/+/id" ADD DOUBLE "value" WITH TOPIC "sensors/+/value" AS TRIGGER ADD STRING "unit" WITH "celsius" ADD OBJECT "metadata" ADD STRING "location" WITH TOPIC "sensors/+/location" ADD INT "floor" WITH TOPIC "sensors/+/floor" ADD STRING "building" WITH TOPIC "sensors/+/building" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` **Produces:** ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "sensor_id": "TEMP001", "value": 25.5, "unit": "celsius", "metadata": { "location": "Room 101", "floor": 3, "building": "Building A" }, "timestamp": "2025-10-25T14:30:15Z" } ``` The indentation level determines the nesting. Fields at the same depth as `ADD OBJECT` (like `"timestamp"` above) return to the parent object. Indentation must be consistent. Use either tabs or spaces throughout your model — mixing them will cause parsing errors. Each nesting level adds one indentation step. ### What Else Can Models Do? The examples above cover the fundamentals, but models go much further: * **Nested Objects** — Use the `OBJECT` type with indentation to group related fields into sub-objects, producing clean nested JSON hierarchies. [See examples →](./examples#sensor-with-metadata) * **Wildcard Models** — Define one model with `+` wildcards and it automatically handles every matching device. One definition, hundreds of instances, zero duplication. [See examples →](./examples#multi-instance-sensor-model) * **Inline Calculations** — Compute efficiency percentages, unit conversions, and running averages directly inside the model definition — no external processing needed. [See examples →](./examples#production-record-with-calculations) * **Conditional Logic** — Dynamically tag field values based on live data: label readings as `"HIGH"` or `"NORMAL"`, flag quality as `"PASS"` or `"FAIL"`, assign priority levels. * **Action-Published Templates** — Use `COLLAPSED` models as reusable schemas, then publish them from actions with full control over timing, routing, and conditional decisions. [Learn more →](./publish-model) * **Protobuf (beta)** — Match a vendor `.proto` with `WITH FORMAT PROTOBUF`, `PROTO_TAG`, and `ADD MODEL`, then decode with `GET PROTO` or emit bytes with `PUBLISH MODEL`. [Learn more →](./protobuf) For example, a single wildcard model automatically scales across every matching device — one definition handles your entire fleet: ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph singleDef [Single Definition] A["GenericSensor
sensors/+/formatted"] end subgraph autoInst [Auto-Created Instances] B[sensors/temp001/formatted] C[sensors/pressure002/formatted] D[sensors/humidity003/formatted] end A --> B A --> C A --> D ``` [See the full wildcard example →](./examples#multi-instance-sensor-model) *** ## Model Types LoT supports three model patterns, each suited for different scenarios: | Pattern | Use When | | ------------------------------ | ---------------------------------------------------- | | **Basic Model** | You want automatic triggering on data changes | | **Template Model (COLLAPSED)** | You need control over when and where data is created | | **Inheritance (FROM)** | You have related data types sharing common fields | ### Basic Models Basic models automatically trigger and publish structured data when their trigger field updates: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading WITH TOPIC "sensors/formatted/temperature" ADD STRING "sensor_id" WITH "TEMP001" ADD DOUBLE "value" WITH TOPIC "sensors/raw/temperature" AS TRIGGER ADD STRING "unit" WITH "celsius" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` **Key characteristics:** * `WITH TOPIC` specifies where output is published * `AS TRIGGER` defines which field change causes publication * Runs automatically when trigger field updates ### Template Models (COLLAPSED) Template models are reusable schemas for dynamic data creation from within actions. The `COLLAPSED` keyword indicates they have no automatic trigger: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL AlarmRecord COLLAPSED ADD STRING "alarm_id" ADD STRING "equipment_id" ADD STRING "severity" ADD STRING "timestamp" DEFINE ACTION ProcessAlarm ON TOPIC "alarms/+/input" DO SET "equip_id" WITH TOPIC POSITION 2 PUBLISH MODEL AlarmRecord TO "alarms/structured/" + {equip_id} WITH alarm_id = (RANDOM UUID) equipment_id = {equip_id} severity = (GET JSON "severity" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" ``` **Key characteristics:** * `COLLAPSED` means no automatic trigger * Published explicitly with `PUBLISH MODEL` * Full control over when and where data is created Learn how to publish template models from actions in [Publishing Models](./publish-model). ### Model Inheritance (FROM) Create model families with shared fields using the `FROM` keyword: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BaseAlert COLLAPSED ADD STRING "alert_id" ADD STRING "timestamp" ADD STRING "severity" DEFINE MODEL TemperatureAlert FROM BaseAlert ADD DOUBLE "temperature_value" ADD DOUBLE "threshold" ``` **Key characteristics:** * Child models inherit all parent fields * Add specialized fields as needed * Promotes code reuse and consistency Learn more about designing model hierarchies in [Model Inheritance](./model-inheritance). *** ## Data Sources Model fields can be populated from various sources: Use static values for constants like units, default thresholds, or fixed identifiers that never change: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL StaticExample WITH TOPIC "output/static" ADD STRING "unit" WITH "celsius" ADD INT "default_threshold" WITH 100 ADD BOOL "enabled" WITH TRUE ``` Pull live values from MQTT topics. Use `AS TRIGGER` on the field that should cause the model to publish: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TopicExample WITH TOPIC "output/dynamic" ADD DOUBLE "temperature" WITH TOPIC "sensors/raw/temp" ADD STRING "status" WITH TOPIC "equipment/status" AS TRIGGER ``` Add automatic timestamps in UTC format or as Unix epoch integers for time-series data: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TimestampExample WITH TOPIC "output/timed" ADD STRING "created_at" WITH TIMESTAMP "UTC" ADD INT "unix_time" WITH TIMESTAMP "UNIX" ``` Compute derived values inline using arithmetic operations on topic data: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL CalculatedExample WITH TOPIC "output/calculated" ADD DOUBLE "efficiency" WITH (GET TOPIC "produced" AS DOUBLE / GET TOPIC "target" AS DOUBLE * 100) ``` Apply conditional logic to set field values based on dynamic conditions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ConditionalExample WITH TOPIC "output/conditional" ADD STRING "status" WITH IF (GET TOPIC "value" > 80) THEN "HIGH" ELSE "NORMAL" ``` *** ## Next Steps Learn the complete syntax for defining model fields and triggers. Create model families with shared fields using FROM. # MODEL Protobuf Source: https://docs.coreflux.org/latest/lot-language/models/protobuf Decode and encode binary protobuf on MQTT with LoT models, GET PROTO, and PUBLISH MODEL ## Speak the same binary schema as your gateway Industrial gateways and PLCs often publish **binary protobuf** on MQTT while the broker maps plant tags (Modbus, S7, OPC UA) to plain topics. LoT models let you **decode** those commands, run normal tag logic in the middle, and **encode** telemetry back — without a separate transformation service. Dynamic protobuf encoding and decoding in LoT is **beta**. Prefer **`PUBLISH MODEL`** for binary MQTT output. Match your vendor `.proto` with `PROTO_TAG` and report issues with that layout and your broker version. **Wire numbers are mailbox slots, not labels.** Protobuf finds a field by its number (`PROTO_TAG`), not by the LoT or `.proto` field name. If the slot numbers drift, the message still arrives — it just lands in the wrong boxes, or looks empty. ### When to use this Use protobuf models when: * a gateway or SCADA system publishes **raw protobuf bytes** on MQTT; * you must **emit protobuf** that a vendor decoder already understands; * field numbers in the `.proto` are **sparse** (for example `101`) or nested. Use [JSON models](./overview) and [`GET JSON`](/latest/lot-language/actions/operations#get-json) when payloads are JSON. Use the [Sparkplug B Route](/latest/lot-language/routes/system/sparkplug-b) when the schema is Sparkplug B (`spBv1.0/#`), not a custom plant `.proto`. *** Start from the direction you need: decode inbound bytes, publish outbound bytes, or both. ## Quick Start Read top-level fields from a binary command payload with `GET PROTO`. Declare every command arm so unused submessages keep their wire numbers: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL DeviceCommand WITH FORMAT PROTOBUF COLLAPSED ADD INT "command_type" PROTO_TAG 1 ADD OBJECT "header" PROTO_TAG 2 ADD OBJECT "write_setpoint" PROTO_TAG 3 ADD OBJECT "ack_alarm" PROTO_TAG 4 ADD OBJECT "reserved_slot" PROTO_TAG 5 ADD OBJECT "read_snapshot" PROTO_TAG 6 ``` The Action reads `command_type` from the triggering payload: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION OnPlcCommand ON TOPIC "plant/+/+/command" DO SET "cmd" WITH (GET PROTO "command_type" IN PAYLOAD AS INT USING MODEL "DeviceCommand") KEEP TOPIC "plant/pending_command" WITH {cmd} ``` Nested messages you fill on publish use `ADD MODEL`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MessageHeader WITH FORMAT PROTOBUF COLLAPSED ADD STRING "device_id" PROTO_TAG 1 ADD STRING "timestamp" PROTO_TAG 2 DEFINE MODEL ProcessValues WITH FORMAT PROTOBUF COLLAPSED ADD DOUBLE "temperature" PROTO_TAG 1 ADD DOUBLE "pressure" PROTO_TAG 2 DEFINE MODEL TelemetryMessage WITH FORMAT PROTOBUF COLLAPSED ADD INT "message_type" PROTO_TAG 1 ADD MODEL MessageHeader "header" PROTO_TAG 2 ADD MODEL ProcessValues "process_values" PROTO_TAG 3 ``` The Action packs JSON tag values into that envelope and publishes **bytes** on the gateway topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PublishTelemetry ON TOPIC "plant/+/+/tags" DO SET "line" WITH TOPIC POSITION 2 SET "device_id" WITH TOPIC POSITION 3 SET "temp" WITH GET JSON "Temperature" IN PAYLOAD AS DOUBLE SET "pressure" WITH GET JSON "Pressure" IN PAYLOAD AS DOUBLE SET "ts" WITH TIMESTAMP "UNIX" PUBLISH MODEL TelemetryMessage TO "plant/" + {line} + "/" + {device_id} + "/telemetry" WITH message_type = 2 header.timestamp = {ts} process_values.temperature = {temp} process_values.pressure = {pressure} ``` *** ## Where protobuf applies | Mechanism | Protobuf on MQTT? | | ------------------------------------------------------------ | ------------------------------------------------------------------------- | | **`PUBLISH MODEL … TO "topic"`** in an Action | **Yes** — binary (or JSON plus a `/protobuf` sidecar topic for `BOTH`) | | **Trigger-based `DEFINE MODEL … WITH TOPIC` + `AS TRIGGER`** | **No** — still JSON on the base topic; use **`PUBLISH MODEL`** for binary | | **OT route tag topics** (Modbus, S7, OPC UA mappings) | Usually **JSON or plain values** — not protobuf unless you add it | Define protobuf models for **edge** topics. Use **`GET PROTO`** to read and **`PUBLISH MODEL`** to write. Tag reads and writes stay on your existing OT routes. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sequenceDiagram participant GW as Gateway or SCADA participant Broker participant PLC as PLC via OT route GW->>Broker: DeviceCommand proto bytes Broker->>Broker: GET PROTO command_type Broker->>PLC: TRIGGER route or tag write PLC->>Broker: tag values on MQTT topics Broker->>GW: PUBLISH MODEL TelemetryMessage proto bytes ``` *** ## Match your `.proto` To stay aligned with a hand-written schema (including **non-contiguous** field numbers and nested messages): 1. **`PROTO_TAG`** — wire numbers must match the `.proto` (not field names). 2. **Nested messages you fill on publish** — **`ADD MODEL ChildName "field"`** where `ChildName` is a `DEFINE MODEL`. 3. **Reserved wire slots** — **`ADD OBJECT "field" PROTO_TAG n`** when the parent must keep empty submessage fields so later tags stay aligned. `GET PROTO` and `GET PROTOBUF` are the same verb. `PROTO` and `PROTOBUF` are the same format keyword. ### Type mapping A field is only decoded when the **wire type** the sender used matches the type your model implies. Get this wrong and that field is skipped — there is no error on the wire. | Proto type | Declare in LoT | Read with `GET PROTO … AS` | | -------------------------------------------------------------------- | ------------------------- | ---------------------------- | | `double` | `ADD DOUBLE` | `DOUBLE` | | `int32` · `int64` · `uint32` · `uint64` · `sint32` · `sint64` · enum | `ADD INT` | `INT` | | `bool` | `ADD BOOL` | `BOOL` | | `string` | `ADD STRING` | `STRING` | | `bytes` | `ADD OBJECT` | `BYTES` | | nested `message` (publishing a filled body) | `ADD MODEL Child "field"` | — (fill via `PUBLISH MODEL`) | | nested `message` (holding a wire slot open) | `ADD OBJECT "field"` | `BYTES` | `ADD INT` is a **varint**. Proto `fixed32`, `sfixed32`, `fixed64`, and `sfixed64` use a different wire encoding and have no LoT keyword (`ADD FIXED32` does not exist). If those types appear in the vendor schema, declaring them as `ADD INT` will skip or misread the field. ### What happens on a mismatch The decoder follows the protobuf unknown-field rule rather than failing the whole message: | Situation | Result | | -------------------------------------------- | ------------------------------------------------------------------ | | Declared type matches the incoming wire type | Field decodes | | Field number not in your model | Skipped | | Truncated or malformed field | Decoding of that message stops there; fields already read are kept | A skipped field is **absent**, and `GET PROTO` returns `null` for it. There is no error log — an unexpected `null` almost always means a wire-type mismatch. ### How field numbers are assigned The broker does not ship a plant schema. Numbers come from your `DEFINE MODEL`: | Your model definition | Wire field numbers | | --------------------- | ----------------------------------------------- | | No `PROTO_TAG` | **1, 2, 3, …** in LoT source order | | Every field tagged | Exactly those tags | | Mixed | Tagged = explicit; untagged = next free integer | If the `.proto` uses field **101** but LoT omits `PROTO_TAG 101`, the next sequential number is used and the gateway decoder fails. *** ## Inbound: `GET PROTO` Prefer **`IN PAYLOAD`** on the triggering topic. Use the other sources when the bytes are already cached or wrapped as Base64: | Source | Bytes come from | | ----------------------- | ------------------------------------------------------- | | **`IN PAYLOAD`** | The MQTT payload that fired the Action | | **`IN GET TOPIC "…"`** | Last payload stored for that topic | | **`IN` a LoT variable** | A Base64 string in that variable (decoded before parse) | Pass **`USING MODEL "Name"`** unless a `DEFINE MODEL … WITH TOPIC` already registered that schema on the topic. **`AS INT` / `DOUBLE` / `STRING` / `BOOL` / `BYTES`** selects the LoT type after decode. Read from the last payload stored on another topic: ```lot wrap focus={3} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION RetryCachedCommand ON TOPIC "plant/+/+/command/retry" DO SET "cmd" WITH (GET PROTO "command_type" IN GET TOPIC "plant/line1/plc01/command" AS INT USING MODEL "DeviceCommand") ``` Or decode Base64 that arrived as JSON: ```lot wrap focus={4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION DecodeBase64Command ON TOPIC "plant/+/encoded" DO SET "b64" WITH GET JSON "payload_b64" IN PAYLOAD AS STRING SET "cmd" WITH (GET PROTO "command_type" IN {b64} AS INT USING MODEL "DeviceCommand") ``` Notes that matter in practice: * **Each `GET PROTO` decodes the payload from scratch.** Reading two fields means two independent passes. Do not use `IF PAYLOAD IS PROTO` to detect binary MQTT. Call **`GET PROTO … IN PAYLOAD`** instead. ### Parent command with reserved field numbers Vendor `.proto` files often declare several command arms but send only one. List **every** submessage slot on the parent so unused fields do not shift wire numbers. Use **`ADD OBJECT`** for unused slots. When **publishing** a filled nested message, use **`ADD MODEL`** on the parent instead. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL DeviceCommand WITH FORMAT PROTOBUF COLLAPSED ADD INT "command_type" PROTO_TAG 1 ADD OBJECT "header" PROTO_TAG 2 ADD OBJECT "write_setpoint" PROTO_TAG 3 ADD OBJECT "ack_alarm" PROTO_TAG 4 ADD OBJECT "reserved_slot" PROTO_TAG 5 ADD OBJECT "read_snapshot" PROTO_TAG 6 ``` *** ## Middle: tags stay JSON Between command and telemetry, most OT setups already have **tag values** on topics — from a `DEFINE ROUTE` mapping, a collapsed JSON model, or single-value publishes. That step stays JSON or plain text unless your plant standard requires protobuf on those topics too. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ReadPlcTags ON TOPIC "plant/line1/plc01/tags" DO SET "temp" WITH GET JSON "Temperature" IN PAYLOAD AS DOUBLE SET "pressure" WITH GET JSON "Pressure" IN PAYLOAD AS DOUBLE SET "speed" WITH GET JSON "MotorSpeed" IN PAYLOAD AS DOUBLE PUBLISH TOPIC "plant/line1/plc01/snapshot/temperature" WITH {temp} ``` *** ## Outbound: `PUBLISH MODEL` ### Topic vs format | Model format | Payload on **`TO "…"`** topic | | ---------------------------------------- | -------------------------------------------------------------- | | **`WITH FORMAT JSON`** (default) | JSON string | | **`WITH FORMAT PROTOBUF`** / **`PROTO`** | Raw protobuf **bytes** | | **`WITH FORMAT BOTH`** | JSON on the main topic + binary on a `/protobuf` sidecar topic | The next `PUBLISH MODEL` to the same topic **merges**: binary for `PROTO` only; JSON from the main topic for `JSON` / `BOTH`. **`KEEP` + protobuf** updates the byte cache without per-field JSON fan-out. Trigger-based models (`WITH TOPIC` + `AS TRIGGER`) still auto-publish **JSON** on the base topic. Use `PUBLISH MODEL` when the edge must see binary. ### Response envelope with sparse tags Alarms and events often use high field numbers (**101**, **102**). Tag them explicitly; use **`ADD MODEL`** for nested bodies you fill on publish. `MessageHeader` and `ProcessValues` match Quick Start — the new pieces are `AlarmEvent` and field **101**: ```lot wrap focus={10-18} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MessageHeader WITH FORMAT PROTOBUF COLLAPSED ADD STRING "device_id" PROTO_TAG 1 ADD STRING "timestamp" PROTO_TAG 2 DEFINE MODEL ProcessValues WITH FORMAT PROTOBUF COLLAPSED ADD DOUBLE "temperature" PROTO_TAG 1 ADD DOUBLE "pressure" PROTO_TAG 2 ADD INT "motor_speed" PROTO_TAG 3 DEFINE MODEL AlarmEvent WITH FORMAT PROTOBUF COLLAPSED ADD INT "alarm_code" PROTO_TAG 1 ADD STRING "alarm_text" PROTO_TAG 2 DEFINE MODEL TelemetryMessage WITH FORMAT PROTOBUF COLLAPSED ADD INT "message_type" PROTO_TAG 1 ADD MODEL MessageHeader "header" PROTO_TAG 2 ADD MODEL ProcessValues "process_values" PROTO_TAG 3 ADD MODEL AlarmEvent "alarm_event" PROTO_TAG 101 ``` LoT names are for **`PUBLISH MODEL`** / **`GET PROTO`**. Wire numbers come only from **`PROTO_TAG`**. Set `message_type` to the enum integer your `.proto` expects. ### Raw `bytes` fields Some messages carry opaque **`bytes`** (recipe blob, certificate, file chunk). Assign them with **`BYTES FROM BASE64`**: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL RecipeBlock WITH FORMAT PROTOBUF COLLAPSED ADD INT "recipe_id" PROTO_TAG 1 ADD OBJECT "payload" PROTO_TAG 2 ``` Then the Action fills `payload` from a Base64 JSON field: ```lot wrap focus={3-5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION StoreRecipe ON TOPIC "plant/+/recipe/upload" DO PUBLISH MODEL RecipeBlock TO "plant/archive/recipe" WITH recipe_id = 42 payload = BYTES FROM BASE64 (GET JSON "data_b64" IN PAYLOAD AS STRING) ``` To forward an entire protobuf payload unchanged (no field access), use [`PAYLOAD AS BYTES`](/latest/lot-language/actions/operations#binary-passthrough--payload-as-bytes) instead of `GET PROTO`. *** ## Common pitfalls **`ADD OBJECT`** when **publishing** a populated nested **message** produces JSON-in-bytes. Use **`ADD MODEL`**. Events at **101+** need an explicit tag. Omitting unused command slots on the parent also shifts later wire indexes. `device` is a LoT keyword, so `SET "device"` may parse but interpolating that name does not. Use `device_id`. If an Action fails with `Unexpected '' where IDENTIFIER was expected`, rename the variable. *** ## Troubleshooting `GET PROTO` returns `"null"` rather than raising, so a wrong schema looks like missing data. | Symptom | Likely cause | Fix | | ------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------- | | Every field is `null` | Wrong model in `USING MODEL`, or the payload is not protobuf | Confirm the message name and that the publisher sends raw bytes, not JSON | | Values decode but the gateway rejects what you publish | Field numbers drifted from the `.proto` | Add explicit `PROTO_TAG` to every field | | Numbers are wildly wrong (huge or negative) | Fixed-width integer read as a varint, or signedness mismatch | Check `fixed32` vs `sfixed32` in the `.proto` | To confirm what is on the wire, decode a captured payload with a protobuf raw decoder (`protoc --decode_raw`). It reports field numbers and wire types without needing the schema — that pair must match your `DEFINE MODEL`. **Beta limitations** * Trigger-based models auto-publish JSON only — use `PUBLISH MODEL` for binary. * No native `google.protobuf.Timestamp`; carry timestamps as `STRING` or Unix `INT`. * The sidecar topic for `BOTH` is a `/protobuf` suffix on the destination topic (not `/proto`). * No protobuf **groups** (wire types 3 and 4) — a group stops decoding of that message. * Packed repeated scalars are skipped so decoding can continue; they are not expanded into LoT arrays. *** ## Next Steps Control when and where COLLAPSED models publish, including protobuf bytes. Full GET PROTO syntax, sources, and types. # MODEL Publishing Source: https://docs.coreflux.org/latest/lot-language/models/publish-model Create structured data dynamically from actions using COLLAPSED models and PUBLISH MODEL ## When to Use Action Models Action models (COLLAPSED models) are templates for structured data that you publish explicitly from within actions. They're ideal when you need: * **Control over timing** - Publish only when specific conditions are met * **Dynamic routing** - Send data to different topics based on logic * **Data transformation** - Process and enrich data before structuring Unlike basic models that trigger automatically on topic changes, action models let you decide exactly when and where structured data is created. With `WITH FORMAT PROTOBUF`, that output is binary protobuf bytes rather than JSON — see [Protobuf Models](./protobuf). ### Example Use Case Imagine you receive raw alarm data and need to: 1. Extract relevant fields from JSON 2. Add a unique ID and timestamp 3. Route critical alarms to a special topic 4. Publish a structured record ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL AlarmRecord COLLAPSED ADD STRING "alarm_id" ADD STRING "equipment_id" ADD STRING "severity" ADD STRING "timestamp" DEFINE ACTION ProcessAlarm ON TOPIC "alarms/+/input" DO SET "equipment_id" WITH TOPIC POSITION 2 SET "severity" WITH (GET JSON "severity" IN PAYLOAD AS STRING) PUBLISH MODEL AlarmRecord TO "alarms/structured/" + {equipment_id} WITH alarm_id = (RANDOM UUID) equipment_id = {equipment_id} severity = {severity} timestamp = TIMESTAMP "UTC" IF {severity} EQUALS "CRITICAL" THEN PUBLISH TOPIC "alerts/critical/" + {equipment_id} WITH PAYLOAD ``` *** ## Step-by-Step Guide Create a template with all required fields. The `COLLAPSED` keyword indicates this model has no automatic trigger: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorRecord COLLAPSED ADD STRING "sensor_id" ADD STRING "sensor_type" ADD DOUBLE "temperature" ADD DOUBLE "humidity" ADD STRING "quality_status" ADD STRING "timestamp" ``` Define an action that extracts data and publishes the model: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessSensorJSON ON TOPIC "sensors/+/json_data" DO SET "sensor_id" WITH TOPIC POSITION 2 // Extract from JSON payload SET "temp" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) SET "hum" WITH (GET JSON "humidity" IN PAYLOAD AS DOUBLE) SET "type" WITH (GET JSON "type" IN PAYLOAD AS STRING) SET "quality" WITH (GET JSON "quality" IN PAYLOAD AS STRING) // Publish structured record PUBLISH MODEL SensorRecord TO "sensors/processed/" + {sensor_id} WITH sensor_id = {sensor_id} sensor_type = {type} temperature = {temp} humidity = {hum} quality_status = {quality} timestamp = TIMESTAMP "UTC" ``` **Publish to:** `sensors/temp001/json_data` ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "temperature": 23.5, "humidity": 65, "type": "environmental", "quality": "GOOD" } ``` **Result at:** `sensors/processed/temp001` ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "sensor_id": "temp001", "sensor_type": "environmental", "temperature": 23.5, "humidity": 65, "quality_status": "GOOD", "timestamp": "2025-10-25T14:30:15Z" } ``` *** ## Use Case Examples Route alarms by severity while creating structured records: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL AlarmRecord COLLAPSED ADD STRING "alarm_id" ADD STRING "equipment_id" ADD STRING "alarm_type" ADD DOUBLE "trigger_value" ADD STRING "severity" ADD STRING "timestamp" DEFINE ACTION ProcessAlarmJSON ON TOPIC "alarms/+/input" DO SET "equipment_id" WITH TOPIC POSITION 2 PUBLISH MODEL AlarmRecord TO "alarms/structured/" + {equipment_id} WITH alarm_id = (RANDOM UUID) equipment_id = {equipment_id} alarm_type = (GET JSON "type" IN PAYLOAD AS STRING) trigger_value = (GET JSON "value" IN PAYLOAD AS DOUBLE) severity = (GET JSON "severity" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" // Route critical alarms SET "sev" WITH (GET JSON "severity" IN PAYLOAD AS STRING) IF {sev} EQUALS "CRITICAL" THEN PUBLISH TOPIC "alerts/critical/" + {equipment_id} WITH PAYLOAD ``` Calculate metrics and generate quality reports: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL QualityReport COLLAPSED ADD STRING "report_id" ADD STRING "station_id" ADD INT "items_tested" ADD INT "items_passed" ADD DOUBLE "pass_rate" ADD STRING "timestamp" DEFINE ACTION GenerateQualityReport ON TOPIC "quality/+/results" DO SET "station" WITH TOPIC POSITION 2 SET "tested" WITH (GET JSON "total" IN PAYLOAD AS INT) SET "passed" WITH (GET JSON "passed" IN PAYLOAD AS INT) SET "rate" WITH ({passed} AS DOUBLE / {tested} AS DOUBLE * 100) PUBLISH MODEL QualityReport TO "reports/quality/" + {station} WITH report_id = (RANDOM UUID) station_id = {station} items_tested = {tested} items_passed = {passed} pass_rate = {rate} timestamp = TIMESTAMP "UTC" IF {rate} < 90 THEN PUBLISH TOPIC "alerts/quality/low_rate" WITH {station} + ": " + {rate} + "%" ``` Create structured event logs with context: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL EventLog COLLAPSED ADD STRING "event_id" ADD STRING "event_type" ADD STRING "source" ADD STRING "description" ADD STRING "severity" ADD STRING "timestamp" DEFINE ACTION LogEvent ON TOPIC "events/+/occurred" DO SET "event_type" WITH TOPIC POSITION 2 PUBLISH MODEL EventLog TO "logs/events/" + {event_type} WITH event_id = (RANDOM UUID) event_type = {event_type} source = (GET JSON "source" IN PAYLOAD AS STRING) description = (GET JSON "message" IN PAYLOAD AS STRING) severity = (GET JSON "severity" IN PAYLOAD AS STRING) timestamp = TIMESTAMP "UTC" ``` *** ## JSON vs protobuf on the destination topic `PUBLISH MODEL` writes whatever format the model declares: | Model format | Payload on **`TO "…"`** | | -------------------------------- | -------------------------------------------------------------- | | `WITH FORMAT JSON` (default) | JSON string | | `WITH FORMAT PROTOBUF` / `PROTO` | Raw protobuf **bytes** | | `WITH FORMAT BOTH` | JSON on the main topic + binary on a `/protobuf` sidecar topic | Trigger-based models (`WITH TOPIC` + `AS TRIGGER`) still auto-publish JSON. Use a `COLLAPSED` model and `PUBLISH MODEL` when a gateway must receive protobuf. Nested messages you fill on publish use `ADD MODEL Child "field"`, not indented `ADD OBJECT`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TelemetryMessage WITH FORMAT PROTOBUF COLLAPSED ADD INT "message_type" PROTO_TAG 1 ADD DOUBLE "temperature" PROTO_TAG 2 ADD DOUBLE "pressure" PROTO_TAG 3 ``` The Action copies JSON tags into that envelope and publishes **bytes**: ```lot wrap focus={6-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PublishTelemetry ON TOPIC "plant/+/tags" DO SET "temp" WITH GET JSON "Temperature" IN PAYLOAD AS DOUBLE SET "pressure" WITH GET JSON "Pressure" IN PAYLOAD AS DOUBLE PUBLISH MODEL TelemetryMessage TO "plant/gateway/telemetry" WITH message_type = 2 temperature = {temp} pressure = {pressure} ``` Full type mapping, `PROTO_TAG`, and `GET PROTO` are on [Protobuf Models](./protobuf). *** ## Best Practices Explicit type casting prevents runtime errors: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Good SET "temp" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) // Risky SET "temp" WITH (GET JSON "temperature" IN PAYLOAD) ``` Check data before publishing: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "value" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE) IF {value} >= 0 AND {value} <= 100 THEN PUBLISH MODEL ValidData TO "output" WITH value = {value} ELSE PUBLISH TOPIC "errors/validation" WITH "Invalid value: " + {value} ``` Include identifiers in output topics for easy filtering: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // For topic "sensors/temp001/data" - wildcard is at position 2 SET "sensor_id" WITH TOPIC POSITION 2 PUBLISH MODEL SensorData TO "processed/" + {sensor_id} + "/data" WITH ... ``` Use `RANDOM UUID` for record identification: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} PUBLISH MODEL Record TO "records/new" WITH record_id = (RANDOM UUID) ... ``` ## Next Steps See complete examples for common use cases. Review available operations for action logic. # MODEL Syntax and Components Source: https://docs.coreflux.org/latest/lot-language/models/schema-definition Complete reference for defining model schemas, field types, data sources, and triggers ## Why Define a Schema? Models transform scattered MQTT data into structured, predictable JSON. Instead of parsing raw payloads in your application, you define *once* how data should look—and Coreflux handles the rest. **Like a form that fills itself out.** You define what fields the form should have (temperature, humidity, timestamp), and the model automatically grabs the latest values from different sources whenever it needs to publish. ### When to Use This Reference Use this page when you need to: * Define field types for your model (STRING, INT, DOUBLE, etc.) * Pull values from multiple MQTT topics into a single output * Calculate or transform values inline * Control *when* your model publishes using triggers * Align protobuf field numbers with a vendor `.proto` (`PROTO_TAG`, `WITH FORMAT PROTOBUF`) *** ## Basic Model Syntax Every model follows this structure: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL WITH TOPIC "" ADD "" WITH [AS TRIGGER] ADD "" WITH ... ``` ### Components Unique identifier for the model. Use descriptive names like `SensorReading`, `EquipmentStatus`, `ProductionRecord`. The MQTT topic where the model's JSON output will be published. Supports wildcards for multi-instance models. Not required on `COLLAPSED` templates published with `PUBLISH MODEL`. Optional. `JSON` (default), `PROTOBUF` / `PROTO`, or `BOTH`. Controls what `PUBLISH MODEL` puts on the destination topic. See [Protobuf Models](./protobuf). One of: `STRING`, `INT`, `DOUBLE`, `BOOL`, `ARRAY`, `OBJECT`, or `MODEL` (nested protobuf message) Optional. Marks this field as the trigger—the model publishes only when this field's source topic updates. *** ## Trigger Mechanism The `AS TRIGGER` modifier controls *when* your model publishes. Without it, you'd flood your broker with updates every time *any* source topic changes. **Like a doorbell that takes a photo.** The model waits quietly until the trigger topic "rings," then takes a snapshot of all fields and publishes the complete picture. No ring, no photo. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TriggeredModel WITH TOPIC "output/triggered" ADD STRING "equipment_id" WITH TOPIC "equipment/id" ADD STRING "status" WITH TOPIC "equipment/status" AS TRIGGER ADD INT "runtime" WITH TOPIC "equipment/runtime" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` **Behavior:** * Model publishes **only** when `equipment/status` changes * Changes to `equipment/id` or `equipment/runtime` alone do not trigger publication * All field values are fetched at trigger time ### No Trigger Specified If no field has `AS TRIGGER`, the model acts as a template. You must publish it explicitly using `PUBLISH MODEL` from an Action. Learn how to publish models on-demand in [Publishing Models](./publish-model). ### Multiple Data Sources, Single Trigger You can pull data from many topics but trigger on just one: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MultiSourceModel WITH TOPIC "output/multi" ADD STRING "sensor_a" WITH TOPIC "sensors/a/value" ADD STRING "sensor_b" WITH TOPIC "sensors/b/value" ADD STRING "sensor_c" WITH TOPIC "sensors/c/value" AS TRIGGER ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` **Only publishes when sensor C updates**, but includes current values from sensors A and B. *** ## Field Types Each field in your model has a type that determines how the value is serialized to JSON (or to protobuf when the model uses `WITH FORMAT PROTOBUF`). ### STRING Text data of any length: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD STRING "sensor_id" WITH "TEMP001" ADD STRING "status" WITH TOPIC "equipment/status" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` ### INT Whole numbers (integers): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD INT "count" WITH 0 ADD INT "runtime_hours" WITH TOPIC "equipment/runtime" ADD INT "total" WITH (GET TOPIC "passed" + GET TOPIC "failed") ``` ### DOUBLE Decimal numbers (floating-point): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD DOUBLE "temperature" WITH 23.5 ADD DOUBLE "efficiency" WITH TOPIC "metrics/efficiency" ADD DOUBLE "percentage" WITH (GET TOPIC "actual" AS DOUBLE / GET TOPIC "target" AS DOUBLE * 100) ``` ### BOOL Boolean values (true/false): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD BOOL "enabled" WITH TRUE ADD BOOL "maintenance_required" WITH TOPIC "equipment/needs_maintenance" ADD BOOL "is_valid" WITH (GET TOPIC "value" > 0) ``` ### ARRAY JSON arrays: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ARRAY "readings" WITH TOPIC "sensors/batch/readings" ADD ARRAY "tags" WITH ["production", "line1", "active"] ``` ### OBJECT Nested JSON objects. Fields indented one level deeper than `ADD OBJECT` become properties of the sub-object. When the indentation returns to the parent level, subsequent fields belong to the parent again. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OBJECT "metadata" ADD STRING "location" WITH "Building A" ADD INT "floor" WITH 3 ``` Produces a nested structure in the JSON output: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "metadata": { "location": "Building A", "floor": 3 } } ``` Indentation must be consistent throughout the model. Use either tabs or spaces — mixing them causes parsing errors. ### MODEL (nested protobuf) Names another `DEFINE MODEL` as a nested protobuf message. Required when you **publish** a filled submessage. Pair with `PROTO_TAG` so the wire number matches the `.proto`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MODEL AudioInfo "audio_info" PROTO_TAG 2 ``` Do not confuse this with indented `ADD OBJECT` for JSON nesting. For protobuf wire layout, see [Protobuf Models](./protobuf). ### PROTO\_TAG Sets the protobuf field number. Without it, tags are assigned **1, 2, 3, …** in source order. Use an explicit tag whenever the `.proto` is not sequential. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD INT "message_type" PROTO_TAG 1 ADD MODEL AlarmEvent "alarm_event" PROTO_TAG 101 ``` *** ## Data Sources Fields can pull values from several sources. Mix and match as needed. ### Static Values Fixed values that never change—useful for metadata, units, or default settings: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL StaticExample WITH TOPIC "output/static" ADD STRING "unit" WITH "celsius" ADD INT "default_threshold" WITH 100 ADD DOUBLE "conversion_factor" WITH 1.8 ADD BOOL "enabled" WITH TRUE ADD STRING "location" WITH "Factory Floor A" ``` ### Topic Data Pull live values from other MQTT topics. The value is fetched *at publish time*, not when the model is defined. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL TopicExample WITH TOPIC "output/dynamic" ADD DOUBLE "temperature" WITH TOPIC "sensors/raw/temperature" ADD STRING "status" WITH TOPIC "equipment/current/status" AS TRIGGER ADD INT "count" WITH TOPIC "production/count" ``` **Example:** If `sensors/raw/temperature` currently holds `23.5`, the model's `temperature` field will be `23.5` when it publishes. If the value changes to `24.0` before the next publish, the model will output `24.0`. ### Timestamps Built-in timestamp generation—no external time source needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD STRING "timestamp" WITH TIMESTAMP "UTC" // "2025-10-25T14:30:15Z" ADD STRING "iso_time" WITH TIMESTAMP "ISO" // ISO 8601 format ADD INT "unix_time" WITH TIMESTAMP "UNIX" // 1729864215 ADD INT "unix_ms" WITH TIMESTAMP "UNIX-MS" // Milliseconds since epoch ``` ### Calculated Values Perform inline math using topic data: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL CalculatedExample WITH TOPIC "output/calculated" ADD DOUBLE "efficiency" WITH (GET TOPIC "produced" AS DOUBLE / GET TOPIC "target" AS DOUBLE * 100) ADD INT "total" WITH (GET TOPIC "passed" + GET TOPIC "failed") ADD DOUBLE "fahrenheit" WITH (GET TOPIC "celsius" AS DOUBLE * 9 / 5 + 32) ADD DOUBLE "average" WITH ((GET TOPIC "val1" + GET TOPIC "val2" + GET TOPIC "val3") / 3) ``` Always use `AS DOUBLE` or `AS INT` when performing mathematical operations to ensure correct type handling. ### Conditional Values Dynamic values based on conditions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ConditionalExample WITH TOPIC "output/conditional" ADD STRING "status" WITH IF (GET TOPIC "value" > 80) THEN "HIGH" ELSE "NORMAL" ADD STRING "quality" WITH IF (GET TOPIC "defects" = 0) THEN "PASS" ELSE "FAIL" ADD INT "priority" WITH IF (GET TOPIC "severity" EQUALS "CRITICAL") THEN 1 ELSE IF (GET TOPIC "severity" EQUALS "HIGH") THEN 2 ELSE 3 ``` *** ## Wildcard Topics ### Multi-Instance Models Use `+` wildcards to create models that handle multiple instances automatically: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MultiInstanceModel WITH TOPIC "sensors/+/formatted" ``` **Matches:** * `sensors/temp001/formatted` * `sensors/pressure002/formatted` * `sensors/humidity003/formatted` Each unique topic creates a separate model instance. ### JSON Reception with Wildcards When JSON arrives at a wildcard topic, the broker "explodes" it into individual topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL MaterialData WITH TOPIC "Process/+/+/Material/Content" ``` **When JSON arrives at** `Process/Machine/33/Material/Content`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} {"MaterialID": 132323, "Name": "Peppers", "Quantity": 21} ``` **Explodes to:** * `Process/Machine/33/Material/Content/MaterialID` → `132323` * `Process/Machine/33/Material/Content/Name` → `Peppers` * `Process/Machine/33/Material/Content/Quantity` → `21` *** ## Complete Examples This model aggregates equipment data from multiple topics into a single, structured output. It triggers whenever the status changes. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL EquipmentStatus WITH TOPIC "equipment/status/formatted" ADD STRING "equipment_id" WITH TOPIC "equipment/current/id" ADD STRING "status" WITH TOPIC "equipment/current/status" AS TRIGGER ADD INT "runtime_hours" WITH TOPIC "equipment/current/runtime" ADD DOUBLE "efficiency" WITH TOPIC "equipment/current/efficiency" ADD BOOL "maintenance_required" WITH TOPIC "equipment/current/maintenance_flag" ADD STRING "last_update" WITH TIMESTAMP "UTC" ADD STRING "location" WITH "Factory Floor A" ``` **Output:** ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "equipment_id": "PUMP001", "status": "RUNNING", "runtime_hours": 1250, "efficiency": 87.5, "maintenance_required": false, "last_update": "2025-10-25T14:30:15Z", "location": "Factory Floor A" } ``` This model calculates efficiency metrics inline. It triggers when the quantity updates, capturing a snapshot of the production run. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ProductionRecord WITH TOPIC "production/records/completed" ADD STRING "batch_id" WITH TOPIC "production/current/batch_id" ADD STRING "product_code" WITH TOPIC "production/current/product_code" ADD INT "quantity_produced" WITH TOPIC "production/current/quantity" AS TRIGGER ADD INT "quantity_target" WITH TOPIC "production/current/target" ADD DOUBLE "efficiency_percent" WITH (GET TOPIC "production/current/quantity" AS DOUBLE / GET TOPIC "production/current/target" AS DOUBLE * 100) ADD STRING "operator" WITH TOPIC "production/current/operator" ADD STRING "completion_time" WITH TIMESTAMP "UTC" ``` **Output:** ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "batch_id": "BATCH-2025-1025", "product_code": "PRD-A1234", "quantity_produced": 850, "quantity_target": 1000, "efficiency_percent": 85.0, "operator": "John Smith", "completion_time": "2025-10-25T16:45:30Z" } ``` *** ## Best Practices Establish naming conventions and follow them across all models: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Good - consistent across models ADD STRING "timestamp" ADD STRING "sensor_id" ADD STRING "equipment_id" // Avoid - inconsistent ADD STRING "time_stamp" ADD STRING "sensorID" ADD STRING "equip_ID" ``` Explicit type casting prevents errors and ensures predictable results: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Good ADD DOUBLE "efficiency" WITH (GET TOPIC "produced" AS DOUBLE / GET TOPIC "target" AS DOUBLE * 100) // Risky - implicit type handling ADD DOUBLE "efficiency" WITH (GET TOPIC "produced" / GET TOPIC "target" * 100) ``` Always include fields that help downstream consumers understand the data: * Identification fields (IDs, names) * Timestamps * Units of measurement * Status or quality indicators Trigger on the primary data field, not metadata: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Good - trigger on the main measurement ADD DOUBLE "value" WITH TOPIC "sensors/temperature" AS TRIGGER // Bad - triggers too frequently or unpredictably ADD STRING "timestamp" WITH TIMESTAMP "UTC" AS TRIGGER ``` *** ## Next Steps Learn to publish models dynamically from Actions. See complete model examples for common use cases. # Actions & Events Source: https://docs.coreflux.org/latest/lot-language/panels/actions-events Reacting to clicks and value changes — publishing commands and navigating between panels. An operator control panel with Start and Stop Conveyor buttons and a speed setpoint field, each wired to an event handler that publishes commands Events make a panel interactive: a click publishes a command, a field change writes a setpoint. The pattern is always `ON DO `. **Like a light switch wired to more than a light.** Flipping it (the event) can turn on the lamp, ring a bell, and log the time (the actions) — the switch doesn't care what's wired to it. *** First the event triggers, then each action you can wire to them. The broker parses this grammar; which triggers the HUB actually dispatches, and any write-back caveats, are documented with the [input components](/hub/dashboards/components/inputs) — that catalog ships with the HUB, not the broker. ## Event triggers | Event | Fires when | | -------------- | ----------------------------------------------------------------------------------------------------------- | | `ON CLICK DO` | Mouse click / tap | | `ON CHANGE DO` | Value changed (`text-field`, `number-field`) | | `ON TOGGLE DO` | Toggle state changed — see [input components](/hub/dashboards/components/inputs) for HUB write-back support | ## Actions ### Publish to a topic The workhorse: send a command or value to any MQTT topic. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ON CLICK DO PUBLISH TOPIC "factory/commands/start" WITH 1 ``` Literal payloads on buttons work out of the box. On `text-field` and `number-field`, use `WITH VALUE` to publish what the operator typed — see the [Input components](/hub/dashboards/components/inputs) page for full write-back examples. ### Navigate between panels Jump to another panel by name: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ON CLICK DO NAVIGATE TO PANEL "Zone1Details" ``` Or return to the previous panel: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ON CLICK DO NAVIGATE BACK ``` See the [Navigation section in Defining a Panel](/latest/lot-language/panels/defining-panels#navigation) for a complete overview-and-detail example with screenshots. ## Common mistakes Input components don't publish by themselves — the `ON DO` handler does. A button without `ON CLICK DO PUBLISH ...` is a label in practice. `PUBLISH` writes to the broker; the panel only reflects it if some component **binds** to that topic. Check the bound topic and the published topic match (command vs. feedback topics are often intentionally different). ## Next Steps The controls that fire these events, with full write-back examples. Complete panels that put events and actions to work. # Data Binding Source: https://docs.coreflux.org/latest/lot-language/panels/data-binding Wiring components to live MQTT topics — JSON extraction, coercion, and formatting. A radial gauge displaying a live temperature value received from an MQTT topic A binding is the live wire between a component and your data. When the source changes, the widget updates — no polling, no refresh. Most "why is my widget blank?" questions are answered on this page. **Like plugging an appliance into a socket.** The widget is the appliance, the topic is the socket — once plugged in, the power (data) flows on its own. *** Start by wiring one widget to one topic; the deeper reference — targets, coercion, JSON extraction — follows after. ## Bind a widget in 30 seconds Pick a topic you can see updating in the [Data Viewer](/hub/mqtt/data-viewer), and point a component at it with one `BIND` line: ```lot wrap focus={10} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL SensorMonitor WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Sensor Monitor" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ``` Save, publish a number to `sensor/temperature`, and the needle moves. That one highlighted line is all a binding is: *which aspect of the widget* (`VALUE`) gets fed *from where* (`TOPIC "sensor/temperature"`). ## Where data comes from Today, bindings read from **MQTT topics** — the widget updates on every message. Browse what's available in the [Data Viewer](/hub/mqtt/data-viewer): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} BIND VALUE TO TOPIC "sensor/temperature" ``` ## Extracting from JSON payloads Real device payloads are rarely a bare number. When the topic carries a JSON object, add `JSON_PATH` on the component to pick a field: ```lot wrap focus={3} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "EnergyDisplay" WITH TYPE "value" BIND VALUE TO TOPIC "machines/pump1/data" SET JSON_PATH "$.energy_wh" SET UNIT "Wh" ``` Always inspect the payload in the [Data Viewer](/hub/mqtt/data-viewer) before binding — you'll see immediately whether you need `JSON_PATH`, `AS NUMBER`, or neither. *** That covers most panels. The rest of the page is the reference: the full grammar, every bind target, and the transformation options that work today. ## Anatomy of a binding ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} BIND TO TOPIC "" ``` Three parts, all required: * **Target** — which aspect of the component receives the data (`VALUE`, `STATE`, `DATA`, ...). * **`TO TOPIC`** — both words are mandatory; the source is always an MQTT topic path. * **Topic path** — the topic string in quotes. ## Bind targets | Target | Used with | Description | | ------------- | ------------------------------------------- | -------------------- | | `VALUE` | gauge, value, slider, chart, realtime-chart | Numeric value | | `STATE` | indicator, toggle, switch | Boolean / state | | `CONTENT` | text, label, alert | Text content | | `MAX` / `MIN` | gauge, value | Dynamic range bounds | | `COLOR` | Any | Dynamic color | | `TITLE` | card, panel | Dynamic title | | `LABEL` | button, toggle, text | Dynamic label | | `DATA` | chart, table, list | Dataset | | `VISIBLE` | Any | Show / hide | | `SRC` | image | Dynamic source URL | ## Type coercion If a topic publishes text but the component expects a number or boolean, append a coercion keyword to the bind line: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} BIND VALUE TO TOPIC "sensor/raw_value" AS NUMBER BIND STATE TO TOPIC "status/text" AS BOOLEAN ``` Validated on a live panel: a text payload `"87.5"` coerced with `AS NUMBER` displays as `87.50` on a `value` component. ## Formatting readouts Apply units and decimal places on the **component**, not on the `BIND` line: ```lot wrap focus={4-5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD COMPONENT "FlowRate" WITH TYPE "value" BIND VALUE TO TOPIC "flow/rate" SET UNIT "L/min" SET DECIMALS 1 ``` `SET UNIT` and `SET DECIMALS` are documented per widget in [Display components](/hub/dashboards/components/display). ## Common mistakes Check the binding line word by word — these are the usual silent breakers: | Wrong (silently broken) | Correct | | -------------------------- | ---------------------------------------------- | | `BIND TOPIC "kpi/x"` | `BIND VALUE TO TOPIC "kpi/x"` — target missing | | `BIND VALUE TOPIC "kpi/x"` | `BIND VALUE TO TOPIC "kpi/x"` — `TO` missing | The topic carries JSON — add `SET JSON_PATH "$.field"` to the component, as shown in [Extracting from JSON payloads](#extracting-from-json-payloads). ## Next Steps The other half of interactivity: reacting to clicks, changes, and thresholds. Which target each widget accepts, with rendered examples. # Defining a Panel Source: https://docs.coreflux.org/latest/lot-language/panels/defining-panels Reading and writing panel definitions: structure, layouts, positioning, and lifecycle. The Coreflux HUB Dashboard Manager showing a panel's LoT definition next to the rendered live panel Every dashboard is one `DEFINE PANEL` block, written in the Dashboard Manager or generated by the AI Assistant. The block names the panel, sets a few panel-wide attributes, and lists the components it contains — this page walks through each part. **Like a recipe card for a screen.** The header says what the dish is and how the table is set (title, layout); each ingredient line below adds one widget and says where its data comes from. *** Start with the smallest complete panel, then each section below adds one concept: reading the syntax, layouts, positioning, and lifecycle. ## Panel skeleton A panel opens with `DEFINE PANEL`, four panel-level attributes, then one or more components: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL MyPanel WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "My Panel" WITH LAYOUT "grid" COLUMNS 3 ADD COMPONENT "MyComponent" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 BIND VALUE TO TOPIC "sensor/temperature" ``` * The panel **name** (`MyPanel`) identifies the panel in the Dashboard Manager. * `WITH VISIBILITY` and `WITH STATE` declare the panel's audience and lifecycle — see [Lifecycle and visibility](#lifecycle-and-visibility). * `WITH TITLE` is the heading people see; `WITH LAYOUT` arranges the components. * Components are added with `ADD COMPONENT "" WITH TYPE ""`. **Always include `WITH VISIBILITY PUBLIC` and `WITH STATE PUBLISHED`.** A panel saved without them may not appear in the Dashboard Manager list. ## Layouts The layout is declared on the panel header and controls how the panel's top-level components are arranged: | Layout | Syntax | Use it for | | ---------- | ------------------------------------- | ---------------------------------------------- | | Grid | `WITH LAYOUT "grid" COLUMNS 3 ROWS 2` | Most dashboards — explicit placement and spans | | Vertical | `WITH LAYOUT "vertical"` | Simple stacked mobile/tablet views | | Horizontal | `WITH LAYOUT "horizontal"` | Strips of KPIs on wide screens | Each tab below arranges the same three station readouts with a different layout — only the `WITH LAYOUT` line changes: Components flow into the declared columns, one cell each: A panel with a three-column grid layout showing temperature, humidity, and pressure readouts side by side ```lot wrap focus={5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL StationGrid WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Station — Grid" WITH LAYOUT "grid" COLUMNS 3 ADD COMPONENT "Temperature" WITH TYPE "value" BIND VALUE TO TOPIC "station/temperature" SET UNIT "°C" ADD COMPONENT "Humidity" WITH TYPE "value" BIND VALUE TO TOPIC "station/humidity" SET UNIT "%" ADD COMPONENT "Pressure" WITH TYPE "value" BIND VALUE TO TOPIC "station/pressure" SET UNIT "bar" ``` Components stack top to bottom, each taking the full width: A panel with a vertical layout showing temperature, humidity, and pressure readouts stacked full-width ```lot wrap focus={5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL StationVertical WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Station — Vertical" WITH LAYOUT "vertical" ADD COMPONENT "Temperature" WITH TYPE "value" BIND VALUE TO TOPIC "station/temperature" SET UNIT "°C" ADD COMPONENT "Humidity" WITH TYPE "value" BIND VALUE TO TOPIC "station/humidity" SET UNIT "%" ADD COMPONENT "Pressure" WITH TYPE "value" BIND VALUE TO TOPIC "station/pressure" SET UNIT "bar" ``` Components sit side by side in a single row: A panel with a horizontal layout showing temperature, humidity, and pressure readouts in a single row ```lot wrap focus={5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL StationHorizontal WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Station — Horizontal" WITH LAYOUT "horizontal" ADD COMPONENT "Temperature" WITH TYPE "value" BIND VALUE TO TOPIC "station/temperature" SET UNIT "°C" ADD COMPONENT "Humidity" WITH TYPE "value" BIND VALUE TO TOPIC "station/humidity" SET UNIT "%" ADD COMPONENT "Pressure" WITH TYPE "value" BIND VALUE TO TOPIC "station/pressure" SET UNIT "bar" ``` ### Mixing layouts with cards The panel layout positions **top-level** components. To group several widgets into one grid slot, wrap them in a [`card`](/hub/dashboards/components/containers#card): the card occupies a cell (and can span columns like any component), while its children stack inside it. This is how you mix a coarse grid with finer-grained grouping: A two-column dashboard with a Zone 1 card containing a temperature readout and a running indicator on the left, and a speed gauge at 64% on the right ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL MixedLayout WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Mixed Layout" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "ZoneCard" WITH TYPE "card" SET TITLE "Zone 1" ADD COMPONENT "ZoneTemp" WITH TYPE "value" BIND VALUE TO TOPIC "zone1/temperature" SET UNIT "°C" ADD COMPONENT "ZoneStatus" WITH TYPE "indicator" BIND STATE TO TOPIC "zone1/status" SET ON_VALUE "running" WITH COLOR "green" SET ON_VALUE "stopped" WITH COLOR "red" ADD COMPONENT "SpeedGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 SET UNIT "%" BIND VALUE TO TOPIC "zone1/conveyor/speed" ``` The grid sees two components — the card and the gauge — while the card internally stacks a readout and an indicator. Use grid position for independent widgets; use a card when components belong to the same asset and should travel together. ## Positioning components on a grid Use `AT COLUMN x ROW y` to place a component, and `SPAN n COLUMNS` to stretch it across the grid: A dashboard grid with a wide chart across the top row and two gauges in the second row ```lot wrap focus={7,8,12,13,17,18} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL GridExample WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Grid Example" WITH LAYOUT "grid" COLUMNS 3 ROWS 2 ADD COMPONENT "FullWidthChart" WITH TYPE "realtime-chart" AT COLUMN 1 ROW 1 SPAN 3 COLUMNS SET LABEL "Throughput" BIND VALUE TO TOPIC "factory/throughput" ADD COMPONENT "Gauge1" WITH TYPE "gauge" AT COLUMN 1 ROW 2 SET RANGE FROM 0 TO 100 BIND VALUE TO TOPIC "sensor/1" ADD COMPONENT "Gauge2" WITH TYPE "gauge" AT COLUMN 2 ROW 2 SET RANGE FROM 0 TO 100 BIND VALUE TO TOPIC "sensor/2" ``` ## Navigation A dashboard doesn't have to fit on one screen. Any component with a click handler can jump to another panel with `NAVIGATE TO PANEL ""`, and the target panel can return with `NAVIGATE BACK` — the classic pattern is an overview panel with a button per zone, each leading to a detail panel. The overview panel links to the detail panel by name: A Zone Overview panel with a temperature readout and a Zone 1 details button ```lot wrap focus={11-14} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL NavOverview WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Zone Overview" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "Zone1Temp" WITH TYPE "value" BIND VALUE TO TOPIC "sensor/temperature" SET UNIT "°C" ADD COMPONENT "Zone1Details" WITH TYPE "button" SET LABEL "Zone 1 details" ON CLICK DO NAVIGATE TO PANEL "NavDetail" ``` The detail panel offers the way back with `NAVIGATE BACK`: A Zone 1 Detail panel with a temperature gauge and a Back to overview button ```lot wrap focus={12-15} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL NavDetail WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Zone 1 — Detail" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "DetailTemp" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD COMPONENT "BackButton" WITH TYPE "button" SET LABEL "Back to overview" ON CLICK DO NAVIGATE BACK ``` Navigation is just another action — it works from buttons, cards, or anything else with an `ON CLICK` handler. The full action reference is in [Actions & Events](/latest/lot-language/panels/actions-events#navigate-between-panels). ## Lifecycle and visibility A panel also carries lifecycle attributes: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Zone1 WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Zone 1" ``` | Attribute | Values | Meaning | | ----------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | `WITH STATE` | `DRAFT` · `PUBLISHED` · `ARCHIVED` | **Draft** while building (hidden), **Published** for daily use, **Archived** to retire without deleting | | `WITH VISIBILITY` | `PUBLIC` · `SHARED` · `PRIVATE` | Who is allowed to open the panel | * New panels open in **Draft** — switch to `PUBLISHED` when operators should rely on the panel. * `PUBLIC` panels are open to any connected client; `PRIVATE` restricts to the creator and admins. * The same lifecycle is surfaced as badges in the Dashboard Manager — see [Dashboards in the HUB](/hub/dashboards/overview). **Current HUB behaviour.** These attributes are honoured by the renderer but are not yet manageable from the HUB itself: a panel saved without explicit `WITH VISIBILITY` / `WITH STATE` values may not appear in the Dashboard Manager list at all, and the manager offers no way to view or change them outside the source text. Until that integration improves, always write both attributes explicitly in the definition. ## Common mistakes Properties use `SET`, not `WITH`. `WITH UNIT "°C"`, `WITH RANGE 0, 100`, and `WITH DECIMALS 2` do nothing — write `SET UNIT "°C"`, `SET RANGE FROM 0 TO 100`, `SET DECIMALS 2`. Component **types** are set on the header, not in the body. `WITH TYPE "line"` inside a chart body is silently ignored — use `SET CHART_TYPE "line"` instead. Check that both `WITH VISIBILITY PUBLIC` and `WITH STATE PUBLISHED` are present — panels saved without them may not appear in the panel list. ## Next Steps The widgets you place inside a panel, property by property. Wire each component to a topic, model, or query. # AI Routes Source: https://docs.coreflux.org/latest/lot-language/routes/ai-routes Deploy AI agents as LoT Routes — then reach them over MQTT interaction topics or CALL AGENT from Actions, solo or as a multi-agent team ## AI Agents in LoT Your broker already moves sensor data, drives PLCs, and stores history. **AI agents** add something new: specialists that *reason* over that world — in plain language, on the same machine, with the same MQTT and Routes you already defined. In Coreflux, each agent is a **LoT Route** with `TYPE AGENT`. One block gives you the model, the persona, the tools, and the guardrails. Spin up an operator that answers shift questions. Add a classifier that never sees a chat window. Run five agents on one broker, each with its own brain and boundaries — no sidecar service, no copy-paste into a separate chatbot. **Defining the Route is only half the story.** The other half is *who gets to knock on the door*: operators on MQTT topics the Route owns, or LoT Actions that dial the agent when something happens on the plant floor. Same agent definition — two front doors. Use one, use both, mix them across a team of Routes. **Like hiring for the control room, then choosing desk phone vs. internal line.** `DEFINE ROUTE … WITH TYPE AGENT` is the hire. `INTERACTION_TOPIC` is the desk line for people and apps. `CALL AGENT` is what your Actions use when a sensor fires or a timer ticks — no public chat required. ### When to use AI Routes * **One agent, one job** — chat helper, classifier, or scheduled production/maintenance/solar report; each gets its own Route * **Multi-agent teams** — split models, prompts, and permissions by role ([Multi-Agent Deployments](#multi-agent-deployments)) * **Ask the plant, don't only dashboard it** — "how many sensors are reporting?" before you wire another panel * **MQTT-facing chat** — `INTERACTION_TOPIC` on the Route, plus MQTT ask/reply topics (your Action or custom app) * **Hands-off automation** — `CALL AGENT` when a topic fires, a timer ticks, or a pipeline needs a verdict First time? Build both patterns in ten minutes: [Creating Agents in Coreflux](/latest/quick-start/creating-agents). Then come back here for providers, tools, and production detail. *** ## Complete Example (Copy & Deploy) Pick one tab to ship something fast, or deploy **both** on the same broker. Each example is self-contained — you do not need to read the rest of this page first. **Prerequisites (both tabs):** Broker running and an [OpenAI API key](https://platform.openai.com/api-keys) stored on the broker as a secret (once): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP SECRET "OPENAI_KEY" WITH "sk-..." ``` The examples below reference it with `GET SECRET "OPENAI_KEY"`. Prefer local inference? Swap to `PROVIDER "ollama"` — see [Picking a Provider](#picking-a-provider). **Pattern:** [Route interaction topics](#pattern-1-route-interaction-topics) — the AGENT Route owns the model and `INTERACTION_TOPIC`; a thin Action exposes **ask/reply MQTT topics** you can hit from MQTT Explorer or the HUB [Data Viewer](/hub/mqtt/data-viewer). The [HUB AI Assistant](/hub/ai-assistant) (Coreflux icon in the dock) is a **separate** built-in agent installed by the AI Setup wizard — it is not wired to `PlantAssistant` or your custom `INTERACTION_TOPIC`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantAssistant WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.5" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "interactive" WITH INTERACTION_TOPIC "plant/assistant/interact" WITH SYSTEM_PROMPT "You are the plant assistant. Use broker tools to answer from live MQTT data. Be concise." DEFINE ACTION AskPlantAssistant ON TOPIC "plant/assistant/ask" DO CALL AGENT "PlantAssistant.execute" WITH (task = PAYLOAD) RETURN AS {reply} PUBLISH TOPIC "plant/assistant/reply" WITH {reply} ``` Paste both blocks into **Coreflux HUB** (LoT Editor → Routes, then Actions), a LoT Notebook, or your usual deploy path. Confirm **PlantAssistant** (AGENT) and **AskPlantAssistant** (Action) are healthy. In **MQTT Explorer** or HUB **Data Viewer** (MQTT app), publish your question to `plant/assistant/ask` and subscribe to `plant/assistant/reply`: | Direction | Topic | Example payload | | --------- | ----------------------- | ----------------------------------------- | | Publish | `plant/assistant/ask` | `Which MQTT topics are active right now?` | | Subscribe | `plant/assistant/reply` | Agent answer (plain text) | Try another live-data prompt, for example: `What is the latest payload on sensors/zone1/temperature?` If the agent needs clarification, it publishes to `plant/assistant/interact/ask`. Reply on `plant/assistant/interact/reply/{request_id}` with the `{request_id}` from that message — separate from the `plant/assistant/ask` topic your Action listens on. You can **talk with your data** over MQTT — the agent uses broker tools, not a static chat transcript. **Pattern:** [`CALL AGENT` in LoT Actions](#pattern-2-call-agent-in-lot-actions) — LoT runs the agent on a schedule; no chat UI. Example: **hourly production report** from live `production/` MQTT data — throughput, off-target lines, and follow-ups for supervisors. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionReporter WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.4-mini" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "autonomous" WITH SYSTEM_PROMPT "You write production shift reports. Use broker tools on production-related topics. Bullet points: throughput, downtime signals, quality risks." DEFINE ACTION HourlyProductionReport ON EVERY 1 HOUR DO CALL AGENT "ProductionReporter.execute" WITH (task = "Hourly production report: summarise output and efficiency from production/ topics, flag lines or batches that look off-target, note anything that needs follow-up before the next shift.", use_history = "false") RETURN AS {report} PUBLISH TOPIC "reports/production/hourly" WITH {report} ``` `ProductionReporter` omits `INTERACTION_TOPIC` — only the Action triggers it. Maintenance or solar: reuse the pattern with `maintenance/` or `solar/` topics. More use cases: [Creating Agents in Coreflux](/latest/quick-start/creating-agents). Add **ProductionReporter** under Routes, then **HourlyProductionReport** under Actions. Subscribe to `reports/production/hourly` in MQTT Explorer or your dashboard. Publish test data under `production/` if needed. Temporarily use `ON EVERY 30 SECONDS`, verify, then restore `ON EVERY 1 HOUR`. **Automatic production reporting** — LoT triggers the agent; supervisors read the output topic. Run **both tabs** together when you want shift chat (`PlantAssistant`) and an hourly production report (`ProductionReporter`) on the same broker. Everything below covers providers, tools, multi-agent teams, and production settings in depth. *** ## Two Ways to Interact You write the agent once in LoT. **How the world talks to it** splits two ways — pick the door that fits, or run both on the same broker. | Pattern | Where it lives | Best for | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | **[Route interaction topics](#pattern-1-route-interaction-topics)** | `INTERACTION_TOPIC` on the AGENT Route (+ MQTT topics your Action or app uses) | People, custom chat UIs, MQTT Explorer, HUB Data Viewer | | **[`CALL AGENT` in Actions](#pattern-2-call-agent-in-lot-actions)** | LoT `CALL AGENT "RouteName.execute"` inside an Action | Sensor pipelines, timers, branching logic, structured jobs with no public chat surface | ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart TB subgraph define [Define once in LoT] Route[AGENT Route
model, tools, prompt] end subgraph mqtt [Pattern 1 — Route topics] Client[App, HUB, or MQTT client] Prefix[INTERACTION_TOPIC prefix] Client <-->|ask / reply| Prefix Prefix --> Route end subgraph lot [Pattern 2 — LoT Action] Trigger[ON TOPIC or ON EVERY] Action[Action body] Trigger --> Action Action -->|CALL AGENT| Route end ``` You can mix them: an **Operator** Route with `INTERACTION_TOPIC` for shift handover chat, plus a **Classifier** Route that only ever runs via `CALL AGENT` from a sensor Action. *** ## Minimal Example The shortest possible AI Route points at a local Ollama model and nothing else. It has no tools yet — it simply answers questions using the LLM: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH MODEL "llama3.2" ``` This default assumes Ollama is running locally at `http://localhost:11434`. Pull the model first with `ollama pull llama3.2` if you haven't already. If the broker runs in Docker, use the Ollama container hostname in Compose (e.g. `http://ollama:11434`) or `http://host.docker.internal:11434` when Ollama is on the host. *** ## Picking a Provider AI Routes support four providers out of the box. Pick the one that matches where your model lives — local or cloud. Cloud providers need an API key, which you should store as a secret rather than pasting into the Route. Runs entirely on your machine — no API key, no cloud dependency. Good for getting started and for privacy-sensitive workloads. The broker default is `llama3.2`; for stronger local quality in 2026, many teams use `qwen3:8b` or `qwen3:4b` on the same Ollama endpoint. **Prerequisite:** [Install Ollama](https://ollama.com/download) and have it running before you deploy this Route. Pull your model in Ollama first (e.g. `ollama pull qwen3:8b`) — the `MODEL` value must match a model already available in `ollama list`. Coreflux connects to your Ollama endpoint; it does not install Ollama or download models for you. Lightweight models may work for a quick test, but often are not capable enough for reliable agent work or good answer quality. If the broker runs in **Docker**, `localhost` inside the container is not the host—set `BASE_URL` to the reachable Ollama endpoint. With **Docker Compose**, use the Ollama service name (e.g. `http://ollama:11434`). If Ollama runs on the host, use `http://host.docker.internal:11434` (Docker Desktop) or the host's LAN IP. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "ollama" WITH BASE_URL "http://localhost:11434" WITH MODEL "qwen3:8b" ``` Uses OpenAI's GPT-5 generation (e.g. `gpt-5.4-mini` for cost-sensitive work, `gpt-5.5` for frontier reasoning). Store the API key as a secret so it never appears in your Route definition. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.4-mini" WITH API_KEY GET SECRET "OPENAI_KEY" ``` Uses Claude 4.6/4.7 models. Same secret-based pattern — `claude-sonnet-4-6` for most production agents, `claude-opus-4-7` when you need maximum reasoning depth. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "anthropic" WITH MODEL "claude-sonnet-4-6" WITH API_KEY GET SECRET "ANTHROPIC_KEY" ``` Uses Mistral's hosted models — `mistral-large-latest` (Large 3) for quality, `mistral-small-latest` when you want lower cost and latency. Works the same way: set the provider and point to a stored API key. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "mistral" WITH MODEL "mistral-large-latest" WITH API_KEY GET SECRET "MISTRAL_KEY" ``` Model IDs change often. Use the exact slug from your provider's catalog (OpenAI model guide, Anthropic model overview, Mistral API list, `ollama list`). Set secrets with `KEEP SECRET "OPENAI_KEY" WITH "sk-..."` — they never appear in logs or stored Route definitions. *** ## Core Configuration These are the parameters most users will touch. The `AGENT` route has more advanced knobs (history pruning, tracing, token limits, confirmation flows), but you don't need them on day one. | Parameter | Type | Default | What it does | | ------------------- | ------- | ------------ | -------------------------------------------------------------------- | | `PROVIDER` | string | `ollama` | Which LLM backend to use: `ollama`, `openai`, `anthropic`, `mistral` | | `MODEL` | string | `llama3.2` | Model name for the chosen provider | | `API_KEY` | string | — | API key for cloud providers (use `GET SECRET`) | | `TEMPERATURE` | number | `0.7` | Creativity: `0.0` is deterministic, `1.0+` is more creative | | `SYSTEM_PROMPT` | string | *(built-in)* | Sets the agent's persona and rules | | `BROKER_TOOLS` | boolean | `false` | Give the agent built-in tools to read/write MQTT and Routes | | `MCP_ROUTES` | string | — | Comma-separated names of MCP Routes the agent can call | | `INTERACTION_MODE` | string | `autonomous` | `autonomous` runs to completion, `interactive` can ask you questions | | `INTERACTION_TOPIC` | string | *(auto)* | Base MQTT topic for interaction messages | | `AGENT_MODE` | string | `agent` | `agent` (full control) or `insight` (read-only exploration) | More options exist for production deployments — conversation history pruning, execution tracing, tool confirmation, max iteration limits. Start with the basics above, then add them as you need them. *** ## What Tools Can the Agent Use? A fresh agent only knows how to chat. To make it actually *do* things, you give it tools. There are three sources, and you can mix all three. ### Broker Tools Setting `BROKER_TOOLS true` unlocks built-in tools so the agent can interact with your broker directly — no extra configuration required. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH MODEL "llama3.2" WITH BROKER_TOOLS "true" ``` With broker tools enabled, the agent can: * **List MQTT topics** and peek at their latest payloads * **Read a specific topic** on demand * **Publish messages** to any topic * **List your Routes** to see what's connected This is the fastest way to get an agent that can answer questions like *"what's the current temperature on factory/sensor/01?"* or *"which Routes are running right now?"*. ### MCP Tools MCP (Model Context Protocol) servers expose tools like file systems, Slack, Google Drive, or custom integrations. Any [MCP Route](./mcp-routes) you've defined can be wired into an AI Route with `MCP_ROUTES`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Assistant WITH TYPE AGENT ADD AGENT_CONFIG WITH MODEL "llama3.2" WITH MCP_ROUTES "FileMcp, SlackMcp" ``` The agent discovers every tool exposed by those MCP servers automatically. If `SlackMcp` offers a `send-message` tool, the agent can call it when appropriate. ### Your Other Routes The agent also sees every other Route you've already defined. If you have a PostgreSQL Route with an `InsertReading` event, the agent automatically gets a tool named `SensorDB.trigger_InsertReading`. Same for Modbus, REST, MongoDB, and so on — you don't need to re-declare anything. Define your data Routes first, then add the AI Route. The agent inherits all of them as tools the moment it starts. *** ## Insight vs. Agent Mode Not every AI Route should be able to publish messages or trigger equipment. Use `AGENT_MODE` to control how much power the agent has: | Mode | What it can do | Good for | | ----------------- | -------------------------------------------------------------- | ------------------------------------------------- | | `insight` | Read-only: list topics, read payloads, explore Routes | Exploring a broker, answering "what's connected?" | | `agent` (default) | Full: read **and** write, trigger Routes, modify configuration | Automation, chat assistants, hands-on operators | **Start in `insight` mode while you experiment.** The agent can still explain your broker and read data — but it can't accidentally publish to production topics while you're tuning the prompt. *** ## Pattern 1: Route Interaction Topics Set `INTERACTION_TOPIC` on the AGENT Route and the broker wires **interaction traffic** under that prefix (for example follow-up questions on `{prefix}/ask` and replies on `{prefix}/reply/{request_id}` in interactive mode). To **start** a conversation from MQTT Explorer or the HUB [Data Viewer](/hub/mqtt/data-viewer), add a thin Action that listens on your ask topic, runs `CALL AGENT "YourRoute.execute"`, and publishes the answer — as in the [Complete Example](#complete-example-copy--deploy) chat tab. Custom apps can implement the same prefix without that Action if they speak the broker's interaction protocol. The [HUB AI Assistant](/hub/ai-assistant) is not your custom AGENT Route — it is the dock chat installed via **AI Setup** (`BrokerAgent`). Use it to build dashboards and LoT; use `INTERACTION_TOPIC` + MQTT (or your UI) for agents you define in LoT. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Operator WITH TYPE AGENT ADD AGENT_CONFIG WITH MODEL "llama3.2" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "interactive" WITH INTERACTION_TOPIC "plant/operator" WITH INTERACTION_TIMEOUT "120" WITH SYSTEM_PROMPT "You are the plant assistant. Help operators using broker tools." ``` | Setting | Role | | --------------------- | ------------------------------------------------------------------------------------------------ | | `INTERACTION_TOPIC` | Base MQTT prefix for this Route's chat (here: `plant/operator`) | | `INTERACTION_MODE` | `interactive` lets the agent pause and ask follow-ups; `autonomous` runs each task to completion | | `INTERACTION_TIMEOUT` | Seconds to wait for a human reply in interactive mode before continuing | When the agent needs clarification in **interactive** mode, traffic uses subtopics under your prefix: | Direction | Topic | What happens | | -------------- | ---------------------------------------- | ---------------------------------------------- | | Agent → client | `{INTERACTION_TOPIC}/ask` | Agent publishes a follow-up question | | Client → agent | `{INTERACTION_TOPIC}/reply/{request_id}` | Your UI or MQTT client answers; work continues | Example with `INTERACTION_TOPIC "plant/operator"`: subscribe to `plant/operator/ask`, publish replies to `plant/operator/reply/{request_id}`. Use **Route topics** when humans or external apps drive the agent. Use **`CALL AGENT`** when LoT logic drives it — you do not need both on every Route. *** ## Pattern 2: `CALL AGENT` in LoT Actions Keep the AGENT Route as the brain, but invoke it **from LoT** with `CALL AGENT "RouteName.execute"`. The Action's trigger (`ON TOPIC`, `ON EVERY`, and so on) decides *when* the agent runs; the `task` argument carries *what* to do. The result lands in a variable you can branch on or publish anywhere. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ClassifyReading ON TOPIC "sensors/+/reading" DO CALL AGENT "Classifier.execute" WITH (task = PAYLOAD, use_history = "false") RETURN AS {label} PUBLISH TOPIC "sensors/classified" WITH {label} ``` | Argument | Typical use | | ------------- | ----------------------------------------------------------------------------------------- | | `task` | Instruction or payload text (required) | | `session_id` | Same id on the next call continues one thread (per device, ticket, or line) | | `use_history` | `"false"` for one-off labels; `"true"` when follow-up calls should remember earlier steps | Specialist and watchdog agents often **omit** `INTERACTION_TOPIC` so nothing listens on a public chat prefix — they only run when an Action calls them. You *can* bridge `CALL AGENT` to custom MQTT topics if you want a bespoke ask/reply pair, but those topics belong to **your Action**, not the Route's built-in interaction surface: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AskAssistant ON TOPIC "assistant/ask" DO CALL AGENT "Assistant.execute" WITH (task = PAYLOAD) RETURN AS {reply} PUBLISH TOPIC "assistant/reply" WITH {reply} ``` Here `assistant/ask` and `assistant/reply` are Action-defined topics; the agent still runs via `CALL AGENT`, not via `INTERACTION_TOPIC`. Running **several AI Routes**? Pass `session_id` and `use_history` on each `CALL AGENT` so parallel automation jobs do not share the wrong memory. See [Multi-Agent Deployments](#multi-agent-deployments). *** ## How It All Fits Together The diagram below shows both patterns on one broker: MQTT clients use the Route's `INTERACTION_TOPIC`; LoT Actions use `CALL AGENT`. Optional tool sources are the same in either case. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart TB subgraph callers [Callers] MQTT[MQTT client or HUB] LotAction[LoT Action] end subgraph route [AGENT Route Assistant] Prefix[INTERACTION_TOPIC
plant/operator] AgentCore[LLM + tools] Prefix --> AgentCore LotAction -->|CALL AGENT| AgentCore end MQTT <-->|chat prefix| Prefix BrokerTools[Broker tools] MCP[MCP Routes] Others[Other Routes] AgentCore --> BrokerTools AgentCore --> MCP AgentCore --> Others ``` Every tool box is optional. A minimal agent with only `MODEL` still answers — it just cannot read your broker until you enable tools. *** ## Multi-Agent Deployments You are not limited to a single AI Route. The same Coreflux broker can run **several agents at once** — each one a normal Route with its own name, model, system prompt, and permissions. That matters whether you are learning the product or sizing it for production: you can give **operators** a conversational helper, give **automation** a fast classifier that never sees a chat window, and keep **guests** on a read-only view — without buying extra middleware or spinning up separate services for each role. From a buyer's perspective, multi-agent is how you **match cost to workload** (local models for high-volume tasks, cloud models where quality matters most) and **match risk to audience** (full control for trusted staff, insight-only for everyone else). From a newcomer's perspective, it is simply "define another `DEFINE ROUTE … WITH TYPE AGENT` block with a different name" — the broker runs them side by side. **Think of it like staffing a control room with specialists instead of one generalist** — a friendly voice for the team at the desk, a quiet expert that only labels sensor readings, and a scheduled check-in that writes a health summary. They share the same broker, not the same job description. ### Why give the broker more than one agent? One catch-all assistant is hard to tune and expensive to run everywhere. Splitting the work pays off because: * **Personalities differ.** A chat assistant should be warm and conversational; a data classifier should be terse and deterministic. * **Access levels differ.** The Route that can trigger equipment should not be the same one you hand to a visitor. * **Models and costs differ.** A premium cloud model is a poor fit for every sensor reading — a local model can handle volume for free while heavier reasoning stays on the model you pay for. * **Memory should stay isolated.** The person at shift handover should not inherit someone else's half-finished conversation by accident. ### How people and software reach each agent Map each Route to [Pattern 1](#pattern-1-route-interaction-topics) or [Pattern 2](#pattern-2-call-agent-in-lot-actions) — or use both patterns on different Routes in the same project: | Entry | Pattern | Typical setup | | ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------- | | Operators, chat apps, MQTT clients | **Route topics** | `INTERACTION_TOPIC` + ask/reply Action or custom UI; often `INTERACTION_MODE "interactive"` | | Sensor pipelines, timers, batch jobs | **`CALL AGENT`** | `ON TOPIC` / `ON EVERY` Action, no `INTERACTION_TOPIC` on the Route | | Background-only specialists | **`CALL AGENT` only** | `autonomous` mode, omit `INTERACTION_TOPIC` — no public chat prefix | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL AGENT "Classifier.execute" WITH (task = {job}, session_id = {deviceId}, use_history = "false") RETURN AS {result} ``` ### Conversations and memory Each **named AI Route** keeps its own conversation state. Two different Routes never share the same chat history — they are separate assistants. **Within** one Route, a **session id** (from your MQTT topic path when using interactive chat, or from `session_id` on `CALL AGENT`) keeps parallel conversations apart: two operators, two browser tabs, or two machines can talk to the same Route without their threads colliding. If you omit `session_id`, the broker still runs the task using its default session behaviour for that call — pass an explicit `session_id` when you need a reliable multi-step thread (for example one id per device or per ticket). ### How multi-agent traffic can look ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR Human[Operator or app] ChatTopic[MQTT chat prefix] OpRoute[Operator AI Route] SensorEvent[Sensor or event] LotAction[LoT Action] WorkerRoute[Specialist AI Route] ResultTopic[Result topic] Human -->|question| ChatTopic ChatTopic --> OpRoute OpRoute -->|answer| Human SensorEvent --> LotAction LotAction -->|CALL AGENT| WorkerRoute WorkerRoute --> ResultTopic ``` ### Four agent archetypes Most teams mix a few of these patterns — use one on a small setup, combine several when roles split. **How to read the tabs:** **Operator** and **Reader** are usually **MQTT-facing** (people or HUB). **Specialist** is typically **LoT-only** (no chat topic). **Watchdog** is triggered on a **schedule** from LoT but does not need its own chat line. The staff-facing assistant. Lives on a chat topic, has broker tools, and runs in interactive mode so it can ask follow-up questions. This is the agent your team talks to. **Entry:** MQTT under your `INTERACTION_TOPIC` prefix (via a thin ask/reply Action or a custom UI). ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Operator WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.5" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "interactive" WITH INTERACTION_TOPIC "plant/operator" WITH SYSTEM_PROMPT "You are the plant assistant. Help operators understand what is happening and take action when asked." ``` **Best for:** chat UIs, control-room copilots, customer-facing assistants. A silent workhorse with no chat surface — no `INTERACTION_TOPIC`, no MQTT entry point. The only way in is a `CALL AGENT` from another LoT Action. Perfect for structured, repeatable tasks like classification or extraction. **Entry:** LoT only. Keep `INTERACTION_MODE` **`autonomous`** (the default) and skip `INTERACTION_TOPIC` so the Route does not listen for public chat traffic. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Classifier WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "ollama" WITH MODEL "qwen3:8b" WITH INTERACTION_MODE "autonomous" WITH TEMPERATURE "0.0" WITH SYSTEM_PROMPT "Classify sensor readings as NORMAL, WARNING, or CRITICAL. Reply with only the label." ``` Then drive it from any Action that needs a label: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ClassifySensor ON TOPIC "sensors/+/reading" DO SET "device" WITH TOPIC POSITION 2 SET "reading" WITH (PAYLOAD AS STRING) CALL AGENT "Classifier.execute" WITH (task = "Reading from " + {device} + ": " + {reading}) RETURN AS {label} PUBLISH TOPIC "sensors/" + {device} + "/class" WITH {label} ``` **Best for:** classification, tagging, extraction — anything called from automation, not humans. A scheduled sentinel. It wakes up on a timer, inspects broker state via broker tools, and publishes a summary or alert. No human triggers it. **Entry:** A timed LoT Action that calls `CALL AGENT` — no dedicated chat topic required. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Watchdog WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "ollama" WITH MODEL "qwen3:4b" WITH INTERACTION_MODE "autonomous" WITH BROKER_TOOLS "true" WITH SYSTEM_PROMPT "You are a health monitor. Check all routes for errors and publish a concise alert summary if any are unhealthy." ``` Wire it to a timed Action so it runs unattended: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION HourlyHealthCheck ON EVERY 1 HOUR DO CALL AGENT "Watchdog.execute" WITH (task = "Check all routes for errors and summarise the broker health.") RETURN AS {report} PUBLISH TOPIC "plant/health/hourly" WITH {report} ``` **Best for:** health checks, scheduled reports, overnight summaries. The agent you can hand to anyone. It runs in `insight` mode, so it can read every topic and Route but cannot write anything — even if asked nicely. Safe for visitors, dashboards, and read-only audiences. **Entry:** MQTT read-only chat on your `INTERACTION_TOPIC` — safe for guests because `AGENT_MODE` is `insight`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Reader WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.4-mini" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH AGENT_MODE "insight" WITH INTERACTION_MODE "interactive" WITH INTERACTION_TOPIC "plant/reader" WITH SYSTEM_PROMPT "You are a read-only plant observer. Answer questions about current state. You cannot change anything." ``` **Best for:** management dashboards, visitor demos, on-call engineers who need to look but not touch. ### Mixing providers across agents `PROVIDER` and `MODEL` are set per Route, so every agent in the team can run on a different brain. Swap any of them by redeploying a single Route definition — no broker restart, no infrastructure change. | Use case | Suggested model | Why | | ---------------------------- | ------------------------------------------------- | ------------------------------------------------------------- | | High-stakes operator chat | OpenAI `gpt-5.5` or Anthropic `claude-opus-4-7` | Frontier reasoning, handles ambiguity and multi-step tool use | | Balanced production chat | OpenAI `gpt-5.4` or Anthropic `claude-sonnet-4-6` | Strong quality at lower cost than top-tier models | | Low-latency classifiers | Ollama `qwen3:8b` (local) | Fast, private, good tool-following for structured labels | | Scheduled operations reports | OpenAI `gpt-5.4-mini` | Production, maintenance, or solar digests on a timer | | Management read portal | OpenAI `gpt-5.4-mini` | Cost-effective for low-volume read queries | ### Smart habits when you run several agents * **Name Routes by role** — `Line3Classifier` or `VisitorGuide`, not `Agent2`, so your team and your LoT Actions stay readable. * **Give each worker only the tools it needs** — A classifier rarely needs broker-wide publish access or MCP; fewer tools mean faster runs and fewer surprises. * **Remember that cost adds up** — Each Route can call a cloud model on its own schedule. More agents and more `CALL AGENT` traffic multiply tokens and rate limits. * **Match power to trust** — Use [Insight vs. Agent Mode](#insight-vs-agent-mode) for read-only audiences; reserve full `agent` mode for staff Routes that must act. * **Add confirmation where mistakes hurt** — `TOOL_EXECUTION_MODE "confirm"` on any Route that should pause before a destructive tool runs. ### Per-agent safety rails Safety settings apply **per Route**, so a strict assistant and a powerful one can live on the same broker. Pair the ideas in [Insight vs. Agent Mode](#insight-vs-agent-mode) with the habits above: lock read-only audiences to `insight`, keep trusted operators in full `agent` mode, and use `TOOL_EXECUTION_MODE "confirm"` only where you need a human gate before risky tools. Trace topics, iteration limits, and history pruning are also per agent — turn tracing on for the Route you are debugging so the rest of the team stays quiet. *** ## Best Practices Never paste API keys directly into a Route definition. Use `KEEP SECRET "MY_KEY" WITH "sk-..."` once, then reference it with `WITH API_KEY GET SECRET "MY_KEY"`. Secrets are never logged or exposed in stored definitions. When you're still shaping the system prompt or picking a model, run the agent with `WITH AGENT_MODE "insight"`. It can still read topics and explore Routes, but it can't publish or trigger anything — so a bad prompt won't affect production data. The default prompt is generic. Tell the agent *who it is* and *what it should refuse to do*: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SYSTEM_PROMPT "You are a factory assistant. Answer questions about the production line using broker tools. Do not publish to any topic starting with 'control/' without explicit confirmation." ``` `MAX_ITERATIONS` caps how many tool calls the agent can make per task. The default (`10`) is fine for most questions. Lift it only if you have complex multi-step workflows — runaway loops are both slow and expensive on cloud providers. *** ## Troubleshooting * Make sure Ollama is running: `ollama list` should respond without errors * Confirm the model is pulled: `ollama pull llama3.2` * If Ollama is on a different machine, set `WITH BASE_URL "http://host:11434"` explicitly * If the broker runs in **Docker**, `localhost` points at the container—not the host. With **Docker Compose**, use the Ollama service (e.g. `http://ollama:11434`). If Ollama is on the host, try `http://host.docker.internal:11434` (Docker Desktop) or the host LAN IP * Check firewall rules between the broker and the Ollama host * Confirm `BROKER_TOOLS "true"` is set (or that `MCP_ROUTES` lists valid Routes) * Smaller local models sometimes ignore tools — try a more capable pull (e.g. `qwen3:8b` over `llama3.2`, or switch to a cloud model such as `gpt-5.4-mini` or `claude-sonnet-4-6`) * Sharpen the `SYSTEM_PROMPT` so the agent knows it *should* use tools for data questions * Verify the secret actually exists: publishing `LIST SECRETS` via the command console should show its name * Check you're using the right `PROVIDER` — Anthropic keys don't work for OpenAI and vice versa * For Anthropic, make sure your key has access to the specific model you set in `MODEL` * Raise `MAX_TOKENS` — long answers need more room (default is `4096`) * Check the model actually supports the request (some smaller models struggle with long context) * If you chained several tool calls, the agent may have hit `MAX_ITERATIONS` — increase it and retry *** ## Next Steps Hands-on tutorial — chat over MQTT, automatic reports, broker tools, and multi-agent layouts in minutes. Plug external tools — Slack, file systems, Google Drive, custom servers — into your agent. # CrateDB Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/cratedb Store MQTT data in CrateDB for distributed time-series analytics and SQL queries ## CrateDB Overview The `CRATEDB` route stores MQTT messages in CrateDB, a distributed SQL database optimized for time-series and machine data. It combines the familiarity of SQL with the scalability needed for IoT workloads. CrateDB excels at high-volume time-series data with fast ingestion and SQL query capabilities. Use it when you need real-time analytics on sensor data at scale. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO sensor_data (ts, sensor_id, value) VALUES (?, ?, ?)" ``` *** ## Connection Configuration ### CRATEDB\_CONFIG Parameters CrateDB server hostname or IP address. Database username. Database password. CrateDB PostgreSQL protocol port. Default: 5432. Target database/schema name. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH HOST GET ENV "DB_HOST" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## Writing Data Store sensor readings with timestamps: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TimeSeriesDB WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT StoreTimeSeries WITH SOURCE_TOPIC "sensors/+/reading" WITH DESTINATION_TOPIC "db/crate/status" WITH QUERY "INSERT INTO sensor_data (ts, sensor_id, value) VALUES (?, ?, ?)" ``` Optimized for high ingestion rates: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MetricsDB WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "metrics" WITH USERNAME "metrics_writer" WITH PASSWORD "secure_password" ADD EVENT StoreMetrics WITH SOURCE_TOPIC "metrics/#" WITH QUERY "INSERT INTO metrics (ts, topic, data) VALUES (?, ?, ?)" ``` Route to different tables by topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiTableDB WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT StoreSensors WITH SOURCE_TOPIC "sensors/#" WITH QUERY "INSERT INTO sensors (ts, topic, data) VALUES (?, ?, ?)" ADD EVENT StoreAlerts WITH SOURCE_TOPIC "alerts/#" WITH QUERY "INSERT INTO alerts (ts, topic, data) VALUES (?, ?, ?)" ``` Insert or ignore on conflict. CrateDB supports `ON CONFLICT DO NOTHING` to skip duplicate inserts for idempotent ingestion: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EventLog WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT StoreUniqueEvent WITH SOURCE_TOPIC "events/+/occurred" WITH QUERY "INSERT INTO events (event_id, device_id, event_type, ts) VALUES ('{value.json.event_id}', '{value.json.device_id}', '{value.json.event_type}', '{value.json.timestamp}') ON CONFLICT (event_id) DO NOTHING" ``` `ON CONFLICT (event_id) DO NOTHING` ensures duplicate messages — common in IoT delivery — never create duplicate rows. Modify existing records when a status change arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT UpdateDeviceStatus WITH SOURCE_TOPIC "devices/+/status" WITH QUERY "UPDATE devices SET current_status = '{value.json.status}', last_seen = NOW() WHERE device_id = '{value.json.device_id}'" ``` Remove records on a decommission or cleanup event: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT DecommissionDevice WITH SOURCE_TOPIC "devices/+/decommissioned" WITH QUERY "DELETE FROM devices WHERE device_id = '{value.json.device_id}'" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "TimeSeriesDB" WITH TABLE "sensor_data" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "SELECT sensor_id, value FROM sensor_data WHERE sensor_id = '{payload}' ORDER BY ts DESC LIMIT 1" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The query result is published to `db/result/latest`, where your actions or external clients can consume it. ### Timed Polling EVENTs can also run SELECT queries on a fixed schedule using `WITH EVERY`. This is especially powerful with CrateDB's fast aggregations: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TimeSeriesDB WITH TYPE CRATEDB ADD CRATEDB_CONFIG WITH HOST "cratedb.example.com" WITH PORT '5432' WITH DATABASE "iot" WITH USERNAME "crate" WITH PASSWORD "secure_password" ADD EVENT PollLatestReadings WITH EVERY 5 SECONDS WITH DESTINATION_TOPIC "dashboard/sensors/latest" WITH QUERY "SELECT device_id, AVG(value) as avg_value, MAX(ts) as last_seen FROM sensor_data WHERE ts > NOW() - INTERVAL '1 minute' GROUP BY device_id ORDER BY device_id" ``` *** ## Table Schema Examples Create tables optimized for time-series: ```sql wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Partitioned time-series table CREATE TABLE sensor_data ( ts TIMESTAMP WITH TIME ZONE, sensor_id TEXT, value DOUBLE PRECISION ) PARTITIONED BY (ts); -- Metrics with JSON storage CREATE TABLE metrics ( ts TIMESTAMP WITH TIME ZONE, topic TEXT, data OBJECT(DYNAMIC) ) PARTITIONED BY (ts); ``` *** ## Troubleshooting * Verify HOST and PORT are correct * Check CrateDB is running and accepting connections * Verify firewall allows connections on port 5432 * Verify USERNAME and PASSWORD are correct * Check user has permissions on the DATABASE * Verify table exists and has correct schema * Check column types match inserted data *** ## Next Steps Compare all storage options. Configure Snowflake cloud data warehouse. # File Storage Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/file-storage Store MQTT data to local CSV and JSON files for simple persistence and backups ## File Storage Overview The `FILE_STORAGE` route writes MQTT messages to local CSV or JSON files. It provides simple persistence for logging, backups, and data export without requiring a database server. File storage is ideal for simple logging, local backups, and exporting data for offline analysis. Use it when you don't need a full database but want persistent records. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DataLogger WITH TYPE FILE_STORAGE ADD FILE_STORAGE_CONFIG WITH STORAGE_DIR "/data/logs" WITH FORMAT "CSV" WITH FILE_PATTERN "{0}_{date}.csv" WITH APPEND "true" ADD EVENT LogSensorData WITH SOURCE_TOPIC "sensors/#" WITH QUERY "CLEAN:{table: sensor_data, data: {value.json}}" ``` *** ## Connection Configuration ### FILE\_STORAGE\_CONFIG Parameters Directory path for storing files. File name pattern. Use `{0}` for table name, `{date}` for current date. Default: `{0}.csv` File format: `CSV` or `JSON`. Default: CSV. CSV delimiter character. Default: comma (`,`). Append to existing files. Default: false (overwrite). Auto-add timestamp field. Default: false. Maximum records per file before rotation. Default: 10000. Timestamp format string. Default: `yyyy-MM-dd HH:mm:ss`. **Use environment variables for paths in production.** Keep deployment-specific values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH STORAGE_DIR GET ENV "STORAGE_PATH" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## CLEAN Query Format File storage routes use the CLEAN format: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH QUERY "CLEAN:{table: , data: {value.json}}" ``` The `table` name is used in the file pattern with `{0}`. *** ## Writing Data Log sensor data to daily CSV files: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CSVLogger WITH TYPE FILE_STORAGE ADD FILE_STORAGE_CONFIG WITH STORAGE_DIR "/data/logs" WITH FORMAT "CSV" WITH FILE_PATTERN "{0}_{date}.csv" WITH APPEND "true" WITH CSV_DELIMITER "," WITH INCLUDE_TIMESTAMP "true" WITH MAX_RECORDS "10000" ADD EVENT LogSensorData WITH SOURCE_TOPIC "sensors/#" WITH DESTINATION_TOPIC "file/status" WITH QUERY "CLEAN:{table: sensor_data, data: {value.json}}" ``` Creates files like: `sensor_data_2025-01-15.csv` Store events as JSON lines: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE JSONLogger WITH TYPE FILE_STORAGE ADD FILE_STORAGE_CONFIG WITH STORAGE_DIR "/data/json" WITH FORMAT "JSON" WITH FILE_PATTERN "{0}.json" WITH APPEND "true" WITH INCLUDE_TIMESTAMP "true" WITH MAX_RECORDS "5000" ADD EVENT LogEvents WITH SOURCE_TOPIC "events/#" WITH DESTINATION_TOPIC "file/json/status" WITH QUERY "CLEAN:{table: events, data: {value.json}}" ``` Route to different files by data type: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiFileLogger WITH TYPE FILE_STORAGE ADD FILE_STORAGE_CONFIG WITH STORAGE_DIR "/data/logs" WITH FORMAT "CSV" WITH FILE_PATTERN "{0}_{date}.csv" WITH APPEND "true" WITH INCLUDE_TIMESTAMP "true" ADD EVENT LogTemperature WITH SOURCE_TOPIC "sensors/+/temperature" WITH QUERY "CLEAN:{table: temperature, data: {value.json}}" ADD EVENT LogPressure WITH SOURCE_TOPIC "sensors/+/pressure" WITH QUERY "CLEAN:{table: pressure, data: {value.json}}" ADD EVENT LogAlerts WITH SOURCE_TOPIC "alerts/#" WITH QUERY "CLEAN:{table: alerts, data: {value.json}}" ``` Creates: `temperature_2025-01-15.csv`, `pressure_2025-01-15.csv`, `alerts_2025-01-15.csv` Create backup files with rotation: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BackupStorage WITH TYPE FILE_STORAGE ADD FILE_STORAGE_CONFIG WITH STORAGE_DIR "/backup/mqtt" WITH FORMAT "JSON" WITH FILE_PATTERN "{0}_{date}.jsonl" WITH APPEND "true" WITH MAX_RECORDS "100000" WITH INCLUDE_TIMESTAMP "true" ADD EVENT BackupAll WITH SOURCE_TOPIC "#" WITH QUERY "CLEAN:{table: mqtt_backup, data: {value.json}}" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "DataLogger" WITH TABLE "sensor_data" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support read queries to retrieve data from stored files. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`. File Storage read queries use the CLEAN format with `filter`, `sort`, and `limit` instead of SQL: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DataLogger WITH TYPE FILE_STORAGE ADD FILE_STORAGE_CONFIG WITH STORAGE_DIR "/data/logs" WITH FORMAT "CSV" WITH FILE_PATTERN "{0}_{date}.csv" WITH APPEND "true" ADD EVENT GetLatestEntry WITH SOURCE_TOPIC "file/query/latest" WITH DESTINATION_TOPIC "file/result/latest" WITH QUERY "CLEAN:{table: sensor_data, filter: {\"sensor_id\": \"{payload}\"}, sort: {\"timestamp\": -1}, limit: 1}" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: file/query/latest Payload: temp001 ``` The matching record is published to `file/result/latest`, where your actions or external clients can consume it. ### CLEAN Read Operators | SQL Equivalent | File Storage CLEAN Syntax | | ----------------- | ------------------------------ | | `SELECT *` | `{"table": "x"}` | | `WHERE a = 1` | `filter: {"a": 1}` | | `WHERE a > 10` | `filter: {"a": {"$gt": 10}}` | | `ORDER BY a DESC` | `sort: {"a": -1}` | | `LIMIT 10` | `limit: 10` | | `OFFSET 5` | `skip: 5` | | `SELECT a, b` | `projection: {"a": 1, "b": 1}` | *** ## File Pattern Placeholders | Placeholder | Description | Example | | ----------- | --------------------------- | ------------- | | `{0}` | Table name from CLEAN query | `sensor_data` | | `{date}` | Current date (yyyy-MM-dd) | `2025-01-15` | *** ## Output Examples ### CSV Output ```csv wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} timestamp,sensor_id,temperature,humidity 2025-01-15 10:30:00,temp001,23.5,65 2025-01-15 10:30:05,temp002,24.1,62 ``` ### JSON Output (JSON Lines format) ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} {"timestamp":"2025-01-15T10:30:00Z","sensor_id":"temp001","temperature":23.5,"humidity":65} {"timestamp":"2025-01-15T10:30:05Z","sensor_id":"temp002","temperature":24.1,"humidity":62} ``` *** ## Troubleshooting * Verify STORAGE\_DIR exists * Check write permissions on directory * Ensure Coreflux process has access * Verify STORAGE\_DIR path is correct * Check disk space is available * Ensure directory is writable * Verify SOURCE\_TOPIC matches incoming messages * Check QUERY syntax is correct * Verify APPEND is set correctly * Check MAX\_RECORDS is set appropriately * Verify FILE\_PATTERN includes `{date}` for daily rotation *** ## Next Steps Compare all storage options. Configure PostgreSQL database storage. # MariaDB Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/mariadb Store MQTT data in MariaDB with automatic query execution and MySQL compatibility ## MariaDB Overview The `MARIADB` route stores MQTT messages in MariaDB databases. MariaDB is a MySQL-compatible database with enhanced features and performance optimizations. MariaDB offers drop-in MySQL compatibility with additional features like improved replication and storage engines. Use the same configuration as MySQL with the `MARIADB` route type. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO readings (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` *** ## Connection Configuration ### SQL\_CONFIG Parameters MariaDB server hostname or IP address. Target database name. Database username. Database password. MariaDB port. Default: 3306. Enable SSL connection. Default: false. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SERVER GET ENV "DB_HOST" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## Writing Data Store sensor data in MariaDB: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorStorage WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreReading WITH SOURCE_TOPIC "sensors/#" WITH QUERY "INSERT INTO sensor_data (timestamp, topic, payload) VALUES (NOW(), '{source_topic}', '{value.json}')" ``` Secure connection: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureStorage WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.cloud.com" WITH PORT '3306' WITH DATABASE "production" WITH USERNAME "app_user" WITH PASSWORD "secure_password" WITH USE_SSL "true" ADD EVENT StoreEvent WITH SOURCE_TOPIC "events/#" WITH QUERY "INSERT INTO events (ts, topic, data) VALUES (NOW(), '{source_topic}', '{value.json}')" ``` Insert a record or update it if a duplicate key exists. MariaDB supports both `ON DUPLICATE KEY UPDATE` (MySQL-compatible) and `ON CONFLICT` (PostgreSQL-compatible in newer versions): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT RegisterDevice WITH SOURCE_TOPIC "devices/+/registered" WITH QUERY "INSERT INTO devices (device_id, name, area, active, updated_at) VALUES ('{value.json.device_id}', '{value.json.name}', '{value.json.area}', 1, NOW()) ON DUPLICATE KEY UPDATE name = VALUES(name), area = VALUES(area), updated_at = NOW()" ``` Modify existing records when a status change arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT UpdateDeviceStatus WITH SOURCE_TOPIC "devices/+/status" WITH QUERY "UPDATE devices SET current_status = '{value.json.status}', last_seen = NOW() WHERE device_id = '{value.json.device_id}'" ``` Remove records on a decommission or cleanup event: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT DecommissionDevice WITH SOURCE_TOPIC "devices/+/decommissioned" WITH QUERY "DELETE FROM devices WHERE device_id = '{value.json.device_id}' AND active = 0" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "SensorStorage" WITH TABLE "sensor_data" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "SELECT sensor_id, value FROM sensor_data WHERE sensor_id = '{payload}' ORDER BY timestamp DESC LIMIT 1" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The query result is published to `db/result/latest`, where your actions or external clients can consume it. ### Timed Polling EVENTs can also run SELECT queries on a fixed schedule using `WITH EVERY`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MARIADB ADD SQL_CONFIG WITH SERVER "mariadb.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT PollActiveDevices WITH EVERY 10 SECONDS WITH DESTINATION_TOPIC "dashboard/devices/active" WITH QUERY "SELECT device_id, name, current_status, last_seen FROM devices WHERE active = 1 ORDER BY name" ``` *** ## Troubleshooting * Verify SERVER and PORT are correct * Check MariaDB is running and accepting connections * Verify firewall allows connections on port 3306 * Verify USERNAME and PASSWORD are correct * Check user has permissions on the DATABASE * Verify user can connect from your host *** ## Next Steps Compare all storage options. Configure Microsoft SQL Server storage. # MongoDB Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/mongodb Store MQTT data in MongoDB with flexible document schemas and CLEAN query format ## MongoDB Overview The `MONGODB` route stores MQTT messages as documents in MongoDB collections. It uses the CLEAN query format for simplified JSON syntax and supports flexible schemas for IoT data. MongoDB is ideal for IoT data with varying schemas. Store sensor readings, events, and logs without defining rigid table structures upfront. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:password@mongodb.example.com:27017/iot?authSource=admin" WITH DATABASE "iot" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "CLEAN:{collection: readings, document: {sensor_id: {sensor_id}, value: {value.json}, timestamp: {timestamp}}}" ``` *** ## Connection Configuration ### MONGODB\_CONFIG Parameters MongoDB connection URI. Format: `mongodb://user:password@host:port/database?options` Target database name (can also be specified in connection string). **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH CONNECTION_STRING GET SECRET "MONGO_CONNECTION_STRING" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## CLEAN Query Format MongoDB routes use the CLEAN format for simplified document insertion: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH QUERY "CLEAN:{collection: , document: {: , ...}}" ``` ### CLEAN Placeholders | Placeholder | Description | | -------------------- | -------------------------------------- | | `{value.json}` | Full JSON payload as embedded document | | `{value.json.field}` | Specific field from JSON payload | | `{timestamp}` | Message timestamp | | `{source_topic}` | Original MQTT topic | | `{field}` | Field extracted from topic path | *** ## Event Configuration ### EVENT Parameters MQTT topic pattern that triggers the INSERT. Supports wildcards. CLEAN format query for document insertion. MQTT topic to publish operation status. *** ## Writing Data Store sensor readings as documents: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorStorage WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://iot_user:password@mongodb.example.com:27017/iot?authSource=admin" WITH DATABASE "iot" ADD EVENT StoreReading WITH SOURCE_TOPIC "sensors/+/reading" WITH DESTINATION_TOPIC "db/mongo/status" WITH QUERY "CLEAN:{collection: readings, document: {timestamp: {timestamp}, topic: {source_topic}, data: {value.json}}}" ``` Create structured documents with specific fields: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE StructuredStorage WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:pass@mongodb.example.com:27017/iot" WITH DATABASE "iot" ADD EVENT StoreSensor WITH SOURCE_TOPIC "sensors/+/data" WITH QUERY "CLEAN:{collection: sensor_data, document: {sensor_id: {sensor_id}, temperature: {value.json.temperature}, humidity: {value.json.humidity}, recorded_at: {timestamp}}}" ``` **Input at** `sensors/temp001/data`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} {"temperature": 23.5, "humidity": 65} ``` **Stored document:** ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "sensor_id": "temp001", "temperature": 23.5, "humidity": 65, "recorded_at": "2025-01-15T10:30:00Z" } ``` Route to different collections based on topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiCollection WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:pass@mongodb.example.com:27017/iot" WITH DATABASE "iot" ADD EVENT StoreSensors WITH SOURCE_TOPIC "sensors/#" WITH QUERY "CLEAN:{collection: sensors, document: {topic: {source_topic}, data: {value.json}, ts: {timestamp}}}" ADD EVENT StoreAlerts WITH SOURCE_TOPIC "alerts/#" WITH QUERY "CLEAN:{collection: alerts, document: {topic: {source_topic}, alert: {value.json}, ts: {timestamp}}}" ADD EVENT StoreEvents WITH SOURCE_TOPIC "events/#" WITH QUERY "CLEAN:{collection: events, document: {topic: {source_topic}, event: {value.json}, ts: {timestamp}}}" ``` Connect to a MongoDB replica set: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ReplicaSetStorage WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:pass@mongo1.example.com:27017,mongo2.example.com:27017,mongo3.example.com:27017/iot?replicaSet=rs0&authSource=admin" WITH DATABASE "iot" ADD EVENT StoreData WITH SOURCE_TOPIC "data/#" WITH QUERY "CLEAN:{collection: data, document: {topic: {source_topic}, payload: {value.json}, timestamp: {timestamp}}}" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "SensorStorage" WITH TABLE "readings" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support read queries to retrieve data from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`. MongoDB read queries use the CLEAN format with `filter`, `sort`, and `limit` instead of SQL: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:password@mongodb.example.com:27017/iot?authSource=admin" WITH DATABASE "iot" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "CLEAN:{collection: readings, filter: \"{\"sensor_id\": \"{payload}\"}\", sort: \"timestamp desc\", limit: 1}" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The matching document is published to `db/result/latest`, where your actions or external clients can consume it. ### CLEAN Read Operators | SQL Equivalent | MongoDB CLEAN Syntax | | ----------------- | ---------------------------------- | | `SELECT *` | `{}` | | `WHERE a = 1` | `filter: "{\"a\": 1}"` | | `WHERE a > 10` | `filter: "{\"a\": {\"$gt\": 10}}"` | | `ORDER BY a DESC` | `sort: "a desc"` | | `LIMIT 10` | `limit: 10` | | `SELECT a, b` | `projection: "a, b"` | *** ## Connection String Examples | Scenario | Connection String | | ------------- | --------------------------------------------------------- | | Local | `mongodb://localhost:27017/iot` | | Authenticated | `mongodb://user:password@host:27017/iot?authSource=admin` | | Replica Set | `mongodb://host1:27017,host2:27017/iot?replicaSet=rs0` | | Atlas | `mongodb+srv://user:password@cluster.mongodb.net/iot` | *** ## Troubleshooting * Verify CONNECTION\_STRING is correct * Check MongoDB is running and accessible * Verify firewall allows connections on port 27017 * For Atlas, ensure IP whitelist includes your server * Verify username and password in connection string * Check authSource parameter matches authentication database * Verify user has permissions on target database * Verify collection name is valid * Check placeholder names match topic structure * Ensure document structure is valid JSON *** ## Next Steps Compare all storage options. Configure OpenSearch for search and analytics. # MySQL Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/mysql Store MQTT data in MySQL with automatic query execution and SSL support ## MySQL Overview The `MYSQL` route stores MQTT messages in MySQL databases. It supports SSL connections, parameterized queries, and automatic reconnection for reliable data persistence. MySQL is ideal for web applications and general-purpose data storage. Its wide ecosystem support makes it a reliable choice for most IoT data persistence needs. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO readings (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` *** ## Connection Configuration ### SQL\_CONFIG Parameters MySQL server hostname or IP address. Target database name. Database username. Database password. MySQL port. Default: 3306. Enable SSL connection. Default: false. Connection timeout in seconds. Default: 30. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SERVER GET ENV "DB_HOST" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## Event Configuration ### EVENT Parameters MQTT topic pattern that triggers the INSERT. Supports wildcards. SQL INSERT statement with placeholders for dynamic values. MQTT topic to publish query result status. ### Query Placeholders | Placeholder | Description | | -------------------- | ------------------------------- | | `{value.json}` | Full JSON payload | | `{value.json.field}` | Specific field from JSON | | `{timestamp}` | Message timestamp | | `{source_topic}` | Original MQTT topic | | `{field}` | Field extracted from topic path | *** ## Writing Data Store sensor readings with timestamps: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TemperatureDB WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreTemperature WITH SOURCE_TOPIC "sensors/+/temperature" WITH QUERY "INSERT INTO temperature_readings (recorded_at, sensor_id, value) VALUES (NOW(), '{sensor_id}', {value.json.value})" ``` Secure connection with SSL: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureDB WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.cloud.com" WITH PORT '3306' WITH DATABASE "production" WITH USERNAME "app_user" WITH PASSWORD "secure_password" WITH USE_SSL "true" ADD EVENT StoreEvent WITH SOURCE_TOPIC "events/#" WITH QUERY "INSERT INTO events (ts, topic, payload) VALUES (NOW(), '{source_topic}', '{value.json}')" ``` Different tables for different data types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiTableDB WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorData WITH SOURCE_TOPIC "sensors/+/data" WITH QUERY "INSERT INTO sensor_data (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ADD EVENT StoreAlerts WITH SOURCE_TOPIC "alerts/+" WITH QUERY "INSERT INTO alerts (ts, alert_type, message) VALUES (NOW(), '{alert_type}', '{value.json}')" ``` Insert a record or update it if a duplicate key exists. MySQL uses `ON DUPLICATE KEY UPDATE`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT RegisterDevice WITH SOURCE_TOPIC "devices/+/registered" WITH QUERY "INSERT INTO devices (device_id, name, area, active, updated_at) VALUES ('{value.json.device_id}', '{value.json.name}', '{value.json.area}', 1, NOW()) ON DUPLICATE KEY UPDATE name = VALUES(name), area = VALUES(area), updated_at = NOW()" ``` `ON DUPLICATE KEY UPDATE` fires only when a row with the same unique key already exists — the existing row is updated instead of inserting a duplicate. Modify existing records when a status or value change arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT UpdateDeviceStatus WITH SOURCE_TOPIC "devices/+/status" WITH QUERY "UPDATE devices SET current_status = '{value.json.status}', last_seen = NOW() WHERE device_id = '{value.json.device_id}'" ``` Remove records on a decommission or cleanup event: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT DecommissionDevice WITH SOURCE_TOPIC "devices/+/decommissioned" WITH QUERY "DELETE FROM devices WHERE device_id = '{value.json.device_id}' AND active = 0" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "TemperatureDB" WITH TABLE "temperature_readings" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "SELECT sensor_id, value FROM readings WHERE sensor_id = '{payload}' ORDER BY ts DESC LIMIT 1" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The query result is published to `db/result/latest`, where your actions or external clients can consume it. ### Timed Polling EVENTs can also run SELECT queries on a fixed schedule using `WITH EVERY`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE MYSQL ADD SQL_CONFIG WITH SERVER "mysql.example.com" WITH PORT '3306' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT PollActiveDevices WITH EVERY 10 SECONDS WITH DESTINATION_TOPIC "dashboard/devices/active" WITH QUERY "SELECT device_id, name, current_status, last_seen FROM devices WHERE active = 1 ORDER BY name" ``` *** ## Table Schema Examples ```sql wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Basic sensor readings CREATE TABLE sensor_readings ( id INT AUTO_INCREMENT PRIMARY KEY, recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, sensor_id VARCHAR(50), value DOUBLE ); -- Event log CREATE TABLE events ( id INT AUTO_INCREMENT PRIMARY KEY, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP, topic VARCHAR(255), payload JSON ); ``` *** ## Troubleshooting * Verify SERVER and PORT are correct * Check MySQL is running and accepting connections * Verify firewall allows connections on port 3306 * Check `bind-address` in MySQL configuration * Verify USERNAME and PASSWORD are correct * Check user has permissions on the DATABASE * Verify user can connect from your host * Check placeholder names match topic structure * Verify table and column names exist * Test query manually in MySQL client first *** ## Next Steps Compare all storage options. Configure MariaDB storage (MySQL-compatible). # OpenSearch Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/opensearch Index MQTT data in OpenSearch for real-time search, analytics, and visualization ## OpenSearch Overview The `OPENSEARCH` route indexes MQTT messages in OpenSearch for full-text search, real-time analytics, and dashboard visualization. It uses the CLEAN query format for simplified document indexing. OpenSearch is ideal for log aggregation, metrics visualization, and full-text search across IoT data. Connect it to OpenSearch Dashboards for powerful visualizations. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorIndex WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://opensearch.example.com:9200" WITH USERNAME "admin" WITH PASSWORD "secure_password" WITH USE_SSL "true" ADD EVENT IndexSensorReading WITH SOURCE_TOPIC "sensors/+/data" WITH QUERY "CLEAN:{index: sensor-data, body: {@timestamp: {timestamp}, sensor: {sensor_id}, reading: {value.json}}}" ``` *** ## Connection Configuration ### OPENSEARCH\_CONFIG Parameters OpenSearch endpoint URL including port (e.g., `https://opensearch.example.com:9200`). OpenSearch username. OpenSearch password. Enable HTTPS. Default: false. Ignore certificate validation errors. Default: false. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH BASE_URL GET ENV "OPENSEARCH_URL" WITH USERNAME GET ENV "OPENSEARCH_USER" WITH PASSWORD GET SECRET "OPENSEARCH_PASSWORD" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## CLEAN Query Format OpenSearch routes use the CLEAN format for document indexing: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH QUERY "CLEAN:{index: , body: {: , ...}}" ``` ### CLEAN Placeholders | Placeholder | Description | | -------------------- | ----------------------------------------------- | | `{value.json}` | Full JSON payload as embedded object | | `{value.json.field}` | Specific field from JSON payload | | `{timestamp}` | Message timestamp (use `@timestamp` for Kibana) | | `{source_topic}` | Original MQTT topic | | `{field}` | Field extracted from topic path | *** ## Writing Data Index sensor data for search: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorIndex WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://opensearch.example.com:9200" WITH USERNAME "admin" WITH PASSWORD "secure_password" WITH USE_SSL "true" ADD EVENT IndexReading WITH SOURCE_TOPIC "sensors/+/data" WITH DESTINATION_TOPIC "search/status" WITH QUERY "CLEAN:{index: sensor-data, body: {@timestamp: {timestamp}, sensor_id: {sensor_id}, data: {value.json}}}" ``` Aggregate application logs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE LogIndex WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://opensearch.example.com:9200" WITH USERNAME "admin" WITH PASSWORD "admin_password" WITH USE_SSL "true" ADD EVENT IndexLogs WITH SOURCE_TOPIC "logs/+/+" WITH QUERY "CLEAN:{index: application-logs, body: {@timestamp: {timestamp}, source: {source_topic}, level: {value.json.level}, message: {value.json.message}}}" ``` Use date-based index names for time-series: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TimeSeriesIndex WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://opensearch.example.com:9200" WITH USERNAME "admin" WITH PASSWORD "secure_password" WITH USE_SSL "true" ADD EVENT IndexMetrics WITH SOURCE_TOPIC "metrics/#" WITH QUERY "CLEAN:{index: metrics, body: {@timestamp: {timestamp}, topic: {source_topic}, metrics: {value.json}}}" ``` Configure Index Lifecycle Management (ILM) in OpenSearch for automatic rollover. Connect with certificate validation disabled: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DevIndex WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://localhost:9200" WITH USERNAME "admin" WITH PASSWORD "admin" WITH USE_SSL "true" WITH IGNORE_CERT_ERRORS "true" ADD EVENT IndexData WITH SOURCE_TOPIC "data/#" WITH QUERY "CLEAN:{index: dev-data, body: {@timestamp: {timestamp}, topic: {source_topic}, payload: {value.json}}}" ``` Only use IGNORE\_CERT\_ERRORS in development environments. **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "SensorIndex" WITH TABLE "sensor-data" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support search queries to retrieve data from the index. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`. OpenSearch read queries use the CLEAN format with `query`, `sort`, and `size` instead of SQL: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorIndex WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://opensearch.example.com:9200" WITH USERNAME "admin" WITH PASSWORD "secure_password" WITH USE_SSL "true" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "search/query/latest" WITH DESTINATION_TOPIC "search/result/latest" WITH QUERY "CLEAN:{index: sensor-data, query: {\"match\": {\"sensor_id\": \"{payload}\"}}, sort: [{\"@timestamp\": {\"order\": \"desc\"}}], size: 1}" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: search/query/latest Payload: temp001 ``` The matching document is published to `search/result/latest`, where your actions or external clients can consume it. ### CLEAN Read Operators | SQL Equivalent | OpenSearch CLEAN Syntax | | ----------------- | ------------------------------------- | | `SELECT *` | `query: {"match_all": {}}` | | `WHERE a = 1` | `query: {"match": {"a": 1}}` | | `WHERE a > 10` | `query: {"range": {"a": {"gt": 10}}}` | | `ORDER BY a DESC` | `sort: [{"a": {"order": "desc"}}]` | | `LIMIT 10` | `size: 10` | | `OFFSET 5` | `from: 5` | *** ## Index Mapping Example Create an index with explicit mappings for better search and aggregation: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} PUT /sensor-data { "mappings": { "properties": { "@timestamp": { "type": "date" }, "sensor_id": { "type": "keyword" }, "data": { "properties": { "temperature": { "type": "float" }, "humidity": { "type": "float" } } } } } } ``` *** ## Troubleshooting * Verify BASE\_URL is correct including port * Check OpenSearch is running and accessible * Verify firewall allows connections on port 9200 * Verify USERNAME and PASSWORD are correct * Check user has permissions to index documents * Ensure USE\_SSL matches the endpoint (http vs https) * For self-signed certs, set IGNORE\_CERT\_ERRORS "true" * Verify certificate chain is complete * Verify index name is valid (lowercase, no special chars) * Check field mappings don't conflict * Review OpenSearch logs for detailed errors *** ## Next Steps Compare all storage options. Configure CrateDB for time-series data. # Data Storage Routes Overview Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/overview Store MQTT data in databases including PostgreSQL, MySQL, MongoDB, OpenSearch, Snowflake, and file storage ## Data Storage Integration Data storage routes enable automatic persistence of MQTT messages to databases and files. Store sensor data for analytics, create historical archives, aggregate logs, and build time-series datasets—all configured through LoT syntax. **Like a filing cabinet with an assistant.** You can hand your assistant a form (STORE IN with models) and they file it automatically, or you can write specific filing instructions yourself (EVENTs with queries). Either way, your data ends up organized in the right drawer. ## Supported Databases Open-source relational database for structured data storage. Popular relational database with wide ecosystem support. MySQL-compatible database with enhanced features. Microsoft SQL Server for enterprise environments. Document database for flexible JSON storage. Real-time analytics, search, and visualization. Distributed SQL optimized for time-series data. Cloud data warehouse for large-scale analytics. CSV and JSON file storage for simple persistence. *** ## Database Comparison | Database | Type | Best For | Query Format | | ------------ | ---------------- | --------------------------------- | ------------ | | PostgreSQL | Relational | Structured data, complex queries | SQL | | MySQL | Relational | Web applications, general purpose | SQL | | MariaDB | Relational | MySQL compatibility with extras | SQL | | SQL Server | Relational | Enterprise, Windows environments | SQL | | MongoDB | Document | Flexible schemas, JSON data | CLEAN | | OpenSearch | Search/Analytics | Full-text search, dashboards | CLEAN | | CrateDB | Time-Series | High-volume sensor data | SQL | | Snowflake | Cloud Warehouse | Large-scale analytics | SQL | | File Storage | File | Simple logging, backups | CLEAN | **SQL** databases use standard SQL queries. **CLEAN** is a simplified document-style syntax used by non-relational databases and file storage — see [Query Formats](#query-formats) below for details. *** ## How to Build a Data Storage Route A data storage route needs two things: a connection to your database and at least one rule for what to store. You define the connection once, then add events that listen for MQTT messages and run queries — or use a model with `STORE IN` to save data automatically without writing queries at all. ### Quick Example This complete route connects to a PostgreSQL database and automatically stores every sensor reading that arrives on `sensors/+/reading`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH DESTINATION_TOPIC "db/status" WITH QUERY "INSERT INTO readings (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` That's it — the broker handles the rest. The sections below break down each part in detail. ### Step 1: Define the Route Connection Every data storage route starts with a connection configuration. Define the route type and provide the connection details for your database: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ``` Each database type has its own config block (`SQL_CONFIG`, `MONGODB_CONFIG`, `OPENSEARCH_CONFIG`, etc.). See the specific database page for exact parameters. The examples on this page use hardcoded credentials for clarity. In production, use environment variables and encrypted secrets to keep sensitive values out of your LoT definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER GET ENV "DB_HOST" WITH PORT GET ENV "DB_PORT" WITH DATABASE GET ENV "DB_NAME" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` See [Environment Variables and Secrets](/latest/mqtt-broker/secrets-and-env) for setup instructions across Windows, Linux, Docker, and Kubernetes. ### Step 2: Store Data There are two ways to write data into your database. Choose the approach that fits your use case: The `STORE IN` approach ties a model directly to a database route. Every time an action publishes this model, the record is automatically inserted into the specified table — no EVENT or query needed. **1. Define a model with STORE IN:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ProductionCount ADD "f_event_time" WITH TIMESTAMP "UNIX" ADD STRING "machine_id" ADD STRING "product_type" ADD INT "count" STORE IN "ProductionDB" WITH TABLE "production_counts" ``` **2. Define the route the model stores into:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "localhost" WITH PORT '5432' WITH DATABASE "factory" WITH USERNAME "postgres" WITH PASSWORD "secure_password" ``` **3. Publish the model from an action:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION RecordProduction ON TOPIC "factory/+/production" DO SET "machine" WITH TOPIC POSITION 2 SET "product" WITH (GET JSON "product_type" IN PAYLOAD AS STRING) SET "qty" WITH (GET JSON "count" IN PAYLOAD AS INT) PUBLISH MODEL ProductionCount TO "factory/production/recorded" WITH machine_id = {machine} product_type = {product} count = {qty} ``` Every `PUBLISH MODEL` call automatically inserts a row into the `production_counts` table with all the model fields as columns. The EVENT approach uses explicit SQL or CLEAN queries triggered by MQTT messages arriving on a source topic. You write the query yourself, giving full control over column mapping and transformations. **Define an EVENT inside the route:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH DESTINATION_TOPIC "db/status" WITH QUERY "INSERT INTO readings (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` When a message arrives on any topic matching `sensors/+/reading`, the INSERT query runs with placeholders replaced by actual values from the message. ### Step 3: Read Data (SELECT) EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC` with your query parameters, and the result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "localhost" WITH PORT '5432' WITH DATABASE "factory" WITH USERNAME "postgres" WITH PASSWORD "secure_password" ADD EVENT GetLatestCount WITH QUERY "SELECT MAX(count) as count FROM production_counts WHERE machine_id = '{payload}'" WITH SOURCE_TOPIC "factory/production/getLatestCount" WITH DESTINATION_TOPIC "factory/production/latest_count_from_db" ``` To trigger this query, publish the machine ID to the source topic: ``` Topic: factory/production/getLatestCount Payload: MACHINE-001 ``` The query result is published to `factory/production/latest_count_from_db`, where your actions or external clients can consume it. *** ## Common Query Placeholders These placeholders work across all database routes: | Placeholder | Description | | -------------------- | ---------------------------------------------------------- | | `{payload}` | Raw MQTT payload (useful in SELECT queries as a parameter) | | `{value}` | Full MQTT payload as string | | `{value.json}` | Parsed JSON payload | | `{value.json.field}` | Specific field from JSON | | `{timestamp}` | Message timestamp | | `{source_topic}` | Original MQTT topic | | `{field}` | Field extracted from topic path | ### Extracting Fields from Topics For topic `sensors/temp001/reading`, use these placeholders: | Placeholder | Value | | ---------------- | ------------------------------- | | `{source_topic}` | `sensors/temp001/reading` | | `{sensor_id}` | `temp001` (position 2 in topic) | *** ## Query Formats ### SQL Format For relational databases (PostgreSQL, MySQL, MariaDB, SQL Server, CrateDB, Snowflake): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH QUERY "INSERT INTO sensor_readings (timestamp, sensor_id, value) VALUES ('{timestamp}', '{sensor_id}', '{value.json}')" ``` ### CLEAN Format For document databases and search engines (MongoDB, OpenSearch, File Storage): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH QUERY "CLEAN:{collection: readings, document: {sensor_id: {sensor_id}, value: {value.json}, timestamp: {timestamp}}}" ``` The CLEAN format provides simplified JSON syntax without requiring proper escaping. *** ## Common EVENT Parameters MQTT topic pattern that triggers the query. For INSERT operations, incoming message data is used to populate placeholders. For SELECT operations, the payload can be used as a query parameter via `{payload}`. MQTT topic to publish query results. Required for SELECT queries so the result can be consumed by actions or external clients. Optional for INSERT queries (publishes operation status). SQL statement or CLEAN format query with placeholders. Supports both write operations (`INSERT INTO ...`) and read operations (`SELECT ... FROM ...`). *** ## Best Practices Always enable SSL for database connections. Most database routes support SSL configuration through the config block: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH USE_SSL "true" WITH TRUST_SERVER_CERTIFICATE "false" ``` When using certificate-based authentication, store certificate passphrases as encrypted secrets rather than in plain text. See [Environment Variables and Secrets](/latest/mqtt-broker/secrets-and-env#secrets) for details. Never hardcode passwords or API keys in route definitions. Use `GET SECRET` for sensitive values and `GET ENV` for non-sensitive configuration like hostnames and ports. Create dedicated database users with minimal permissions for each route. See [Environment Variables and Secrets](/latest/mqtt-broker/secrets-and-env) for full setup and deployment instructions. Implement data retention policies: * Index lifecycle management * Time-based partitioning * Automatic cleanup jobs When your data has a consistent schema, use `STORE IN` on a model to automatically insert records every time the model is published. This eliminates the need to write INSERT queries manually: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" ADD STRING "timestamp" WITH TIMESTAMP "UTC" STORE IN "SensorDB" WITH TABLE "sensor_readings" ``` For high-throughput scenarios, configure connection pools where supported. *** ## Next Steps Choose the database that matches your requirements: Start with the most popular open-source database. Store flexible JSON documents. # PostgreSQL Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/postgresql Store MQTT data in PostgreSQL and PostgreSQL-compatible databases with automatic query execution and SSL support ## PostgreSQL Overview The `POSTGRESQL` route stores MQTT messages in PostgreSQL databases. It supports SSL connections, parameterized queries, and automatic reconnection for reliable data persistence. Because the route speaks the **PostgreSQL wire protocol**, it also works with any service built on PostgreSQL — you use the same `POSTGRESQL` route type and point `SQL_CONFIG` at that provider's host, port, database, and credentials. | Platform | Typical use | | ----------------------------------- | --------------------------------------------------------------------------------------- | | **PostgreSQL** | Self-hosted, on-premises, or VM deployments | | **Tiger Data** | Managed Postgres with TimescaleDB (Tiger Cloud) for time-series and IoT workloads | | **TimescaleDB** | PostgreSQL extension for hypertables and time-series SQL (self-hosted or on Tiger Data) | | **Amazon RDS / Aurora PostgreSQL** | AWS-managed relational storage | | **Azure Database for PostgreSQL** | Microsoft Azure managed Postgres | | **Google Cloud SQL for PostgreSQL** | GCP managed Postgres | | **Supabase** | Managed Postgres with realtime APIs, often used for app and dashboard backends | PostgreSQL is ideal for structured data that requires complex queries, joins, and ACID compliance. Use it when you need relational integrity and powerful SQL capabilities. For **TimescaleDB** or **Tiger Data** (Tiger Cloud), use standard PostgreSQL connection settings — host, port `5432`, database, user, and password from your service dashboard. Create hypertables and time-series schemas in SQL as you would in any Postgres client; the route executes your `QUERY` strings against that database. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO readings (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` *** ## Connection Configuration ### SQL\_CONFIG Parameters PostgreSQL server hostname or IP address. Target database name. Database username. Database password. PostgreSQL port. Default: 5432. Enable SSL connection. Default: false. Trust server certificate without validation. Default: false. Connection timeout in seconds. Default: 30. Command timeout in seconds. Default: 30. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SERVER GET ENV "DB_HOST" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## Event Configuration ### EVENT Parameters MQTT topic pattern that triggers the INSERT. Supports wildcards. SQL INSERT statement with placeholders for dynamic values. MQTT topic to publish query result status. ### Query Placeholders | Placeholder | Description | | -------------------- | ------------------------------- | | `{value.json}` | Full JSON payload | | `{value.json.field}` | Specific field from JSON | | `{timestamp}` | Message timestamp | | `{source_topic}` | Original MQTT topic | | `{field}` | Field extracted from topic path | *** ## Writing Data Store temperature readings with timestamps: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TemperatureDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreTemperature WITH SOURCE_TOPIC "sensors/+/temperature" WITH DESTINATION_TOPIC "db/status/temperature" WITH QUERY "INSERT INTO temperature_readings (recorded_at, sensor_id, value, unit) VALUES (NOW(), '{sensor_id}', {value.json.value}, '{value.json.unit}')" ``` **Input at** `sensors/temp001/temperature`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} {"value": 23.5, "unit": "celsius"} ``` Secure connection with SSL: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.cloud.com" WITH PORT '5432' WITH DATABASE "production" WITH USERNAME "app_user" WITH PASSWORD "secure_password" WITH USE_SSL "true" WITH TRUST_SERVER_CERTIFICATE "false" ADD EVENT StoreEvent WITH SOURCE_TOPIC "events/#" WITH QUERY "INSERT INTO events (ts, topic, payload) VALUES (NOW(), '{source_topic}', '{value.json}')" ``` Different tables for different data types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiTableDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorData WITH SOURCE_TOPIC "sensors/+/data" WITH QUERY "INSERT INTO sensor_data (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ADD EVENT StoreAlerts WITH SOURCE_TOPIC "alerts/+" WITH QUERY "INSERT INTO alerts (ts, alert_type, message) VALUES (NOW(), '{alert_type}', '{value.json}')" ADD EVENT StoreMetrics WITH SOURCE_TOPIC "metrics/#" WITH QUERY "INSERT INTO metrics (ts, topic, data) VALUES (NOW(), '{source_topic}', '{value.json}')" ``` Store full payload in JSONB column: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE JSONStorage WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreJSON WITH SOURCE_TOPIC "devices/#" WITH QUERY "INSERT INTO device_data (ts, topic, payload) VALUES (NOW(), '{source_topic}', '{value.json}'::jsonb)" ``` This allows powerful JSON queries in PostgreSQL: ```sql wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SELECT * FROM device_data WHERE payload->>'temperature' > '25'; ``` Insert a record, or update it if a conflict on the unique key occurs. Useful for device registration or status tables where duplicate messages are possible: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT RegisterDevice WITH SOURCE_TOPIC "devices/+/registered" WITH QUERY "INSERT INTO devices (device_id, name, area, active, updated_at) VALUES ('{value.json.device_id}', '{value.json.name}', '{value.json.area}', TRUE, NOW()) ON CONFLICT (device_id) DO UPDATE SET name = EXCLUDED.name, area = EXCLUDED.area, updated_at = NOW()" ``` `ON CONFLICT (device_id) DO UPDATE SET` updates only the changed columns when the device already exists — the row is never duplicated. Modify existing records when a status change or update arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT UpdateDeviceStatus WITH SOURCE_TOPIC "devices/+/status" WITH QUERY "UPDATE devices SET current_status = '{value.json.status}', last_seen = NOW() WHERE device_id = '{value.json.device_id}'" ``` Only rows matching the `WHERE` clause are modified. Unmatched messages produce no change — no error is raised. Remove records when a decommission or cleanup event arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT DecommissionDevice WITH SOURCE_TOPIC "devices/+/decommissioned" WITH QUERY "DELETE FROM devices WHERE device_id = '{value.json.device_id}' AND active = FALSE" ``` The `AND active = FALSE` guard prevents accidental deletes if a message arrives before the device is properly deactivated. **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "SensorDB" WITH TABLE "sensor_readings" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "SELECT sensor_id, value FROM readings WHERE sensor_id = '{payload}' ORDER BY ts DESC LIMIT 1" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The query result is published to `db/result/latest`, where your actions or external clients can consume it. ### Timed Polling EVENTs can also run SELECT queries on a schedule instead of waiting for an MQTT trigger. Use `WITH EVERY` to publish fresh data at a fixed interval: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT PollActiveDevices WITH EVERY 10 SECONDS WITH DESTINATION_TOPIC "dashboard/devices/active" WITH QUERY "SELECT device_id, name, current_status, last_seen FROM devices WHERE active = TRUE ORDER BY name" ``` The query result is published to `dashboard/devices/active` every 10 seconds — no trigger message needed. *** ## Table Schema Examples Create tables to match your data: ```sql wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Basic sensor readings CREATE TABLE sensor_readings ( id SERIAL PRIMARY KEY, recorded_at TIMESTAMP DEFAULT NOW(), sensor_id VARCHAR(50), value DOUBLE PRECISION, unit VARCHAR(20) ); -- Full JSON storage CREATE TABLE device_data ( id SERIAL PRIMARY KEY, ts TIMESTAMP DEFAULT NOW(), topic VARCHAR(255), payload JSONB ); -- Alerts table CREATE TABLE alerts ( id SERIAL PRIMARY KEY, ts TIMESTAMP DEFAULT NOW(), alert_type VARCHAR(50), message TEXT ); ``` *** ## Troubleshooting * Verify SERVER and PORT are correct * Check PostgreSQL is running and accepting connections * Verify firewall allows connections on port 5432 * Check `pg_hba.conf` allows your IP address * Verify USERNAME and PASSWORD are correct * Check user has permissions on the DATABASE * Verify authentication method in `pg_hba.conf` * Ensure USE\_SSL matches server requirements * For self-signed certs, set TRUST\_SERVER\_CERTIFICATE "true" * Verify SSL is enabled on PostgreSQL server * Check placeholder names match topic structure * Verify table and column names exist * Test query manually in psql first * Ensure proper escaping for special characters * Increase CONNECTION\_TIMEOUT for slow networks * Increase COMMAND\_TIMEOUT for complex queries * Check server load and query performance *** ## Next Steps Compare all storage options. Configure MySQL database storage. # Snowflake Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/snowflake Store MQTT data in Snowflake cloud data warehouse for large-scale analytics ## Snowflake Overview The `SNOWFLAKE` route stores MQTT messages in Snowflake, a cloud-native data warehouse. It supports high-volume data ingestion with automatic scaling and powerful SQL analytics. Snowflake is ideal for large-scale analytics, data lakes, and enterprise reporting. Use it when you need to analyze IoT data alongside other business data in the cloud. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorWarehouse WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=myuser;password=mypassword" WITH WAREHOUSE "COMPUTE_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "SENSORS" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO SENSOR_READINGS (TIMESTAMP, SENSOR_ID, VALUE) VALUES (CURRENT_TIMESTAMP(), '{sensor_id}', PARSE_JSON('{value.json}'))" ``` *** ## Connection Configuration ### SNOWFLAKE\_CONFIG Parameters Snowflake connection string with account, user, and password. Snowflake compute warehouse name. Target database name. Target schema name. Default: PUBLIC. Snowflake user role. Query timeout in seconds. Default: 60. Connection pool size. Default: 10. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH CONNECTION_STRING GET SECRET "SNOWFLAKE_CONNECTION_STRING" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## Writing Data Store sensor data in Snowflake: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorWarehouse WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "SENSORS" WITH ROLE "IOT_WRITER" ADD EVENT StoreReading WITH SOURCE_TOPIC "sensors/+/reading" WITH DESTINATION_TOPIC "snowflake/status" WITH QUERY "INSERT INTO SENSOR_READINGS (TIMESTAMP, SENSOR_ID, VALUE) VALUES (CURRENT_TIMESTAMP(), '{sensor_id}', PARSE_JSON('{value.json}'))" ``` Optimized for high throughput: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE HighVolumeWarehouse WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "EVENTS" WITH QUERY_TIMEOUT '60' WITH POOL_SIZE '20' ADD EVENT StoreEvents WITH SOURCE_TOPIC "events/#" WITH QUERY "INSERT INTO EVENT_LOG (TIMESTAMP, TOPIC, PAYLOAD) VALUES (CURRENT_TIMESTAMP(), '{source_topic}', PARSE_JSON('{value.json}'))" ``` Route to different tables: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiTableWarehouse WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "PUBLIC" ADD EVENT StoreSensors WITH SOURCE_TOPIC "sensors/#" WITH QUERY "INSERT INTO SENSORS (TS, TOPIC, DATA) VALUES (CURRENT_TIMESTAMP(), '{source_topic}', PARSE_JSON('{value.json}'))" ADD EVENT StoreAlerts WITH SOURCE_TOPIC "alerts/#" WITH QUERY "INSERT INTO ALERTS (TS, TOPIC, DATA) VALUES (CURRENT_TIMESTAMP(), '{source_topic}', PARSE_JSON('{value.json}'))" ``` Snowflake uses `MERGE INTO` to insert or update depending on whether a matching row exists: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "DEVICES" ADD EVENT RegisterDevice WITH SOURCE_TOPIC "devices/+/registered" WITH QUERY "MERGE INTO DEVICES AS target USING (SELECT '{value.json.device_id}' AS device_id, '{value.json.name}' AS name, '{value.json.area}' AS area) AS source ON target.DEVICE_ID = source.device_id WHEN MATCHED THEN UPDATE SET NAME = source.name, AREA = source.area, UPDATED_AT = CURRENT_TIMESTAMP() WHEN NOT MATCHED THEN INSERT (DEVICE_ID, NAME, AREA, ACTIVE, UPDATED_AT) VALUES (source.device_id, source.name, source.area, TRUE, CURRENT_TIMESTAMP())" ``` Modify existing records when a status change arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "DEVICES" ADD EVENT UpdateDeviceStatus WITH SOURCE_TOPIC "devices/+/status" WITH QUERY "UPDATE DEVICES SET CURRENT_STATUS = '{value.json.status}', LAST_SEEN = CURRENT_TIMESTAMP() WHERE DEVICE_ID = '{value.json.device_id}'" ``` Remove records on a decommission or cleanup event: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "DEVICES" ADD EVENT DecommissionDevice WITH SOURCE_TOPIC "devices/+/decommissioned" WITH QUERY "DELETE FROM DEVICES WHERE DEVICE_ID = '{value.json.device_id}' AND ACTIVE = FALSE" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "SensorWarehouse" WITH TABLE "SENSOR_READINGS" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorWarehouse WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=myuser;password=mypassword" WITH WAREHOUSE "COMPUTE_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "SENSORS" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "SELECT SENSOR_ID, VALUE FROM SENSOR_READINGS WHERE SENSOR_ID = '{payload}' ORDER BY TIMESTAMP DESC LIMIT 1" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The query result is published to `db/result/latest`, where your actions or external clients can consume it. ### Timed Polling EVENTs can also run SELECT queries on a fixed schedule using `WITH EVERY`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SNOWFLAKE ADD SNOWFLAKE_CONFIG WITH CONNECTION_STRING "account=myaccount;user=iot_user;password=secure_password" WITH WAREHOUSE "IOT_WH" WITH DATABASE "IOT_DATA" WITH SCHEMA "DEVICES" ADD EVENT PollActiveDevices WITH EVERY 30 SECONDS WITH DESTINATION_TOPIC "dashboard/devices/active" WITH QUERY "SELECT DEVICE_ID, NAME, CURRENT_STATUS, LAST_SEEN FROM DEVICES WHERE ACTIVE = TRUE ORDER BY NAME" ``` *** ## Table Schema Examples ```sql wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Sensor readings with VARIANT for JSON CREATE TABLE SENSOR_READINGS ( ID NUMBER AUTOINCREMENT, TIMESTAMP TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(), SENSOR_ID VARCHAR(50), VALUE VARIANT ); -- Event log CREATE TABLE EVENT_LOG ( ID NUMBER AUTOINCREMENT, TIMESTAMP TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(), TOPIC VARCHAR(255), PAYLOAD VARIANT ); ``` *** ## Connection String Format ``` account=;user=;password= ``` | Component | Description | | ---------- | -------------------------------------------------------- | | `account` | Snowflake account identifier (e.g., `xy12345.us-east-1`) | | `user` | Snowflake username | | `password` | User password | *** ## Troubleshooting * Verify account identifier is correct * Check username and password * Ensure warehouse exists and is running * Verify network allows Snowflake connections * Verify ROLE has INSERT permissions * Check user has access to WAREHOUSE * Ensure user can access DATABASE and SCHEMA * Increase QUERY\_TIMEOUT value * Check warehouse size is adequate * Verify warehouse is not suspended *** ## Next Steps Compare all storage options. Configure local file storage. # SQL Server Route Source: https://docs.coreflux.org/latest/lot-language/routes/data-storage/sqlserver Store MQTT data in Microsoft SQL Server with automatic query execution and enterprise features ## SQL Server Overview The `SQLSERVER` route stores MQTT messages in Microsoft SQL Server databases. It supports Windows and SQL authentication, SSL connections, and enterprise features. SQL Server is ideal for enterprise environments, especially those integrated with Microsoft ecosystems. It offers advanced features like Always On availability and native JSON support. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO Readings (RecordedAt, SensorId, Value) VALUES (GETDATE(), '{sensor_id}', '{value.json}')" ``` *** ## Connection Configuration ### SQL\_CONFIG Parameters SQL Server hostname or IP address. Can include instance name (e.g., `server\instance`). Target database name. SQL Server username. SQL Server password. SQL Server port. Default: 1433. Enable SSL connection. Default: false. Trust server certificate without validation. Default: false. **Never hardcode credentials in production.** Use environment variables and encrypted secrets to keep sensitive values out of your route definitions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SERVER GET ENV "DB_HOST" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for setup and usage. *** ## Writing Data Store sensor data in SQL Server: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorStorage WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO SensorReadings (Timestamp, SensorId, Value) VALUES (GETDATE(), '{sensor_id}', '{value.json}')" ``` Secure connection with trusted certificate: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureStorage WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.cloud.com" WITH PORT '1433' WITH DATABASE "Production" WITH USERNAME "app_user" WITH PASSWORD "secure_password" WITH USE_SSL "true" WITH TRUST_SERVER_CERTIFICATE "true" ADD EVENT StoreEvent WITH SOURCE_TOPIC "events/#" WITH QUERY "INSERT INTO Events (Timestamp, Topic, Payload) VALUES (GETDATE(), '{source_topic}', '{value.json}')" ``` Connect to a named SQL Server instance: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE InstanceStorage WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "server.example.com\\MSSQLSERVER" WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreData WITH SOURCE_TOPIC "data/#" WITH QUERY "INSERT INTO DataLog (Timestamp, Topic, Data) VALUES (GETDATE(), '{source_topic}', '{value.json}')" ``` SQL Server uses the `MERGE` statement for upsert logic — insert a row if it doesn't exist, update it if it does: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT RegisterDevice WITH SOURCE_TOPIC "devices/+/registered" WITH QUERY "MERGE Devices AS target USING (SELECT '{value.json.device_id}' AS device_id, '{value.json.name}' AS name, '{value.json.area}' AS area) AS source ON (target.DeviceId = source.device_id) WHEN MATCHED THEN UPDATE SET Name = source.name, Area = source.area, UpdatedAt = GETDATE() WHEN NOT MATCHED THEN INSERT (DeviceId, Name, Area, Active, UpdatedAt) VALUES (source.device_id, source.name, source.area, 1, GETDATE());" ``` Modify existing records when a status change arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT UpdateDeviceStatus WITH SOURCE_TOPIC "devices/+/status" WITH QUERY "UPDATE Devices SET CurrentStatus = '{value.json.status}', LastSeen = GETDATE() WHERE DeviceId = '{value.json.device_id}'" ``` Remove records on a decommission or cleanup event: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT DecommissionDevice WITH SOURCE_TOPIC "devices/+/decommissioned" WITH QUERY "DELETE FROM Devices WHERE DeviceId = '{value.json.device_id}' AND Active = 0" ``` **Alternative: STORE IN with Models** — Instead of writing EVENT queries, you can bind a [model](/latest/lot-language/models/overview) directly to this route. Every `PUBLISH MODEL` call automatically inserts a row — no query needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading ADD STRING "sensor_id" ADD DOUBLE "value" STORE IN "SensorStorage" WITH TABLE "SensorReadings" ``` See [Data Storage Overview](./overview#with-models-store-in) for the full STORE IN workflow. *** ## Reading Data EVENTs also support SELECT queries to read data back from the database. Publish a message to the event's `SOURCE_TOPIC`, and the query result is published to `DESTINATION_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT GetLatestReading WITH SOURCE_TOPIC "db/query/latest" WITH DESTINATION_TOPIC "db/result/latest" WITH QUERY "SELECT TOP 1 SensorId, Value FROM Readings WHERE SensorId = '{payload}' ORDER BY RecordedAt DESC" ``` To trigger this query, publish the sensor ID to the source topic: ``` Topic: db/query/latest Payload: temp001 ``` The query result is published to `db/result/latest`, where your actions or external clients can consume it. ### Timed Polling EVENTs can also run SELECT queries on a fixed schedule using `WITH EVERY`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceRegistry WITH TYPE SQLSERVER ADD SQL_CONFIG WITH SERVER "sqlserver.example.com" WITH PORT '1433' WITH DATABASE "IoTData" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT PollActiveDevices WITH EVERY 10 SECONDS WITH DESTINATION_TOPIC "dashboard/devices/active" WITH QUERY "SELECT DeviceId, Name, CurrentStatus, LastSeen FROM Devices WHERE Active = 1 ORDER BY Name" ``` *** ## Table Schema Examples ```sql wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Basic sensor readings CREATE TABLE SensorReadings ( Id INT IDENTITY(1,1) PRIMARY KEY, Timestamp DATETIME2 DEFAULT GETDATE(), SensorId NVARCHAR(50), Value FLOAT ); -- JSON storage (SQL Server 2016+) CREATE TABLE Events ( Id INT IDENTITY(1,1) PRIMARY KEY, Timestamp DATETIME2 DEFAULT GETDATE(), Topic NVARCHAR(255), Payload NVARCHAR(MAX) ); ``` *** ## Troubleshooting * Verify SERVER and PORT are correct * Check SQL Server is running and accepting TCP connections * Enable TCP/IP in SQL Server Configuration Manager * Verify firewall allows connections on port 1433 * Verify USERNAME and PASSWORD are correct * Ensure SQL Server authentication is enabled (mixed mode) * Check user has permissions on the DATABASE * Set TRUST\_SERVER\_CERTIFICATE "true" for self-signed certs * Verify SSL is configured on SQL Server *** ## Next Steps Compare all storage options. Configure MongoDB document storage. # Route Examples Source: https://docs.coreflux.org/latest/lot-language/routes/examples Complete examples of LoT route configurations for common integration scenarios ## Examples Index Find the right example for your use case: | Example | Route Type | Key Features | | ------------------------------------------------------ | ------------ | ----------------------- | | [Edge-to-Cloud Sync](#edge-to-cloud-sensor-sync) | MQTT\_BRIDGE | TLS, outbound sync | | [Cloud Commands](#cloud-command-reception) | MQTT\_BRIDGE | Inbound direction | | [Multi-Site Network](#multi-site-synchronization) | MQTT\_BRIDGE | Multiple mappings | | [MongoDB Sensor Storage](#mongodb-sensor-storage) | MONGODB | CLEAN query format | | [REST API Integration](#rest-api-integration) | REST\_API | HTTP requests from MQTT | | [Critical Alerts Email](#critical-alert-notifications) | EMAIL | Template placeholders | | [OpenSearch Analytics](#opensearch-sensor-archive) | OPENSEARCH | STORE keyword | | [Complete Integration](#complete-integration-example) | Multiple | Combined setup | *** ## MQTT Bridge Examples ### Edge-to-Cloud Sensor Sync This example demonstrates synchronizing sensor data from a local edge device to a cloud broker. The `out` direction ensures data flows from your local broker to the cloud, while TLS encryption secures the connection: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EdgeToCloudSync WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloudprovider.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "EdgeDevice-Factory1" WITH USERNAME "edge_device_001" WITH PASSWORD "secure_cloud_password" WITH USE_TLS true ADD MAPPING allSensors WITH SOURCE_TOPIC "local/sensors/#" WITH DESTINATION_TOPIC "factory1/edge/sensors/#" WITH DIRECTION "out" ADD MAPPING equipmentStatus WITH SOURCE_TOPIC "local/equipment/+/status" WITH DESTINATION_TOPIC "factory1/edge/equipment/+/status" WITH DIRECTION "out" ``` ### Cloud Command Reception Receive commands from cloud systems and distribute them to local devices. The `in` direction means data flows from the remote broker to your local broker: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CloudCommands WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloudprovider.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "EdgeDevice-Factory1-Commands" WITH USE_TLS true ADD MAPPING deviceCommands WITH SOURCE_TOPIC "local/commands/devices/+" WITH DESTINATION_TOPIC "factory1/commands/devices/+" WITH DIRECTION "in" ADD MAPPING configUpdates WITH SOURCE_TOPIC "local/config/#" WITH DESTINATION_TOPIC "factory1/config/#" WITH DIRECTION "in" ``` ### Multi-Site Synchronization Connect multiple factory locations to a central broker. This pattern enables enterprise-wide visibility while keeping local operations independent: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FactoryNetwork WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "central-broker.company.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "Factory-Chicago" WITH USE_TLS true ADD MAPPING productionData WITH SOURCE_TOPIC "production/#" WITH DESTINATION_TOPIC "chicago/production/#" WITH DIRECTION "out" ADD MAPPING qualityData WITH SOURCE_TOPIC "quality/#" WITH DESTINATION_TOPIC "chicago/quality/#" WITH DIRECTION "out" ADD MAPPING centralCommands WITH SOURCE_TOPIC "local/commands/#" WITH DESTINATION_TOPIC "chicago/commands/#" WITH DIRECTION "in" ``` *** ## MongoDB Examples ### MongoDB Sensor Storage Store sensor readings in MongoDB using the CLEAN query format. This example captures all sensor readings and stores them with metadata including the original topic and timestamp: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorStorage WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:password@mongodb.example.com:27017/iot?authSource=admin" WITH DATABASE "iot" ADD EVENT StoreSensorReading WITH QUERY "CLEAN:{collection: sensor_readings, document: {sensor_id: {sensor_id}, value: {value.json}, topic: {source_topic}, timestamp: {timestamp}}}" WITH SOURCE_TOPIC "sensors/+/reading" ``` ### MongoDB Event Logging Create an event log collection for system events: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EventLogger WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://localhost:27017/logs" WITH DATABASE "logs" ADD EVENT LogSystemEvent WITH QUERY "CLEAN:{collection: system_events, document: {event_type: {event_type}, source: {source_topic}, data: {value.json}, created_at: {timestamp}}}" WITH SOURCE_TOPIC "events/+/+" ``` *** ## REST API Examples ### REST API Integration Connect your MQTT broker to external REST APIs. This example calls a weather API when a request arrives on the trigger topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WeatherService WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_CLIENT "true" WITH BASE_ADDRESS "https://api.openweathermap.org" ADD MAPPING GetWeather WITH SOURCE_TOPIC "weather/request" WITH DESTINATION_TOPIC "weather/response" WITH QUERY '{ "method": "GET", "endpoint": "/data/2.5/weather", "headers": { "Content-Type": "application/json" } }' ``` ### REST API with Authentication Call authenticated APIs with headers extracted from the MQTT payload: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE NotificationService WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_CLIENT "true" WITH BASE_ADDRESS "https://api.notification-service.com" ADD MAPPING SendNotification WITH SOURCE_TOPIC "notifications/send" WITH DESTINATION_TOPIC "notifications/result" WITH QUERY '{ "method": "POST", "endpoint": "/v1/notify", "headers": { "Content-Type": "application/json", "Authorization": "Bearer {api_key}" }, "content": "{payload.json}" }' ``` *** ## Email Notification Examples ### Critical Alert Notifications Send emails for critical system alerts. Template placeholders like `{value.json.equipment_id}` are replaced with values from the MQTT payload: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CriticalAlerts WITH TYPE EMAIL ADD MQTT_CONFIG WITH BROKER SELF ADD SMTP_CONFIG WITH HOST "smtp.gmail.com" WITH PORT '587' WITH USERNAME "alerts@mycompany.com" WITH PASSWORD "gmail-app-password" WITH USE_TLS true ADD MAPPING criticalEquipment WITH SOURCE_TOPIC "alerts/critical/equipment/+" WITH TEMPLATE_PATH "/templates/equipment_alert.html" WITH SUBJECT "CRITICAL: Equipment {value.json.equipment_id} - {value.json.alert_type}" WITH RECIPIENT "ops-team@mycompany.com" ADD MAPPING criticalSafety WITH SOURCE_TOPIC "alerts/critical/safety/+" WITH TEMPLATE_PATH "/templates/safety_alert.html" WITH SUBJECT "SAFETY ALERT: {value.json.location}" WITH RECIPIENT "safety-team@mycompany.com" ``` ### Dynamic User Notifications Send to recipients specified in the MQTT payload using `{value.json.email}`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE UserNotifications WITH TYPE EMAIL ADD MQTT_CONFIG WITH BROKER SELF ADD SMTP_CONFIG WITH HOST "smtp.office365.com" WITH PORT '587' WITH USERNAME "noreply@company.com" WITH PASSWORD "office365-password" WITH USE_TLS true ADD MAPPING userAlert WITH SOURCE_TOPIC "users/+/notifications" WITH TEMPLATE_PATH "/templates/user_notification.html" WITH SUBJECT "Hello {value.json.username}, {value.json.notification_type}" WITH RECIPIENT "{value.json.email}" ``` *** ## Data Storage Examples ### OpenSearch Sensor Archive Index all sensor data for real-time analytics and historical queries: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorArchive WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://opensearch.company.com:9200" WITH USERNAME "sensor_writer" WITH PASSWORD "opensearch_password" WITH USE_SSL true ADD EVENT IndexSensorReading WITH QUERY "CLEAN:{index: sensor-readings, body: {@timestamp: {timestamp}, sensor_id: {sensor_id}, data: {value.json}}}" WITH SOURCE_TOPIC "sensors/+/data" ``` ### Combined Bridge and Storage Bridge data from a remote broker and simultaneously store it in OpenSearch using the `STORE` keyword: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProcessAndArchive WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER_ADDRESS "factory-broker.local" WITH BROKER_PORT '1883' WITH CLIENT_ID "ArchiveBridge" ADD DESTINATION_CONFIG WITH BROKER SELF ADD MAPPING sensorData WITH SOURCE_TOPIC "factory/sensors/+/data" WITH DESTINATION_TOPIC "archived/sensors/+/data" WITH DIRECTION "in" STORE SensorArchive WITH INDEX_NAME "factory_sensors" ``` *** ## Complete Integration Example This comprehensive example combines multiple route types into a complete IoT integration setup. It demonstrates how different routes work together: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // 1. MongoDB storage for historical data DEFINE ROUTE DataWarehouse WITH TYPE MONGODB ADD MONGODB_CONFIG WITH CONNECTION_STRING "mongodb://user:pass@mongodb.company.com:27017/iot?ssl=true" WITH DATABASE "iot" ADD EVENT StoreProduction WITH QUERY "CLEAN:{collection: production_data, document: {timestamp: {timestamp}, data: {value.json}}}" WITH SOURCE_TOPIC "production/#" // 2. Cloud synchronization for remote monitoring DEFINE ROUTE CloudSync WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloud.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "Factory-Main" WITH USE_TLS true ADD MAPPING allData WITH SOURCE_TOPIC "production/#" WITH DESTINATION_TOPIC "factory-main/production/#" WITH DIRECTION "out" // 3. REST API for external integrations DEFINE ROUTE ERPIntegration WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_CLIENT "true" WITH BASE_ADDRESS "https://erp.company.com/api" ADD MAPPING UpdateInventory WITH SOURCE_TOPIC "production/completed" WITH DESTINATION_TOPIC "erp/response" WITH QUERY '{"method": "POST", "endpoint": "/inventory/update", "content": "{payload.json}"}' // 4. Email alerts for critical events DEFINE ROUTE CriticalAlerts WITH TYPE EMAIL ADD MQTT_CONFIG WITH BROKER SELF ADD SMTP_CONFIG WITH HOST "smtp.company.com" WITH PORT '587' WITH USERNAME "alerts@company.com" WITH PASSWORD "email_password" WITH USE_TLS true ADD MAPPING critical WITH SOURCE_TOPIC "alerts/critical/#" WITH TEMPLATE_PATH "/templates/critical.html" WITH SUBJECT "CRITICAL: {value.json.type}" WITH RECIPIENT "ops@company.com" ``` *** ## Pattern Summary Choose the right route pattern for your scenario: | Scenario | Route Type | Key Configuration | | ------------------ | ------------ | ----------------------- | | Edge to Cloud | MQTT\_BRIDGE | `WITH DIRECTION "out"` | | Cloud Commands | MQTT\_BRIDGE | `WITH DIRECTION "in"` | | Bidirectional Sync | MQTT\_BRIDGE | `WITH DIRECTION "both"` | | Document Storage | MONGODB | CLEAN query format | | Search/Analytics | OPENSEARCH | Index configuration | | API Integration | REST\_API | HTTP method + endpoint | | Email Alerts | EMAIL | Template + placeholders | ## Next Steps Run multiple Coreflux brokers as a fault-tolerant cluster. Connect to industrial equipment via Modbus. # ADS Route (Beckhoff) Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/ads Connect to Beckhoff TwinCAT PLCs using ADS protocol with AutoDiscovery and comprehensive data type support ## ADS Overview The `ADS` route enables communication with Beckhoff TwinCAT PLCs and systems using the ADS (Automation Device Specification) protocol. It supports automatic symbol discovery, bidirectional communication, and complex data types including arrays and structures. ADS provides direct, high-performance access to TwinCAT variables. The AutoDiscovery feature can automatically find and expose all PLC symbols as MQTT topics. The ADS route is on beta testing. It is planned for a future release. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BeckhoffPLC WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_12345" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING ProcessData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "GVL_Process.rTemperature" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" ``` *** ## AMS Net ID Basics ADS communication requires AMS Net IDs for both local and target systems. The AMS Net ID is typically the IP address followed by `.1.1`. | Component | Format | Example | | ------------ | -------------- | ------------------- | | AMS Net ID | `x.x.x.x.y.y` | `192.168.1.100.1.1` | | IP portion | First 4 octets | `192.168.1.100` | | Node portion | Last 2 octets | `.1.1` (usually) | You can find your TwinCAT system's AMS Net ID in TwinCAT XAE under **SYSTEM** → **Routes** or by running `adsInfo` on the target. *** ## Connection Configuration ### ADS\_CONFIG Parameters Local AMS Net ID (format: x.x.x.x.x.x). This is typically your machine's IP + .1.1. Route name on local machine to reach the target PLC. Local host name (optional). Target PLC's AMS Net ID (format: x.x.x.x.x.x). Target PLC's IP address. ADS port. Typically 851 for TwinCAT 3, 801 for TwinCAT 2. Route name on target PLC to reach the local machine. Username for route creation (typically Administrator). Password for route creation. Connection timeout in milliseconds. Default: 5000. ### Connection Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "PlcRoute" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTServer" WITH TARGET_USER "Administrator" WITH TARGET_PASSWORD "1" WITH TIMEOUT 5000 ``` *** ## AutoDiscovery AutoDiscovery automatically finds all PLC symbols and exposes them as MQTT topics. ### AutoDiscovery Parameters Enable automatic symbol discovery. Default: false. Base MQTT topic for discovered symbols. Default: ads/discovery. Default QoS for discovered symbols (0-2). Default: 0. Default retain flag for discovered symbols. Default: false. Default trigger mode: * `0` - OnStart (read once at connection) * `1` - OnChange (publish on value change) * `2` - Cyclic (poll at interval) Default: 2. Default polling cycle in milliseconds. Default: 1000. Include patterns (comma-separated, \* wildcard). Default: \* (all symbols). Exclude patterns (comma-separated, \* wildcard). Structure expansion depth. Default: 3. Enable parallel reading for large projects. Default: false. Batch size for parallel reading. Default: 50. ### AutoDiscovery Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BeckhoffAutoDiscover WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" WITH AUTO_DISCOVERY "true" WITH DISCOVERY_BASE_TOPIC "beckhoff/plc" WITH DISCOVERY_FILTER_INCLUDES "GVL_*" WITH DISCOVERY_FILTER_EXCLUDES "*_Internal*" WITH DISCOVERY_DEFAULT_CYCLE 500 WITH DISCOVERY_MAX_DEPTH 3 ``` *** ## TwinCAT Data Types | Data Type | TwinCAT Type | Size | Description | | ------------------ | ------------ | -------- | ------------------------------------------------- | | `BOOL` | BOOL | 1 bit | Boolean value | | `BYTE` | BYTE | 8 bits | Unsigned 8-bit | | `SINT` | SINT | 8 bits | Signed 8-bit integer | | `USINT` | USINT | 8 bits | Unsigned 8-bit integer | | `WORD` | WORD | 16 bits | Unsigned 16-bit | | `INT` | INT | 16 bits | Signed 16-bit integer | | `UINT` | UINT | 16 bits | Unsigned 16-bit integer | | `DWORD` | DWORD | 32 bits | Unsigned 32-bit | | `DINT` | DINT | 32 bits | Signed 32-bit integer | | `UDINT` | UDINT | 32 bits | Unsigned 32-bit integer | | `LWORD` | LWORD | 64 bits | Unsigned 64-bit | | `LINT` | LINT | 64 bits | Signed 64-bit integer | | `ULINT` | ULINT | 64 bits | Unsigned 64-bit integer | | `REAL` / `FLOAT` | REAL | 32 bits | 32-bit floating point | | `LREAL` / `DOUBLE` | LREAL | 64 bits | 64-bit floating point | | `CHAR` | CHAR | 8 bits | Single character | | `WCHAR` | WCHAR | 16 bits | Wide character | | `TIME` | TIME | 32 bits | Time duration | | `DATE` | DATE | 16 bits | Date value | | `TOD` | TOD | 32 bits | Time of day | | `STRING` | STRING | Variable | ASCII string; sized with `LENGTH` | | `ARRAY` | ARRAY | Variable | Array of elements (requires `ARRAY_ELEMENT_TYPE`) | | `STRUCT` | STRUCT | Variable | Structured data type | ### String sizing For `STRING` tags, `WITH LENGTH` is the total size in **bytes**, **including the ADS length byte**. It defaults to **256** bytes for reads; writes use an **80**-byte default when no size is supplied. `STRING_SIZE` is accepted as an alias for the same field. `ARRAY_ELEMENT_LENGTH` is a separate setting for `ARRAY` tags whose elements are `STRING` or `STRUCT` — it does not replace `LENGTH` on scalar string tags. Read a TwinCAT `STRING` symbol with an explicit read size (256 bytes here; writes default to 80 bytes when `LENGTH` is omitted): ```lot wrap focus={15} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TwinCATStrings WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING Labels WITH EVERY 1 SECOND ADD TAG ProductLabel WITH ADDRESS "GVL_Process.sProductName" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "STRING" WITH LENGTH 256 WITH SOURCE_TOPIC "production/label" ``` *** ## TAG Configuration ### Symbol-Based Addressing ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Temperature WITH ADDRESS "GVL_Process.rTemperature" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" ``` ### Index-Based Addressing For direct memory access using index group and offset: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG DirectValue WITH ADDRESS "16448.0" WITH ADDRESS_TYPE "INDEX" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "plc/direct" ``` ### Array Handling Array element type (required for ARRAY): BOOL, INT, REAL, STRING, STRUCT, etc. Number of elements in the array. Length of each element in bytes (for STRING/STRUCT arrays). Offset in bytes to skip before array data. Default: 0. Header bytes to skip per element. Default: 0. ### Structure Handling Structure field definitions in format: `name:offset:type:size` (comma-separated). ### String sizing parameter Total string size in bytes, including the ADS length byte. Default: **256** for reads; **80** for writes when omitted. Alias: `STRING_SIZE`. *** ## Event-Based Operations For on-demand ADS operations (not polling), use the EVENT syntax. Publish a message to SOURCE\_TOPIC to trigger the operation; the route executes it and publishes the result to DESTINATION\_TOPIC. ### Supported Operations | Operation | Description | Query Parameters | | --------- | ----------------------------- | ------------------------------ | | `READ` | Read a symbol value on demand | `symbol`, `data_type` | | `WRITE` | Write a value to a symbol | `symbol`, `data_type`, `value` | ### Query Parameters | Parameter | Description | Example | | ----------- | --------------------------------- | --------------------------------------------- | | `operation` | Operation type: `READ` or `WRITE` | `READ` | | `symbol` | TwinCAT symbol path | `GVL_Process.rTemperature`, `GVL_Control.rSP` | | `data_type` | TwinCAT data type | `REAL`, `LREAL`, `DINT`, `BOOL`, `INT` | | `value` | Value to write (WRITE only) | `75.0`, `42`, `true` | ### Read Example Read a process variable on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadSymbol WITH SOURCE_TOPIC "ads/commands/read" WITH DESTINATION_TOPIC "ads/responses/read" WITH QUERY "{operation: READ, symbol: GVL_Process.rTemperature, data_type: REAL}" ``` ### Write Example Write a setpoint on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "ads/commands/write" WITH DESTINATION_TOPIC "ads/responses/write" WITH QUERY "{operation: WRITE, symbol: GVL_Control.rSP, data_type: REAL, value: 75.0}" ``` *** ## Complete Examples Read PLC variables using symbol names: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TwinCATBasic WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING ProcessVariables WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "GVL_Process.rTemperature" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/process/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "GVL_Process.rPressure" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/process/pressure" WITH UNIT "bar" ADD TAG RunningState WITH ADDRESS "GVL_Process.bRunning" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/process/running" ``` Automatically discover and expose all GVL variables: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TwinCATAutoDiscover WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" WITH AUTO_DISCOVERY "true" WITH DISCOVERY_BASE_TOPIC "twincat" WITH DISCOVERY_FILTER_INCLUDES "GVL_Production.*,GVL_Process.*" WITH DISCOVERY_FILTER_EXCLUDES "*_Debug*,*_Test*" WITH DISCOVERY_DEFAULT_CYCLE 500 WITH DISCOVERY_MAX_DEPTH 2 WITH DISCOVERY_PARALLEL_READING "true" WITH DISCOVERY_BATCH_SIZE 100 ``` Read process values and write setpoints: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TwinCATControl WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING ControlLoop WITH EVERY 200 MILLISECONDS ADD TAG ProcessValue WITH ADDRESS "GVL_Control.rPV" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/pv" WITH UNIT "°C" ADD TAG Setpoint WITH ADDRESS "GVL_Control.rSP" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/sp" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/sp/write" WITH MIN_VALUE 0 WITH MAX_VALUE 100 ADD TAG Output WITH ADDRESS "GVL_Control.rOutput" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/output" WITH UNIT "%" ADD TAG AutoMode WITH ADDRESS "GVL_Control.bAutoMode" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "control/auto" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/auto/write" ``` Read array values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TwinCATArrays WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING ArrayData WITH EVERY 1 SECOND ADD TAG TemperatureArray WITH ADDRESS "GVL_Data.arTemperatures" WITH DATA_TYPE "ARRAY" WITH ARRAY_ELEMENT_TYPE "REAL" WITH ARRAY_SIZE 10 WITH SOURCE_TOPIC "plc/temperatures" WITH PUBLISH_MODE "JSON" ADD TAG StatusBits WITH ADDRESS "GVL_Data.arStatus" WITH DATA_TYPE "ARRAY" WITH ARRAY_ELEMENT_TYPE "BOOL" WITH ARRAY_SIZE 16 WITH SOURCE_TOPIC "plc/status" ``` Read structured data: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TwinCATStruct WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING StructData WITH EVERY 500 MILLISECONDS ADD TAG MachineData WITH ADDRESS "GVL_Machine.stMachineData" WITH DATA_TYPE "STRUCT" WITH STRUCT_FIELD_DEFINITIONS "Speed:0:REAL:4,Current:4:REAL:4,Status:8:INT:2,ErrorCode:10:WORD:2" WITH SOURCE_TOPIC "plc/machine" WITH PUBLISH_MODE "JSON" ``` Continuous monitoring with on-demand read and write events in the same route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullADSSetup WITH TYPE ADS ADD ADS_CONFIG WITH LOCAL_NET_ID "192.168.1.10.1.1" WITH LOCAL_ROUTE_TO_TARGET "CX_Route" WITH TARGET_NET_ID "192.168.1.100.1.1" WITH TARGET_IP "192.168.1.100" WITH TARGET_PORT 851 WITH TARGET_ROUTE_TO_LOCAL "MQTTBridge" ADD MAPPING ProcessVariables WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "GVL_Process.rTemperature" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "GVL_Process.rPressure" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/pressure" WITH UNIT "bar" ADD EVENT ReadSymbol WITH SOURCE_TOPIC "ads/commands/read" WITH DESTINATION_TOPIC "ads/responses/read" WITH QUERY "{operation: READ, symbol: GVL_Process.rOutput, data_type: REAL}" ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "ads/commands/write" WITH DESTINATION_TOPIC "ads/responses/write" WITH QUERY "{operation: WRITE, symbol: GVL_Control.rSP, data_type: REAL, value: 0}" ``` The MAPPING continuously reads temperature and pressure. To trigger an on-demand read of the output value, publish any message to `ads/commands/read`. To write a setpoint, publish to `ads/commands/write`. *** ## Route Setup Process To establish ADS communication, routes must be configured on both sides: ### 1. On TwinCAT System 1. Open TwinCAT XAE 2. Go to **SYSTEM** → **Routes** 3. Add a static route: * **Route Name**: Match `TARGET_ROUTE_TO_LOCAL` parameter * **AMS Net ID**: Your Coreflux machine's AMS Net ID * **Transport Type**: TCP/IP * **Address**: Your Coreflux machine's IP ### 2. In Coreflux Configuration The route automatically creates a corresponding route on the local machine using the configured parameters. Both route names (`LOCAL_ROUTE_TO_TARGET` and `TARGET_ROUTE_TO_LOCAL`) must match the actual route names configured on each system. *** ## Troubleshooting * Verify AMS Net IDs are correct on both sides * Check routes are properly configured in TwinCAT * Ensure TARGET\_PORT is correct (851 for TC3, 801 for TC2) * Verify firewall allows ADS port (typically 48898) * Check TARGET\_USER/PASSWORD for route creation * Verify symbol path matches exactly (case-sensitive) * Check symbol exists in PLC project * Ensure PLC is in RUN mode * Use AutoDiscovery to find correct symbol names * Check DISCOVERY\_FILTER\_INCLUDES patterns * Verify DISCOVERY\_MAX\_DEPTH is sufficient * For large projects, enable DISCOVERY\_PARALLEL\_READING * Check PLC project is compiled and deployed * Ensure WRITABLE is set to "true" * Verify PLC variable is not constant * Check value is within MIN\_VALUE/MAX\_VALUE * Verify PLC is in RUN mode * Increase TIMEOUT value * Check network connectivity * Verify TwinCAT runtime is running * Reduce DISCOVERY\_BATCH\_SIZE if using AutoDiscovery *** ## Next Steps Connect to Rockwell Automation PLCs. Cross-platform OPC UA communication. # Allen-Bradley Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/allen-bradley Connect to Rockwell Automation PLCs over EtherNet/IP with symbolic tag names, program scope, UDTs, arrays, and bidirectional writes. ## Connect to Rockwell PLCs by Tag Name The `ALLEN_BRADLEY` route connects Coreflux to Rockwell Automation PLCs using **symbolic tag names** over EtherNet/IP. Define an `ALLEN_BRADLEY_CONFIG` block, add TAGs with PLC tag addresses, and publish each data point to its own MQTT topic — with optional scaling, units, deadband, and bidirectional writes. **Like reading labels on a control panel.** Instead of numeric register addresses, you use the same tag names you see in Studio 5000 — `MainCounter`, `Program:MainProgram.Temperature`, `Motor1.Speed`. For **explicit CIP paths** (`4.100.3.0`), use the [EtherNet/IP route](./ethernetip) instead. This route is for symbolic tag-name access; EtherNet/IP is for Class/Instance/Attribute addressing. ### Route and config aliases | Alias | Canonical form | | -------------------------------- | -------------------------- | | `TYPE AB` or `TYPE ALLENBRADLEY` | `TYPE ALLEN_BRADLEY` | | `ADD AB_CONFIG` | `ADD ALLEN_BRADLEY_CONFIG` | ### Key capabilities * **Symbolic tag names** — controller-scoped, program-scoped, UDT members, and arrays * **Multiple PLC families** — ControlLogix, CompactLogix, Micro800, MicroLogix, PLC5, SLC500 * **TAG-based polling** — per-point MQTT topics with configurable intervals * **Bidirectional writes** — `WRITABLE` tags with `DESTINATION_TOPIC` * **Engineering units** — `SCALING`, `OFFSET`, `UNIT`, `DECIMAL_PLACES`, min/max clamping * **Publish modes** — `VALUE_ONLY` (default) or `JSON` with quality metadata *** ## Quick Start Read a controller-scoped DINT counter every second: ```lot wrap focus={9-13} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ControlLogixPLC WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.10" WITH DEVICE_FAMILY 0 WITH PATH "1,0" ADD MAPPING ProductionData WITH EVERY 1 SECOND ADD TAG ProductCounter WITH ADDRESS "MainCounter" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "production/counter" ``` Read tags inside a specific program with units and deadband: ```lot wrap focus={9-16} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ControlLogix WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.20" WITH DEVICE_FAMILY 0 ADD MAPPING ProcessControl WITH EVERY 500 MILLISECONDS ADD TAG ReactorTemp WITH ADDRESS "Program:MainProgram.Temperature" WITH DATA_TYPE "REAL" WITH UNIT "°C" WITH DECIMAL_PLACES 2 WITH DEADBAND 0.5 WITH SOURCE_TOPIC "process/reactor/temperature" ``` Poll a setpoint and accept MQTT writes to update it: ```lot wrap focus={9-18} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TemperatureControl WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.60" WITH DEVICE_FAMILY 0 ADD MAPPING ControlLoop WITH EVERY 1 SECOND ADD TAG TempSetpoint WITH ADDRESS "TempSetpoint" WITH DATA_TYPE "REAL" WITH UNIT "°C" WITH MIN_VALUE 0.0 WITH MAX_VALUE 100.0 WITH SOURCE_TOPIC "control/temperature/setpoint" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/temperature/setpoint/write" ``` Read built-in I/O on a Micro800 controller: ```lot wrap focus={9-14} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Micro800PLC WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.70" WITH DEVICE_FAMILY 4 ADD MAPPING Micro800Data WITH EVERY 1 SECOND ADD TAG DigitalIn WITH ADDRESS "_IO_EM_DI_00" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "micro800/input0" ``` *** ## Connection Configuration ### ALLEN\_BRADLEY\_CONFIG Parameters | Parameter | Required | Default | Description | | --------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `IP` | Yes | — | PLC IP address | | `DEVICE_FAMILY` | No | `0` | Device family code (0–6, see table below) | | `PORT` | No | `44818` | EtherNet/IP port. Only honored for **Micro800** (`DEVICE_FAMILY 4`), which connects at `IP:PORT` — other families always use the default EtherNet/IP port. | | `SLOT` | No | `0` | Parsed for legacy parity only. Backplane routing uses **`PATH`, not `SLOT`** — set the CPU slot in `PATH` (for example `"1,0"`). | | `PATH` | No | `"1,0"` | Routing path to the PLC (format: `backplane,slot`). | | `TIMEOUT` | No | `5000` | Per-tag read/write timeout in milliseconds | The config block may be declared as `ADD ALLEN_BRADLEY_CONFIG` or `ADD AB_CONFIG` — both are equivalent. Loading the route marks it ready; a transport is opened per tag read/write. `-checkRouteConnection` confirms the route is loaded but **does not probe the PLC** — verify connectivity with polling or a test read. ### Device Family Codes | Code | Family | Use when | | ---- | --------------------------- | ------------------------------------------ | | `0` | ControlLogix / CompactLogix | 1756-L6x, L7x, L8x, 1769-Lxx (most common) | | `1` | PLC5 | 1785-L series (legacy) | | `2` | SLC500 | 1747-L series (legacy) | | `3` | ControlLogix PLC5 | Mixed environment | | `4` | Micro800 | 2080-LC series | | `5` | MicroLogix | 1766, 1764 series | | `6` | Omron | NJ/NX with CIP support | ### Connection Examples Standard backplane path to CPU in slot 0: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.100" WITH DEVICE_FAMILY 0 WITH PATH "1,0" WITH TIMEOUT 5000 ``` MicroLogix 1100/1400: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.50" WITH DEVICE_FAMILY 5 WITH TIMEOUT 5000 ``` CPU in slot 2 on backplane 1: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.100" WITH DEVICE_FAMILY 0 WITH PATH "1,2" WITH TIMEOUT 5000 ``` Micro800 — `PATH` is usually omitted: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.70" WITH DEVICE_FAMILY 4 WITH TIMEOUT 5000 ``` *** ## Tag Addressing Allen-Bradley PLCs use **tag-based addressing** (not register numbers). Tag names are case-sensitive. | Scope | Format | Example | | ------------- | ----------------------------- | ----------------------------- | | Controller | `TagName` | `MainCounter` | | Program | `Program:ProgramName.TagName` | `Program:MainProgram.Counter` | | Array element | `TagName[index]` | `Temps[5]` | | UDT member | `UDTInstance.Member` | `Valve1.Status` | | Nested UDT | `Parent.Child.GrandChild` | `Tank1.Level.Current` | | I/O tags | `Local:Slot:Type.Data[index]` | `Local:1:I.Data[0]` | ### Tag naming rules * Letters, numbers, and underscore only * Must start with a letter or underscore * Case-sensitive * No spaces ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Valid WITH ADDRESS "MainCounter" WITH ADDRESS "Program:MainProgram.Temperature" WITH ADDRESS "Valve1.Status" WITH ADDRESS "Temps[5]" ``` *** ## Data Types | Coreflux `DATA_TYPE` | AB type | Notes | | -------------------- | ------- | ------------------------------------------------------------------ | | `BOOL` | BOOL | Boolean | | `SINT` | SINT | Signed 8-bit | | `USINT` / `BYTE` | USINT | Unsigned 8-bit | | `INT` | INT | Signed 16-bit | | `UINT` / `WORD` | UINT | Unsigned 16-bit | | `DINT` | DINT | Signed 32-bit | | `UDINT` / `DWORD` | UDINT | Unsigned 32-bit | | `LINT` | LINT | Signed 64-bit | | `ULINT` / `LWORD` | ULINT | Unsigned 64-bit | | `REAL` / `FLOAT` | REAL | 32-bit float | | `LREAL` / `DOUBLE` | LREAL | 64-bit float — MQTT values round to `DECIMAL_PLACES` (default `2`) | | `CHAR` | CHAR | Single character | | `WCHAR` | WCHAR | Wide character | | `STRING` | STRING | Fixed 88-byte CIP layout — see encoding notes below | | `TIME` | TIME | Duration in ms — published as `T#XdXhXmXsXms` | | `DATE` | DATE | Date value | | `TOD` | TOD | Time of day | ### STRING encoding The route uses a **fixed 88-byte** CIP `STRING` layout. Neither `LENGTH` nor `STRING_SIZE` resizes this layout — `STRING_SIZE` is accepted as an alias for the same field and has the same limitation. Encoding still depends on `DEVICE_FAMILY`: * **ControlLogix / CompactLogix (default):** 4-byte DINT length prefix + up to 82 ASCII characters * **Micro800:** 1-byte length prefix + characters ### TIME encoding Raw value is a signed 32-bit millisecond count. MQTT publishes Allen-Bradley-style duration strings (for example `T#1h1m1s1ms`). *** ## TAG Configuration Every polled data point is an `ADD TAG` inside an `ADD MAPPING` block. ### Complete TAG Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG MotorSpeed WITH ADDRESS "Motor1.ActualSpeed" WITH ADDRESS_TYPE "SYMBOL" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/motor1/speed" WITH SCALING 1 WITH OFFSET 0 WITH UNIT "RPM" WITH DECIMAL_PLACES 2 WITH MIN_VALUE 0 WITH MAX_VALUE 3600 WITH DEADBAND 10 WITH PUBLISH_MODE "JSON" WITH DESCRIPTION "Motor 1 actual speed in RPM" ``` ### TAG Parameters PLC tag name — controller-scoped (`MainCounter`), program-scoped (`Program:MainProgram.Counter`), UDT member (`Valve1.Status`), or array (`Temps[0]`). `SYMBOL` (default) or `PATH`. Logix data type: `BOOL`, `SINT`, `INT`, `DINT`, `LINT`, `USINT`, `UINT`, `UDINT`, `ULINT`, `REAL`, `LREAL`, `STRING`, `TIME`, etc. Multiply the parsed value after read. Alias: `SCALING_FACTOR`. Default: `1.0`. Add after scaling. Default: `0.0`. Engineering unit (for example `°C`, `bar`, `RPM`). Included in JSON publish mode. Rounding precision for float MQTT payloads. Default: `2`. Clamp minimum allowed value (especially for writable tags). Clamp maximum allowed value. Suppress MQTT publish when the change is below this threshold. Default: `0.0`. MQTT topic where polled values are published. `VALUE_ONLY` (default) or `JSON` (value, unit, quality, timestamp, description). When `"true"`, subscribe to `DESTINATION_TOPIC` and write incoming MQTT values to the PLC. MQTT topic to receive write commands. Required when `WRITABLE` is `"true"`. Human-readable description (included in JSON publish mode). ### Publish Modes **VALUE\_ONLY** (default) — publishes just the scaled value: ``` 12345 ``` **JSON** — metadata wrapper when explicitly set: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "tag_name": "SafetyPressure", "value": 8.456, "unit": "bar", "quality": "GOOD", "timestamp": "2025-12-17T10:30:45.123Z", "description": "Critical safety pressure sensor" } ``` *** ## Event-Based Operations For on-demand tag reads and writes (not continuous polling), add an `ADD EVENT` block. Publish any message to `SOURCE_TOPIC` to trigger the operation; results appear on `DESTINATION_TOPIC`. ### Query Parameters | Parameter | Description | Example | | ----------- | --------------------------- | -------------------------------------------- | | `operation` | `READ` or `WRITE` | `READ` | | `variable` | PLC tag name | `Program:MainProgram.Counter`, `MainCounter` | | `data_type` | Logix data type | `DINT`, `REAL`, `BOOL` | | `value` | Value to write (WRITE only) | `42`, `75.0` | ### Read Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadOnDemand WITH SOURCE_TOPIC "ab/commands/read" WITH DESTINATION_TOPIC "ab/responses/read" WITH QUERY "{operation: READ, variable: Program:MainProgram.Counter, data_type: DINT}" ``` ### Write Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "ab/commands/write" WITH DESTINATION_TOPIC "ab/responses/write" WITH QUERY "{operation: WRITE, variable: TIC100.SP, data_type: REAL, value: 150.0}" ``` *** ## Examples Monitor BOOL, DINT, and REAL tags in one mapping: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionLine WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.30" WITH DEVICE_FAMILY 0 ADD MAPPING MachineMonitor WITH EVERY 1 SECOND ADD TAG MachineRunning WITH ADDRESS "MachineStatus.Running" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "machine/status/running" ADD TAG ProductCount WITH ADDRESS "ProductionCounters.TotalCount" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "machine/production/total" ADD TAG LineSpeed WITH ADDRESS "LineSpeed" WITH DATA_TYPE "REAL" WITH UNIT "units/min" WITH SOURCE_TOPIC "machine/speed" ``` Read User-Defined Type structure members: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE UDTAccess WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.40" WITH DEVICE_FAMILY 0 ADD MAPPING ValveControl WITH EVERY 500 MILLISECONDS ADD TAG Valve1Position WITH ADDRESS "Valve1.Position" WITH DATA_TYPE "INT" WITH UNIT "%" WITH SOURCE_TOPIC "valves/valve1/position" ADD TAG Valve1Status WITH ADDRESS "Valve1.Status" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "valves/valve1/status" ``` Read specific array indices: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ArrayData WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.50" WITH DEVICE_FAMILY 0 ADD MAPPING ArrayMonitor WITH EVERY 1 SECOND ADD TAG Zone1Temp WITH ADDRESS "ZoneTemperatures[0]" WITH DATA_TYPE "REAL" WITH UNIT "°C" WITH SOURCE_TOPIC "zones/zone1/temperature" ADD TAG Zone2Temp WITH ADDRESS "ZoneTemperatures[1]" WITH DATA_TYPE "REAL" WITH UNIT "°C" WITH SOURCE_TOPIC "zones/zone2/temperature" ``` Read process value and write setpoint from MQTT: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ControlLoop WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.100" WITH DEVICE_FAMILY 0 ADD MAPPING TempControl WITH EVERY 200 MILLISECONDS ADD TAG PV WITH ADDRESS "TIC100.PV" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/tic100/pv" WITH UNIT "°F" ADD TAG SP WITH ADDRESS "TIC100.SP" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/tic100/sp" WITH UNIT "°F" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/tic100/sp/set" WITH MIN_VALUE 32 WITH MAX_VALUE 212 ``` Scale a 4–20 mA sensor to engineering units: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AnalogSensors WITH TYPE ALLEN_BRADLEY ADD ALLEN_BRADLEY_CONFIG WITH IP "192.168.1.80" WITH DEVICE_FAMILY 0 ADD MAPPING AnalogData WITH EVERY 500 MILLISECONDS ADD TAG Pressure WITH ADDRESS "AI_Pressure" WITH DATA_TYPE "REAL" WITH UNIT "bar" WITH DECIMAL_PLACES 3 WITH DEADBAND 0.01 WITH SOURCE_TOPIC "sensors/pressure" ``` *** ## Best Practices Use `DEVICE_FAMILY 0` for ControlLogix and CompactLogix (most common), `4` for Micro800, and `5` for MicroLogix. Legacy PLC5/SLC500 families (`1`/`2`) only for existing installations. Controller-scoped tags use the name alone (`GlobalCounter`). Program-scoped tags need the full prefix: `Program:MainProgram.LocalCounter`. Set the CPU slot in `PATH` (`"1,0"` for backplane 1, slot 0). `SLOT` is kept for legacy parity, but routing is always resolved from `PATH`. Use `WRITABLE "true"` with `MIN_VALUE` / `MAX_VALUE` on setpoints. Ensure the PLC is in Remote Run mode for writes to succeed. Use `DEADBAND` on noisy analog values. Match `EVERY` to how fast the value changes — 500 ms for critical data, 1–5 s for slow status. *** ## Troubleshooting * Verify IP with `ping` * Confirm port 44818 is open * Ensure PLC is powered on and in **RUN** mode * Check `DEVICE_FAMILY` matches your controller type * Increase `TIMEOUT` on slow networks * Remember: `-checkRouteConnection` does not probe the PLC — use a polling TAG to verify * Tag names are **case-sensitive** — match Studio 5000 exactly * Program-scoped tags need `Program:ProgramName.TagName` (not `ProgramName:Tag`) * Verify the tag exists in Controller Tags or Program Tags in Studio 5000 * For UDTs, include the member: `Valve1.Status` not `Valve1` * For arrays, include the index: `Temps[0]` not `Temps` * Use `PATH "1,0"` for standard backplane routing (backplane, slot) * For Micro800, omit `PATH` or leave it unset * Multi-hop paths use comma-separated segments: `"1,2,2,1"` through bridge modules * Set `DEVICE_FAMILY 4` * Use Micro800 I/O tag names (`_IO_EM_DI_00`, `_IO_EM_AI_00`) * STRING uses 1-byte length prefix (not ControlLogix 4-byte format) * Set `WRITABLE "true"` and provide `DESTINATION_TOPIC` * PLC must be in **Remote Run** mode * Verify `DATA_TYPE` matches the PLC tag definition * Check value is within `MIN_VALUE` / `MAX_VALUE` if set * Match PLC types exactly: DINT → `DINT`, REAL → `REAL`, BOOL → `BOOL` * `TIME` tags publish as `T#...` duration strings on MQTT *** ## Next Steps Explicit CIP Class/Instance/Attribute addressing for assembly objects and legacy devices. Compare OT routes and choose the right protocol for your devices. # BACnet/IP Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/bacnet Connect Coreflux to building automation controllers over BACnet/IP with TAG polling, COV subscriptions, alarms, and bidirectional writes. ## Connect Coreflux to Building Automation The BACnet route connects Coreflux to building automation controllers (ASHRAE 135) using the unified OT TAG system. Define the BACnet configuration, add Tags for continuous polling or COV subscriptions, and handle alarm operations. BACnet is the dominant protocol for building management systems. If your controller supports BACnet/IP, this route maps each data point to an MQTT topic using the same TAG model as every other Coreflux industrial route. ### When to use this Use the BACnet route when you need to integrate HVAC, lighting, metering, or access-control controllers that expose **BACnet/IP** — read live values on a schedule, subscribe to change notifications, write setpoints from MQTT, receive intrinsic alarms, or run ad-hoc reads and writes triggered by MQTT commands. ### Key capabilities * **BACnet/IP over UDP** — standard port 47808 (0xBAC0) with broadcast or direct-IP discovery * **TAG-based polling** — per-point MQTT topics with a configurable polling interval * **Change-of-Value (COV)** — push subscriptions per tag (`WITH COV true`) without polling * **Event alarms** — BACnet Event-Notifications to MQTT with per-tag or device-wide streams * **Bidirectional writes** — MQTT-triggered writes to analog, binary, and value objects * **On-demand events** — `ADD EVENT` with `READ`, `WRITE`, and `ACK` queries triggered by any MQTT publish * **Operator ACK workflow** — auto-registered ACK handler per alarming tag * **Direct IP mode** — connect to controllers that do not respond to Who-Is/I-Am * **Docker and hostname support** — resolve container service names before opening the BACnet socket *** ## Quick Start Pick the starting point that matches what you need. Each tag publishes to `{SOURCE_TOPIC}/{tagName}` unless the tag sets its own `SOURCE_TOPIC`. Connect to a controller and read a room temperature every five seconds: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD MAPPING RoomSensors WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 5 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" ``` Read a setpoint and allow MQTT writes back to the device: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE HVAC WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "10.0.0.50" WITH DEVICE_ID 2000 WITH WRITE_PRIORITY 8 ADD MAPPING Setpoints WITH SOURCE_TOPIC "bacnet/hvac" WITH EVERY 10 SECONDS ADD TAG CoolingSetpoint WITH ADDRESS "AV:1" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" WITH WRITABLE true WITH DESTINATION_TOPIC "bacnet/hvac/CoolingSetpoint/set" ``` Subscribe to Change-of-Value so the device pushes updates only when values change — no polling interval needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 WITH COV_LIFETIME 600 ADD MAPPING CovPoints WITH SOURCE_TOPIC "bacnet/bms/cov" ADD TAG RoomTempCov WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH COV true ``` When the controller does not answer Who-Is/I-Am, connect straight to its IP address: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "10.0.0.50" WITH PORT 47808 WITH DEVICE_ID 1234 WITH DIRECT_IP true ADD MAPPING RoomSensors WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 5 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" ``` Trigger a single BACnet read or write by publishing to an MQTT command topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD EVENT ReadProperty WITH SOURCE_TOPIC "bacnet/cmd/read" WITH DESTINATION_TOPIC "bacnet/cmd/read/result" WITH QUERY "READ:AI:0:PRESENT_VALUE" ADD EVENT WriteProperty WITH SOURCE_TOPIC "bacnet/cmd/write" WITH DESTINATION_TOPIC "bacnet/cmd/write/result" WITH QUERY "{payload}" ``` Each alarming point gets its own MQTT alarm stream and auto-registered ACK topic: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 WITH LOCAL_DEVICE_ID 4194300 ADD MAPPING LimitPoints WITH SOURCE_TOPIC "bacnet/bms" ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH COV true WITH ALARM true ``` *** ## Protocol Overview BACnet (Building Automation and Control networks) is an open protocol for building automation, standardized as ANSI/ASHRAE 135 and ISO 16484-5. It enables interoperability between devices from different manufacturers in HVAC, lighting, fire detection, access control, and energy management. | Concept | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Object model** | Every data point is a typed object (Analog Input, Binary Value, etc.) identified by instance number, accessed by properties (Present Value, High Limit, Event State, …) | | **Service model** | Clients read and write properties using BACnet services (ReadProperty, WriteProperty, SubscribeCOV, AcknowledgeAlarm) | | **Device discovery** | Who-Is/I-Am broadcast locates devices by Device Instance without knowing the IP address | | **Intrinsic alarming** | Controllers generate Event-Notifications when values cross configured limits — alarm logic lives in the device | ### BACnet variants | Variant | Transport | Port / Layer | Notes | | ------------------------ | ------------------ | -------------- | ---------------------------- | | **BACnet/IP** | UDP/IP | 47808 (0xBAC0) | Most common; fully supported | | **BACnet MSTP** | Serial RS-485 | N/A | Not supported | | **BACnet over Ethernet** | Ethernet (Annex H) | — | Not supported | | **BACnet/SC** | TLS/WebSocket | — | Not currently implemented | Coreflux supports **BACnet/IP** (UDP). MS/TP parameters are parsed but the transport is not validated. Use BACnet/IP for all production deployments. ### Supported controllers and platforms | Category | Examples | | ------------------------- | ---------------------------------------------------------------------- | | **HVAC controllers** | Siemens Desigo, Johnson Controls Metasys, Honeywell WEBs, Carrier i-Vu | | **DDC / field panels** | Distech Controls ECB, Alerton Ascent, Delta Controls ORCAview | | **Meters and sensors** | Veris BMeter, Onset HOBO, Accuenergy Acuvim | | **Gateways** | Contemporary Controls BASRT-B, Babel Buster (Modbus→BACnet) | | **Simulators** | IOTech BACnet device simulator (Docker); VTS (Visual Test Shell) | | **Integration platforms** | Niagara (Tridium), EBO (Schneider) | *** ## Configuration Only `DEVICE_ID` is strictly required. For a working route, also set `IP` and add at least one `ADD MAPPING` with tags. ```lot wrap focus={2-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD MAPPING RoomSensors WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 5 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" ``` ### All configuration options ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH PORT 47808 WITH LOCAL_PORT 0 WITH DEVICE_ID 1234 WITH TIMEOUT 3 WITH DISCOVERY_TIMEOUT 5 WITH DIRECT_IP false WITH WRITE_PRIORITY 8 WITH RETRIES 3 WITH MAX_APDU 1476 WITH COV_LIFETIME 300 WITH COV_CONFIRMED false WITH LOCAL_DEVICE_ID 4194300 WITH ALARMS false WITH ALARMS_TOPIC "bacnet/bms/alarms" ``` ### Configuration parameters | Setting | Required | Default | Description | | ------------------------- | ----------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `DEVICE_ID` | **Yes** | `1` | Target BACnet device instance (Who-Is/I-Am and ReadProperty) | | `IP` / `HOST` / `ADDRESS` | Conditional | `192.168.1.1` | Remote device IPv4 or hostname. **Required** when `WITH DIRECT_IP true`; strongly recommended for Docker and routed networks | | `PORT` | No | `47808` | Remote device BACnet/IP UDP listen port | | `LOCAL_PORT` | No | `0` | Local UDP port the broker binds on this host (`0` = OS ephemeral) | | `TRANSPORT` | No | `IP` | `IP` or `MSTP` (MS/TP not validated — use BACnet/IP) | | `TIMEOUT` | No | `3` | Read/write timeout in seconds | | `DISCOVERY_TIMEOUT` | No | `5` | Who-Is/I-Am wait in seconds | | `DIRECT_IP` | No | `false` | Skip discovery; connect to `IP:PORT` and verify with ReadProperty | | `WRITE_PRIORITY` | No | `8` | BACnet command priority (1 = highest, 16 = lowest) | | `RETRIES` | No | `3` | Retry count on failure | | `MAX_APDU` | No | `1476` | Maximum BACnet/IP frame size in bytes; this is **not** a string-size setting | | `COV_LIFETIME` | No | `300` | Default COV subscription lifetime in seconds (`0` = infinite) | | `COV_CONFIRMED` | No | `false` | Request confirmed COV notifications by default | | `LOCAL_DEVICE_ID` | Conditional | `0` | Local device instance for directed I-Am. **Required** when `WITH ALARMS true` or any TAG has `WITH ALARM true` | | `ALARMS` | No | `false` | Publish all device Event-Notifications to `ALARMS_TOPIC` | | `ALARMS_TOPIC` | No | `bacnet/{routeName}/alarms` | MQTT topic for device-wide alarms when `WITH ALARMS true` | | `ALARM_TOPIC` | No | — | Alias for `ALARMS_TOPIC` on `BACNET_CONFIG` | ### Network setup: IP, PORT, and LOCAL\_PORT BACnet/IP is UDP. Three settings control two different endpoints — the BACnet device (remote) and the Coreflux broker (local). | Setting | Side | What it is | Typical value | | ------------ | ------ | ----------------------------------------------------- | --------------------------------------- | | `IP` | Remote | IPv4 address or hostname of the BACnet controller | `192.168.1.10`, `coreflux-route-bacnet` | | `PORT` | Remote | UDP port where the device accepts BACnet/IP traffic | `47808` | | `LOCAL_PORT` | Local | UDP port the broker binds on before sending/receiving | `0` (OS-assigned) | | Scenario | `IP` | `PORT` | `LOCAL_PORT` | | ------------------------------------------------ | ----------------------------------------------- | ------- | ------------ | | Sandbox simulator on the same host | `127.0.0.1` | `47808` | `0` | | Simulator in Docker, broker in another container | `coreflux-route-bacnet` (docker container name) | `47808` | `0` | | Device on non-standard port | `10.0.0.5` | `47809` | `0` | | Firewall requires fixed source port | `10.0.0.5` | `47808` | `47808` | `WITH IP` accepts hostnames and Docker service names (for example `coreflux-route-bacnet`, `host.docker.internal`). The broker resolves them to IPv4 before opening the BACnet socket. ### Direct IP mode Use when the controller does not respond to Who-Is/I-Am but accepts ReadProperty at a known UDP endpoint: ```lot wrap focus={6} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "10.0.0.50" WITH PORT 47808 WITH DEVICE_ID 1234 WITH DIRECT_IP true ``` ### Write priority Present-value writes use BACnet priority slots (1 = highest, 16 = lowest). If a device holds a value at a higher priority, a write at a lower priority will not override the active present value. The IOTech sandbox profile seeds `AO:0` at priority **1** — use `WITH WRITE_PRIORITY 1` for that test device. If a write appears to succeed but the present value does not change, the device may be holding the value at a higher priority. Try `WITH WRITE_PRIORITY 1` and check whether a BMS already owns the point. *** ## TAG System ### Mapping parameters ```lot wrap focus={1,2,3,4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING RoomSensors WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 5 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" WITH WRITABLE false WITH COV false WITH ALARM false ``` | Parameter | Required | Default | Description | | -------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | | `SOURCE_TOPIC` | **Yes**\* | — | Base MQTT topic; each tag publishes to `{SOURCE_TOPIC}/{tagName}` unless the tag sets its own | | `EVERY` | Conditional | — | Polling interval (e.g. `1 SECONDS`, `200 MILLISECONDS`). Required when the mapping has polled tags; omit for COV-only mappings | | `ADD TAG` | **Yes** | — | At least one tag per mapping | \* You may set `SOURCE_TOPIC` on each tag instead of on the mapping, but one of the two is required. ### Tag parameters ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" WITH WRITABLE false WITH COV false WITH ALARM false ``` | Parameter | Required | Default | Description | | ------------------------- | ----------- | ---------------------------------- | -------------------------------------------------------------------------------- | | `ADDRESS` | **Yes** | — | BACnet object `TYPE:INSTANCE` (e.g. `AI:0`, `AV:3`, `BI:0`) | | `DATA_TYPE` | **Yes** | — | `BOOL`, `INT16`, `UINT16`, `INT32`, `UINT32`, `FLOAT`, `DOUBLE`, `STRING` | | `SOURCE_TOPIC` | Conditional | `{mapping SOURCE_TOPIC}/{tagName}` | MQTT topic for tag data. Required on the tag **or** on the parent mapping | | `DESTINATION_TOPIC` | Conditional | — | MQTT topic to subscribe for writes. Required when `WITH WRITABLE true` | | `ADDRESS_TYPE` | No | `PRESENT_VALUE` | BACnet property to read/write (e.g. `OBJECT_NAME`, `HIGH_LIMIT`) | | `LENGTH` | No | `256` | Maximum string size in bytes when `DATA_TYPE` is `STRING`. Alias: `STRING_SIZE`. | | `WRITABLE` | No | `false` | Allow MQTT writes to this object | | `SCALING` | No | `1.0` | Multiply raw value | | `OFFSET` | No | `0.0` | Add after scaling | | `UNIT` | No | `""` | Engineering unit label (e.g. `degC`, `%RH`) | | `DECIMAL_PLACES` | No | `2` | Rounding precision | | `DEADBAND` | No | `0.0` | Minimum change before republishing | | `MIN_VALUE` / `MAX_VALUE` | No | — | Clamp published values | | `PUBLISH_MODE` | No | `VALUE_ONLY` | `VALUE_ONLY` or `JSON` | | `DESCRIPTION` | No | `""` | Documentation string only | | `COV` | No | `false` | Subscribe to BACnet Change-of-Value instead of polling | | `COV_LIFETIME` | No | route `COV_LIFETIME` | Per-tag COV subscription lifetime in seconds | | `COV_CONFIRMED` | No | route `COV_CONFIRMED` | Request confirmed COV for this tag | | `ALARM` | No | `false` | Enable MQTT alarm stream on `{tag_topic}/alarms` | | `ALARM_TOPIC` | No | `{tag_topic}/alarms` | Override alarm MQTT topic (only when `WITH ALARM true`) | ### Object address format | Shorthand | Full name | Example | | --------- | ------------------ | ------- | | `AI` | `ANALOG_INPUT` | `AI:0` | | `AO` | `ANALOG_OUTPUT` | `AO:1` | | `AV` | `ANALOG_VALUE` | `AV:0` | | `BI` | `BINARY_INPUT` | `BI:0` | | `BO` | `BINARY_OUTPUT` | `BO:0` | | `BV` | `BINARY_VALUE` | `BV:0` | | `MSV` | `MULTISTATE_VALUE` | `MSV:0` | `ADDRESS_TYPE` is the **BACnet property** (e.g. `PRESENT_VALUE`, `OBJECT_NAME`), not the object type. ### String sizing For `STRING` tags, `WITH LENGTH` is the maximum string size in bytes and defaults to **256**. `STRING_SIZE` is accepted as an alias for the same field. Route-level `MAX_APDU` limits the BACnet/IP **frame** size — it does not size strings. Read an object's name property as a STRING. `WITH LENGTH 64` overrides the 256-byte default to match a shorter BACnet property allocation: ```lot wrap focus={12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD MAPPING DeviceInfo WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 30 SECONDS ADD TAG RoomName WITH ADDRESS "AI:0" WITH ADDRESS_TYPE "OBJECT_NAME" WITH DATA_TYPE "STRING" WITH LENGTH 64 WITH SOURCE_TOPIC "bacnet/bms/RoomName" ``` ### Change-of-Value (COV) Tags with `WITH COV true` subscribe to BACnet Change-of-Value notifications instead of polling. A mapping with only COV tags does not need `WITH EVERY`. Mixed mappings poll non-COV tags on `EVERY` and receive COV tags via push. COV requires support on the device side. If you receive no change notifications, confirm the controller supports COV and that `WITH COV true` is set on the tag. *** ## Alarms and Event Notifications The broker receives BACnet **Event-Notification** messages from the field device and publishes them to MQTT. Alarm limits and Notification Class membership are configured on the controller — the broker only needs the device to send events to its `LOCAL_DEVICE_ID`. ### Alarm message format Every time the BACnet device reports an alarm or state change, the broker publishes **one JSON object** to the alarm topic. Subscribe to that topic to see events as they happen, or subscribe to `{alarm_topic}/active` for a retained snapshot of alarms that are still open. **Example** — a room temperature crosses its high limit (`bacnet/bms/RoomTemp/alarms`): ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "route_name": "BMS", "device_id": 1234, "address": "AI:0", "tag_name": "RoomTemp", "tag_topic": "bacnet/bms/RoomTemp", "alarm_topic": "bacnet/bms/RoomTemp/alarms", "object_type": "ANALOG_INPUT", "instance": 0, "event_type": "EVENT_OUT_OF_RANGE", "from_state_short": "NORMAL", "to_state_short": "HIGH_LIMIT", "fundamental_state": "OFFNORMAL", "transition": "TO_OFFNORMAL", "event_state_to_ack": "HIGH_LIMIT", "ack_required": true, "present_value": 28.5, "bacnet_timestamp": "2026-06-10T15:28:21.000Z", "timestamp": "2026-06-10T16:28:21.003Z" } ``` **Fields to know:** | Field | What it tells you | | ------------------------------------- | ----------------------------------------------------------------------------------------- | | `address` | Which BACnet point raised the alarm (e.g. `AI:0` = Analog Input 0) | | `tag_name` / `tag_topic` | The route TAG and MQTT topic for live values on that point | | `from_state_short` → `to_state_short` | State change just reported (e.g. `NORMAL` → `HIGH_LIMIT`) | | `transition` | Direction of the change: `TO_OFFNORMAL` (new alarm), `TO_NORMAL` (cleared), or `TO_FAULT` | | `fundamental_state` | Overall condition: `OFFNORMAL` (limit alarm), `FAULT`, or `NORMAL` | | `present_value` | Value on the device when the event fired (when provided) | | `ack_required` | If `true`, an operator must acknowledge the alarm on the device | | `event_state_to_ack` | State to send when acknowledging — use this in ACK payloads | | `bacnet_timestamp` | When the device recorded the event — include when acknowledging | **Common `to_state_short` values:** | Value | Meaning | | ------------ | ------------------------------------------ | | `HIGH_LIMIT` | Value rose above the configured high limit | | `LOW_LIMIT` | Value fell below the configured low limit | | `FAULT` | Device reported a fault condition | | `NORMAL` | Point returned to normal (alarm cleared) | ### Setting Alarms on BACnet ROUTE | Mode | Configuration | Default MQTT topic | Coverage | | --------------- | ------------------------------------- | --------------------------------------------------- | ---------------------------------------- | | **Device-wide** | `WITH ALARMS true` on `BACNET_CONFIG` | `bacnet/{routeName}/alarms` (+ retained `…/active`) | Every Event-Notification from the device | | **Per-tag** | `WITH ALARM true` on TAG | `{tag_topic}/alarms` (+ retained `…/active`) | Only events matching the TAG's `ADDRESS` | Both modes can coexist. When both are enabled for the same object, the event is published to both topics. Publish every Event-Notification from the device to a single MQTT topic — useful for commissioning and catch-all monitoring: ```lot wrap focus={6-7} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 WITH LOCAL_DEVICE_ID 4194300 WITH ALARMS true WITH ALARMS_TOPIC "bacnet/bms/alarms" ADD MAPPING CovPoints WITH SOURCE_TOPIC "bacnet/bms" ADD TAG CovCounter WITH ADDRESS "AV:0" WITH DATA_TYPE "FLOAT" WITH COV true ``` Alarm events appear on `bacnet/bms/alarms`. Operator ACK via `bacnet/bms/alarms/ack`. Each alarming point gets its own MQTT alarm stream and ACK topic: ```lot wrap focus={12,17} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 WITH LOCAL_DEVICE_ID 4194300 ADD MAPPING LimitPoints WITH SOURCE_TOPIC "bacnet/bms" ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH COV true WITH ALARM true ADD TAG ChillerPressure WITH ADDRESS "AI:2" WITH DATA_TYPE "FLOAT" WITH EVERY 5 SECONDS WITH ALARM true ``` | Data topic | Alarm topic | ACK topic | | ---------------------------- | ----------------------------------- | --------------------------------------- | | `bacnet/bms/RoomTemp` | `bacnet/bms/RoomTemp/alarms` | `bacnet/bms/RoomTemp/alarms/ack` | | `bacnet/bms/ChillerPressure` | `bacnet/bms/ChillerPressure/alarms` | `bacnet/bms/ChillerPressure/alarms/ack` | On connect, when alarms are enabled and `LOCAL_DEVICE_ID` is set, the broker sends a **directed I-Am** so Notification Class recipients learn the broker's UDP endpoint. Set a non-zero `WITH LOCAL_DEVICE_ID` before enabling alarms. Without it, Event-Notifications are unlikely to arrive because the field device does not know where to send them. ### Active alarm list In addition to live events on `{alarm_topic}`, the broker maintains a **retained** snapshot on `{alarm_topic}/active`. Subscribe here to build an alarm panel — late subscribers receive the current state immediately without waiting for the next transition. | Topic | Retain | Use for | | ---------------------- | ------- | -------------------------------------------------------- | | `{alarm_topic}` | No | Live transitions as they happen (logging, notifications) | | `{alarm_topic}/active` | **Yes** | Current open alarms (dashboards, alarm panels) | **How the list updates:** | Event | Effect on `/active` | | ------------------------------------- | ---------------------------------------------------------- | | `TO_OFFNORMAL` or `TO_FAULT` | Add or update the entry for that BACnet `address` | | `TO_NORMAL` | Remove the entry for that `address` | | Successful ACK on `{alarm_topic}/ack` | Remove the entry matching `address` + `event_state_to_ack` | Per-tag lists usually hold **0 or 1** entry (one BACnet object per tag). Device-wide lists hold **one entry per alarming object** on the device. **Example** — device-wide active list on `bacnet/bms/alarms/active`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "route_name": "BMS", "device_id": 1234, "alarm_topic": "bacnet/bms/alarms", "active_topic": "bacnet/bms/alarms/active", "updated_at": "2026-06-16T12:00:00.000Z", "count": 2, "alarms": [ { "address": "AI:0", "tag_name": "RoomTemp", "to_state_short": "HIGH_LIMIT", "fundamental_state": "OFFNORMAL", "transition": "TO_OFFNORMAL", "event_state_to_ack": "HIGH_LIMIT", "ack_required": true, "present_value": 28.5, "bacnet_timestamp": "2026-06-10T15:28:21.000Z" }, { "address": "AI:2", "tag_name": "ChillerPressure", "to_state_short": "HIGH_LIMIT", "fundamental_state": "OFFNORMAL", "transition": "TO_OFFNORMAL", "event_state_to_ack": "HIGH_LIMIT", "ack_required": false, "present_value": 95.2, "bacnet_timestamp": "2026-06-10T15:30:05.000Z" } ] } ``` Each object in the `alarms` array uses the same fields as a single live alarm event. When no alarms are active, `count` is `0` and `alarms` is `[]` — the broker still publishes valid retained JSON. ### Acknowledging alarms When a BACnet device raises an alarm, the broker publishes it to MQTT. To clear or acknowledge it on the device, an operator publishes to the **ACK topic**. The broker sends `AcknowledgeAlarm` to the controller and replies with a result. **How it works:** 1. **Subscribe** to the alarm topic (live events) or `{alarm_topic}/active` (current alarm list). 2. **Publish** your acknowledgement to `{alarm_topic}/ack`. 3. **Check** `{alarm_topic}/ack/result` for `success: true` or `false`. No additional configuration is needed for ACK — the broker registers the handler automatically when you enable device-wide alarms or per-tag `WITH ALARM true`. | Topic | What you see | | -------------------------- | --------------------------------------------- | | `{alarm_topic}` | Live alarm events as they happen | | `{alarm_topic}/active` | Retained list of alarms that are still active | | `{alarm_topic}/ack` | **Publish here** to acknowledge | | `{alarm_topic}/ack/result` | Success or failure of your acknowledgement | Copy the alarm JSON you received, add an `ack_text` note, and publish it to the ACK topic. This is the safest approach because you keep all fields the device expects (`event_state_to_ack`, `bacnet_timestamp`, and so on). **1. An alarm arrives** on `bacnet/bms/RoomTemp/alarms`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "address": "AI:0", "tag_name": "RoomTemp", "to_state_short": "HIGH_LIMIT", "event_state_to_ack": "HIGH_LIMIT", "ack_required": true, "present_value": 28.5, "bacnet_timestamp": "2026-06-10T15:28:21.000Z" } ``` **2. Publish to** `bacnet/bms/RoomTemp/alarms/ack` — same JSON, plus your note: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "address": "AI:0", "tag_name": "RoomTemp", "to_state_short": "HIGH_LIMIT", "event_state_to_ack": "HIGH_LIMIT", "ack_required": true, "present_value": 28.5, "bacnet_timestamp": "2026-06-10T15:28:21.000Z", "ack_text": "Checked by operator — HVAC team notified" } ``` Use any MQTT client (such as MQTT Explorer) to publish the payload above to the ACK topic. **3. Result on** `bacnet/bms/RoomTemp/alarms/ack/result`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": true, "route_name": "BMS", "operation": "ACK", "address": "AI:0", "event_state": "HIGH_LIMIT", "timestamp": "2026-06-10T16:30:00.000Z" } ``` If `success` is `true`, the device accepted the acknowledgement. The alarm is removed from `{alarm_topic}/active`. For scripts or integrations that do not store the full alarm JSON, publish a short text command to the ACK topic. **Publish to** `bacnet/bms/RoomTemp/alarms/ack`: ```text wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ACK:AI:0:HIGH_LIMIT:Checked by operator ``` Format: `ACK:OBJECT:INSTANCE:STATE:optional note` | Part | Example | Meaning | | -------- | --------------------- | --------------------------------- | | Object | `AI` | Analog Input | | Instance | `0` | Object instance number | | State | `HIGH_LIMIT` | Active alarm state to acknowledge | | Note | `Checked by operator` | Optional operator comment | Use the **state name** from the alarm (`HIGH_LIMIT`, `LOW_LIMIT`, `FAULT`, or `NORMAL`) — not labels like `OFFNORMAL`. **Result on** `bacnet/bms/RoomTemp/alarms/ack/result`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": true, "route_name": "BMS", "operation": "ACK", "address": "AI:0", "event_state": "HIGH_LIMIT", "timestamp": "2026-06-10T16:30:00.000Z" } ``` If `success` is `false`, the state or timestamp may not match what the device expects. Prefer the recommended method when possible — echo the alarm JSON and add `ack_text`. Before expecting MQTT alarms, confirm on the BMS or engineering tool: 1. **Notification Class** — recipient list includes `LOCAL_DEVICE_ID` 2. **Intrinsic alarming** on each object — `NOTIFICATION_CLASS`, `HIGH_LIMIT` / `LOW_LIMIT`, `EVENT_ENABLE`, `LIMIT_ENABLE` 3. **Present value** actually crosses a configured limit COV working does **not** guarantee alarms — they use separate BACnet services. *** ## On-Demand Events Use `ADD EVENT` when you need a single read, write, or alarm acknowledgement triggered by an MQTT publish — instead of continuous TAG polling. | Parameter | Required | Description | | ------------------- | -------- | -------------------------------------------------------- | | `SOURCE_TOPIC` | **Yes** | MQTT topic that triggers the BACnet operation | | `DESTINATION_TOPIC` | **Yes** | MQTT topic where the JSON result is published | | `QUERY` | **Yes** | BACnet operation: `READ:…`, `WRITE:…`, `ACK:…`, or JSON | | `EVERY` | No | Not supported on BACnet events — use TAG polling instead | ### Query formats | Style | Example | Notes | | --------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Colon-separated read | `READ:AI:0:PRESENT_VALUE` | Shorthand or full type name | | Colon-separated write | `WRITE:AV:0:PRESENT_VALUE:72.5` | Value is everything after the property | | JSON | `{"operation":"READ","object_type":"AI","instance":"0","property":"PRESENT_VALUE"}` | Also supports `"operation":"WRITE"` with `"value"` | | Topic placeholder | `READ:{topic[0]}:0:PRESENT_VALUE` | `{topic[0]}` replaced from the matched MQTT topic segment | | Dynamic payload | `{payload}` | Use the MQTT message body as the query (e.g. `WRITE:AV:0:PRESENT_VALUE:21.5`) | Supported operations: **READ**, **WRITE**, and **ACK** (alarm acknowledgement). ```lot wrap focus={5-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD EVENT ReadProperty WITH SOURCE_TOPIC "bacnet/cmd/read" WITH DESTINATION_TOPIC "bacnet/cmd/read/result" WITH QUERY "READ:AI:0:PRESENT_VALUE" ``` Publish any message to `bacnet/cmd/read` and the result appears on `bacnet/cmd/read/result`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": true, "route_name": "BMS", "operation": "READ", "address": "AI:0", "property": "PRESENT_VALUE", "value": 22.5, "timestamp": "2026-05-31T10:00:00.000Z" } ``` The route must be connected before events run. If an event returns `"success": false`, check that the device is reachable and that the object and property in the query are correct. ### When to use what | Need | Recommended approach | | ---------------------------- | -------------------------------------------------------------------------- | | Periodic telemetry | `ADD MAPPING` + `ADD TAG` + `WITH EVERY` | | Write from MQTT | `WITH WRITABLE true` + `WITH DESTINATION_TOPIC` on a tag | | Ad-hoc read/write on command | `ADD EVENT` + `WITH QUERY` | | Alarm acknowledgement | Auto-registered handler on `{tag_topic}/alarms/ack`, or custom `ADD EVENT` | *** ## Examples Reads two analog inputs and one analog value every five seconds: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD MAPPING RoomSensors WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 5 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" ADD TAG Humidity WITH ADDRESS "AI:1" WITH DATA_TYPE "FLOAT" WITH UNIT "%RH" ADD TAG Setpoint WITH ADDRESS "AV:0" WITH DATA_TYPE "FLOAT" WITH UNIT "degC" ``` Publishes to `bacnet/bms/RoomTemp` → `22.5`, `bacnet/bms/Humidity` → `55.0`, `bacnet/bms/Setpoint` → `21.0`. Subscribe to Change-of-Value for fast-changing points — no polling interval needed: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 WITH COV_LIFETIME 600 ADD MAPPING CovPoints WITH SOURCE_TOPIC "bacnet/bms/cov" ADD TAG RoomTempCov WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" WITH COV true ADD TAG OccupancyStatus WITH ADDRESS "BI:0" WITH DATA_TYPE "BOOL" WITH COV true ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 WITH LOCAL_DEVICE_ID 4194300 WITH ALARMS true WITH ALARMS_TOPIC "bacnet/bms/alarms" ADD MAPPING CovPoints WITH SOURCE_TOPIC "bacnet/bms" ADD TAG CovCounter WITH ADDRESS "AV:0" WITH DATA_TYPE "FLOAT" WITH COV true ``` Alarm events appear on `bacnet/bms/alarms`. Operator ACK via `bacnet/bms/alarms/ack`. Connects to the IOTech BACnet Docker simulator when the broker runs in another container: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Sandbox WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "coreflux-route-bacnet" WITH PORT 47808 WITH DEVICE_ID 1234 WITH DIRECT_IP true ADD MAPPING TestPoints WITH SOURCE_TOPIC "bacnet/sandbox" WITH EVERY 2 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" ADD TAG AnalogOut WITH ADDRESS "AO:0" WITH DATA_TYPE "FLOAT" WITH WRITABLE true WITH DESTINATION_TOPIC "bacnet/sandbox/AnalogOut/set" ``` *** ## Combining with Models and Actions A BACnet route publishes each point to its own MQTT topic, so the rest of LoT can use that data like any other topic. First, the route publishes each point under `bacnet/bms`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BMS WITH TYPE BACNET ADD BACNET_CONFIG WITH IP "192.168.1.10" WITH DEVICE_ID 1234 ADD MAPPING RoomSensors WITH SOURCE_TOPIC "bacnet/bms" WITH EVERY 5 SECONDS ADD TAG RoomTemp WITH ADDRESS "AI:0" WITH DATA_TYPE "FLOAT" ADD TAG Humidity WITH ADDRESS "AI:1" WITH DATA_TYPE "FLOAT" ADD TAG Setpoint WITH ADDRESS "AV:0" WITH DATA_TYPE "FLOAT" ``` Then the model aggregates those topics into one object on `bacnet/bms/Info`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BacnetRoomSensors COLLAPSED WITH TOPIC "bacnet/bms/Info" ADD DOUBLE "RoomTemp" WITH TOPIC "bacnet/bms/RoomTemp" AS TRIGGER ADD DOUBLE "Humidity" WITH TOPIC "bacnet/bms/Humidity" ADD DOUBLE "Setpoint" WITH TOPIC "bacnet/bms/Setpoint" ADD STRING "TimeStamp" WITH TIMESTAMP "ISO" ``` An action can watch per-tag alarm topics and forward events: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION BmsAlarmMonitor ON TOPIC "bacnet/bms/+/alarms" DO PUBLISH TOPIC "site/alarm/log" WITH PAYLOAD PUBLISH TOPIC "site/alarm/last" WITH PAYLOAD ``` *** ## Best Practices The default `192.168.1.1` is rarely correct. On Docker or routed networks, broadcast Who-Is may never reach the device. Set `WITH IP` to the controller's address or hostname. If a device does not answer Who-Is/I-Am, set `WITH DIRECT_IP true` to connect to `IP:PORT` and verify with ReadProperty. Otherwise the route waits on discovery timeout every connect. Polling at one-second intervals wastes bandwidth on BACnet/IP. Use `WITH COV true` for occupancy sensors, fast process variables, and alarm-related points. The broker must announce itself as a BACnet device via directed I-Am so the field device knows where to send Event-Notifications. If a BMS holds a point at priority 1, a write at priority 8 will not change the present value. Match priority to your application and verify on the device. COV-only mappings do not need a polling interval. Adding `WITH EVERY` on a COV-only mapping adds confusion without benefit. Per-tag alarms (`WITH ALARM true`) give each monitoring point its own ACK topic. Use device-wide (`WITH ALARMS true`) as a catch-all or for commissioning. Include `event_state_to_ack` and `bacnet_timestamp` from the received alarm message. Wrong state or missing timestamp causes ACK to fail. *** ## Troubleshooting ### Connection check Publish `-checkRouteConnection BMS` to `$SYS/Coreflux/Command`. The broker returns a JSON status for the named route indicating whether the BACnet device was reached. | Symptom | Check | Solution | | ---------------------------------- | -------------------------------- | ------------------------------------------------------------------ | | Route never connects | `IP` and `DEVICE_ID` correct? | Verify device IP; use Wireshark to confirm Who-Is packet sent | | Device not discovered | Device answers broadcast Who-Is? | Set `WITH DIRECT_IP true` if the device does not respond to Who-Is | | Docker: route cannot reach device | `WITH IP "127.0.0.1"` in Docker? | Use the container hostname (e.g. `coreflux-route-bacnet`) | | Discovery timeout on every connect | `DISCOVERY_TIMEOUT` too low? | Increase to 10+ seconds for slow devices | | Symptom | Check | Solution | | ------------------------------ | ------------------------- | ----------------------------------------------------------------- | | No MQTT values | `DEVICE_ID` match device? | Sandbox default is `1234` — confirm with device profile | | Wrong object value | Instance number correct? | Verify object index in device BACnet browser or profile | | Write has no effect | Priority conflict? | Try `WITH WRITE_PRIORITY 1`; check if BMS holds a higher priority | | COV notifications not arriving | Device supports COV? | Check device profile; simulator may require `--cov` flag | | Symptom | Check | Solution | | ---------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- | | No MQTT alarms (COV works) | `LOCAL_DEVICE_ID` set? | Add non-zero `LOCAL_DEVICE_ID`; verify device Notification Class recipient | | Alarms on wrong topic | `SOURCE_TOPIC` or `ALARM_TOPIC` misconfigured? | Default is `{SOURCE_TOPIC}/{tagName}/alarms` | | ACK returns `success: false` | Wrong `event_state_to_ack`? | Echo the received alarm JSON with `ack_text` field added | | Events only after reconnect | Directed I-Am not sent? | Ensure `LOCAL_DEVICE_ID` is set and alarms are enabled before first connect | | Symptom | Solution | | -------------------------------- | ---------------------------------------------------------------------------------------------- | | `PORT` vs `LOCAL_PORT` confusion | `PORT` is the device's listen port (47808). `LOCAL_PORT` is the broker's bind port (leave `0`) | | Event returns `success: false` | Route not connected, or wrong object/property in `QUERY`; check broker logs | | `LOCAL_DEVICE_ID is 0` warning | Set a non-zero device instance unique on the BACnet network | ### Additional resources * [ASHRAE 135 Standard](https://www.ashrae.org/technical-resources/bookstore/bacnet) — official BACnet standard * [BACnet International](https://www.bacnetinternational.org/) — conformance testing and product listings *** ## Next Steps Learn the patterns shared across all OT routes. Connect to cross-platform industrial servers with OPC UA. # EtherNet/IP Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/ethernetip Connect to CIP-compatible devices over EtherNet/IP with TAG polling, bit extraction, ARRAY/STRUCT decoding, and bidirectional writes. ## Connect to CIP Devices over EtherNet/IP The `ETHERNETIP` route connects Coreflux to CIP-compatible devices using EtherNet/IP **explicit messaging** (TCP port 44818). Define an `ETHERNETIP_CONFIG` block, add TAGs with CIP addresses, and publish each data point to its own MQTT topic — with optional scaling, bit extraction, joined attribute reads, and bidirectional writes. **Like reading labeled drawers in a filing cabinet.** Each CIP address (`Class.Instance.Attribute.Index`) points to a specific property on the device. The route opens the drawer, reads the value, and places it on an MQTT topic — on a schedule or when you send a command. ### When to use this Use the EtherNet/IP route when you need explicit messaging to CIP objects on Allen-Bradley PLCs, remote I/O modules, VFDs, or other ODVA-compatible devices. Addresses use the numeric CIP path format `ClassID.InstanceID.AttributeID.AttributeIndex`. For **symbolic tag names** (for example `Program:MainProgram.Temperature`), use the dedicated [Allen-Bradley route](./allen-bradley) instead. The EtherNet/IP route reads and writes via CIP class/instance/attribute paths. ### Key capabilities * **Explicit messaging** — direct read/write to CIP attributes over TCP (port 44818) * **TAG-based polling** — per-point MQTT topics with configurable intervals * **Bit extraction** — `BIT_ADDRESS` to read a single bit from an integer word * **Joined attribute reads** — combine bytes from two CIP attributes (legacy asset parity) * **ARRAY and STRUCT decoding** — packed assembly buffers published as JSON arrays or objects * **Bidirectional writes** — `WRITABLE` scalar tags with `DESTINATION_TOPIC` * **Engineering units** — `SCALING` / `OFFSET` / `DEADBAND` per tag *** ## Quick Start Read an analog input point every 500 ms and publish to MQTT: ```lot wrap focus={9-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AnalogIO WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 WITH POLLING_MS 500 ADD MAPPING AnalogInputs WITH EVERY 500 MILLISECONDS ADD TAG AnalogIn1 WITH ADDRESS "102.1.3.0" WITH DATA_TYPE "FLOAT32" WITH SOURCE_TOPIC "io/analog/in/1" ``` Poll an assembly output word and accept MQTT writes to update it: ```lot wrap focus={9-15} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AssemblyIO WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 ADD MAPPING Outputs WITH EVERY 500 MILLISECONDS ADD TAG OutputWord0 WITH ADDRESS "4.104.3.0" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "eip/output/word0" WITH WRITABLE true WITH DESTINATION_TOPIC "eip/output/word0/set" ``` Extract alarm bit 5 from a UINT16 status attribute, joining a second CIP attribute for legacy device parity: ```lot wrap focus={12-22} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Kaeser WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "10.16.7.99" WITH PORT 44818 WITH POLLING_MS 2000 ADD MAPPING KaeserData WITH EVERY 1000 MILLISECONDS ADD TAG C1_Alarm WITH ADDRESS "4.100.3.1" WITH DATA_TYPE "BOOL" WITH BIT_ADDRESS 5 WITH LENGTH 1 WITH ENABLE_JOIN_ATTRIBUTE "true" WITH JOIN_CLASS_ID 120 WITH JOIN_INSTANCE_ID 3711 WITH JOIN_ATTRIBUTE_ID 1 WITH JOIN_INDEX 0 WITH JOIN_LENGTH 1 WITH SOURCE_TOPIC "Air/Kaeser/Compressor/C1/Alerts/Alarm/Status" ``` Decode a packed INT32 array from an assembly buffer: ```lot wrap focus={9-14} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AssemblyArray WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 ADD MAPPING PackedData WITH EVERY 1 SECOND ADD TAG CounterArray WITH ADDRESS "4.104.1.0" WITH DATA_TYPE "ARRAY" WITH ARRAY_ELEMENT_TYPE "INT32" WITH ARRAY_SIZE 5 WITH SOURCE_TOPIC "eip/assembly/counters" ``` *** ## Connection Configuration ### ETHERNETIP\_CONFIG Parameters | Parameter | Required | Default | Description | | -------------------- | -------- | ------- | -------------------------------------------- | | `IP` | Yes | — | Device IP address | | `PORT` | No | `44818` | EtherNet/IP explicit messaging port (TCP) | | `POLLING_MS` | No | `750` | Default polling interval in milliseconds | | `CONNECTION_TIMEOUT` | No | `5000` | Connection timeout in milliseconds | | `READ_WRITE_TIMEOUT` | No | `3000` | Read/write operation timeout in milliseconds | | `RETRY_ATTEMPTS` | No | `3` | Retry count on failure (0–10) | | `RETRY_TIME_SECONDS` | No | `5` | Delay between retries in seconds | The config block may be declared as `ADD ETHERNETIP_CONFIG` or `ADD EIP_CONFIG` — both are equivalent. ### Connection Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 WITH POLLING_MS 500 WITH CONNECTION_TIMEOUT 5000 WITH READ_WRITE_TIMEOUT 3000 WITH RETRY_ATTEMPTS 3 WITH RETRY_TIME_SECONDS 5 ``` ### Implicit I/O Mode (Forward Open) **Not implemented in release 2.2.** The route parser accepts `IO_MODE "IMPLICIT"` and related assembly/RPI settings for forward compatibility, but the broker does **not** establish Forward Open / cyclic UDP connections yet. Use explicit CIP assembly attributes (`4.x.y.z` paths) for I/O adapter devices until implicit mode ships. Planned parameters (validated when present, not yet wired to the adapter): | Parameter | Default | Description | | ----------------- | ---------- | ---------------------------------------------------------------------- | | `IO_MODE` | `EXPLICIT` | `EXPLICIT` (implemented) or `IMPLICIT` (planned) | | `INPUT_ASSEMBLY` | `103` | T→O assembly instance (implicit mode) | | `OUTPUT_ASSEMBLY` | `104` | O→T assembly instance (implicit mode) | | `CONFIG_ASSEMBLY` | `1` | Configuration assembly for the connection path | | `INPUT_SIZE` | `20` | Input assembly size in bytes | | `OUTPUT_SIZE` | `12` | Output assembly size in bytes | | `RPI` | `100000` | Requested Packet Interval in **microseconds** (e.g. `100000` = 100 ms) | *** ## CIP Addressing EtherNet/IP uses CIP (Common Industrial Protocol) addressing. The adapter issues CIP `Get_Attribute_Single` / `Set_Attribute_Single` against numeric paths. **Required format:** `ClassID.InstanceID.AttributeID.AttributeIndex` — four numeric segments, dot-separated. | Component | Description | Example | | -------------- | ------------------------------------------------------------------------- | ------------------------------ | | ClassID | CIP object class | `4` (Assembly), `1` (Identity) | | InstanceID | Object instance | `100`, `1` | | AttributeID | Attribute number | `3` (Data on Assembly objects) | | AttributeIndex | **Byte offset** within the attribute buffer where this tag's slice starts | `0` | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Assembly attribute, full buffer from byte 0 WITH ADDRESS "4.100.3.0" # Slice starting at byte 2 inside attribute 3 (packed bits) WITH ADDRESS "4.110.1.2" # Writable INT32 at instance 100, attribute 4 WITH ADDRESS "4.100.4.0" ``` ### Common CIP Classes | Class ID | Name | Description | | -------- | --------------------- | -------------------------------------- | | 1 | Identity | Device identification | | 2 | Message Router | Message routing | | 4 | Assembly | I/O data assemblies | | 6 | Connection Manager | Connection handling | | 100 | Program Tags | Program-scoped tags (Allen-Bradley) | | 107 | Controller Tags | Controller-scoped tags (Allen-Bradley) | | 102 | Analog Input Point | Analog inputs | | 103 | Analog Output Point | Analog outputs | | 104 | Discrete Input Point | Digital inputs | | 105 | Discrete Output Point | Digital outputs | ### EtherNet/IP Communication Types | Type | Protocol | Port | Use case | | ------------------ | -------- | ----- | ------------------------------------------------- | | Explicit messaging | TCP | 44818 | CIP read/write — **used by this route** | | Implicit messaging | UDP | 2222 | Cyclic real-time I/O — **not implemented in 2.2** | *** ## Data Types | Data Type | Aliases | Size / format | | --------- | ----------------- | -------------------------------------------------------------- | | `BOOLEAN` | `BOOL` | 1 bit | | `BYTE` | `USINT` | 8 bits | | `SINT` | — | 8 bits (signed) | | `INT16` | `INT` | 16 bits | | `UINT16` | `UINT`, `WORD` | 16 bits | | `INT32` | `DINT` | 32 bits | | `UINT32` | `UDINT`, `DWORD` | 32 bits | | `INT64` | `LINT` | 64 bits | | `UINT64` | `ULINT`, `LWORD` | 64 bits | | `FLOAT32` | `REAL`, `FLOAT` | 32 bits | | `FLOAT64` | `LREAL`, `DOUBLE` | 64 bits — MQTT values round to `DECIMAL_PLACES` (default `2`) | | `CHAR` | — | Single ASCII character | | `ASCII` | — | Alias for single-byte ASCII (same as `CHAR` on read) | | `STRING` | — | CIP string: 2-byte `UINT16` length prefix + ASCII payload | | `TIME` | — | 32-bit signed duration in milliseconds | | `TOD` | `TIME_OF_DAY` | 32-bit ms since midnight → `hh:mm:ss.fff` | | `DATE` | — | 32-bit Unix-style date | | `WCHAR` | — | 16-bit wide character | | `ARRAY` | — | Packed elements — requires `ARRAY_ELEMENT_TYPE` + `ARRAY_SIZE` | | `STRUCT` | — | Packed fields — requires `LENGTH` + `STRUCT_FIELD_DEFINITIONS` | ### String sizing For `STRING` tags, `WITH LENGTH` sets the maximum string size in bytes. `STRING_SIZE` is accepted only as a parser alias for `STRING` sizing. *** ### Allen-Bradley Type Mapping | Allen-Bradley | Coreflux `DATA_TYPE` | | ------------- | -------------------- | | BOOL | `BOOL` | | SINT / USINT | `BYTE` | | INT | `INT16` | | DINT | `INT32` | | LINT | `INT64` | | UINT | `UINT16` | | UDINT | `UINT32` | | ULINT | `UINT64` | | REAL | `FLOAT32` | | LREAL | `FLOAT64` | | STRING | `STRING` | *** ## TAG Configuration Every polled data point is an `ADD TAG` inside an `ADD MAPPING` block. Tags without their own `SOURCE_TOPIC` inherit `{mapping SOURCE_TOPIC}/{tagName}` when the mapping sets a base topic. ### Complete TAG Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG ProcessTemperature WITH ADDRESS "102.1.3.0" WITH DATA_TYPE "FLOAT32" WITH SOURCE_TOPIC "eip/process/temperature" WITH SCALING_FACTOR 0.1 WITH OFFSET 0 WITH DEADBAND 0.5 WITH PUBLISH_MODE "JSON" WITH LENGTH 1 ``` ### TAG Parameters CIP path: `ClassID.InstanceID.AttributeID.AttributeIndex` CIP data type (see table above). Bytes to read from the primary CIP attribute starting at `AttributeIndex` (the fourth segment of `ADDRESS`). Default: `1`. For `STRING` tags, this is the maximum string size in bytes; `STRING_SIZE` is accepted only as a parser alias for that `STRING` sizing (not for join, `STRUCT`, or generic attribute-width uses). For numeric tags in join mode, `LENGTH + JOIN_LENGTH` must equal the byte width of `DATA_TYPE` (for example `1 + 1 = 2` for `INT16`). Extract a single bit (0-based) from the underlying integer word. Alias: `BIT`. Use with integer types (`UINT16`, `INT32`, etc.) or with `BOOL` when the device returns a packed word. For `BOOL` + `BIT_ADDRESS`, read width auto-widens: bits 0–7 read 1 byte, 8–15 read 2 bytes, 16–31 read 4 bytes, 32–63 read 8 bytes. When `"true"`, perform a second CIP `Get_Attribute_Single` against the join target and concatenate those bytes onto the primary read before parsing, scaling, or bit extraction. Default: `false`. CIP Class ID of the joined attribute. Required when `ENABLE_JOIN_ATTRIBUTE` is `true` (must be > 0). CIP Instance ID of the joined attribute. Required when join is enabled (must be >= 0; instance `0` is valid on some devices). CIP Attribute ID of the joined attribute. Required when join is enabled (must be > 0). Byte offset within the joined CIP response where the slice starts. Default: `0`. Bytes to take from the joined attribute starting at `JOIN_INDEX`. Required when join is enabled (must be > 0). **Not** related to `SCALING_FACTOR`. Multiply the parsed numeric value after byte parsing. Alias: `SCALING_FACTOR`. Default: `1.0`. **Unrelated to byte layout** — does not replace `JOIN_LENGTH`. Add after scaling. Default: `0.0`. Suppress MQTT publish when the change is below this threshold. Default: `0.0`. Decimal places for `FLOAT32` / `FLOAT64` MQTT payloads. Default: `2`. MQTT topic where polled values are published. `VALUE_ONLY` (default — publish just the value) or `JSON` (value, quality, and timestamp). Aliases: `VALUE`, `SIMPLE` for value-only; `FULL`, `METADATA` for JSON. When `true`, subscribe to `DESTINATION_TOPIC` and write incoming MQTT values to the device. MQTT topic to receive write commands. Required when `WRITABLE` is `true`. ### Publish Modes **VALUE\_ONLY** (default) — publishes just the scaled scalar value: ``` 75.5 ``` **JSON** — publishes a metadata wrapper when explicitly set: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "tag_name": "AnalogIn1", "value": 75.5, "quality": "GOOD", "timestamp": "2025-12-17T10:30:45.123Z" } ``` ### ARRAY and STRUCT Tags CIP assembly buffers often contain packed arrays or structured records. Use `DATA_TYPE "ARRAY"` or `DATA_TYPE "STRUCT"` to decode them into JSON on MQTT. #### ARRAY ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG ArrInt32 WITH ADDRESS "4.104.1.0" WITH DATA_TYPE "ARRAY" WITH ARRAY_ELEMENT_TYPE "INT32" WITH ARRAY_SIZE 5 WITH SOURCE_TOPIC "eip/complex/arr_int32" ``` | Parameter | Required | Description | | --------------------------- | -------- | ---------------------------------------------------------------- | | `ARRAY_ELEMENT_TYPE` | Yes | Element type (`INT32`, `FLOAT`, `BOOLEAN`, etc.) | | `ARRAY_SIZE` | Yes | Number of elements | | `ARRAY_ELEMENT_LENGTH` | No | Byte width per element (for `STRING` / nested `STRUCT` elements) | | `ARRAY_START_OFFSET` | No | Skip N bytes before the first element | | `ARRAY_ELEMENT_HEADER_SIZE` | No | Skip N bytes at the start of each element | **MQTT payload:** JSON array, e.g. `[10, 20, 30, 40, 50]`. #### STRUCT ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG StructTag WITH ADDRESS "4.105.1.0" WITH DATA_TYPE "STRUCT" WITH LENGTH 12 WITH STRUCT_FIELD_DEFINITIONS "temp:0:FLOAT:4,pressure:4:FLOAT:4,status:8:INT32:4" WITH SOURCE_TOPIC "eip/complex/struct" ``` Field spec format: `name:byte_offset:type:size` (comma-separated). Types follow the same aliases as scalar `DATA_TYPE`. **MQTT payload:** JSON object, e.g. `{"temp":25.5,"pressure":101.325,"status":1}`. ARRAY and STRUCT tags are **read-focused** in release 2.2. Writable scalar tags use normal CIP encoding; complex-type writes from JSON arrays/objects are not yet supported. ### Joined Attribute Reads Some legacy CIP devices split a logical value across two attributes (for example a status word plus an extension attribute). When `ENABLE_JOIN_ATTRIBUTE` is `"true"`, the broker performs two `Get_Attribute_Single` reads per cycle, concatenates the byte buffers (primary first, joined second), then parses, scales, and applies `BIT_ADDRESS`. If the join read fails, a warning is logged and the primary buffer is parsed alone — the polling loop continues. For numeric `DATA_TYPE` values in join mode, `LENGTH + JOIN_LENGTH` must equal the type byte width (`2` for 16-bit, `4` for 32-bit, `8` for 64-bit). `BOOL` tags with `BIT_ADDRESS` are exempt (Kaeser-style alarm bit pattern). `SCALING` / `SCALING_FACTOR` multiplies the parsed value — it does **not** replace `JOIN_LENGTH`. Using scaling to compensate for a missing join length produces silently wrong values. *** ## Migrating from a Legacy EtherNet/IP Asset If you are converting an EtherNet/IP **Asset** (Coreflux HUB Configure / Tags panel) into a Route, map each asset field to the LoT parameter below. | Asset field (HUB) | Route LoT parameter | Notes | | ------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------- | | Class Id / Instance Id / Attribute Id / Attribute Index | `WITH ADDRESS "C.I.A.Index"` | Four numeric segments separated by `.` | | Data Type | `WITH DATA_TYPE ""` | See aliases above | | Bit | `WITH BIT_ADDRESS ` (alias `WITH BIT `) | For boolean bits packed in an integer word | | Length | `WITH LENGTH ` | Bytes from the primary attribute | | Enable Join Attribute | `WITH ENABLE_JOIN_ATTRIBUTE "true"` | Quoted `"true"` / `"false"` | | Join Class / Instance / Attribute Id | `JOIN_CLASS_ID`, `JOIN_INSTANCE_ID`, `JOIN_ATTRIBUTE_ID` | | | Join Index | `WITH JOIN_INDEX ` | Byte offset in joined response | | Join Length | `WITH JOIN_LENGTH ` | Bytes from joined attribute — **not** `SCALING` | | Topic (Source) | `WITH SOURCE_TOPIC ""` | Where polled values publish | | Write Direction | `WITH WRITABLE "true"` + `WITH DESTINATION_TOPIC ""` | For MQTT-to-device writes | *** ## Examples Read identity object attributes on a slow interval: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DeviceIdentity WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 ADD MAPPING IdentityData WITH EVERY 30 SECONDS ADD TAG VendorID WITH ADDRESS "1.1.1.0" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "device/identity/vendor" ADD TAG SerialNumber WITH ADDRESS "1.1.6.0" WITH DATA_TYPE "UINT32" WITH SOURCE_TOPIC "device/identity/serial" ADD TAG ProductName WITH ADDRESS "1.1.7.0" WITH DATA_TYPE "STRING" WITH LENGTH 32 WITH SOURCE_TOPIC "device/identity/name" ``` Read a 4–20 mA value with engineering-unit scaling and deadband: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AnalogInputs WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.40" ADD MAPPING AnalogData WITH EVERY 500 MILLISECONDS ADD TAG TankLevel WITH ADDRESS "102.1.3.0" WITH DATA_TYPE "INT16" WITH SCALING_FACTOR 0.00625 WITH OFFSET -25.0 WITH DEADBAND 1.0 WITH SOURCE_TOPIC "tank/level" ``` Three boolean status bits from one INT16 word with joined attributes (legacy asset parity): ```lot wrap focus={12-28} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Kaeser WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "10.16.7.99" WITH PORT 44818 WITH POLLING_MS 2000 ADD MAPPING KaeserData WITH EVERY 1000 MILLISECONDS ADD TAG C1_On WITH ADDRESS "4.100.3.1" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "Air/Kaeser/Compressor/C1/On" ADD TAG C1_Alarm WITH ADDRESS "4.100.3.1" WITH DATA_TYPE "BOOL" WITH BIT 5 WITH LENGTH 1 WITH ENABLE_JOIN_ATTRIBUTE "true" WITH JOIN_CLASS_ID 120 WITH JOIN_INSTANCE_ID 3711 WITH JOIN_ATTRIBUTE_ID 1 WITH JOIN_INDEX 0 WITH JOIN_LENGTH 1 WITH SOURCE_TOPIC "Air/Kaeser/Compressor/C1/Alerts/Alarm/Status" ADD TAG C1_Alert WITH ADDRESS "4.100.3.1" WITH DATA_TYPE "BOOL" WITH BIT 6 WITH LENGTH 1 WITH ENABLE_JOIN_ATTRIBUTE "true" WITH JOIN_CLASS_ID 120 WITH JOIN_INSTANCE_ID 3711 WITH JOIN_ATTRIBUTE_ID 1 WITH JOIN_INDEX 0 WITH JOIN_LENGTH 1 WITH SOURCE_TOPIC "Air/Kaeser/Compressor/C1/Alerts/Alert/Status" ``` Fast critical data and slow diagnostics in one route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiRateEIP WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 ADD MAPPING FastData WITH EVERY 100 MILLISECONDS ADD TAG CriticalStatus WITH ADDRESS "104.1.3.0" WITH DATA_TYPE "BOOLEAN" WITH SOURCE_TOPIC "fast/status" ADD MAPPING SlowData WITH EVERY 5 SECONDS ADD TAG DeviceStatus WITH ADDRESS "1.1.5.0" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "slow/device_status" ``` Decode packed assembly data as JSON: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ComplexAssembly WITH TYPE ETHERNETIP ADD ETHERNETIP_CONFIG WITH IP "192.168.1.100" WITH PORT 44818 ADD MAPPING PackedData WITH EVERY 1 SECOND ADD TAG TempArray WITH ADDRESS "4.104.1.0" WITH DATA_TYPE "ARRAY" WITH ARRAY_ELEMENT_TYPE "INT32" WITH ARRAY_SIZE 5 WITH SOURCE_TOPIC "eip/array/counters" ADD TAG ProcessStruct WITH ADDRESS "4.105.1.0" WITH DATA_TYPE "STRUCT" WITH LENGTH 12 WITH STRUCT_FIELD_DEFINITIONS "temp:0:FLOAT:4,pressure:4:FLOAT:4,status:8:INT32:4" WITH SOURCE_TOPIC "eip/struct/process" ``` *** ## CIP Object Reference | Attribute | Description | Data Type | | --------- | ------------- | ---------- | | 1 | Vendor ID | UINT16 | | 2 | Device Type | UINT16 | | 3 | Product Code | UINT16 | | 4 | Revision | UINT16\[2] | | 5 | Status | UINT16 | | 6 | Serial Number | UINT32 | | 7 | Product Name | STRING | | Attribute | Description | Data Type | | --------- | --------------- | --------------- | | 3 | Value | FLOAT32 / INT16 | | 4 | Status | UINT16 | | 5 | Scaling Minimum | FLOAT32 | | 6 | Scaling Maximum | FLOAT32 | | Attribute | Description | Data Type | | --------- | ----------- | --------- | | 3 | Value | BOOLEAN | | 4 | Status | UINT16 | | Attribute | Description | Data Type | | --------- | ----------- | --------- | | 3 | Data | Variable | | 4 | Size | UINT16 | *** ## Best Practices Match the interval to how fast the value changes: 100 ms for safety-critical data, 1 s for process variables, 10 s or more for slow-changing status. Use `DEADBAND` on noisy analog values to reduce MQTT traffic. Set `0.0` on digital status tags so every change publishes. Increase `CONNECTION_TIMEOUT` and `READ_WRITE_TIMEOUT` on congested or remote networks. Raise `RETRY_ATTEMPTS` when connections are intermittent. Always set `JOIN_LENGTH` explicitly when `ENABLE_JOIN_ATTRIBUTE` is `"true"`. Verify `LENGTH + JOIN_LENGTH` matches the numeric type width before deploying to production. Use numeric CIP paths on this route. For Rockwell tag-name access (`Program:MainProgram.Counter`), use the [Allen-Bradley route](./allen-bradley). *** ## Troubleshooting * Verify IP with `ping` * Confirm port 44818 (TCP) is open through firewalls * Ensure the device is powered and allows external connections * Increase `CONNECTION_TIMEOUT` and `RETRY_ATTEMPTS` * Confirm four-segment format: `ClassID.InstanceID.AttributeID.AttributeIndex` * Check the device manual for valid class/instance/attribute numbers * Instance numbering varies by device (often starts at 1, not 0) * Verify `DATA_TYPE` matches the device attribute * Check `DEADBAND` is not suppressing small changes * For join tags, confirm `JOIN_LENGTH` and `LENGTH` byte layout * For bit tags, confirm `BIT_ADDRESS` is within the read width * `JOIN_CLASS_ID` and `JOIN_ATTRIBUTE_ID` must be > 0 * `JOIN_INSTANCE_ID` must be present and >= 0 * `JOIN_LENGTH` must be > 0 * For numeric types, `LENGTH + JOIN_LENGTH` must equal the type byte width (unless `BOOL` + `BIT_ADDRESS`) *** ## Next Steps Symbolic tag-name access for ControlLogix, CompactLogix, and MicroLogix PLCs. Compare OT routes and choose the right protocol for your devices. # FINS Route (Omron) Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/fins Connect to Omron PLCs using FINS protocol with support for multiple memory areas and TCP/UDP transport ## FINS Overview The `FINS` route enables communication with Omron PLCs (CJ, CS, CP, and NJ/NX series) using the FINS (Factory Interface Network Service) protocol. It supports both TCP and UDP transport, multiple memory areas, and bit-level access. FINS is Omron's native protocol providing fast, efficient access to PLC memory. For newer NJ/NX series, consider OPC UA as an alternative. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronPLC WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" WITH LOCAL_NODE 0 WITH REMOTE_NODE 0 ADD MAPPING ProcessData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "100" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" ``` *** ## Connection Configuration ### FINS\_CONFIG Parameters Target Omron device IP address. FINS protocol port. Default: 9600. Protocol type: `TCP` or `UDP`. Default: TCP. Local node address (0-255). Default: 0. Remote node address - target PLC (0-255). Default: 0. Connection timeout in seconds. Default: 2. Number of retries for FINS communication. Default: 5. Polling interval in milliseconds. Default: 200. ### Connection Examples Standard TCP connection (recommended): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" WITH LOCAL_NODE 0 WITH REMOTE_NODE 0 WITH TIMEOUT 2 WITH RETRIES 5 ``` TCP transport for FINS is currently in development. Use UDP for production deployments. UDP for lower latency: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "UDP" WITH LOCAL_NODE 1 WITH REMOTE_NODE 0 WITH TIMEOUT 2 ``` UDP requires proper node addressing and may have firewall considerations. *** ## Memory Areas Omron PLCs organize data in different memory areas: | Memory Type | Code | Description | Access | | ----------- | ---- | ------------------------ | ---------- | | `CIO` | 0 | Core I/O and work area | Read/Write | | `WR` | 1 | Work area | Read/Write | | `HR` | 2 | Holding area (retentive) | Read/Write | | `AR` | 3 | Auxiliary area | Read-only | | `DM` | 4 | Data Memory | Read/Write | | `CNT` | 5 | Counter values | Read/Write | | `TIM` | 6 | Timer values | Read/Write | | `EM` | 7 | Extended Memory | Read/Write | DM (Data Memory) is the most commonly used area for user data. CIO is typically used for I/O and internal relays. *** ## Data Types | Data Type | Description | Size | | ------------------ | --------------------------------- | -------- | | `BOOL` | Boolean (single bit) | 1 bit | | `BYTE` | Unsigned 8-bit | 8 bits | | `SINT` | Signed 8-bit integer | 8 bits | | `USINT` | Unsigned 8-bit integer | 8 bits | | `INT` | Signed 16-bit integer | 16 bits | | `UINT` / `WORD` | Unsigned 16-bit integer | 16 bits | | `DINT` | Signed 32-bit integer | 32 bits | | `UDINT` / `DWORD` | Unsigned 32-bit integer | 32 bits | | `LINT` | Signed 64-bit integer | 64 bits | | `ULINT` / `LWORD` | Unsigned 64-bit integer | 64 bits | | `REAL` / `FLOAT` | 32-bit floating point | 32 bits | | `LREAL` / `DOUBLE` | 64-bit floating point | 64 bits | | `CHAR` | Single character | 8 bits | | `WCHAR` | Wide character | 16 bits | | `TIME` | Time duration | 32 bits | | `DATE` | Date value | 16 bits | | `TOD` | Time of day | 32 bits | | `ASCII` | ASCII string | 8 bits | | `STRING` | String value; sized with `LENGTH` | Variable | ### String sizing For `STRING` tags, `WITH LENGTH` is the string **payload** size in bytes and defaults to **256**. `STRING_SIZE` is accepted as an alias for the same field. The FINS route reserves **two additional header bytes**, so the protocol buffer is `LENGTH + 2` bytes. Read a product label stored as a STRING in DM memory: ```lot wrap focus={14} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronStrings WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" WITH LOCAL_NODE 176 WITH REMOTE_NODE 201 ADD MAPPING Labels WITH EVERY 1 SECOND ADD TAG ProductLabel WITH ADDRESS "500" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "STRING" WITH LENGTH 256 WITH SOURCE_TOPIC "production/label" ``` *** ## TAG Configuration ### Complete TAG Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG ProcessTemperature WITH ADDRESS "100" WITH BIT_ADDRESS 0 WITH DATA_TYPE "REAL" WITH MEMORY_TYPE "DM" WITH SOURCE_TOPIC "plc/process/temperature" WITH DESTINATION_TOPIC "plc/process/temperature/write" WITH WRITABLE "true" WITH SCALING 0.1 WITH OFFSET 0 WITH UNIT "°C" WITH DECIMAL_PLACES 2 WITH MIN_VALUE -50 WITH MAX_VALUE 500 WITH DEADBAND 0.5 WITH PUBLISH_MODE "JSON" WITH DESCRIPTION "Main process temperature sensor" ``` ### TAG Parameters Word address in PLC memory (e.g., 100, 200, 500). Bit address for bit-specific data types (0-15). Used for BOOL values within a word. Memory area: `CIO`, `WR`, `HR`, `AR`, `DM`, `CNT`, `TIM`, `EM`. Default: DM. FINS data type: BOOL, BYTE, INT, UINT, DINT, UDINT, LINT, ULINT, REAL, LREAL, ASCII, STRING. String payload size in bytes; FINS adds two header bytes (`LENGTH + 2` on the wire). Default: **256**. Alias: `STRING_SIZE`. Multiplier for value transformation. Default: 1.0. Offset added after scaling. Default: 0.0. Decimal places in output. Default: 2. Minimum allowed value. Maximum allowed value. Minimum change to trigger publish. Default: 0.0. Topic where PLC values are published. Subscribe here to receive sensor data. Topic to send write commands. Publish a value here to write it to the PLC. Output format: `VALUE_ONLY` or `JSON`. Default: VALUE\_ONLY. Engineering unit for documentation. Human-readable description. Allow writing to this address. Default: false. *** ## Addressing Examples ### Word Addressing Access data at word level: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG DataWord100 WITH ADDRESS "100" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "INT" WITH SOURCE_TOPIC "plc/dm/100" ``` ### Bit Addressing Access individual bits within a word: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Bit0 WITH ADDRESS "100" WITH BIT_ADDRESS 0 WITH MEMORY_TYPE "DM" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/dm/100.0" ADD TAG Bit15 WITH ADDRESS "100" WITH BIT_ADDRESS 15 WITH MEMORY_TYPE "DM" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/dm/100.15" ``` ### 32-bit Values 32-bit values span two consecutive words: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG FloatValue WITH ADDRESS "200" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/float" ``` REAL and DINT types use two consecutive words (e.g., DM200 and DM201). *** ## Event-Based Operations For on-demand FINS operations (not polling), use the EVENT syntax. Publish a message to SOURCE\_TOPIC to trigger the operation; the route executes it and publishes the result to DESTINATION\_TOPIC. ### Supported Operations | Operation | Description | Query Parameters | | --------- | --------------------------------- | ---------------------------------------------- | | `READ` | Read from a memory area on demand | `memory_type`, `address`, `data_type`, `count` | | `WRITE` | Write a value to a memory area | `memory_type`, `address`, `data_type`, `value` | ### Query Parameters | Parameter | Description | Example | | ------------- | -------------------------------------------------------------- | ------------------------------------- | | `operation` | Operation type: `READ` or `WRITE` | `READ` | | `memory_type` | Memory area: `DM`, `CIO`, `WR`, `HR`, `AR`, `TIM`, `CNT`, `EM` | `DM` | | `address` | Word address in the memory area | `100`, `200` | | `data_type` | FINS data type | `REAL`, `INT`, `UINT`, `DINT`, `BOOL` | | `count` | Number of consecutive values to read (READ only) | `10` | | `value` | Value to write (WRITE only) | `25.5`, `100`, `true` | ### Read Example Read a REAL value from DM area on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadDM WITH SOURCE_TOPIC "fins/commands/read" WITH DESTINATION_TOPIC "fins/responses/read" WITH QUERY "{operation: READ, memory_type: DM, address: 100, data_type: REAL}" ``` ### Write Example Write a REAL value to DM area on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteDM WITH SOURCE_TOPIC "fins/commands/write" WITH DESTINATION_TOPIC "fins/responses/write" WITH QUERY "{operation: WRITE, memory_type: DM, address: 200, data_type: REAL, value: 25.5}" ``` *** ## Complete Examples Read values from DM area: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronBasic WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" WITH LOCAL_NODE 0 WITH REMOTE_NODE 0 ADD MAPPING DataMemory WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "100" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" WITH SCALING 0.1 WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "102" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/pressure" WITH UNIT "bar" ADD TAG Counter WITH ADDRESS "104" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "plc/counter" ``` Read CIO and Work areas: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronIO WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" ADD MAPPING IOStatus WITH EVERY 100 MILLISECONDS ADD TAG DigitalInputs WITH ADDRESS "0" WITH MEMORY_TYPE "CIO" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/io/di" ADD TAG DigitalOutputs WITH ADDRESS "100" WITH MEMORY_TYPE "CIO" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/io/do" ADD TAG WorkBits WITH ADDRESS "0" WITH MEMORY_TYPE "WR" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/work" ``` Access individual bits: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronBits WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" ADD MAPPING BitStatus WITH EVERY 100 MILLISECONDS ADD TAG StartButton WITH ADDRESS "0" WITH BIT_ADDRESS 0 WITH MEMORY_TYPE "CIO" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/buttons/start" ADD TAG StopButton WITH ADDRESS "0" WITH BIT_ADDRESS 1 WITH MEMORY_TYPE "CIO" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/buttons/stop" ADD TAG MotorRunning WITH ADDRESS "100" WITH BIT_ADDRESS 0 WITH MEMORY_TYPE "CIO" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/motor/running" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plc/motor/running/set" ``` Read timer and counter values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronTimerCounter WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" ADD MAPPING TimersCounters WITH EVERY 500 MILLISECONDS ADD TAG Timer0 WITH ADDRESS "0" WITH MEMORY_TYPE "TIM" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/timer/0" WITH SCALING 0.1 WITH UNIT "s" ADD TAG Timer1 WITH ADDRESS "1" WITH MEMORY_TYPE "TIM" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/timer/1" ADD TAG Counter0 WITH ADDRESS "0" WITH MEMORY_TYPE "CNT" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/counter/0" ADD TAG Counter1 WITH ADDRESS "1" WITH MEMORY_TYPE "CNT" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/counter/1" ``` Read and write process values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronControl WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" ADD MAPPING ControlLoop WITH EVERY 200 MILLISECONDS ADD TAG ProcessValue WITH ADDRESS "200" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/pv" WITH UNIT "°C" WITH PUBLISH_MODE "JSON" ADD TAG Setpoint WITH ADDRESS "202" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/sp" WITH UNIT "°C" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/sp/write" WITH MIN_VALUE 0 WITH MAX_VALUE 100 ADD TAG Output WITH ADDRESS "204" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/output" WITH UNIT "%" ADD TAG AutoMode WITH ADDRESS "206" WITH BIT_ADDRESS 0 WITH MEMORY_TYPE "DM" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "control/auto" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/auto/write" ``` Read retentive data from HR area: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OmronHolding WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" ADD MAPPING RetentiveData WITH EVERY 5 SECONDS ADD TAG TotalRuntime WITH ADDRESS "0" WITH MEMORY_TYPE "HR" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "plc/stats/runtime" WITH UNIT "seconds" ADD TAG ProductionTotal WITH ADDRESS "2" WITH MEMORY_TYPE "HR" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "plc/stats/production" ADD TAG LastErrorCode WITH ADDRESS "4" WITH MEMORY_TYPE "HR" WITH DATA_TYPE "UINT" WITH SOURCE_TOPIC "plc/stats/error" ``` Continuous monitoring with on-demand read and write events in the same route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullFINSSetup WITH TYPE FINS ADD FINS_CONFIG WITH IP "192.168.1.100" WITH PORT 9600 WITH PROTOCOL "TCP" WITH LOCAL_NODE 0 WITH REMOTE_NODE 0 ADD MAPPING ProcessData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "100" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "102" WITH MEMORY_TYPE "DM" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/pressure" WITH UNIT "bar" ADD EVENT ReadDM WITH SOURCE_TOPIC "fins/commands/read" WITH DESTINATION_TOPIC "fins/responses/read" WITH QUERY "{operation: READ, memory_type: DM, address: 200, data_type: REAL}" ADD EVENT WriteDM WITH SOURCE_TOPIC "fins/commands/write" WITH DESTINATION_TOPIC "fins/responses/write" WITH QUERY "{operation: WRITE, memory_type: DM, address: 300, data_type: REAL, value: 0}" ``` The MAPPING continuously reads temperature and pressure from DM. To trigger an on-demand read, publish any message to `fins/commands/read`. To write a value, publish to `fins/commands/write`. *** ## Memory Area Details * Used for I/O mapping and internal relays * Words 0-99: Typically input/output modules * Words 100-999: Work bits and internal relays * Addresses are PLC model dependent * Main user data storage * Typically DM0-DM32767 * Best for process data, setpoints, configuration * Retentive (battery-backed) * Always retentive data * Survives power cycles * Use for counters, totals, settings * Typically HR0-HR511 * General purpose work bits * Non-retentive (cleared on power cycle) * Fast access for temporary data * TIM: Timer present values * CNT: Counter present values * Values are 16-bit unsigned * Timer values often in 0.1s units *** ## Troubleshooting * Verify IP address is correct * Check firewall allows port 9600 * Ensure PLC Ethernet module is configured * Try both TCP and UDP protocols * Verify node addresses match PLC configuration * LOCAL\_NODE should be unique on the network * REMOTE\_NODE is the target PLC's node number * Check PLC's FINS settings for correct node configuration * For direct connections, try 0 for both nodes * Verify address is within valid range for memory type * Check MEMORY\_TYPE matches where data is stored * For 32-bit types, ensure two consecutive words are available * BIT\_ADDRESS must be 0-15 * Increase TIMEOUT value * Check network connectivity * Verify PLC is in RUN mode * Reduce POLLING rate if reading many tags * Try TCP instead of UDP * Ensure WRITABLE is "true" * Verify memory area is writable (not AR) * Check PLC is not in monitor mode * Verify value is within data type range *** ## Next Steps Universal industrial protocol support. Common patterns for all industrial routes. # Modbus Serial Client Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/modbus-serial-client Connect to PLCs and industrial devices using Modbus RTU over RS-232/RS-485 serial connections ## Modbus Serial Client This page documents the **client** route (`MODBUS_SERIAL`) over serial RTU—the broker connects **to** a remote Modbus device on a COM port. For Modbus TCP client, see [Modbus TCP Client](./modbus-tcp-client). The `MODBUS_SERIAL` route enables communication with industrial devices using Modbus RTU protocol over serial connections (RS-232 or RS-485). It supports the same TAG-based configuration as Modbus TCP but adds serial-specific parameters for baud rate, parity, and data bits. Modbus Serial (RTU) is commonly used for legacy equipment, long-distance communication, and multi-drop RS-485 networks where multiple devices share a single bus. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SerialDevice WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM1" WITH BAUD_RATE 9600 WITH PARITY "NONE" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ADD MAPPING SensorReadings WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "serial/temperature" ``` *** ## Serial Configuration ### MODBUS\_CONFIG Parameters Must be set to `SERIAL` for serial communication. Serial port name. Windows: `COM1`, `COM2`, etc. Linux: `/dev/ttyUSB0`, `/dev/ttyS0`, etc. Communication speed in bits per second. Common values: 9600, 19200, 38400, 57600, 115200. Parity checking: `NONE`, `ODD`, `EVEN`, `MARK`, or `SPACE`. Default: NONE. Number of data bits per character: 7 or 8. Default: 8. Number of stop bits: `ONE`, `TWO`, or `ONEPOINTFIVE`. Default: ONE. Modbus slave/unit ID (1-247). Each device on the bus must have a unique ID. Read operation timeout in milliseconds. Default: 1000. Write operation timeout in milliseconds. Default: 1000. Number of retry attempts on failure. Default: 3. Delay between retries in milliseconds. Default: 100. *** ## Common Serial Settings Different devices use different serial settings. Here are common configurations: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM1" WITH BAUD_RATE 9600 WITH PARITY "NONE" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ``` Settings: 9600 8N1 ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM1" WITH BAUD_RATE 9600 WITH PARITY "EVEN" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ``` Settings: 9600 8E1 ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "/dev/ttyUSB0" WITH BAUD_RATE 115200 WITH PARITY "NONE" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ``` Settings: 115200 8N1 ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "/dev/ttyUSB0" WITH BAUD_RATE 9600 WITH PARITY "NONE" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ``` On Linux, USB-to-serial adapters typically appear as `/dev/ttyUSB0`, `/dev/ttyUSB1`, etc. *** ## RS-485 Multi-Drop Networks RS-485 allows multiple devices on a single bus. Each device must have a unique slave ID: ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} graph LR subgraph bus [RS-485 Bus] Master[Coreflux] Device1[Device 1
ID: 1] Device2[Device 2
ID: 2] Device3[Device 3
ID: 3] end Master --- Device1 Master --- Device2 Master --- Device3 ``` ### Multi-Device Configuration Create separate routes or use different slave IDs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TemperatureController WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM1" WITH BAUD_RATE 9600 WITH SLAVE_ID 1 ADD MAPPING TempController WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "0" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "modbus/device1/temperature" DEFINE ROUTE FlowMeter WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM1" WITH BAUD_RATE 9600 WITH SLAVE_ID 2 ADD MAPPING FlowMeter WITH EVERY 1 SECOND ADD TAG FlowRate WITH ADDRESS "0" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "modbus/device2/flow" ``` When sharing a serial port between multiple slave IDs, ensure polling intervals are set to avoid bus collisions. The route handles sequencing automatically, but very fast polling across many devices may cause timeouts. *** ## TAG Configuration The TAG configuration for Modbus Serial matches Modbus TCP, including string sizing. See the [Modbus TCP Client documentation](./modbus-tcp-client) for the full TAG parameter reference. ### String sizing For `STRING` tags, `WITH LENGTH` sets the maximum string size in **bytes** (default **256**). `STRING_SIZE` is accepted as an alias. Each register holds two bytes, so the route reads enough **consecutive registers** from the tag's starting `ADDRESS` to cover the configured size. ```lot wrap focus={16} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ModbusStrings WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "/dev/ttyUSB0" WITH BAUD_RATE 9600 WITH PARITY "NONE" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ADD MAPPING ProductData WITH EVERY 1 SECOND ADD TAG ProductName WITH ADDRESS "0" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "STRING" WITH LENGTH 32 WITH SOURCE_TOPIC "factory/product/name" ``` For cross-protocol defaults and caps, see [String sizing by Route](./overview#string-sizing-by-route) on the industrial overview. ### TAG parameters **STRING only.** Maximum string size in bytes. The route allocates consecutive registers from the starting `ADDRESS` (2 bytes per register). Alias: `STRING_SIZE`. ### Quick Reference ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG SensorValue WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "serial/sensor" WITH SCALING 0.1 WITH OFFSET 0 WITH UNIT "°C" WITH DEADBAND 0.5 WITH PUBLISH_MODE "JSON" WITH WRITABLE "true" WITH DESTINATION_TOPIC "serial/sensor/set" WITH BYTE_ORDER "BIGENDIAN" WITH WORD_ORDER "BIGENDIAN" ``` *** ## Event-Based Operations For on-demand Modbus Serial operations (not polling), use the EVENT syntax. Publish a message to SOURCE\_TOPIC to trigger the operation; the route executes it and publishes the result to DESTINATION\_TOPIC. Modbus Serial supports the same EVENT operations as Modbus TCP. See the [Modbus TCP Client Event-Based Operations](./modbus-tcp-client#event-based-operations) for the full supported operations table and query parameter details. ### Read Example Read 10 holding registers on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadRegisters WITH SOURCE_TOPIC "serial/commands/read" WITH DESTINATION_TOPIC "serial/responses/read" WITH QUERY "{operation: READ_HOLDING_REGISTERS, start_address: 0, count: 10}" ``` ### Write Example Write a single register on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteRegister WITH SOURCE_TOPIC "serial/commands/write" WITH DESTINATION_TOPIC "serial/responses/write" WITH QUERY "{operation: WRITE_SINGLE_REGISTER, address: 100, value: 500}" ``` *** ## Complete Examples Basic connection to a single Modbus RTU device: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EnergyMeter WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM3" WITH BAUD_RATE 9600 WITH PARITY "EVEN" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 WITH READ_TIMEOUT 1000 WITH RETRY_COUNT 3 ADD MAPPING Readings WITH EVERY 1 SECOND ADD TAG Voltage WITH ADDRESS "0" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "meter/voltage" WITH UNIT "V" ADD TAG Current WITH ADDRESS "2" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "meter/current" WITH UNIT "A" ADD TAG Power WITH ADDRESS "4" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "meter/power" WITH UNIT "kW" ``` Read process value and write setpoint: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TempController WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "/dev/ttyUSB0" WITH BAUD_RATE 9600 WITH PARITY "NONE" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ADD MAPPING Control WITH EVERY 500 MILLISECONDS ADD TAG ProcessValue WITH ADDRESS "0" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "INT16" WITH SOURCE_TOPIC "tempctrl/pv" WITH SCALING 0.1 WITH UNIT "°C" ADD TAG Setpoint WITH ADDRESS "1" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "INT16" WITH SOURCE_TOPIC "tempctrl/sp" WITH SCALING 0.1 WITH UNIT "°C" WITH WRITABLE "true" WITH DESTINATION_TOPIC "tempctrl/sp/set" ADD TAG Output WITH ADDRESS "2" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "INT16" WITH SOURCE_TOPIC "tempctrl/output" WITH SCALING 0.1 WITH UNIT "%" ``` Control a motor drive: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE VFDControl WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM2" WITH BAUD_RATE 19200 WITH PARITY "EVEN" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ADD MAPPING DriveStatus WITH EVERY 200 MILLISECONDS ADD TAG ActualSpeed WITH ADDRESS "8451" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "vfd/speed/actual" WITH SCALING 0.1 WITH UNIT "Hz" ADD TAG MotorCurrent WITH ADDRESS "8448" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "vfd/current" WITH SCALING 0.01 WITH UNIT "A" ADD TAG SpeedReference WITH ADDRESS "8502" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "vfd/speed/reference" WITH SCALING 0.1 WITH UNIT "Hz" WITH WRITABLE "true" WITH DESTINATION_TOPIC "vfd/speed/set" ADD TAG ControlWord WITH ADDRESS "8501" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "vfd/control" WITH WRITABLE "true" WITH DESTINATION_TOPIC "vfd/control/set" ``` Continuous monitoring with on-demand read and write events in the same route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullSerialSetup WITH TYPE MODBUS_SERIAL ADD MODBUS_CONFIG WITH CONNECTION_TYPE "SERIAL" WITH COM_PORT "COM3" WITH BAUD_RATE 9600 WITH PARITY "EVEN" WITH DATA_BITS 8 WITH STOP_BITS "ONE" WITH SLAVE_ID 1 ADD MAPPING ProcessReadings WITH EVERY 1 SECOND ADD TAG Voltage WITH ADDRESS "0" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "meter/voltage" WITH UNIT "V" ADD TAG Current WITH ADDRESS "2" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "meter/current" WITH UNIT "A" ADD EVENT ReadRegisters WITH SOURCE_TOPIC "serial/commands/read" WITH DESTINATION_TOPIC "serial/responses/read" WITH QUERY "{operation: READ_HOLDING_REGISTERS, start_address: 0, count: 10}" ADD EVENT WriteRegister WITH SOURCE_TOPIC "serial/commands/write" WITH DESTINATION_TOPIC "serial/responses/write" WITH QUERY "{operation: WRITE_SINGLE_REGISTER, address: 100, value: 0}" ``` The MAPPING continuously reads voltage and current. To trigger an on-demand register read, publish any message to `serial/commands/read`. To write, publish to `serial/commands/write`. *** ## Troubleshooting * Verify COM port is correct and not in use by another application * Check physical wiring (TX/RX, A/B for RS-485) * Ensure baud rate matches device settings * Verify slave ID is correct * Check device power and status indicators * CRC errors indicate data corruption * Check cable quality and length * For RS-485, ensure proper termination resistors (120Ω at each end) * Reduce baud rate for long cable runs * Check for electrical interference * Increase `READ_TIMEOUT` value * Verify serial settings match device exactly * Check for bus conflicts (multiple masters) * For RS-485, ensure proper biasing * Verify register addresses in device documentation * Check `BYTE_ORDER` and `WORD_ORDER` settings * Confirm `DATA_TYPE` matches register size * Some devices use 1-based addressing (subtract 1 from documented address) * Windows: Check Device Manager for correct COM port number * Linux: Check `/dev/tty*` with `ls -l /dev/tty*` * Ensure USB-to-serial driver is installed * Verify user has permissions to access serial port *** ## RS-485 Wiring Tips Proper wiring is critical for reliable RS-485 communication. | Wire | Function | Description | | ------ | -------- | ----------------------- | | A (D-) | Data - | Negative data line | | B (D+) | Data + | Positive data line | | GND | Ground | Signal ground reference | **Best Practices:** * Use twisted pair cable for A and B lines * Add 120Ω termination resistors at both ends of the bus * Keep cable length under 1200m (4000ft) * Use bias resistors if experiencing idle-line issues * Ensure all grounds are connected *** ## Next Steps Connect to Modbus devices over Ethernet. Connect directly to Siemens PLCs. # Modbus TCP Client Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/modbus-tcp-client Connect to PLCs and industrial devices using Modbus TCP protocol with TAG-based polling and automatic batch optimization ## Modbus TCP Client This page documents the **client** route (`MODBUS_TCP`)—the broker connects **to** a remote Modbus device and polls registers over MQTT. To expose MQTT topics **to** Modbus masters instead (SCADA, HMIs connect to the broker), see [Modbus TCP Server](./modbus-tcp-server). The `MODBUS_TCP` route enables communication with PLCs, RTUs, and industrial devices using the Modbus TCP/IP protocol. It supports reading and writing registers and coils with configurable polling intervals and automatic batch optimization. Modbus TCP is one of the most widely supported industrial protocols. If your device supports Modbus, this route provides a reliable way to integrate it with MQTT. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ModbusDevice WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING SensorReadings WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "modbus/temperature" ``` *** ## Connection Configuration ### MODBUS\_CONFIG Parameters Modbus TCP server hostname or IP address. Modbus TCP port. Default: 502. Modbus slave/unit ID (1-247). Default: 1. Connection timeout in milliseconds. Default: 5000. Read operation timeout in milliseconds. Default: 1000. Write operation timeout in milliseconds. Default: 1000. Number of retry attempts on failure. Default: 3. Delay between retries in milliseconds. Default: 100. Treat plain numeric tag addresses as one-based (address `1` = PDU register 0). Modicon-style addresses (`400001+`, `300001+`, `100001+`) are always decoded regardless of this flag. See [Modbus addressing](#modbus-addressing). ### Connection Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 WITH CONNECTION_TIMEOUT 5000 WITH READ_TIMEOUT 1000 WITH WRITE_TIMEOUT 1000 WITH RETRY_COUNT 3 WITH RETRY_DELAY 100 ``` *** ## Address Types Modbus defines four address spaces: | Address Type | Description | Function Codes | Typical Use | | ------------------ | --------------------------- | ---------------- | --------------------------- | | `HOLDING_REGISTER` | Read/Write 16-bit registers | FC03, FC06, FC16 | Setpoints, configuration | | `INPUT_REGISTER` | Read-only 16-bit registers | FC04 | Sensor values, measurements | | `COIL` | Read/Write single bits | FC01, FC05, FC15 | Digital outputs, controls | | `DISCRETE_INPUT` | Read-only single bits | FC02 | Digital inputs, status | Most Modbus devices use Holding Registers for configuration and Input Registers for measured values. Check your device documentation for the correct address type. *** ## Modbus Addressing Modbus addressing varies by vendor. Coreflux supports three styles—choose the one that matches your device documentation. ### Style 1 — Zero-based PDU offsets (default) The Modbus protocol uses zero-based addresses in the PDU. This is the default (`ONE_BASED_ADDRESSES = false`). | Device manual label | Tag `ADDRESS` | | ------------------- | ------------- | | Register 1 | `"0"` | | Register 100 | `"99"` | Use this when documentation labels the first register as `0`, or when targeting integration tests that follow PDU numbering. ### Style 2 — One-based plain addresses Most device manuals label the first holding register as **1**, not 0. Set `WITH ONE_BASED_ADDRESSES true` on `MODBUS_CONFIG` to use those labels directly in tag addresses. | Device manual label | Tag `ADDRESS` | | ------------------- | ------------- | | Register 1 | `"1"` | | Register 100 | `"100"` | ```lot wrap focus={5-11} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ModbusDevice WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 WITH ONE_BASED_ADDRESSES true ADD MAPPING Sensors WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "1" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "sensors/temperature" ``` ### Style 3 — Modicon notation Traditional Modicon prefix notation is always accepted. The prefix determines the address type automatically: | Modicon range | Address type | Example | | ------------- | ---------------- | ---------------- | | 400001–465536 | Holding register | `400001` → PDU 0 | | 300001–365536 | Input register | `300001` → PDU 0 | | 100001–165536 | Discrete input | `100001` → PDU 0 | ```lot wrap focus={4-8} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ModbusDevice WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH SLAVE_ID 1 ADD MAPPING Setpoints WITH EVERY 1 SECOND ADD TAG Setpoint WITH ADDRESS "400100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "INT16" WITH SOURCE_TOPIC "plc/setpoint" ``` Modicon-style addresses are decoded the same way regardless of `ONE_BASED_ADDRESSES`. The flag only affects plain numeric addresses (those without a Modicon prefix). ### Conversion reference | Device manual label | `ONE_BASED_ADDRESSES false` | `ONE_BASED_ADDRESSES true` | Modicon notation | | ------------------- | --------------------------- | -------------------------- | ---------------- | | Register 1 | `"0"` | `"1"` | `"400001"` | | Register 100 | `"99"` | `"100"` | `"400100"` | | Register 1000 | `"999"` | `"1000"` | `"401000"` | Multi-word types (`FLOAT`, `INT32`, and so on) occupy consecutive registers from the resolved starting address. *** ## Data Types The data type determines how raw register values are interpreted: | Data Type | Size | Description | | --------- | -------- | ---------------------------------------------------------------------------------- | | `BOOL` | 1 bit | Boolean value (for coils/discrete inputs) | | `BIT` | 1 bit | Alias for BOOL | | `INT8` | 8 bits | Signed 8-bit integer | | `UINT8` | 8 bits | Unsigned 8-bit integer | | `INT16` | 16 bits | Signed 16-bit integer (1 register) | | `UINT16` | 16 bits | Unsigned 16-bit integer (1 register) | | `INT32` | 32 bits | Signed 32-bit integer (2 registers) | | `UINT32` | 32 bits | Unsigned 32-bit integer (2 registers) | | `INT64` | 64 bits | Signed 64-bit integer (4 registers) | | `UINT64` | 64 bits | Unsigned 64-bit integer (4 registers) | | `FLOAT` | 32 bits | 32-bit floating point (2 registers) | | `DOUBLE` | 64 bits | 64-bit floating point (4 registers) | | `STRING` | Variable | ASCII string across consecutive registers; sized with `LENGTH` (default 256 bytes) | ### String sizing For `STRING` tags, `WITH LENGTH` sets the maximum string size in **bytes** (default **256**). `STRING_SIZE` is accepted as an alias for the same field. Each Modbus register holds two bytes, so the route reads enough **consecutive registers** starting at the tag's `ADDRESS` to cover the configured size. ```lot wrap focus={12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ModbusStrings WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING ProductData WITH EVERY 1 SECOND ADD TAG ProductName WITH ADDRESS "0" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "STRING" WITH LENGTH 32 WITH SOURCE_TOPIC "factory/product/name" ``` For cross-protocol defaults and caps, see [String sizing by Route](./overview#string-sizing-by-route) on the industrial overview. *** ## TAG Configuration ### Complete TAG Parameters ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG TemperatureSensor WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "modbus/sensors/temperature" WITH SCALING 0.1 WITH OFFSET 0 WITH UNIT "°C" WITH DECIMAL_PLACES 2 WITH MIN_VALUE -40 WITH MAX_VALUE 150 WITH DEADBAND 0.5 WITH PUBLISH_MODE "JSON" WITH WRITABLE "true" WITH DESTINATION_TOPIC "modbus/sensors/temperature/set" WITH BYTE_ORDER "BIGENDIAN" WITH WORD_ORDER "BIGENDIAN" WITH DESCRIPTION "Main process temperature sensor" ``` ### TAG Parameters Reference Register or coil address. Plain numeric (0–65535 zero-based by default, or 1–65536 when `ONE_BASED_ADDRESSES true`), or Modicon notation (`400001+`, `300001+`, `100001+`). See [Modbus addressing](#modbus-addressing). Type of Modbus address: `HOLDING_REGISTER`, `INPUT_REGISTER`, `COIL`, or `DISCRETE_INPUT`. **STRING only.** Maximum string size in bytes. The route allocates consecutive registers from the starting `ADDRESS` (2 bytes per register). Alias: `STRING_SIZE`. Multiplier applied to raw value. Default: 1.0. Example: Raw value 245 with SCALING 0.1 becomes 24.5. Value added after scaling. Default: 0.0. Formula: `result = (raw * scaling) + offset`. Number of decimal places in published value. Default: 2. Minimum allowed value. Values below this threshold are not published. Maximum allowed value. Values above this threshold are not published. Minimum change required to publish a new value. Default: 0.0. Topic where PLC values are published. Subscribe here to receive sensor data. Output format: `VALUE_ONLY` or `JSON`. Default: VALUE\_ONLY. Engineering unit for documentation (e.g., °C, bar, RPM). Human-readable description of the TAG. Allow writing to this register/coil. Default: false. Topic to send write commands. Publish a value here to write it to the PLC. Byte order for multi-byte values: `BIGENDIAN` or `LITTLEENDIAN`. Default: BIGENDIAN. Word order for 32/64-bit values: `BIGENDIAN` or `LITTLEENDIAN`. Default: BIGENDIAN. Different manufacturers use different byte/word ordering. If values appear incorrect, try changing BYTE\_ORDER or WORD\_ORDER. *** ## Event-Based Operations For on-demand Modbus operations (not polling), use the EVENT syntax. Publish a message to SOURCE\_TOPIC to trigger the operation; the route executes it and publishes the result to DESTINATION\_TOPIC. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadOnDemand WITH SOURCE_TOPIC "modbus/commands/read" WITH DESTINATION_TOPIC "modbus/responses/read" WITH QUERY "{operation: READ_HOLDING_REGISTERS, start_address: 0, count: 10}" ``` ### Query Syntax The `WITH QUERY` value uses LoT object syntax: unquoted keys, unquoted identifiers, and numeric values. The entire query is wrapped in double quotes. | Element | Format | Example | | ---------------- | -------------------- | -------------------------------------- | | Keys | Unquoted | `operation`, `start_address`, `count` | | Operation values | Unquoted identifiers | `READ_HOLDING_REGISTERS`, `READ_COILS` | | Numeric values | Unquoted | `0`, `10`, `100` | | String values | Unquoted (addresses) | `address: 100` | ### Supported Operations | Operation | Description | | -------------------------- | --------------------------------------- | | `READ_HOLDING_REGISTERS` | Read holding registers (FC03) | | `READ_INPUT_REGISTERS` | Read input registers (FC04) | | `READ_COILS` | Read coils (FC01) | | `READ_DISCRETE_INPUTS` | Read discrete inputs (FC02) | | `WRITE_SINGLE_REGISTER` | Write single holding register (FC06) | | `WRITE_MULTIPLE_REGISTERS` | Write multiple holding registers (FC16) | | `WRITE_SINGLE_COIL` | Write single coil (FC05) | | `WRITE_MULTIPLE_COILS` | Write multiple coils (FC15) | ### Query Parameters by Operation | Operation | Parameters | Example | | -------------------------- | ------------------------- | ---------------------------------------------------------------------------------- | | `READ_HOLDING_REGISTERS` | `start_address`, `count` | `{operation: READ_HOLDING_REGISTERS, start_address: 0, count: 10}` | | `READ_INPUT_REGISTERS` | `start_address`, `count` | `{operation: READ_INPUT_REGISTERS, start_address: 0, count: 5}` | | `READ_COILS` | `start_address`, `count` | `{operation: READ_COILS, start_address: 0, count: 8}` | | `READ_DISCRETE_INPUTS` | `start_address`, `count` | `{operation: READ_DISCRETE_INPUTS, start_address: 0, count: 8}` | | `WRITE_SINGLE_REGISTER` | `address`, `value` | `{operation: WRITE_SINGLE_REGISTER, address: 100, value: 42}` | | `WRITE_MULTIPLE_REGISTERS` | `start_address`, `values` | `{operation: WRITE_MULTIPLE_REGISTERS, start_address: 0, values: [100, 200]}` | | `WRITE_SINGLE_COIL` | `address`, `value` | `{operation: WRITE_SINGLE_COIL, address: 0, value: true}` | | `WRITE_MULTIPLE_COILS` | `start_address`, `values` | `{operation: WRITE_MULTIPLE_COILS, start_address: 0, values: [true, false, true]}` | To write a value on demand, publish a message to SOURCE\_TOPIC. The route executes the write and publishes the result to DESTINATION\_TOPIC: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "modbus/commands/write" WITH DESTINATION_TOPIC "modbus/responses/write" WITH QUERY "{operation: WRITE_SINGLE_REGISTER, address: 100, value: 42}" ``` *** ## Complete Examples Read temperature and pressure from a Modbus device: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProcessSensors WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING Sensors WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "process/temperature" WITH SCALING 0.1 WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "102" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "process/pressure" WITH UNIT "bar" ``` Group TAGs by update frequency: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PLCConnection WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.50" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING FastLoop WITH EVERY 100 MILLISECONDS ADD TAG MotorSpeed WITH ADDRESS "200" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "plc/motor/speed" ADD TAG MotorCurrent WITH ADDRESS "201" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/motor/current" ADD MAPPING SlowLoop WITH EVERY 5 SECONDS ADD TAG TotalRuntime WITH ADDRESS "300" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT32" WITH SOURCE_TOPIC "plc/stats/runtime" ADD TAG FaultCode WITH ADDRESS "400" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "UINT16" WITH SOURCE_TOPIC "plc/status/fault" ``` Read values and write setpoints: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ControlSystem WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING ControlLoop WITH EVERY 500 MILLISECONDS ADD TAG ProcessValue WITH ADDRESS "100" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "control/pv" WITH PUBLISH_MODE "JSON" ADD TAG Setpoint WITH ADDRESS "200" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "control/sp" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/sp/set" WITH MIN_VALUE 0 WITH MAX_VALUE 100 ADD TAG OutputValue WITH ADDRESS "300" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "control/output" ``` Read digital inputs and control outputs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DigitalIO WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.75" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING DigitalInputs WITH EVERY 100 MILLISECONDS ADD TAG StartButton WITH ADDRESS "0" WITH ADDRESS_TYPE "DISCRETE_INPUT" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "io/inputs/start" ADD TAG StopButton WITH ADDRESS "1" WITH ADDRESS_TYPE "DISCRETE_INPUT" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "io/inputs/stop" ADD TAG RunningLight WITH ADDRESS "0" WITH ADDRESS_TYPE "COIL" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "io/outputs/running" WITH WRITABLE "true" WITH DESTINATION_TOPIC "io/outputs/running/set" ``` Continuous monitoring with on-demand read and write events in the same route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullModbusSetup WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT 502 WITH SLAVE_ID 1 ADD MAPPING ProcessSensors WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "process/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "102" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "process/pressure" WITH UNIT "bar" ADD EVENT ReadRegisters WITH SOURCE_TOPIC "modbus/commands/read" WITH DESTINATION_TOPIC "modbus/responses/read" WITH QUERY "{operation: READ_HOLDING_REGISTERS, start_address: 0, count: 10}" ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "modbus/commands/write" WITH DESTINATION_TOPIC "modbus/responses/write" WITH QUERY "{operation: WRITE_SINGLE_REGISTER, address: 200, value: 0}" ``` To trigger the on-demand read, publish any message to `modbus/commands/read`. The route reads registers 0-9 and publishes the result to `modbus/responses/read`. To write, publish to `modbus/commands/write`. *** ## Troubleshooting * Verify IP address and port are correct * Check firewall settings (port 502 must be open) * Ensure device is powered on and connected to network * Try increasing `CONNECTION_TIMEOUT` * Verify `ADDRESS` matches device documentation—try `WITH ONE_BASED_ADDRESSES true` if the manual labels registers starting at 1 * Use Modicon notation (`400100`) when the datasheet shows prefixed addresses * Check `BYTE_ORDER` and `WORD_ORDER` settings * Confirm `DATA_TYPE` matches register size * Verify `SCALING` and `OFFSET` calculations * Ensure `WRITABLE` is set to "true" * Verify `ADDRESS_TYPE` is `HOLDING_REGISTER` or `COIL` * Check device permissions for write operations * Verify value is within `MIN_VALUE` and `MAX_VALUE` * Confirm correct `SLAVE_ID` (usually 1 for TCP devices) * Some gateways use different slave IDs for different devices * Check device documentation for correct unit ID *** ## Next Steps Connect to RS-232/RS-485 Modbus RTU devices. Expose MQTT topics as Modbus registers for SCADA and HMIs. # Modbus TCP Server Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/modbus-tcp-server Expose MQTT topics as Modbus TCP registers and coils so SCADA, HMIs, and Modbus masters connect to your Coreflux broker ## Modbus TCP Server This page documents the **server** route (`MODBUS_SERVER`)—the broker **hosts** a Modbus TCP server and exposes MQTT topics as register and coil addresses. External masters connect to the broker over TCP. To connect the broker **to** a remote Modbus device instead, see [Modbus TCP Client](./modbus-tcp-client). The `MODBUS_SERVER` route makes your broker act as a **Modbus TCP server (slave)**. External masters—SCADA, HMIs, historians, PLCs—connect **to the broker** and read or write a declared register/coil address space that bridges bidirectionally to MQTT topics. Use `MODBUS_TCP` when you need to **poll a device**. Use `MODBUS_SERVER` when OT systems need to **read and write** values that live on your MQTT bus—no separate gateway required. **Like a shop counter with a price list.** MQTT updates what's on the shelf (register values), and customers (Modbus masters) read the tags—or, for writable addresses, change items that publish back to MQTT. ## When to Use This Route * **SCADA / HMI integration** — Let plant-floor tools poll broker data over standard Modbus without custom drivers. * **OT/IT bridge** — Expose IoT sensor streams to masters that only speak Modbus. * **Bidirectional control** — Writable tags let masters adjust setpoints; the broker publishes the new value to MQTT on a cadence you choose. * **Client-only field devices** — Integrate master-only equipment into MQTT with no middleware between the broker and the bus. *** The sections below cover data flow, configuration, persistence, and production patterns. ## Quick Start Map an MQTT topic to a holding register masters can poll: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TempServer WITH TYPE MODBUS_SERVER ADD MAPPING Sensors ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plant/line1/temperature" ``` Publish `23.5` to `plant/line1/temperature` → registers 100–101 hold the FLOAT; a master reading them gets `23.5`. Allow a master to write a register; the broker publishes the value to MQTT within one `WITH EVERY` interval: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SetpointServer WITH TYPE MODBUS_SERVER ADD MAPPING Control WITH EVERY 500 MILLISECONDS WITH SOURCE_TOPIC "plant/line1" ADD TAG Setpoint WITH ADDRESS "200" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plant/line1/setpoint/written" ``` MQTT publishes to `plant/line1/Setpoint` update registers 200–201. A master write surfaces on `plant/line1/setpoint/written` within 500 ms. Listen on port 502 as unit 1 with multiple tags and connection settings: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH HOST "0.0.0.0" WITH PORT 502 WITH UNIT_ID 1 WITH MAX_CONNECTIONS 64 ADD MAPPING ServerTags WITH EVERY 500 MILLISECONDS WITH SOURCE_TOPIC "plant/modbus" ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" ADD TAG Setpoint WITH ADDRESS "200" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plant/modbus/setpoint/set" ADD TAG Running WITH ADDRESS "0" WITH ADDRESS_TYPE "COIL" WITH DATA_TYPE "BOOL" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plant/modbus/running/set" ``` Masters read `Temperature`; `Setpoint` and `Running` are bidirectional—MQTT drives them and master writes publish to their `DESTINATION_TOPIC` within 500 ms. Persist master-written setpoints across broker restarts: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ResilientServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH PORT 502 WITH UNIT_ID 1 WITH PERSISTENCE true ADD MAPPING Control WITH EVERY 500 MILLISECONDS WITH SOURCE_TOPIC "plant/line1" ADD TAG Setpoint WITH ADDRESS "200" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plant/line1/setpoint/written" ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" ``` After a restart, a master reading `Setpoint` (200–201) gets the last value it wrote. `Temperature` (100–101) refreshes from the live MQTT source, not the snapshot. *** ## How Data Flows ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} graph LR subgraph mqtt [MQTT] Topics[Topics] end subgraph broker [Coreflux Broker] Route[MODBUS_SERVER Route] Registers[Register / Coil Space] end subgraph ot [OT Masters] Master[SCADA / HMI / PLC] end Topics -->|publish sets value| Route Route --> Registers Registers -->|read / write| Master Master -->|write writable tag| Route Route -->|publish on EVERY cadence| Topics ``` Sync is **hybrid**—`WITH EVERY` controls outbound latency: | Direction | Behavior | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **MQTT → register (inbound)** | A publish on a tag's `SOURCE_TOPIC` updates the backing register or coil immediately. Works for every address type, including read-only `INPUT_REGISTER` and `DISCRETE_INPUT`. Retained MQTT values are seeded at startup. | | **register → MQTT (outbound)** | When a master writes a `WRITABLE` `HOLDING_REGISTER` or `COIL`, the broker detects the change on the mapping's `WITH EVERY` cadence and publishes the decoded value to the tag's `DESTINATION_TOPIC` (rounded per `DECIMAL_PLACES`, retained). A master write surfaces on MQTT within one cadence interval. | A master write never loops back into the register from MQTT, and an MQTT-driven change is never re-published to `DESTINATION_TOPIC` (direction guard). On server routes, route status **Connected** means the endpoint is **listening**—not that a remote session is established. *** ## Server Configuration All transport settings live in the `MODBUS_SERVER_CONFIG` block. A valid route needs only `WITH TYPE MODBUS_SERVER` plus a mapping with one tag—it listens on `0.0.0.0:502` as unit 1 by default. ```lot wrap focus={2-6} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH HOST "0.0.0.0" WITH PORT 502 WITH UNIT_ID 1 WITH MAX_CONNECTIONS 64 ``` ### Connection parameters Bind interface to listen on (`0.0.0.0` = all interfaces). `IP` is accepted as an alias. TCP listen port. Each server route claims its port exclusively—a second route on the same port is refused. Best-effort soft cap on concurrent master connections. See [Connection-limit behavior](#connection-limit-behavior). Modbus unit ID the broker answers as (1–247). `SLAVE_ID` is accepted as an alias. Persist served values across broker restarts. See [Data persistence & recovery](#data-persistence--recovery). Treat plain numeric tag addresses as one-based (address `1` = PDU register 0). Modicon-style addresses (`400001+`, `300001+`, `100001+`) are always decoded regardless of this flag. See [Address notation](#address-notation). Modbus has no native authentication—there is no credential configuration. Bind to a specific interface and restrict access at the network layer; MQTT-side access is governed by HUB topic permissions. Use `GET ENV` / `GET SECRET` for environment-derived values (for example `WITH PORT GET ENV "MODBUS_PORT"`). *** ## Tag and Mapping Configuration Each exposed register or coil is declared as an `ADD TAG`. Only declared addresses are served—reads or writes to undeclared addresses return a Modbus **Illegal Data Address** exception. ```lot wrap focus={7-20} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH HOST "0.0.0.0" WITH PORT 502 WITH UNIT_ID 1 WITH MAX_CONNECTIONS 64 ADD MAPPING ServerTags WITH EVERY 500 MILLISECONDS WITH SOURCE_TOPIC "modbus/server" ADD TAG Setpoint WITH ADDRESS "200" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "modbus/server/setpoint" WITH WRITABLE "true" WITH DESTINATION_TOPIC "modbus/server/setpoint/set" WITH BYTE_ORDER "BIGENDIAN" WITH WORD_ORDER "BIGENDIAN" WITH DECIMAL_PLACES 2 WITH DESCRIPTION "Operator setpoint" ADD TAG ProductCode WITH ADDRESS "300" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "STRING" WITH LENGTH 40 WITH WRITABLE "true" WITH DESTINATION_TOPIC "modbus/server/product/code" ``` ### Server vs. client semantics | Setting | Client (`MODBUS_TCP`) | Server (`MODBUS_SERVER`) | | ------------------- | ---------------------------- | ------------------------------------------- | | Role | Master—dials out to a device | Slave—listens for masters | | `HOST` / `IP` | Device IP to dial | **Bind** interface to listen on | | `SOURCE_TOPIC` | Register value → MQTT (read) | MQTT → register (write into address space) | | `DESTINATION_TOPIC` | MQTT → device write | Register → MQTT when a master writes | | `EVERY` | Poll interval | Outbound sync cadence (master write → MQTT) | | Transport | TCP / Serial (RTU) | TCP | ### Modbus address banks | `ADDRESS_TYPE` | Bank | Element | Master access | Function codes | | ------------------ | ----------------- | ----------- | ------------- | -------------- | | `HOLDING_REGISTER` | Holding registers | 16-bit word | read / write | 3, 6, 16 | | `INPUT_REGISTER` | Input registers | 16-bit word | read-only | 4 | | `COIL` | Coils | 1 bit | read / write | 1, 5, 15 | | `DISCRETE_INPUT` | Discrete inputs | 1 bit | read-only | 2 | ### Address notation Tag `ADDRESS` values are converted to the **PDU offset** the broker exposes on the wire. External Modbus masters always use zero-based PDU addresses in their read/write requests—the `ONE_BASED_ADDRESSES` flag only affects how you label tags in LoT, not how masters address the server. Three styles are supported (same rules as the [Modbus TCP Client](./modbus-tcp-client#modbus-addressing) route): | Style | Config | Tag address for PDU 0 | Tag address for PDU 99 | | -------------------------- | --------------------------- | --------------------- | ---------------------- | | Zero-based plain (default) | `ONE_BASED_ADDRESSES false` | `"0"` | `"99"` | | One-based plain | `ONE_BASED_ADDRESSES true` | `"1"` | `"100"` | | Modicon holding register | either | `"400001"` | `"400100"` | ```lot wrap focus={2-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH PORT 502 WITH UNIT_ID 1 WITH ONE_BASED_ADDRESSES true ADD MAPPING ServerTags WITH SOURCE_TOPIC "plant/modbus" ADD TAG Temperature WITH ADDRESS "1" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" ``` Modicon-style addresses are decoded the same way regardless of `ONE_BASED_ADDRESSES`. The flag only affects plain numeric addresses. ### Tag parameters Register or coil address. Plain numeric (0–65535 zero-based by default, or 1–65536 when `ONE_BASED_ADDRESSES true`), or Modicon notation (`400001+`, `300001+`, `100001+`). Multi-word types occupy consecutive PDU addresses from the resolved offset. See [Address notation](#address-notation). Which bank the tag lives in: `HOLDING_REGISTER`, `INPUT_REGISTER`, `COIL`, or `DISCRETE_INPUT`. Wire type: `BOOL`, `INT16`, `UINT16`, `INT32`, `UINT32`, `INT64`, `UINT64`, `FLOAT`, `DOUBLE`, or `STRING`. `INT32`/`UINT32`/`FLOAT` span 2 registers; `INT64`/`UINT64`/`DOUBLE` span 4. **STRING only.** Maximum string size in **bytes** (2 bytes per register). Register count is `ceil(bytes / 2)` (integer form: `(bytes + 1) / 2`). When omitted, defaults to **40 bytes** (20 registers) and logs a Warning. Alias: `STRING_SIZE`. Must resolve to at most **123 registers** (**246 bytes**), the FC16 write cap; larger values are rejected during route validation. MQTT topic whose publishes set this register or coil. Defaults to `{mapping SOURCE_TOPIC}/{TagName}`. Allow masters to write the address. Only `HOLDING_REGISTER` and `COIL` are master-writable. Topic published to when a master writes this (writable) address. Byte order within each register for multi-byte types: `BIGENDIAN` or `LITTLEENDIAN`. Word order across registers for 32-/64-bit types (high word first by default): `BIGENDIAN` or `LITTLEENDIAN`. Rounding applied to `FLOAT`/`DOUBLE` values published to `DESTINATION_TOPIC`. Per-tag override of the route persistence default. Precedence: tag → mapping → route. See [Data persistence & recovery](#data-persistence--recovery). Free-text documentation for the tag. On the mapping block: register → MQTT sync cadence for master writes. ### All four address types One server exposing every bank type: ```lot wrap focus={4-22} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MixedServer WITH TYPE MODBUS_SERVER ADD MAPPING Points WITH SOURCE_TOPIC "plant/mixed" ADD TAG Pressure WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" ADD TAG MotorSpeed WITH ADDRESS "50" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "UINT16" ADD TAG PumpEnable WITH ADDRESS "0" WITH ADDRESS_TYPE "COIL" WITH DATA_TYPE "BOOL" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plant/mixed/pump/cmd" ADD TAG DoorClosed WITH ADDRESS "0" WITH ADDRESS_TYPE "DISCRETE_INPUT" WITH DATA_TYPE "BOOL" ``` All four points are fed from `plant/mixed/`; only `PumpEnable` accepts master writes (→ `plant/mixed/pump/cmd`). ### STRING tag example Reserve a writable 40-byte holding-register range for barcode values a master can write with FC16: ```lot wrap focus={7} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ScannerServer WITH TYPE MODBUS_SERVER ADD MAPPING ScannerTags ADD TAG Barcode WITH ADDRESS "0" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "STRING" WITH LENGTH 40 WITH WRITABLE "true" WITH DESTINATION_TOPIC "scanner/barcode" ``` `WITH LENGTH 40` reserves holding registers 0–19 (`ceil(40 / 2) = 20` registers). A master FC16 write to those 20 registers is decoded and published to `scanner/barcode`. `STRING_SIZE` is accepted as an alias for `LENGTH`. *** ## Protocol Conformance This server implements Modbus Application Protocol **Conformance Class 0 and Class 1** over Modbus/TCP—the interoperability bar for standard masters: | Class | Function codes | Supported | | ------- | ------------------------------------------------------------------------------------------------------------------------------ | --------- | | Class 0 | FC 03 Read Holding Registers, FC 16 Write Multiple Registers | ✅ | | Class 1 | FC 01 Read Coils, FC 02 Read Discrete Inputs, FC 04 Read Input Registers, FC 05 Write Single Coil, FC 06 Write Single Register | ✅ | | Class 2 | FC 15 Write Multiple Coils | ✅ | Writes are accepted only on `WRITABLE` `HOLDING_REGISTER`/`COIL` addresses. Undeclared addresses and writes to read-only banks return exception **02 (Illegal Data Address)**. Modbus/TCP is **plaintext**—the protocol has no native authentication or encryption. Secure deployments by binding to a specific interface and restricting access at the network layer (firewall, VLAN, VPN). Modbus/TCP Security (TLS on port 802) is not yet implemented. *** ## Data Persistence & Recovery By default a server route holds register and coil values **in memory only**—a broker restart resets every address to zero. Set `WITH PERSISTENCE true` on `MODBUS_SERVER_CONFIG` to snapshot served values and restore them on startup. ### Ownership-aware policy What is safe to persist depends on who owns the value: | Bank | Owner | Default | On restart | | ----------------------------------------------------------- | ----------------------------------------------- | ----------------- | -------------------------------------------------------------- | | `HOLDING_REGISTER` / `COIL`, `WRITABLE true` | Master-written setpoint lives only in the slave | **Persisted** | Restored **authoritatively**—wins over retained MQTT | | `INPUT_REGISTER` / `DISCRETE_INPUT`, and non-writable banks | Fed from live MQTT | **Not persisted** | Recovered from **live MQTT** (retained message / next publish) | Master-written setpoints survive a restart; live telemetry always reflects the current MQTT source. ### Per-tag overrides `WITH PERSISTENCE true|false` on an individual `ADD TAG` overrides the route default: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OverrideServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH PORT 502 WITH PERSISTENCE true ADD MAPPING Registers WITH SOURCE_TOPIC "plant/line1" ADD TAG Setpoint WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH WRITABLE "true" ADD TAG LastGoodTemp WITH ADDRESS "200" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH PERSISTENCE "true" ADD TAG ScratchSetpoint WITH ADDRESS "300" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "INT16" WITH WRITABLE "true" WITH PERSISTENCE "false" ``` * `Setpoint` — restored authoritatively to the last master-written value. * `LastGoodTemp` — served from snapshot until `plant/line1/LastGoodTemp` next publishes. * `ScratchSetpoint` — always comes back at zero. ### Caching slow or infrequently-updated sources Broker-fed tags are not persisted by default. When a source updates infrequently—an hourly energy total, a shift-latched value—force-persist with `WITH PERSISTENCE "true"` so the last received value is re-served on cold start until the next live publish overrides it. ```lot wrap focus={6-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MeterServer WITH TYPE MODBUS_SERVER ADD MODBUS_SERVER_CONFIG WITH PORT 502 WITH PERSISTENCE true ADD MAPPING Meters WITH SOURCE_TOPIC "plant/energy" ADD TAG TotalEnergy WITH ADDRESS "100" WITH ADDRESS_TYPE "INPUT_REGISTER" WITH DATA_TYPE "FLOAT" WITH PERSISTENCE "true" ``` If every tag in a mapping is a slow source, set `WITH PERSISTENCE "true"` once on the `ADD MAPPING` block instead of per tag. ### Storage & durability * Values are written through a non-blocking background worker on every change (rapid updates coalesce to the latest value). * The snapshot lives in an encrypted-at-rest database under the broker data directory. * A snapshot is keyed to the route's tag definitions by a fingerprint. If addresses or types change between runs, the stale snapshot is discarded rather than replayed into reshaped addresses. *** ## Verify Your Server Add the route definition to your broker and confirm route status shows **Connected** (the endpoint is listening). Use any standards-compliant Modbus TCP master. Connect to `:` and address the route's `UNIT_ID` (default 1). Publish a test value to a bound `SOURCE_TOPIC`. Read the corresponding address from the master and confirm the decoded value matches. For a writable tag with a `DESTINATION_TOPIC`, write a value from the master. Subscribe to the destination topic with MQTT Explorer or any MQTT client and confirm the publish arrives within one `WITH EVERY` interval. *** ## Limitations Each `MODBUS_SERVER` route binds exclusively to its `PORT`. A second route on the same port fails to start. Give each route its own port. *** ## Troubleshooting Publish `-checkRouteConnection ` to `$SYS/Coreflux/Command`. For a server route this reports whether the hosted endpoint is **listening**, along with the endpoint, port, unit ID, and current connection count. * **Port in use** — Another route or process owns `PORT`. Choose a free port or stop the conflicting service. * **Bind error** — Check broker logs. Ensure `HOST` is a valid interface on the machine. Confirm `HOST`/`PORT`, network path, and firewall rules. The master must target the route's `UNIT_ID` (default 1). Registers default to zero until first set. Verify `SOURCE_TOPIC` has published; retained MQTT values are seeded at startup. * Confirm the tag is `WRITABLE` on a `HOLDING_REGISTER` or `COIL` with a `DESTINATION_TOPIC`. * The value surfaces within one `WITH EVERY` interval—verify the cadence. Read-only banks (`INPUT_REGISTER`, `DISCRETE_INPUT`) and non-writable tags reject master writes by design. Undeclared addresses return Illegal Data Address. **Accepted master writes:** FC06 (Write Single Register) and FC16 (Write Multiple Registers) on `WRITABLE` `HOLDING_REGISTER` tags; FC05 (Write Single Coil) and FC15 (Write Multiple Coils) on `WRITABLE` `COIL` tags. **Rejected master writes:** any write to `INPUT_REGISTER` or `DISCRETE_INPUT`; writes to non-`WRITABLE` tags; writes to addresses outside the declared tag range (including partial FC16 ranges that extend beyond a STRING tag's reserved registers). **Problem:** A master writes a STRING with FC16, but the server returns Illegal Data Address. **Check:** Confirm the full holding-register range is declared by the tag. STRING capacity is `ceil(LENGTH / 2)` registers. An omitted `WITH LENGTH` reserves the default **40 bytes** (**20 registers**) and logs a Warning; older broker versions reserved only one register when the setting was omitted. `STRING_SIZE` remains accepted as an alias. **Solution:** Set `WITH LENGTH` to the complete byte width the master writes, up to **246 bytes** (**123 registers**). Rejected undeclared holding-register ranges log `Master write rejected: … Check WITH LENGTH on STRING tags.` The broker logs master activity for this route in the broker log: * **Accepted writes** — FC06/FC16 on writable `HOLDING_REGISTER` tags; FC05/FC15 on writable `COIL` tags, with bank-aware function-code labels. * **Rejected writes** — read-only banks, non-writable tags, and undeclared address ranges (including partial FC16 spans beyond a STRING tag's reserved registers). * **Rejected reads** — undeclared or out-of-range read requests. * **Undeclared holding-register ranges** — rejection messages include `Check WITH LENGTH on STRING tags` when sizing is the likely cause. * **Connections** — open/close events with the active connection count from the existing watchdog. These logs **cannot identify the master's source IP** — it is not available to the server route. Align `BYTE_ORDER` and `WORD_ORDER` with the master's expectation. Default is big-endian, high word first. Confirm tag `ADDRESS` values match how the master addresses the server. Use `WITH ONE_BASED_ADDRESSES true` when your tag labels follow device-manual numbering (register 1 = address `"1"`). Modicon notation (`400100`) works regardless of the flag. `MAX_CONNECTIONS` is a **best-effort** soft cap—the underlying library exposes no per-connection control, so exceeding the cap is logged rather than enforced at accept time. Restrict concurrency at the network layer if a hard cap is required. *** ## Best Practices Prefer `WITH HOST "192.168.10.5"` over `0.0.0.0` when only one network should reach the server. Use `WITH PORT GET ENV "MODBUS_PORT"` instead of hard-coding environment-specific ports. Only declared addresses are served. One `ADD TAG` per register or coil a master needs. Leave `WRITABLE` unset for read-only points. Non-writable holding registers and read-only banks reject writes with a Modbus exception. Set `WORD_ORDER "LITTLEENDIAN"` when the master expects low word first. Default is big-endian, high word first. Use a shorter interval for responsive control loops; a longer one to reduce churn for slowly-changing values. Document which route owns which port to avoid startup conflicts when adding routes. Modbus TCP has no authentication. Place the server behind a firewall or VLAN and rely on HUB topic permissions for MQTT-side security. Watch `$SYS/Coreflux/Routes/{RouteName}/status`. `Connected` means the server is listening—investigate `Error` immediately (usually a port conflict). *** ## Next Steps Connect to remote Modbus devices and poll register data into MQTT. Connect to remote Modbus RTU devices over serial. # OPC UA Client Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/opcua-client Connect to remote OPC UA servers and poll device data into MQTT with security and BrowsePath support ## OPC UA Client This page documents the **client** route (`OPCUA`)—the broker connects **to** a remote OPC UA server and reads or writes device data over MQTT. To expose MQTT topics **to** OPC UA clients instead (SCADA, UaExpert, PLCs connect to the broker), see [OPC UA Server](./opcua-server). The `OPCUA` route enables communication with OPC UA servers, providing a standardized, cross-platform protocol for industrial automation. It supports multiple authentication modes, security policies, and both NodeId and BrowsePath addressing. OPC UA is vendor-neutral and widely supported by modern industrial equipment. Use it when you need secure, standardized communication across different manufacturer's devices. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OPCServer WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING ProcessData WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "ns=2;s=Channel1.Device1.Temperature" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/temperature" ``` *** ## Connection Configuration ### OPCUA\_CONFIG Parameters OPC UA server endpoint URL (e.g., `opc.tcp://localhost:4840`). Authentication mode: * `0` - Anonymous (no authentication) * `1` - Username/Password * `2` - Certificate Default: 0. Username for authentication (required if AUTH\_MODE is 1). Password for authentication (required if AUTH\_MODE is 1). Enable security mode. Default: false. Security policy level: * `0` - None * `1` - Basic128Rsa15 * `2` - Basic256 * `3` - Basic256Sha256 * `4` - Aes128\_Sha256\_RsaOaep * `5` - Aes256\_Sha256\_RsaPss Default: 0. Message security mode: * `0` - None * `1` - Sign * `2` - SignAndEncrypt Default: 0. Root directory for the four-folder PKI store (`own`, `issuer`, `trusted`, `rejected`). Defaults to the OPC Foundation layout under local application data. Set an explicit path—and mount it as a volume in Docker—so certificates persist across restarts. See [Certificate Management](#certificate-management). Path to a PKCS#12 (`.pfx`) or PEM client certificate used when `AUTH_MODE` is `2`. The broker loads it, adopts its application URI, and stores it under `PKI_DIR/own`. When omitted, a self-signed certificate is auto-generated on first connect. Path to the private key PEM file when `CLIENT_CERT_PATH` points to a certificate-only PEM. Not needed for PKCS#12 bundles. Password for the PKCS#12 bundle or encrypted private key. Use `GET SECRET`—never hard-code passwords in route definitions. Automatically accept untrusted server certificates. Default: false. For production, leave false and trust the server certificate explicitly—see [Certificate Management](#certificate-management). Suppress nonce validation errors. Default: false. Connection timeout in milliseconds. Default: 90000. *** ## Security Configuration For development or trusted networks: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH USE_SECURITY "false" WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ``` Basic authentication: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 1 WITH USERNAME "opcuser" WITH PASSWORD "secure_password" WITH USE_SECURITY "true" WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 ``` Authenticate with an X.509 certificate identity. The application instance certificate secures the channel and identifies the client to the server: ```lot wrap focus={4-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 2 WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH PKI_DIR "/data/opcua/pki" WITH CLIENT_CERT_PATH "/certs/client.pfx" WITH CLIENT_CERT_PASSWORD GET SECRET "OPCUA_CLIENT_CERT_PASSWORD" ``` Omit `CLIENT_CERT_PATH` to auto-generate a self-signed certificate. You still need mutual trust between broker and server—see [Certificate Management](#certificate-management). *** ## Certificate Management A secured OPC UA client connection requires **two certificates that trust each other**: the broker's **application instance certificate** (channel security and, with `AUTH_MODE 2`, user identity) and the **server's certificate** (must be in your `trusted` store). For server-side trust workflows, see [OPC UA Server](./opcua-server#certificate-management). **Like exchanging ID badges at a secure facility.** Both sides must recognize each other's badge before the door opens—moving a certificate from `rejected` to `trusted` is how you approve a new badge. ### PKI directory layout Both client and server routes store certificates in four folders under `PKI_DIR`: | Folder | Holds | File form | | ---------- | ---------------------------------------------------------------------------- | ----------------- | | `own` | This application's certificate (`own/certs`) and private key (`own/private`) | `*.der` + `*.pfx` | | `issuer` | Trusted CA / issuer certificates for chain validation | `*.der` | | `trusted` | Trusted peer certificates—the server you accept goes here (`trusted/certs`) | `*.der` | | `rejected` | Certificates seen during a handshake but not yet trusted | `*.der` | ### Where certificates live When `PKI_DIR` is not set, it defaults to `%LocalApplicationData%/OPC Foundation/pki`: | OS | Default path | | ------- | -------------------------------------------------------------------------- | | macOS | `~/Library/Application Support/OPC Foundation/pki` | | Linux | `$XDG_DATA_HOME/OPC Foundation/pki` or `~/.local/share/OPC Foundation/pki` | | Windows | `%LOCALAPPDATA%\OPC Foundation\pki` | Relocate the store to a path you control: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://server:4840" WITH PKI_DIR "/data/opcua/pki" ``` In Docker, the default PKI location is ephemeral—the broker regenerates its certificate on every restart and any server that trusted the previous one will reject the new one. **Always set `PKI_DIR` to a mounted volume.** ### Inspecting and exporting certificates Certificates are plain files you can inspect with `openssl`: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # The broker's own certificate and private key ls "$PKI_DIR"/own/certs/*.der ls "$PKI_DIR"/own/private/*.pfx # Inspect subject, validity, and application URI SAN openssl x509 -inform der -in "$PKI_DIR"/own/certs/.der -noout -subject -dates -ext subjectAltName # Export public cert for the server's trust list openssl x509 -inform der -in "$PKI_DIR"/own/certs/.der -out broker-client.pem ``` Give peers the **public** certificate (`own/certs/*.der`). The private key (`own/private/*.pfx`) never leaves the broker. ### Auto-generated client certificate With `AUTH_MODE 2` and no `CLIENT_CERT_PATH`, the broker auto-generates a self-signed certificate (`CN=CorefluxBroker_OpcUaClient, O=Coreflux`) in `PKI_DIR/own` on first connect: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantClient WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://server:4840" WITH AUTH_MODE 2 WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH PKI_DIR "/data/opcua/pki" ``` You still must (1) get this broker certificate trusted by the server and (2) trust the server's certificate—see the workflows below. An application instance certificate **must** carry an application URI in its Subject Alternative Name. The OPC UA stack matches certificates by that URI and rejects certificates without one. ### Supply your own client certificate Use a certificate from your PKI via `CLIENT_CERT_PATH`. Generate it with a mandatory URI SAN: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Self-signed example—use your CA in production. The URI SAN is mandatory. openssl req -x509 -newkey rsa:2048 -keyout client-key.pem -out client-cert.pem -days 825 -nodes \ -subj "/CN=PlantClient/O=ACME" \ -addext "subjectAltName=URI:urn:acme:opcua:plant-client" \ -addext "keyUsage=digitalSignature,keyEncipherment,dataEncipherment,nonRepudiation" \ -addext "extendedKeyUsage=clientAuth,serverAuth" # Bundle into PKCS#12 (.pfx) openssl pkcs12 -export -inkey client-key.pem -in client-cert.pem \ -out client.pfx -passout pass:CHANGEME -name plantclient ``` ```lot wrap focus={6-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantClient WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://server:4840" WITH AUTH_MODE 2 WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH CLIENT_CERT_PATH "/certs/client.pfx" WITH CLIENT_CERT_PASSWORD GET SECRET "OPCUA_CLIENT_CERT_PASSWORD" WITH PKI_DIR "/data/opcua/pki" ``` Point `CLIENT_CERT_PATH` at the certificate PEM. If the key is in a separate file, add `CLIENT_KEY_PATH`: ```lot wrap focus={6-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantClient WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://server:4840" WITH AUTH_MODE 2 WITH USE_SECURITY true WITH CLIENT_CERT_PATH "/certs/client-cert.pem" WITH CLIENT_KEY_PATH "/certs/client-key.pem" WITH CLIENT_CERT_PASSWORD GET SECRET "OPCUA_CLIENT_KEY_PASSWORD" WITH PKI_DIR "/data/opcua/pki" ``` ### Trust the server's certificate Required when `USE_SECURITY true` and `AUTO_ACCEPT_UNTRUSTED_CERTIFICATES false`: The first handshake fails and the server's certificate is written to `PKI_DIR/rejected/certs`. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} mv "$PKI_DIR"/rejected/certs/.der "$PKI_DIR"/trusted/certs/ ``` The route reconnects automatically—the handshake now succeeds. For testing only, skip this with `WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES true`. ### Get the broker trusted by the server For `AUTH_MODE 2`, the server must trust the broker's client certificate: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} openssl x509 -inform der -in "$PKI_DIR"/own/certs/.der -out broker-client.pem ``` Import `broker-client.pem` (or the `.der`) into the server's trusted certificate store—via its PKI `trusted` folder or vendor certificate manager. The server now accepts the certificate identity. ### Rotation and renewal * **Auto-generated certificates** — Delete files in `PKI_DIR/own/certs` and `PKI_DIR/own/private`, then reconnect; the broker regenerates a new self-signed certificate. * **Supplied certificates** — Replace the `.pfx`/PEM file, remove the old copy from `PKI_DIR/own`, and redeploy. Re-distribute the new public certificate to the server's trust list before the old one expires. * Plan rotation **before expiry**—an expired application instance certificate fails the handshake. ### Verify the connection After mutual trust is configured, confirm the route connects: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -checkRouteConnection PlantClient ``` Publish that command to `$SYS/Coreflux/Command`. A response with `"success": true` means the certificate-authenticated session is established. *** ## NodeId Addressing OPC UA uses NodeIds to identify variables. The format is: `ns=;s=` or `ns=;i=` | Format | Example | Description | | ------------------ | --------------------------------------------- | ------------------------------------ | | String identifier | `ns=2;s=Channel1.Device1.Tag1` | Most common, human-readable | | Numeric identifier | `ns=2;i=1234` | More efficient, used by some servers | | GUID identifier | `ns=2;g=12345678-1234-1234-1234-123456789012` | Globally unique identifiers | ### Finding NodeIds Use an OPC UA browser tool (like UaExpert, Prosys OPC UA Browser, or similar) to explore the server's address space and find the correct NodeIds. ### BrowsePath Mode For servers that support it, you can use symbolic paths instead of NodeIds: Enable BrowsePath mode - treat all TAG addresses as browse paths instead of NodeIds. Root path for browsing when USE\_BROWSE\_PATH is enabled. Default: Objects. #### BrowsePath Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH USE_BROWSE_PATH "true" WITH BROWSE_ROOT_PATH "Objects" ADD MAPPING Tags WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "Aliases/MyDevice/Temperature" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/temperature" ``` *** ## Data Types | Data Type | OPC UA Type | Description | | ------------------------------ | ----------- | ------------------------------------------------------- | | `BOOLEAN` / `BOOL` | Boolean | True/False value | | `SBYTE` / `SINT` | SByte | Signed 8-bit integer | | `BYTE` / `USINT` | Byte | Unsigned 8-bit integer | | `INT16` / `INT` / `WORD` | Int16 | 16-bit integer | | `UINT16` / `UINT` | UInt16 | Unsigned 16-bit integer | | `INT32` / `DINT` | Int32 | Signed 32-bit integer | | `UINT32` / `UDINT` / `DWORD` | UInt32 | Unsigned 32-bit integer | | `INT64` / `LINT` | Int64 | Signed 64-bit integer | | `UINT64` / `ULINT` / `LWORD` | UInt64 | Unsigned 64-bit integer | | `FLOAT` / `FLOAT32` / `REAL` | Float | 32-bit floating point | | `DOUBLE` / `FLOAT64` / `LREAL` | Double | 64-bit floating point | | `CHAR` | Char | Single character | | `WCHAR` | WChar | Wide character | | `TIME` | Time | Time duration | | `DATE` | Date | Date value | | `TOD` | TimeOfDay | Time of day | | `STRING` | String | Unicode string; sized with `LENGTH` (default 256 bytes) | ### String sizing For `STRING` tags, `WITH LENGTH` sets the maximum string size in **bytes** (default **256**). `STRING_SIZE` is accepted as an alias for the same field. ```lot wrap focus={11} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OpcUaStrings WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING ProductData WITH EVERY 1 SECOND ADD TAG ProductName WITH ADDRESS "ns=2;s=Product.Name" WITH DATA_TYPE "STRING" WITH LENGTH 128 WITH SOURCE_TOPIC "opcua/product/name" ``` For cross-protocol defaults and caps, see [String sizing by Route](./overview#string-sizing-by-route) on the industrial overview. *** ## TAG Configuration ### Complete TAG Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG ProcessTemperature WITH ADDRESS "ns=2;s=Process.Temperature" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/process/temperature" WITH SCALING 1 WITH OFFSET 0 WITH UNIT "°C" WITH DECIMAL_PLACES 2 WITH MIN_VALUE -50 WITH MAX_VALUE 500 WITH DEADBAND 0.5 WITH PUBLISH_MODE "JSON" WITH WRITABLE "true" WITH DESTINATION_TOPIC "opcua/process/temperature/set" WITH IS_ARRAY "false" WITH DESCRIPTION "Main process temperature sensor" ``` ### TAG Parameters OPC UA NodeId (e.g., `ns=2;s=MyVariable` or `ns=2;i=1234`) or BrowsePath if USE\_BROWSE\_PATH is enabled. OPC UA data type: `BOOLEAN`, `BYTE`, `INT16`, `UINT16`, `INT32`, `UINT32`, `INT64`, `UINT64`, `FLOAT`, `DOUBLE`, `STRING`. Indicates if the value is an array. Default: false. **STRING only.** Maximum string size in bytes. Alias: `STRING_SIZE`. Multiplier for value transformation. Default: 1.0. Offset added after scaling. Default: 0.0. Decimal places in output. Default: 2. Minimum allowed value. Maximum allowed value. Minimum change to trigger publish. Default: 0.0. Topic where PLC values are published. Subscribe here to receive sensor data. Output format: `VALUE_ONLY` or `JSON`. Default: VALUE\_ONLY. Engineering unit for documentation. Human-readable description. Allow writing to this node. Default: false. Topic to send write commands. Publish a value here to write it to the PLC. *** ## Event-Based Operations For on-demand OPC UA operations (not polling), use the EVENT syntax. Publish a message to SOURCE\_TOPIC to trigger the operation; the route executes it and publishes the result to DESTINATION\_TOPIC. ### Supported Operations | Operation | Description | Query Parameters | | --------- | --------------------------- | ------------------------------- | | `READ` | Read a node value on demand | `node_id`, `data_type` | | `WRITE` | Write a value to a node | `node_id`, `data_type`, `value` | ### Query Parameters | Parameter | Description | Example | | ----------- | --------------------------------- | ------------------------------------ | | `operation` | Operation type: `READ` or `WRITE` | `READ` | | `node_id` | OPC UA NodeId (string or numeric) | `ns=2;i=2`, `ns=2;s=Process.Temp` | | `data_type` | OPC UA data type | `INT32`, `FLOAT`, `DOUBLE`, `STRING` | | `value` | Value to write (WRITE only) | `42`, `23.5`, `"hello"` | ### Read Example Read a node value on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadOnDemand WITH SOURCE_TOPIC "opcua/commands/read" WITH DESTINATION_TOPIC "opcua/responses/read" WITH QUERY "{operation: READ, node_id: ns=2;i=2, data_type: INT32}" ``` ### Write Example Write a FLOAT value to a node on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "opcua/commands/write" WITH DESTINATION_TOPIC "opcua/responses/write" WITH QUERY "{operation: WRITE, node_id: ns=2;s=Process.Setpoint, data_type: FLOAT, value: 75.0}" ``` *** ## Complete Examples Simple OPC UA connection with anonymous access: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OPCServer WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING ProcessData WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "ns=2;s=Channel1.Device1.Temperature" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "ns=2;s=Channel1.Device1.Pressure" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/pressure" WITH UNIT "bar" ADD TAG Status WITH ADDRESS "ns=2;s=Channel1.Device1.Status" WITH DATA_TYPE "INT32" WITH SOURCE_TOPIC "opcua/status" ``` Production setup with encryption: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureOPC WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 1 WITH USERNAME "operator" WITH PASSWORD "secure_password_here" WITH USE_SECURITY "true" WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH TIMEOUT 30000 ADD MAPPING SecureData WITH EVERY 500 MILLISECONDS ADD TAG CriticalValue WITH ADDRESS "ns=2;s=Safety.CriticalValue" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "secure/critical" WITH PUBLISH_MODE "JSON" ``` Using symbolic paths: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BrowsePathOPC WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH USE_BROWSE_PATH "true" WITH BROWSE_ROOT_PATH "Objects" WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING ProcessTags WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "DataAccess/AnalogItems/Temperature" WITH DATA_TYPE "DOUBLE" WITH SOURCE_TOPIC "opcua/temperature" ADD TAG Setpoint WITH ADDRESS "DataAccess/AnalogItems/Setpoint" WITH DATA_TYPE "DOUBLE" WITH SOURCE_TOPIC "opcua/setpoint" WITH WRITABLE "true" WITH DESTINATION_TOPIC "opcua/setpoint/write" ``` Reading array data: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ArrayOPC WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING ArrayData WITH EVERY 1 SECOND ADD TAG TemperatureArray WITH ADDRESS "ns=2;s=Process.TemperatureArray" WITH DATA_TYPE "FLOAT" WITH IS_ARRAY "true" WITH SOURCE_TOPIC "opcua/temperatures" WITH PUBLISH_MODE "JSON" ADD TAG StatusBits WITH ADDRESS "ns=2;s=Process.StatusBits" WITH DATA_TYPE "BOOLEAN" WITH IS_ARRAY "true" WITH SOURCE_TOPIC "opcua/status/bits" ``` Connecting to Kepware OPC UA server: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE KepwareServer WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.50:49320" WITH AUTH_MODE 0 WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING KepwareTags WITH EVERY 500 MILLISECONDS ADD TAG Motor1Speed WITH ADDRESS "ns=2;s=Channel1.Device1.Motor1_Speed" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "kepware/motor1/speed" WITH UNIT "RPM" ADD TAG Motor1Current WITH ADDRESS "ns=2;s=Channel1.Device1.Motor1_Current" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "kepware/motor1/current" WITH UNIT "A" ADD TAG Motor1Running WITH ADDRESS "ns=2;s=Channel1.Device1.Motor1_Running" WITH DATA_TYPE "BOOLEAN" WITH SOURCE_TOPIC "kepware/motor1/running" ``` Continuous monitoring with on-demand read and write events in the same route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullOPCSetup WITH TYPE OPCUA ADD OPCUA_CONFIG WITH ENDPOINT_URL "opc.tcp://192.168.1.100:4840" WITH AUTH_MODE 0 WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES "true" ADD MAPPING ProcessData WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "ns=2;s=Channel1.Device1.Temperature" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "ns=2;s=Channel1.Device1.Pressure" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "opcua/pressure" WITH UNIT "bar" ADD EVENT ReadNode WITH SOURCE_TOPIC "opcua/commands/read" WITH DESTINATION_TOPIC "opcua/responses/read" WITH QUERY "{operation: READ, node_id: ns=2;s=Channel1.Device1.Status, data_type: INT32}" ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "opcua/commands/write" WITH DESTINATION_TOPIC "opcua/responses/write" WITH QUERY "{operation: WRITE, node_id: ns=2;s=Channel1.Device1.Setpoint, data_type: FLOAT, value: 0}" ``` The MAPPING continuously reads temperature and pressure. To trigger an on-demand status read, publish any message to `opcua/commands/read`. To write a setpoint, publish to `opcua/commands/write`. *** ## Security Best Practices Always enable security for production deployments: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH USE_SECURITY "true" WITH SECURITY_POLICY 3 // Basic256Sha256 WITH MESSAGE_SECURITY 2 // SignAndEncrypt ``` In production, properly configure certificate trust: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES false ``` Move server certificates from `PKI_DIR/rejected/certs` to `PKI_DIR/trusted/certs` during commissioning. See [Certificate Management](#certificate-management). Prefer username/password or certificate authentication over anonymous: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH AUTH_MODE 1 // or '2' for certificate WITH USERNAME "operator" WITH PASSWORD "strong_password_here" ``` *** ## Troubleshooting * Verify endpoint URL is correct * Check firewall allows TCP port (usually 4840) * Ensure server is running and accessible * Try increasing TIMEOUT value * Verify security policy matches server configuration * **Server certificate not trusted** — Move it from `PKI_DIR/rejected/certs` to `PKI_DIR/trusted/certs`, or set `AUTO_ACCEPT_UNTRUSTED_CERTIFICATES true` for testing only * **Supplied certificate ignored** — Reissue with a `subjectAltName=URI:...` SAN; certificates without an application URI are rejected * **Server rejects client certificate** — Export the broker's public cert from `PKI_DIR/own/certs` and add it to the server's trust list * **Certificate regenerates every restart** — Set `PKI_DIR` to a persistent volume (especially in Docker) * **`BadCertificateUriInvalid`** — Regenerate the certificate or set a stable application URI in the SAN * Verify the file path is correct and readable by the broker * Check `CLIENT_CERT_PASSWORD` matches the PKCS#12 bundle or encrypted key * For a split PEM (certificate and key in separate files), set `CLIENT_KEY_PATH` * The broker log names the failing file—check it for the exact cause * Use OPC UA browser to verify correct NodeId * Check namespace index (ns=) is correct * Verify node exists in server's address space * Try BrowsePath mode if NodeId is unclear * Verify username and password * Check user has appropriate permissions * Ensure AUTH\_MODE matches server requirements * Verify DATA\_TYPE matches server variable type * Check IS\_ARRAY setting for array values * Verify SCALING and OFFSET calculations *** ## Next Steps Expose MQTT topics as OPC UA nodes for SCADA and OT clients. Connect to TwinCAT systems. # OPC UA Server Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/opcua-server Expose MQTT topics as OPC UA nodes so SCADA systems, PLCs, and OPC UA clients connect to your Coreflux broker ## OPC UA Server This page documents the **server** route (`OPCUA_SERVER`)—the broker **hosts** an OPC UA server and exposes MQTT topics as nodes. External clients connect to the broker. To connect the broker **to** a remote OPC UA device instead, see [OPC UA Client](./opcua-client). The `OPCUA_SERVER` route makes your broker **host** an OPC UA server. External clients—SCADA, UaExpert, PLCs—connect **to the broker** and see your MQTT topics as OPC UA nodes. Use `OPCUA` when you need to **read from** a device. Use `OPCUA_SERVER` when OT systems need to **browse and write** values that live on your MQTT bus. **Like turning your broker into a shop window.** MQTT messages update what's on display (node values), and customers (OPC UA clients) can look—and, for writable tags, change items that publish back to MQTT. ## When to Use This Route * **SCADA integration** — Let a SCADA or HMI browse broker data without custom drivers. * **OT/IT bridge** — Expose IoT sensor streams to plant-floor tools that speak OPC UA. * **Bidirectional control** — Writable tags let clients adjust setpoints; the broker publishes the new value to MQTT. * **Dynamic discovery** — Wildcard topic mappings create nodes automatically as matching topics first publish. *** The sections below cover data flow, configuration, persistence, and production patterns. ## Quick Start Map MQTT topics to fixed OPC UA nodes. Each `ADD TAG` creates one node; publishing to `{SOURCE_TOPIC}/{TagName}` updates it. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Line1 WITH SOURCE_TOPIC "factory/line1" ADD TAG Temperature WITH DATA_TYPE "FLOAT" ADD TAG Counter WITH DATA_TYPE "INT32" ``` * Publish `23.5` to `factory/line1/Temperature` → node `ns=2;s=PlantServer/Temperature` reads `23.5`. * Publish `1042` to `factory/line1/Counter` → node `ns=2;s=PlantServer/Counter` reads `1042`. Allow OPC UA clients to write a node; the broker publishes the value back to MQTT on the same topic, retained. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Line1 WITH SOURCE_TOPIC "factory/line1" ADD TAG Setpoint WITH DATA_TYPE "FLOAT" WITH WRITABLE true WITH DECIMAL_PLACES 1 WITH MIN_VALUE 0 WITH MAX_VALUE 100 ``` MQTT publishes set the node. A client writing `72.4` to the Setpoint node publishes `72.4` to `factory/line1/Setpoint` (clamped and rounded per tag settings). Omit `ADD TAG` and use an MQTT wildcard in `SOURCE_TOPIC`. Nodes appear automatically when matching topics first publish—see [Dynamic topic handling](#dynamic-topic-handling). ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DynamicSensors WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Sensors WITH SOURCE_TOPIC "sensors/+/value" ``` First publish to `sensors/press1/value` creates node `ns=2;s=sensors/press1/value`. First publish to `sensors/tank2/value` creates another node. No route edit required. Encrypt traffic, require credentials, and combine static tags with dynamic discovery: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecurePlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH ROOT_FOLDER "Plant" WITH NAMESPACE_URI "urn:acme:plant" WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH ALLOW_ANONYMOUS false WITH USERNAME GET ENV "OPCUA_USER" WITH PASSWORD GET SECRET "OPCUA_PASSWORD" ADD MAPPING Line1 WITH SOURCE_TOPIC "factory/line1" ADD TAG Setpoint WITH DATA_TYPE "FLOAT" WITH WRITABLE true WITH DECIMAL_PLACES 1 WITH MIN_VALUE 0 WITH MAX_VALUE 100 ADD MAPPING Sensors WITH SOURCE_TOPIC "sensors/+/value" ``` Clients connect with `opc.tcp://:4840` using Basic256Sha256 and SignAndEncrypt. Store credentials with `GET ENV` / `GET SECRET`—never hard-code passwords. Persist client-written setpoints across broker restarts: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ResilientServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH PERSISTENCE true ADD MAPPING Control WITH SOURCE_TOPIC "plant/line1" ADD TAG Setpoint WITH DATA_TYPE "FLOAT" WITH WRITABLE true WITH DECIMAL_PLACES 1 WITH MIN_VALUE 0 WITH MAX_VALUE 100 ADD TAG Temperature WITH DATA_TYPE "FLOAT" ``` After a restart, a client reading `Setpoint` gets the last value it wrote instead of `BadWaitingForInitialData`. `Temperature` refreshes from the live MQTT source, not the snapshot. *** ## How Data Flows ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} graph LR subgraph mqtt [MQTT] Topics[Topics] end subgraph broker [Coreflux Broker] Route[OPCUA_SERVER Route] Nodes[OPC UA Nodes] end subgraph ot [OT Clients] Client[SCADA / UaExpert / PLC] end Topics -->|publish sets value| Route Route --> Nodes Nodes -->|browse / read| Client Client -->|write writable tag| Route Route -->|publish retained| Topics ``` | Direction | Behavior | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **MQTT → node** | Each tag binds to an MQTT topic via `SOURCE_TOPIC`. A publish on that topic sets the node value. The MQTT payload is authoritative—no scaling or rounding is applied inbound. Retained values present before the route starts are seeded at startup. | | **node → MQTT** | When a tag has `WITH WRITABLE true`, an external client writing the node publishes the new value back to the **same** `SOURCE_TOPIC`, retained. `FLOAT` and `DOUBLE` values are rounded to `DECIMAL_PLACES` (default 2) and clamped by `MIN_VALUE` / `MAX_VALUE`. Client writes do not loop back into duplicate node updates. | | **Wildcard mappings** | Wildcard `SOURCE_TOPIC` patterns create nodes on first publish. See [Dynamic topic handling](#dynamic-topic-handling). | *** ## Server Configuration All settings live in the `OPCUA_SERVER_CONFIG` block. Store credentials with `GET ENV` or `GET SECRET`. The example below shows a production-oriented config: custom identity, encrypted endpoint, username/password auth, and a persistent certificate store. Clients connect at `opc.tcp://:4840/coreflux`. ```lot wrap focus={2-16} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantOpcServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH ENDPOINT_PATH "/coreflux" WITH APPLICATION_NAME "Coreflux Plant Server" WITH APPLICATION_URI "urn:plant.example.com:Coreflux:PlantOpcServer" WITH NAMESPACE_URI "urn:plant.example.com:opcua" WITH ROOT_FOLDER "Plant" WITH PKI_DIR "/data/opcua-pki" WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH ALLOW_ANONYMOUS false WITH USERNAME GET ENV "OPCUA_USER" WITH PASSWORD GET SECRET "OPCUA_PASSWORD" WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES false ADD MAPPING Tags WITH SOURCE_TOPIC "plant/tags" ADD TAG Status WITH DATA_TYPE "INT32" ``` ### Connection and identity TCP port the server listens on (`opc.tcp`). Each server route claims its port process-wide—two routes cannot share the same port. Optional path appended to the base address, for example `/coreflux`. Default: empty. Application display name and certificate common name (CN). Application URI. Must match the certificate SAN URI. Changing this regenerates the certificate. Namespace URI for exposed nodes. Root folder under `Objects` where tags are organized. Directory for own, issuer, trusted, and rejected certificate stores. Defaults to the OPC Foundation PKI layout. In Docker, mount this path as a volume so certificates persist across restarts. See [Certificate Management](#certificate-management). ### Security Offer a signed or encrypted endpoint. This defaults to `true` to comply with OPC Foundation specifications, which require secure endpoints. For testing purposes, you might need to set it to `false`. Security policy offered when `USE_SECURITY` is true: | Value | Policy | | ----- | ----------------------- | | `0` | None | | `3` | Basic256Sha256 | | `4` | Aes128\_Sha256\_RsaOaep | | `5` | Aes256\_Sha256\_RsaPss | Message security mode. Requires `SECURITY_POLICY` ≥ 1 when using Sign or SignAndEncrypt. | Value | Mode | | ----- | -------------- | | `0` | None | | `1` | Sign | | `2` | SignAndEncrypt | When `USE_SECURITY` is true, also offer a None/None endpoint. Use for testing only. ### Authentication Offer anonymous login. Set to `false` for production and configure `USERNAME`/`PASSWORD` or `ALLOW_CERTIFICATE_AUTH`. Username for password-based login. Use `GET ENV` or `GET SECRET`. Password for password-based login. Use `GET ENV` or `GET SECRET`. Offer X.509 certificate user authentication. Automatically trust connecting client certificates. When false, untrusted client certs are placed in the `rejected` store. Persist served node values across broker restarts. See [Data persistence & recovery](#data-persistence--recovery). An application instance certificate is auto-generated (self-signed) on first start under `PKI_DIR/own`. If you change `APPLICATION_URI`, the certificate is regenerated to match. *** ## Tag and Mapping Configuration Tags reuse the same vocabulary as the [OPC UA client route](./opcua-client), but several settings carry different meaning on the server side. ### Server vs. client semantics | Setting | Client (`OPCUA`) | Server (`OPCUA_SERVER`) | | -------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- | | `SOURCE_TOPIC` | Topic where read values are **published** | **Bidirectional binding** — MQTT sets the node; client writes publish back here | | `WRITABLE` | Broker may write the remote device | External clients may write the node | | `DESTINATION_TOPIC` | Write-command topic | Not used on server routes | | `ADDRESS` | Required remote NodeId | Optional explicit NodeId; default `s={ROOT_FOLDER}/{TagName}` | | `EVERY` | Poll interval | Ignored — updates are event-driven from MQTT | | `QUERY` events | On-demand read/write | Not supported | | `SCALING` / `OFFSET` / `DEADBAND` | Applied to reads from the device | Not applied inbound — MQTT payload is authoritative | | `DECIMAL_PLACES` / `MIN_VALUE` / `MAX_VALUE` | Applied to reads | Applied when a client write is published to MQTT | | `STORE TO` / triggers | Supported on client routes | Not supported | On server routes, route status **Connected** means the endpoint is **listening**—not that a remote session is established. ### Explicit NodeId By default, a tag named `Temperature` under route `PlantServer` with `ROOT_FOLDER` defaulting to the route name maps to `ns=2;s=PlantServer/Temperature`. Override with `ADDRESS`: ```lot wrap focus={6,7} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Line1 WITH SOURCE_TOPIC "factory/line1" ADD TAG Temperature WITH ADDRESS "ns=2;s=Custom/Line1/Temp" WITH DATA_TYPE "FLOAT" ``` ### Writable tag parameters For writable tags, configure outbound formatting the same way as on the client route: Allow OPC UA clients to write this node. Writes publish to the tag's `SOURCE_TOPIC`. Decimal places applied to `FLOAT`/`DOUBLE` values when a client write is published to MQTT. Minimum value after a client write. Values below this are clamped. Maximum value after a client write. Values above this are clamped. Per-tag override of the route persistence default. Precedence: tag → mapping → route. See [Data persistence & recovery](#data-persistence--recovery). ### Supported data types Use the same `DATA_TYPE` values as the [OPC UA client route](./opcua-client)—for example `BOOLEAN`, `INT32`, `FLOAT`, `DOUBLE`, and `STRING`. Tags can also expose one-dimensional **arrays** or **struct objects**—see [Array and struct nodes](#array--struct-nodes). For statically defined scalar tags, set `DATA_TYPE` explicitly. Dynamic nodes infer type from the first MQTT payload (see below). **String sizing:** `STRING` nodes are natively variable-length in OPC UA. The `OPCUA_SERVER` route does not accept `LENGTH` or `STRING_SIZE`—there is no string-size parameter for scalar string tags. For array tags, `ARRAY_SIZE` sets the **node dimension** only (how many elements the array node exposes), not a string buffer width. *** ## Dynamic Topic Handling Wildcard mappings let you expose many MQTT topics without listing each one as an `ADD TAG`. When a topic matches the pattern for the first time, the route creates a new OPC UA node automatically. ### How it works 1. Define a mapping with a wildcard `SOURCE_TOPIC`—for example `sensors/+/value` or `plant/#`. 2. Do **not** add `ADD TAG` entries under that mapping. The wildcard pattern alone drives node creation. 3. On the **first publish** to a matching topic, the route creates an OPC UA node and sets its value from the payload. 4. Later publishes to the same topic update that node. High-frequency updates use an equality check so unchanged values do not spam change notifications. Dynamic nodes are **read-only**. OPC UA clients cannot write them back to MQTT. Use static `ADD TAG` definitions with `WRITABLE true` when you need bidirectional control. ### Topic → node mapping The NodeId string identifier mirrors the full MQTT topic path: | MQTT topic (first publish) | OPC UA NodeId | | -------------------------- | ------------------------------ | | `sensors/press1/value` | `ns=2;s=sensors/press1/value` | | `sensors/tank2/value` | `ns=2;s=sensors/tank2/value` | | `plant/line3/motor/rpm` | `ns=2;s=plant/line3/motor/rpm` | Under `Objects`, folder nodes are created from topic segments so clients can browse a hierarchy that matches your MQTT layout—for example `sensors` → `press1` → `value`. ### Supported wildcards | Pattern | Matches | Example | | ------------------ | -------------------------------- | ----------------------------------------------------------------------------------- | | `+` (single level) | Exactly one topic segment | `sensors/+/value` matches `sensors/press1/value` but not `sensors/line1/temp/value` | | `#` (multi-level) | Zero or more segments at the end | `plant/#` matches `plant/line1/temp` and `plant/line1/motor/rpm` | Combine static and dynamic mappings in the same route. Static `ADD TAG` entries under a non-wildcard `SOURCE_TOPIC` behave as fixed nodes; wildcard mappings handle everything else that matches. ### Type inference Dynamic nodes have no `DATA_TYPE` in the route definition. The broker infers the OPC UA type from the **first** payload and locks it for the lifetime of that node: | First MQTT payload | Inferred OPC UA type | | ------------------------------------ | -------------------- | | Numeric (`42`, `23.5`) | `Double` | | `true` or `false` (case-insensitive) | `Boolean` | | Anything else | `String` | If a later publish cannot be converted to the pinned type, the node keeps its previous value and the broker logs an error. Publish a representative value first—do not send a placeholder string if you expect numeric updates later. ### Example This mapping exposes every `sensors//value` topic as it appears on the bus: ```lot wrap focus={4-5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorGateway WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Sensors WITH SOURCE_TOPIC "sensors/+/value" ``` **Sequence:** 1. Route starts; no dynamic nodes exist yet. 2. Device publishes `45.2` to `sensors/press1/value` → node `ns=2;s=sensors/press1/value` appears with value `45.2` (`Double`). 3. Device publishes `false` to `sensors/alarm1/value` → node `ns=2;s=sensors/alarm1/value` appears with value `false` (`Boolean`). 4. UaExpert refresh shows both nodes under the `sensors` folder. Further publishes update values in place. Retained messages already on the broker when the route starts are seeded the same way as for static tags—matching topics create nodes at startup. ### Dynamic node limits Dynamic nodes are not removed while the route is running. They persist until the route is removed or the broker restarts. Plan wildcard patterns carefully in high-churn environments where topic names change frequently. *** ## Array & Struct Nodes Scalar tags expose one value per node. Use an **array** tag when MQTT carries a list of values on one topic, or a **struct** tag when the payload is a JSON object with named fields. OPC UA clients then browse and write the same shape—no extra config block beyond the tag settings below. ### Array tags An array tag turns a JSON list on MQTT into a single OPC UA array node. Publish a list; the client sees one node with multiple elements. With `WRITABLE true`, a client edit publishes the full list back to the same topic, retained. Set to `"ARRAY"` for list values. Type of each element: `BOOL`, `INT16`, `UINT16`, `INT32`, `UINT32`, `INT64`, `UINT64`, `FLOAT`, `DOUBLE`, or `STRING`. Number of elements in the array. Set this to match your payload length. If omitted, the dimension is unspecified. Define the tag, then publish a JSON array to `{SOURCE_TOPIC}/{TagName}`: ```lot wrap focus={4-10} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Line1 WITH SOURCE_TOPIC "factory/line1" ADD TAG Levels WITH DATA_TYPE "ARRAY" WITH ARRAY_ELEMENT_TYPE "DOUBLE" WITH ARRAY_SIZE 3 WITH WRITABLE true ``` **What happens:** * Publish `[1.5, 2.5, 3.5]` to `factory/line1/Levels` → node `ns=2;s=PlantServer/Levels` shows three values. * Payloads wrapped as `{"value": [1.5, 2.5, 3.5]}` are accepted—the broker unwraps the list automatically. * A client write to the array node publishes the updated list back to `factory/line1/Levels`. ### Struct tags A struct tag maps a JSON object to a folder-like node with one child per field. Field names in MQTT must match the names you declare in `STRUCT_FIELD_DEFINITIONS`. Set to `"STRUCT"` for object payloads. Comma-separated field list in `name:type` form—for example `bRunning:BOOL,iRpm:INT,sState:STRING`. If you reuse a definition from an ADS or EtherNet/IP route (`name:offset:type:size`), the server uses the name and type only; offset and size are ignored. ```lot wrap focus={4-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 ADD MAPPING Line1 WITH SOURCE_TOPIC "factory/line1" ADD TAG Motor WITH DATA_TYPE "STRUCT" WITH WRITABLE true WITH STRUCT_FIELD_DEFINITIONS "bRunning:BOOL,iRpm:INT,sState:STRING" ``` **What happens:** * Publish `{"bRunning": true, "iRpm": 1450, "sState": "RUN"}` to `factory/line1/Motor` → clients see `Motor.bRunning`, `Motor.iRpm`, and `Motor.sState` under the struct node. * With `WRITABLE true`, a client writing one field to OPC-UA (for example `1600` to `Motor.iRpm`) merges into the current struct and publishes the **full** object back to MQTT `factory/line1/Motor`—for example `{"bRunning": true, "iRpm": 1600, "sState": "RUN"}`. * Fields that were never set by MQTT and were not written by the client are left out of that publish. `DECIMAL_PLACES`, `MIN_VALUE`, and `MAX_VALUE` apply to scalar writable tags only—not to individual struct fields. *** ## Security Examples For lab or trusted networks where encryption is not required: ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DevServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH ALLOW_ANONYMOUS true ADD MAPPING Demo WITH SOURCE_TOPIC "demo/tags" ADD TAG Value WITH DATA_TYPE "FLOAT" ``` Require credentials and encrypt traffic: ```lot wrap focus={4-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProdServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH ALLOW_ANONYMOUS false WITH USERNAME GET ENV "OPCUA_USER" WITH PASSWORD GET SECRET "OPCUA_PASSWORD" ADD MAPPING Tags WITH SOURCE_TOPIC "plant/tags" ADD TAG Setpoint WITH DATA_TYPE "FLOAT" WITH WRITABLE true ``` Accept X.509 certificates for client login: ```lot wrap focus={4-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CertServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH USE_SECURITY true WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH ALLOW_ANONYMOUS false WITH ALLOW_CERTIFICATE_AUTH true WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES false ADD MAPPING Tags WITH SOURCE_TOPIC "plant/tags" ADD TAG Status WITH DATA_TYPE "INT32" ``` *** ## Certificate Management A secured OPC UA server connection requires **two certificates that trust each other**: the broker's **application instance certificate** (identifies and secures the server) and each **client's certificate** (must be in the server's `trusted` store when using certificate authentication). For client-side trust workflows when the broker dials out to remote servers, see [OPC UA Client](./opcua-client#certificate-management). ### PKI directory layout Both client and server routes store certificates in four folders under `PKI_DIR`: | Folder | Holds | File form | | ---------- | ----------------------------------------------------------------------- | ----------------- | | `own` | This server's certificate (`own/certs`) and private key (`own/private`) | `*.der` + `*.pfx` | | `issuer` | Trusted CA / issuer certificates for chain validation | `*.der` | | `trusted` | Trusted client certificates (`trusted/certs`) | `*.der` | | `rejected` | Client certificates seen during a handshake but not yet trusted | `*.der` | ### Where certificates live When `PKI_DIR` is not set, it defaults to `%LocalApplicationData%/OPC Foundation/pki`: | OS | Default path | | ------- | -------------------------------------------------------------------------- | | macOS | `~/Library/Application Support/OPC Foundation/pki` | | Linux | `$XDG_DATA_HOME/OPC Foundation/pki` or `~/.local/share/OPC Foundation/pki` | | Windows | `%LOCALAPPDATA%\OPC Foundation\pki` | Relocate the store to a path you control and mount it as a volume in Docker: ```lot wrap focus={4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH PKI_DIR "/data/opcua/pki" ``` In Docker, the default PKI location is ephemeral—the broker regenerates its certificate on every restart and clients that trusted the previous one will reject the new one. **Always set `PKI_DIR` to a mounted volume.** ### Inspecting and exporting certificates ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # The server's own certificate and private key ls "$PKI_DIR"/own/certs/*.der ls "$PKI_DIR"/own/private/*.pfx # Inspect subject, validity, and application URI SAN openssl x509 -inform der -in "$PKI_DIR"/own/certs/.der -noout -subject -dates -ext subjectAltName # Export public cert for clients to trust openssl x509 -inform der -in "$PKI_DIR"/own/certs/.der -out server-public.pem ``` Give clients the **public** certificate (`own/certs/*.der`). The private key never leaves the broker. ### The server's own certificate On first start the broker auto-generates a self-signed server certificate (`CN={APPLICATION_NAME}, O=Coreflux`; default `APPLICATION_NAME` is `CorefluxBroker_OpcUaServer`) in `PKI_DIR/own`. Its SAN URI matches `APPLICATION_URI` (default `urn:{host}:Coreflux:{route}`): ```lot wrap focus={4-8} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH APPLICATION_NAME "AcmePlantServer" WITH APPLICATION_URI "urn:acme:opcua:plant-server" WITH SECURITY_POLICY 3 WITH MESSAGE_SECURITY 2 WITH PKI_DIR "/data/opcua/pki" ``` * **Keep `APPLICATION_URI` and the certificate SAN in sync.** Strict clients refuse the handshake on a mismatch; changing `APPLICATION_URI` regenerates the certificate to match. * Clients trust this server by adding `PKI_DIR/own/certs/.der` to their trusted list—the same workflow as [trusting a server certificate on the client route](./opcua-client#trust-the-servers-certificate). ### Enable certificate user authentication Offer the X.509 user token so clients authenticate with a certificate instead of—or alongside—username and password: ```lot wrap focus={6-7} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CertServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH USE_SECURITY true WITH ALLOW_ANONYMOUS false WITH ALLOW_CERTIFICATE_AUTH true ``` When `ALLOW_CERTIFICATE_AUTH true`, the application instance certificate doubles as the user identity—the client's certificate must be trusted and must carry a valid application URI SAN. ### Trust connecting client certificates When a client connects with certificate authentication, its certificate must be in the server's trusted store: The client's certificate lands in `PKI_DIR/rejected/certs`. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} mv "$PKI_DIR"/rejected/certs/.der "$PKI_DIR"/trusted/certs/ ``` The client connects successfully. For testing only, set `WITH AUTO_ACCEPT_UNTRUSTED_CERTIFICATES true` to auto-trust every connecting client certificate. ### Supply your own server certificate The server route has no `CLIENT_CERT_PATH`-style parameter. To run with a CA-issued certificate instead of the auto-generated one, place it in the `own` store **before first start**, with a subject matching `CN={APPLICATION_NAME}, O=Coreflux` and a SAN URI matching `APPLICATION_URI`: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} mkdir -p "$PKI_DIR"/own/certs "$PKI_DIR"/own/private openssl x509 -in server-cert.pem -outform der -out "$PKI_DIR"/own/certs/server.der openssl pkcs12 -export -inkey server-key.pem -in server-cert.pem \ -out "$PKI_DIR"/own/private/server.pfx -passout pass: ``` If a valid matching certificate is already present, the broker uses it instead of generating one. ### Rotation and renewal * **Auto-generated certificates** — Delete files in `PKI_DIR/own/certs` and `PKI_DIR/own/private`, then restart the route; the broker regenerates. * **Changing `APPLICATION_URI`** — Regenerates the certificate; re-trust it on every client. * Plan rotation **before expiry**—an expired application instance certificate fails the handshake on both ends. *** ## Data Persistence & Recovery By default a server route holds node values **in memory only**—a broker restart resets them (nodes return to `BadWaitingForInitialData` until the next publish). Set `WITH PERSISTENCE true` on `OPCUA_SERVER_CONFIG` to snapshot served values and restore them on startup. ```lot wrap focus={3-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH PERSISTENCE true ADD MAPPING Nodes WITH SOURCE_TOPIC "plant/line1" ADD TAG Setpoint WITH DATA_TYPE "FLOAT" WITH WRITABLE true ADD TAG Temperature WITH DATA_TYPE "FLOAT" ``` The same **ownership-aware policy** as the [Modbus TCP Server](./modbus-tcp-server#data-persistence--recovery) route applies: | Node | Owner | Default | On restart | | -------------------- | ------------------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------- | | `WRITABLE true` | Client-written setpoint lives only in the server | **Persisted** | Restored **authoritatively**—wins over retained MQTT; returns a good value instead of `BadWaitingForInitialData` | | Read-only / MQTT-fed | Fed from live MQTT | **Not persisted** | Recovered from **live MQTT** (retained message / next publish) | Client-written setpoints survive a restart; live telemetry always reflects the current MQTT source. ### Per-tag and mapping overrides `WITH PERSISTENCE true|false` on an individual `ADD TAG`—or a whole `ADD MAPPING`—overrides the route default: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OverrideServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH PERSISTENCE true ADD MAPPING Control WITH SOURCE_TOPIC "plant/line1" ADD TAG Setpoint WITH DATA_TYPE "FLOAT" WITH WRITABLE true ADD TAG LastGoodTemp WITH DATA_TYPE "FLOAT" WITH PERSISTENCE "true" ADD MAPPING Scratch WITH SOURCE_TOPIC "plant/scratch" WITH PERSISTENCE "false" ADD TAG TestpointA WITH DATA_TYPE "FLOAT" WITH WRITABLE true ADD TAG TestpointB WITH DATA_TYPE "INT32" WITH WRITABLE true ``` * `Setpoint` — restored authoritatively to the last client-written value. * `LastGoodTemp` — served from snapshot until `plant/line1/LastGoodTemp` next publishes. * `Scratch` mapping tags — never snapshotted; start cold on every restart. ### Caching slow or infrequently-updated sources MQTT-fed nodes are not persisted by default. When a source updates infrequently—an hourly energy total, a shift-latched value—force-persist with `WITH PERSISTENCE "true"` so the last received value is re-served on cold start until the next live publish overrides it. ```lot wrap focus={6-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MeterServer WITH TYPE OPCUA_SERVER ADD OPCUA_SERVER_CONFIG WITH SERVER_PORT 4840 WITH PERSISTENCE true ADD MAPPING Meters WITH SOURCE_TOPIC "plant/energy" ADD TAG TotalEnergy WITH DATA_TYPE "DOUBLE" WITH PERSISTENCE "true" ADD TAG DailyPeak WITH DATA_TYPE "DOUBLE" WITH PERSISTENCE "true" ``` After a restart, a client browsing `TotalEnergy` or `DailyPeak` reads the last published value—a good value rather than `BadWaitingForInitialData`—until the next publish refreshes it. Persistence applies only to **explicitly-declared** `ADD TAG` nodes. Wildcard/dynamic nodes created from MQTT topic patterns are not snapshotted—a slow source behind a wildcard mapping still starts cold. ### Storage & durability * Values are written through a non-blocking background worker on every change (rapid updates coalesce to the latest value). * The snapshot lives in an encrypted-at-rest database under the broker data directory. * A snapshot is keyed to the route's tag definitions by a fingerprint. If addresses or types change between runs, the stale snapshot is discarded rather than replayed into reshaped nodes. *** ## Verify Your Server Add the route definition to your broker and confirm route status shows **Connected** (the endpoint is listening). Use UaExpert or any OPC UA browser. Connect to `opc.tcp://:` (append `ENDPOINT_PATH` if configured). When using security, trust the server certificate—export it from `PKI_DIR/own/certs` or follow the trust workflow in [Certificate Management](#certificate-management). Publish a test value to a bound topic—for example `factory/line1/Temperature`. Refresh the client and confirm the node value updates. For a writable tag, write a value from the OPC UA client. Subscribe to the tag's `SOURCE_TOPIC` with MQTT Explorer or any MQTT client and confirm the retained publish arrives. *** ## Limitations Each `OPCUA_SERVER` route binds exclusively to its `SERVER_PORT`. A second route on the same port fails to start and reports a red route status. *** ## Troubleshooting * **Port in use** — Another process or server route already uses `SERVER_PORT`. Choose a different port or stop the conflicting service. * **PKI directory not writable** — Ensure the broker can write to `PKI_DIR` (or the default PKI location). The application instance certificate regenerates when `PKI_DIR` is ephemeral—for example, an unmounted Docker volume. Mount `PKI_DIR` as a persistent volume so client trust stores remain valid across restarts. * **Client cert not trusted** — Move it from `PKI_DIR/rejected/certs` to `PKI_DIR/trusted/certs`, or set `AUTO_ACCEPT_UNTRUSTED_CERTIFICATES true` for testing only * **Client cert has no application URI SAN** — The connecting client must present a certificate with a valid URI in Subject Alternative Name `APPLICATION_URI` must match the certificate SAN URI. Leave both at their defaults, or change them together so they stay in sync. * Confirm the tag has `WITH WRITABLE true`. * Subscribe to the tag's `SOURCE_TOPIC` and check for the retained publish after the write. * Verify `MIN_VALUE` / `MAX_VALUE` are not clamping the written value away from what you expect. The broker logs client activity for this route in the broker log: * **Sessions** — client session create and close events. * **Accepted external writes** — writes accepted on tags and on individual STRUCT fields. Use these log lines to confirm which clients connected and which node writes reached MQTT. `ALLOW_ANONYMOUS` is `false`. Connect with the configured username and password, or use certificate authentication if enabled. The `SOURCE_TOPIC` has not published yet (or no retained value). Publish the topic, enable route persistence for client-written setpoints, or force-persist a slow MQTT-fed source with `WITH PERSISTENCE "true"`. See [Data persistence & recovery](#data-persistence--recovery). *** ## Best Practices Mount `PKI_DIR` as a persistent volume so server certificates survive broker restarts. Ephemeral storage regenerates the application instance certificate on every boot and breaks client trust stores. See [Certificate Management](#certificate-management). Set `ALLOW_ANONYMOUS false` on plant networks and require username/password or certificate authentication. Anonymous endpoints are appropriate for lab or isolated development only. Each server route binds exclusively to its `SERVER_PORT`. Document which route owns which port to avoid startup conflicts when adding routes. Prefer `GET ENV` and `GET SECRET` over literals in route definitions. Never commit passwords or client credentials to version control. Align `SECURITY_POLICY` and `MESSAGE_SECURITY` with what your SCADA or HMI supports. If the client cannot negotiate encryption, enable `ALLOW_NO_SECURITY` for testing only—not in production. Set `WITH PERSISTENCE true` on `OPCUA_SERVER_CONFIG` so client-written values survive a broker restart instead of returning `BadWaitingForInitialData`. See [Data persistence & recovery](#data-persistence--recovery). *** ## Next Steps Connect to remote OPC UA servers and poll device data into MQTT. Compare protocols and choose the right industrial route for your architecture. # Industrial Routes Overview Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/overview Connect your Coreflux broker to PLCs, industrial controllers, and automation systems using industrial protocol routes ## Why Industrial Routes? Bridge the gap between your PLCs and MQTT without custom middleware. Read sensor data, write setpoints, and integrate industrial equipment into your IoT pipeline—using a single, consistent configuration pattern across all supported protocols. ## Industrial Protocol Integration Industrial routes enable direct communication between your Coreflux MQTT broker and industrial automation equipment. Read data from PLCs, write setpoints, and bridge the gap between IT and OT systems - all through standardized LoT (Language of Things) syntax. **Like a translator at an international meeting.** Industrial routes speak both PLC (Modbus, S7, OPC UA) and MQTT—so your broker can read sensor data and send commands without custom glue code. ## Supported Protocols Poll PLCs and industrial devices over Modbus TCP. RS-232/RS-485 serial communication with Modbus RTU devices. Expose MQTT topics as Modbus registers and coils for SCADA and HMIs. Native S7 protocol for Siemens S7-200/300/400/1200/1500 PLCs. Connect to remote OPC UA servers and poll device data into MQTT. Expose MQTT topics as OPC UA nodes for SCADA and OT clients. TwinCAT ADS protocol for Beckhoff PLCs with AutoDiscovery support. ControlLogix, CompactLogix, and other Rockwell Automation PLCs. CIP-based communication for Allen-Bradley and compatible devices. Omron FINS protocol for CJ/CS/CP series PLCs. BACnet/IP for building automation controllers (ASHRAE 135) — HVAC, lighting, and metering. *** ## Protocol Comparison | Protocol | Transport | Use Case | Vendor | | -------------------- | --------------- | ---------------------------------------------- | ------------ | | Modbus TCP Client | TCP/IP | Poll remote Modbus devices | Multi-vendor | | Modbus Serial Client | RS-232/485 | Legacy devices, long distances | Multi-vendor | | Modbus TCP Server | TCP/IP | Expose MQTT to Modbus masters | Multi-vendor | | Siemens S7 | TCP/IP | Siemens automation systems | Siemens | | OPC UA Client | TCP/IP | Read from remote OPC UA servers | Multi-vendor | | OPC UA Server | TCP/IP | Expose MQTT to SCADA/HMI clients | Multi-vendor | | ADS | TCP/IP | Beckhoff TwinCAT systems | Beckhoff | | Allen-Bradley | TCP/IP | Rockwell Automation | Rockwell | | EtherNet/IP | TCP/IP | CIP devices, explicit messaging | Multi-vendor | | FINS | TCP/UDP | Omron PLCs | Omron | | BACnet | UDP (BACnet/IP) | Building automation (HVAC, lighting, metering) | Multi-vendor | *** ## Common Architecture All industrial routes share a common architecture with three key components: ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} graph LR subgraph broker [Coreflux Broker] Route[Industrial Route] Tags[TAG Definitions] Mapping[MAPPING Config] end subgraph plc [PLC / Controller] Registers[Registers/Variables] end subgraph mqtt [MQTT Topics] Pub[Published Data] Sub[Write Commands] end Route --> Tags Tags --> Mapping Mapping -->|Poll| Registers Registers -->|Read| Pub Sub -->|Write| Registers ``` ### 1. Connection Configuration Each protocol has specific connection parameters: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD _CONFIG WITH HOST "192.168.1.100" WITH PORT 502 // Protocol-specific options... ``` ### 2. MAPPING with Polling **MAPPING Definition** A **MAPPING** is a scheduled, continuous polling operation that automatically reads data from industrial devices (PLCs, Modbus devices, etc.) at regular intervals and publishes the values to MQTT topics. * **Polls:** industrial device at regular intervals (`EVERY`) * **Reads:** multiple data points (TAGs) in a single operation * **Publishes:** each TAG value to its own MQTT topic In LoT, you define a MAPPING like this: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING SensorGroup WITH EVERY 500 MILLISECONDS ADD TAG Temperature // TAG config... ADD TAG Pressure // TAG config... ``` #### Common MAPPING-level fields These fields are available at the MAPPING level across all industrial protocols: Base MQTT topic for publishing tag data. Each TAG publishes to `{SOURCE_TOPIC}/{TAG_NAME}`. Polling interval (e.g. `500 MILLISECONDS`, `1 SECOND`). *** ### 3. TAG Definitions **TAG Definition** A **TAG** is an individual data point within a MAPPING that represents a specific variable or register in an industrial device. * **Identifies:** a specific memory address in the device * **Transforms:** raw value using scaling, offset, and engineering units * **Publishes:** to a dedicated MQTT topic * **Monitors:** changes using deadband filtering - **Data transformation:** Scaling, offset, min/max validation - **Change detection:** Deadband to filter noise - **Formatting:** Publish as JSON or raw value In LoT, you define a TAG like this: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Temperature WITH ADDRESS "100" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/temperature" WITH SCALING 0.1 WITH UNIT "°C" ``` #### Common TAG Parameters These parameters are available across all industrial protocols: Protocol-specific address (register number, variable path, NodeId, etc.). Address type varies by protocol (e.g., HOLDING\_REGISTER, DATABLOCK, SYMBOL). Data type for the value. Common types: `BOOL`, `INT16`, `UINT16`, `INT32`, `UINT32`, `FLOAT`, `DOUBLE`, `STRING`. Maximum string size when `DATA_TYPE` is `STRING` on routes that support configurable sizing. Units and defaults vary—see [String sizing by Route](#string-sizing-by-route). Preferred keyword; `STRING_SIZE` is accepted as an alias where sizing is supported. Topic where PLC values are published. Subscribe here to receive sensor data. Topic to send write commands. Publish a value here to write it to the PLC (requires WRITABLE true). Multiplier applied to raw value. Formula: `published_value = (raw_value * SCALING) + OFFSET`. Default: 1.0. Value added after scaling. Default: 0.0. Number of decimal places in published value. Default: 2. Minimum allowed value. Values below this are filtered out. Maximum allowed value. Values above this are filtered out. Minimum change required to publish a new value. Reduces network traffic for slowly changing values. Default: 0.0. Output format: `VALUE_ONLY` (just the value) or `JSON` (structured object with metadata). Default: VALUE\_ONLY. Engineering unit for documentation and JSON output (e.g., °C, bar, RPM). Human-readable description of the TAG. Allow writing to this TAG via DESTINATION\_TOPIC. Default: false. Byte order for multi-byte values: `BIGENDIAN` or `LITTLEENDIAN`. Default: BIGENDIAN. Word order for 32-bit values: `BIGENDIAN` or `LITTLEENDIAN`. Default: BIGENDIAN. ### String sizing by Route When `DATA_TYPE` is `STRING`, routes that support configurable sizing accept `WITH LENGTH` to declare how many bytes the tag occupies on the wire. Use `LENGTH` as the preferred keyword; `STRING_SIZE` is accepted as an alias where sizing is supported. OPC UA Server strings are natively variable-length (no sizing parameter); Allen-Bradley uses a fixed 88-byte CIP layout—`LENGTH`/`STRING_SIZE` do not resize it. | Route | Unit | Default | Constraint | | -------------- | -------------------------------- | --------------------------- | ------------------------------- | | Modbus Server | bytes, 2 bytes/register | 40 bytes | at most 246 bytes/123 registers | | Modbus clients | bytes | 256 | consecutive registers | | Siemens S7 | bytes including 2-byte header | 256 | — | | EtherNet/IP | bytes | type-driven; 1 in join mode | separate join/array semantics | | OPC UA client | bytes | 256 | — | | OPC UA Server | natively variable | unbounded | no sizing parameter | | ADS | bytes including length byte | read 256; write 80 | — | | Allen-Bradley | fixed layout | 88-byte CIP STRING | sizing keywords do not resize | | FINS | payload bytes plus 2-byte header | 256 | — | | BACnet | bytes | 256 | `MAX_APDU` is unrelated | See the protocol-specific route guide for register math, validation limits, and complete examples. *** ### 4. SOURCE\_TOPIC Configuration There are two ways to configure where TAG values are published. Use group-level when you want consistent topic structure across all TAGs in a MAPPING; use individual TAG when you need custom paths per sensor (e.g., different namespaces for temperature vs pressure). Define `SOURCE_TOPIC` at the MAPPING level. Each TAG automatically publishes to `{SOURCE_TOPIC}/{TAG_NAME}`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING ProductionLine WITH SOURCE_TOPIC "factory/line1" WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "100" WITH DATA_TYPE "FLOAT" ADD TAG Pressure WITH ADDRESS "102" WITH DATA_TYPE "FLOAT" ``` **Result:** Temperature publishes to `factory/line1/Temperature`; Pressure publishes to `factory/line1/Pressure`. Define `SOURCE_TOPIC` at each TAG level for custom topic structure. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING ProductionLine WITH EVERY 1 SECOND ADD TAG Temperature WITH SOURCE_TOPIC "sensors/temp/line1" WITH ADDRESS "100" WITH DATA_TYPE "FLOAT" ADD TAG Pressure WITH SOURCE_TOPIC "sensors/pressure/line1" WITH ADDRESS "102" WITH DATA_TYPE "FLOAT" ``` **Result:** Temperature publishes to `sensors/temp/line1`; Pressure publishes to `sensors/pressure/line1`. *** ### 5. EVENT (On-Demand Operations) Understanding on-demand operations in routes. **EVENT Definition** An **EVENT** is an on-demand operation that waits for messages on an MQTT topic, executes a specific action when triggered, and publishes the results back to another MQTT topic. Use EVENT when you need on-demand reads or writes (triggered by a message); use MAPPING for continuous polling. Syntax varies by protocol—see the protocol-specific docs (e.g., [Modbus TCP Client](./modbus-tcp-client#event-based-operations)) for details. In LoT, you define an EVENT like this: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadOnDemand WITH SOURCE_TOPIC "plc/commands/read" WITH DESTINATION_TOPIC "plc/responses/read" WITH QUERY "" ``` #### Common EVENT Parameters These parameters are available across all industrial protocols for EVENT definitions: MQTT topic where the EVENT listens for trigger messages. Publish a message here to execute the operation. MQTT topic where the EVENT publishes the result of the operation. Protocol-specific specification of the operation (read or write) and its parameters. Uses LoT object syntax. See each protocol's documentation for supported operations. ### When to Use MAPPING vs EVENT Use the right acquisition pattern for the job: | | MAPPING (Cyclic) | EVENT (On-Demand) | | --------------- | ---------------------------------------- | ------------------------------------------------ | | **Trigger** | Automatic, on a timer (`WITH EVERY`) | Manual, via an MQTT message to SOURCE\_TOPIC | | **Direction** | Reads from device, publishes to MQTT | Reads or writes on command, publishes result | | **Best for** | Dashboards, alarms, continuous telemetry | Operator commands, diagnostics, recipe downloads | | **Data volume** | Constant stream at defined interval | Only when requested | Most real-world routes combine both patterns: MAPPING for continuous monitoring and EVENT for on-demand operations within the same `DEFINE ROUTE`. *** ## Basic Example Pattern ### Cyclic Only This minimal pattern works across all industrial protocols for continuous data acquisition: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MyPLCConnection WITH TYPE ADD _CONFIG WITH HOST "192.168.1.100" // Connection parameters... ADD MAPPING CriticalSensors WITH EVERY 100 MILLISECONDS ADD TAG Temperature WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/sensors/temperature" ADD TAG Pressure WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/sensors/pressure" ADD MAPPING SlowChangingData WITH EVERY 5 SECONDS ADD TAG ProductCount WITH ADDRESS "" WITH DATA_TYPE "INT32" WITH SOURCE_TOPIC "plc/production/count" ``` ### Combined (Cyclic + On-Demand) A production route typically combines MAPPING for continuous monitoring with EVENT for on-demand operations. Publish a message to the EVENT's SOURCE\_TOPIC to trigger a one-shot read or write, and receive the result on the DESTINATION\_TOPIC: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MyPLCConnection WITH TYPE ADD _CONFIG WITH HOST "192.168.1.100" // Connection parameters... // Continuous monitoring — polls every 500ms ADD MAPPING ProcessSensors WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/sensors/temperature" ADD TAG Pressure WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/sensors/pressure" // On-demand read — triggered by publishing to SOURCE_TOPIC ADD EVENT ReadRegister WITH SOURCE_TOPIC "plc/commands/read" WITH DESTINATION_TOPIC "plc/responses/read" WITH QUERY "{operation: READ, address: , data_type: FLOAT}" // On-demand write — triggered by publishing to SOURCE_TOPIC ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "plc/commands/write" WITH DESTINATION_TOPIC "plc/responses/write" WITH QUERY "{operation: WRITE, address: , value: 0}" ``` *** ## Advanced Example Pattern When you need engineering-unit conversion, noise filtering, range validation, or bidirectional control, add transformation and publishing parameters to your TAGs. This pattern builds on the basic structure above to unlock the full power of industrial routes. ### Scaled and Filtered Monitoring Apply scaling and offset to convert raw PLC register values into meaningful engineering units, and use deadband to suppress noise: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AdvancedPLCConnection WITH TYPE ADD _CONFIG WITH HOST "192.168.1.100" // Connection parameters... ADD MAPPING ProcessMonitoring WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/sensors/temperature" // Value transformation — raw 245 becomes (245 * 0.1) + (-40) = -15.5 WITH SCALING 0.1 WITH OFFSET -40 WITH UNIT "°C" // Noise filtering — only publish when change exceeds 0.5 WITH DEADBAND 0.5 // Range validation — discard values outside physical limits WITH MIN_VALUE -20 WITH MAX_VALUE 150 // Precision — publish with 1 decimal place WITH DECIMAL_PLACES 1 // Structured output — includes unit, description, and timestamp WITH PUBLISH_MODE "JSON" ADD TAG Pressure WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/sensors/pressure" WITH SCALING 0.01 WITH UNIT "bar" WITH DEADBAND 0.1 ``` ### Writable Setpoints with Validation Enable bidirectional control by marking TAGs as writable. Published values on the DESTINATION\_TOPIC are written to the PLC, with MIN/MAX enforcing safe limits: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING Setpoints WITH EVERY 1 SECOND ADD TAG TemperatureSetpoint WITH ADDRESS "" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/setpoints/temperature" // Bidirectional — publish to DESTINATION_TOPIC to write a value WITH WRITABLE "true" WITH DESTINATION_TOPIC "plc/setpoints/temperature/write" WITH UNIT "°C" // Safety limits — reject values outside the safe operating range WITH MIN_VALUE 0 WITH MAX_VALUE 100 ``` *** ## Best Practices Group TAGs by update frequency: * **Fast (50-100ms)**: Safety signals, real-time control * **Medium (500ms-1s)**: Process values, sensor readings * **Slow (5-30s)**: Configuration, counters, status ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING FastLoop WITH EVERY 100 MILLISECONDS // Critical TAGs only ADD MAPPING SlowLoop WITH EVERY 5 SECONDS // Non-critical TAGs ``` Reduce network traffic for slowly changing values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Temperature WITH DEADBAND 0.5 // Only publish if change > 0.5 ``` Transform raw PLC values to engineering units in the route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Temperature WITH SCALING 0.1 // Raw 245 becomes 24.5 WITH OFFSET -40 // Apply offset if needed WITH UNIT "°C" ``` Include metadata in published messages: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Temperature WITH PUBLISH_MODE "JSON" WITH UNIT "°C" WITH DESCRIPTION "Main reactor temperature" ``` Output: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "value": 24.5, "unit": "°C", "description": "Main reactor temperature", "timestamp": "2024-01-15T10:30:00Z" } ``` Only enable WRITABLE for TAGs that need it, and use specific topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG Setpoint WITH WRITABLE "true" WITH DESTINATION_TOPIC "plc/setpoints/temperature/write" WITH MIN_VALUE 0 WITH MAX_VALUE 100 ``` *** ## Next Steps Choose the protocol that matches your equipment: Start with the most common industrial protocol. Connect to Siemens PLCs directly. # Siemens S7 Route Source: https://docs.coreflux.org/latest/lot-language/routes/industrial/siemens-s7 Connect to Siemens S7-200, S7-300, S7-400, S7-1200, and S7-1500 PLCs using native S7 protocol ## Siemens S7 Overview The `SIEMENS_S7` route enables direct communication with Siemens S7 PLCs using the native S7 protocol (ISO-on-TCP). It supports all major S7 PLC families and provides access to data blocks, memory areas, inputs, outputs, timers, and counters. The S7 route uses native protocol communication, which is typically faster and more efficient than OPC UA for Siemens PLCs. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SiemensPLC WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 ADD MAPPING ProcessData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "DB1.DBD100" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" ``` *** ## Supported CPU Types | CPU Type | Parameter Value | Typical Slot | Description | | -------- | --------------- | ------------ | --------------------- | | S7-200 | `S7200` | 0 | Compact micro PLCs | | S7-300 | `S7300` | 2 | Modular PLCs | | S7-400 | `S7400` | 2/3 | High-performance PLCs | | S7-1200 | `S71200` | 1 | Compact controllers | | S7-1500 | `S71500` | 1 | Advanced controllers | For S7-1200/1500 PLCs, you may need to enable "Permit access with PUT/GET" in the PLC's protection settings (Hardware Configuration → Protection & Security). *** ## Connection Configuration ### S7\_CONFIG Parameters S7 PLC IP address. CPU type: `S7200`, `S7300`, `S7400`, `S71200`, or `S71500`. PLC rack number. Default: 0. PLC slot number. Default: 1 for S7-1200/1500, 2 for S7-300/400. Connection type: `PG` (Programming device), `OP` (Operator Panel), or `S7BASIC`. Default: PG. Number of retry attempts on failure. Default: 3. Seconds to wait between retries. Default: 5. Refresh time in milliseconds. Default: 1000. Read timeout in milliseconds. Default: 5000. Write timeout in milliseconds. Default: 5000. ### Connection Examples ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 WITH CONNECTION_TYPE "PG" ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD S7_CONFIG WITH IP "192.168.1.50" WITH CPU_TYPE "S71200" WITH RACK 0 WITH SLOT 1 ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD S7_CONFIG WITH IP "192.168.1.30" WITH CPU_TYPE "S7300" WITH RACK 0 WITH SLOT 2 ``` ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD S7_CONFIG WITH IP "192.168.1.20" WITH CPU_TYPE "S7400" WITH RACK 0 WITH SLOT 3 ``` *** ## S7 Addressing ### Address Format S7 addresses follow the pattern: `.` or `.` | Address | Description | Example | | ------------ | ------------------------------- | ------------------------------------- | | `DBn.DBXx.y` | Data Block bit | `DB1.DBX0.0` - DB1, byte 0, bit 0 | | `DBn.DBBx` | Data Block byte | `DB1.DBB0` - DB1, byte 0 | | `DBn.DBWx` | Data Block word (16-bit) | `DB1.DBW100` - DB1, word at byte 100 | | `DBn.DBDx` | Data Block double word (32-bit) | `DB1.DBD100` - DB1, dword at byte 100 | | `Mx.y` | Memory bit | `M0.0` - Marker byte 0, bit 0 | | `MBx` | Memory byte | `MB0` - Marker byte 0 | | `MWx` | Memory word | `MW100` - Marker word at byte 100 | | `MDx` | Memory double word | `MD100` - Marker dword at byte 100 | | `Ix.y` | Input bit | `I0.0` - Input byte 0, bit 0 | | `IBx` | Input byte | `IB0` - Input byte 0 | | `IWx` | Input word | `IW0` - Input word at byte 0 | | `Qx.y` | Output bit | `Q0.0` - Output byte 0, bit 0 | | `QBx` | Output byte | `QB0` - Output byte 0 | | `QWx` | Output word | `QW0` - Output word at byte 0 | | `Tx` | Timer | `T0` - Timer 0 | | `Cx` | Counter | `C0` - Counter 0 | ### Address Types Memory area type: `DATABLOCK`, `MEMORY`, `INPUT`, `OUTPUT`, `TIMER`, `COUNTER`. For most configurations, the ADDRESS\_TYPE is inferred from the address format. Use explicit ADDRESS\_TYPE when needed for clarity. *** ## Data Types | Data Type | S7 Type | Size | Description | | ------------------------------------ | ------- | -------- | --------------------------------------------------------------------------------- | | `BOOL` / `BIT` | BOOL | 1 bit | Boolean value | | `BYTE` | BYTE | 8 bits | Unsigned 8-bit | | `SINT` | SINT | 8 bits | Signed 8-bit integer | | `USINT` | USINT | 8 bits | Unsigned 8-bit integer | | `WORD` | WORD | 16 bits | Unsigned 16-bit | | `INT` / `INT16` | INT | 16 bits | Signed 16-bit integer | | `UINT` / `UINT16` | UINT | 16 bits | Unsigned 16-bit integer | | `DWORD` | DWORD | 32 bits | Unsigned 32-bit | | `DINT` / `INT32` | DINT | 32 bits | Signed 32-bit integer | | `UDINT` / `UINT32` | UDINT | 32 bits | Unsigned 32-bit integer | | `LWORD` | LWORD | 64 bits | Unsigned 64-bit | | `LINT` / `INT64` | LINT | 64 bits | Signed 64-bit integer | | `ULINT` / `UINT64` | ULINT | 64 bits | Unsigned 64-bit integer | | `REAL` / `FLOAT` | REAL | 32 bits | 32-bit floating point | | `LREAL` / `DOUBLE` | LREAL | 64 bits | 64-bit floating point | | `STRING` | STRING | Variable | S7 string type; sized with `LENGTH` (default 256 bytes, includes two-byte header) | | `CHAR` | CHAR | 8 bits | Single character | | `WCHAR` | WCHAR | 16 bits | Wide character | | `TIME` | TIME | 32 bits | Time duration | | `DATE` | DATE | 16 bits | Date value | | `TOD` | TOD | 32 bits | Time of day | | `DTL` / `DATETIME` / `DATE_AND_TIME` | DTL | 12 bytes | Date and time combined | ### String sizing For `STRING` tags, `WITH LENGTH` is the **total allocated buffer in bytes** on the wire—the S7 two-byte maximum-length/current-length header **plus** the maximum character payload. It is **not** the character count alone. Default: **256** bytes. `STRING_SIZE` is accepted as an alias for the same field. In TIA Portal, a `STRING[n]` variable occupies **n + 2** bytes (two header bytes plus **n** characters of capacity). Set `WITH LENGTH` to that combined total so the route reads the full allocation from the starting byte address—for example, `STRING[254]` occupies 256 bytes, so use `WITH LENGTH 256`. ```lot wrap focus={13} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE S7Strings WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 ADD MAPPING ProductData WITH EVERY 1 SECOND ADD TAG ProductName WITH ADDRESS "DB1.DBB300" WITH ADDRESS_TYPE "DATABLOCK" WITH DATA_TYPE "STRING" WITH LENGTH 64 WITH SOURCE_TOPIC "plc/product/name" ``` For cross-protocol defaults and caps, see [String sizing by Route](./overview#string-sizing-by-route) on the industrial overview. *** ## TAG Configuration ### Complete TAG Example ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD TAG ProcessTemperature WITH ADDRESS "DB1.DBD100" WITH ADDRESS_TYPE "DATABLOCK" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/process/temperature" WITH SCALING 1 WITH OFFSET 0 WITH UNIT "°C" WITH DECIMAL_PLACES 2 WITH MIN_VALUE -50 WITH MAX_VALUE 200 WITH DEADBAND 0.5 WITH PUBLISH_MODE "JSON" WITH WRITABLE "true" WITH DESTINATION_TOPIC "plc/process/temperature/set" WITH BYTE_ORDER "BIGENDIAN" WITH DESCRIPTION "Main process temperature" ``` ### TAG Parameters S7 address in standard notation (e.g., `DB1.DBW100`, `M0.0`, `I0.0`, `Q0.0`). Address type: `DATABLOCK`, `MEMORY`, `INPUT`, `OUTPUT`, `TIMER`, `COUNTER`. S7 data type: `BOOL`, `BYTE`, `WORD`, `DWORD`, `INT`, `DINT`, `REAL`, `STRING`. **STRING only.** Total string buffer size in bytes, including the two-byte S7 header. Alias: `STRING_SIZE`. Multiplier applied to raw value. Default: 1.0. Value added after scaling. Default: 0.0. Number of decimal places in output. Default: 2. Minimum allowed value. Maximum allowed value. Minimum change to trigger publish. Default: 0.0. Topic where PLC values are published. Subscribe here to receive sensor data. Output format: `VALUE_ONLY` or `JSON`. Default: VALUE\_ONLY. Engineering unit (e.g., °C, bar, RPM). Allow writing to this address. Default: false. Topic to send write commands. Publish a value here to write it to the PLC. *** ## Event-Based Operations For on-demand S7 operations (not polling), use the EVENT syntax. Publish a message to SOURCE\_TOPIC to trigger the operation; the route executes it and publishes the result to DESTINATION\_TOPIC. ### Supported Operations | Operation | Description | Query Parameters | | --------- | --------------------------------- | ------------------------------- | | `READ` | Read data from a memory address | `address`, `data_type`, `count` | | `WRITE` | Write a value to a memory address | `address`, `data_type`, `value` | ### Query Parameters | Parameter | Description | Example | | ----------- | ------------------------------------------------ | ------------------------------ | | `operation` | Operation type: `READ` or `WRITE` | `READ` | | `address` | S7 address in standard notation | `DB1.DBW0`, `MW100`, `I0.0` | | `data_type` | S7 data type | `WORD`, `REAL`, `BOOL`, `DINT` | | `count` | Number of consecutive values to read (READ only) | `10` | | `value` | Value to write (WRITE only) | `42`, `23.5`, `true` | ### Read Example Read 10 words from Data Block 1 on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT ReadOnDemand WITH SOURCE_TOPIC "s7/commands/read" WITH DESTINATION_TOPIC "s7/responses/read" WITH QUERY "{operation: READ, address: DB1.DBW0, data_type: WORD, count: 10}" ``` ### Write Example Write a REAL value to Data Block 10 on demand: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "s7/commands/write" WITH DESTINATION_TOPIC "s7/responses/write" WITH QUERY "{operation: WRITE, address: DB10.DBD4, data_type: REAL, value: 75.0}" ``` *** ## Complete Examples Read values from a data block: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionPLC WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 ADD MAPPING ProductionData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "DB1.DBD0" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "DB1.DBD4" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/pressure" WITH UNIT "bar" ADD TAG Speed WITH ADDRESS "DB1.DBW8" WITH DATA_TYPE "INT" WITH SOURCE_TOPIC "plc/speed" WITH UNIT "RPM" ADD TAG Running WITH ADDRESS "DB1.DBX10.0" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/running" ``` Read markers, inputs, and outputs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE IOMonitor WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71200" WITH RACK 0 WITH SLOT 1 ADD MAPPING DigitalIO WITH EVERY 100 MILLISECONDS ADD TAG StartButton WITH ADDRESS "I0.0" WITH ADDRESS_TYPE "INPUT" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/io/start" ADD TAG StopButton WITH ADDRESS "I0.1" WITH ADDRESS_TYPE "INPUT" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/io/stop" ADD TAG MotorRunning WITH ADDRESS "Q0.0" WITH ADDRESS_TYPE "OUTPUT" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/io/motor" ADD TAG SystemReady WITH ADDRESS "M0.0" WITH ADDRESS_TYPE "MEMORY" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/status/ready" ``` Read process values and write setpoints: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ControlLoop WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 ADD MAPPING ControlData WITH EVERY 200 MILLISECONDS ADD TAG ProcessValue WITH ADDRESS "DB10.DBD0" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/pv" WITH UNIT "°C" WITH PUBLISH_MODE "JSON" ADD TAG Setpoint WITH ADDRESS "DB10.DBD4" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/sp" WITH UNIT "°C" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/sp/set" WITH MIN_VALUE 0 WITH MAX_VALUE 100 ADD TAG Output WITH ADDRESS "DB10.DBD8" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "control/output" WITH UNIT "%" ADD TAG ManualMode WITH ADDRESS "DB10.DBX12.0" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "control/manual" WITH WRITABLE "true" WITH DESTINATION_TOPIC "control/manual/set" ``` Optimize with different update frequencies: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OptimizedPLC WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 ADD MAPPING FastLoop WITH EVERY 100 MILLISECONDS ADD TAG CriticalAlarm WITH ADDRESS "DB1.DBX0.0" WITH DATA_TYPE "BOOL" WITH SOURCE_TOPIC "plc/alarms/critical" ADD TAG MotorSpeed WITH ADDRESS "DB1.DBD10" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/motor/speed" ADD MAPPING MediumLoop WITH EVERY 1 SECOND ADD TAG Temperature WITH ADDRESS "DB2.DBD0" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/process/temp" ADD TAG Pressure WITH ADDRESS "DB2.DBD4" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/process/pressure" ADD MAPPING SlowLoop WITH EVERY 10 SECONDS ADD TAG ProductionCount WITH ADDRESS "DB3.DBD0" WITH DATA_TYPE "DINT" WITH SOURCE_TOPIC "plc/production/count" ADD TAG RuntimeHours WITH ADDRESS "DB3.DBD4" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/stats/runtime" ``` Continuous monitoring with on-demand read and write events in the same route: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullS7Setup WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK 0 WITH SLOT 1 ADD MAPPING ProcessData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "DB1.DBD0" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" WITH UNIT "°C" ADD TAG Pressure WITH ADDRESS "DB1.DBD4" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/pressure" WITH UNIT "bar" ADD EVENT ReadBlock WITH SOURCE_TOPIC "s7/commands/read" WITH DESTINATION_TOPIC "s7/responses/read" WITH QUERY "{operation: READ, address: DB2.DBW0, data_type: WORD, count: 20}" ADD EVENT WriteSetpoint WITH SOURCE_TOPIC "s7/commands/write" WITH DESTINATION_TOPIC "s7/responses/write" WITH QUERY "{operation: WRITE, address: DB1.DBD8, data_type: REAL, value: 0}" ``` The MAPPING continuously reads temperature and pressure from DB1. To trigger an on-demand read of 20 words from DB2, publish any message to `s7/commands/read`. To write a setpoint, publish to `s7/commands/write`. *** ## S7-1200/1500 Setup S7-1200 and S7-1500 PLCs require additional configuration to allow external access. ### Enable PUT/GET Access 1. Open TIA Portal project 2. Navigate to **Device Configuration** → **Properties** 3. Go to **Protection & Security** → **Connection mechanisms** 4. Enable **Permit access with PUT/GET communication from remote partner** ### Optimized Block Access For S7-1500, ensure data blocks are set to allow standard access: 1. Open the Data Block in TIA Portal 2. In Properties, uncheck **Optimized block access** 3. Compile and download to PLC *** ## Troubleshooting * Verify IP address is correct * Check PLC is in RUN mode * For S7-1200/1500: Enable PUT/GET access * Verify correct RACK and SLOT settings * Check firewall allows TCP port 102 Use correct slot for your CPU: * S7-1200/1500: Slot 1 * S7-300: Slot 2 * S7-400: Slot 2 or 3 (check hardware config) For S7-1500: Disable optimized block access in TIA Portal for data blocks you want to access. * Verify address matches TIA Portal * Check DATA\_TYPE corresponds to PLC variable type * For multi-word types, verify byte offset is correct * Check BYTE\_ORDER if values seem swapped * Ensure WRITABLE is set to "true" * Verify PLC is in RUN mode * Check access permissions in PLC * Confirm value is within MIN\_VALUE/MAX\_VALUE *** ## Next Steps Cross-platform industrial communication. Connect to TwinCAT systems. # MCP Routes Source: https://docs.coreflux.org/latest/lot-language/routes/mcp-routes Connect your Coreflux broker to external tools and services through the Model Context Protocol (MCP) ## Plug Your Broker Into Any MCP Tool MCP (Model Context Protocol) is an open standard that defines how applications communicate with external tool servers. MCP Routes let your Coreflux broker connect to any MCP-compatible server — file systems, messaging platforms, cloud storage, spreadsheets, and more — and call their tools directly from LoT Actions. **Like adding power tools to a workbench.** Each MCP Route plugs a new tool into your broker — a Slack server for sending messages, a filesystem server for reading logs, a Google Drive server for cloud backups. Once connected, any LoT Action can pick up and use that tool. *** ## Integration Architecture MCP Routes allow the Coreflux Broker to connect to external MCP servers as a **client**. This enables LoT Actions to call tools provided by any MCP server—file systems, cloud services, communication platforms, and more. ```mermaid actions={false} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} graph LR subgraph mcpServers [External MCP Servers] Filesystem[Filesystem Server] Slack[Slack Server] GDrive[Google Drive] Custom[Custom MCP Server] end subgraph broker [Coreflux Broker] MCPRoutes[MCP Routes] Actions[LoT Actions] Topics[MQTT Topics] end subgraph devices [IoT Layer] Sensors[Sensors] Actuators[Actuators] end MCPRoutes -->|connects to| Filesystem MCPRoutes -->|connects to| Slack MCPRoutes -->|connects to| GDrive MCPRoutes -->|connects to| Custom Actions -->|CALL MCP| MCPRoutes Actions -->|PUBLISH/KEEP| Topics Topics <--> Sensors Topics <--> Actuators ``` ### How It Works 1. **Define MCP Routes** - Each route connects to an external MCP server 2. **Discover Tools** - The broker discovers available tools from each server 3. **Call from Actions** - Use `CALL MCP` in LoT Actions to invoke any tool 4. **Reusable Connections** - Multiple actions can share the same MCP route **MCP as a Connection Type.** Just like database routes or REST API routes, MCP routes provide a reusable connection that any action can leverage. Define the route once, use it everywhere. *** ## Basic Syntax The `MCP` route type connects your Coreflux broker to an external MCP server, making that server's tools callable from LoT Actions. Each route manages one MCP server connection, and you can define multiple routes to connect to different servers simultaneously. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MCPFilesystem WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "npx" WITH SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-filesystem, /data" WITH CLIENT_NAME "CorefluxFS" ``` Once connected, any LoT Action can use `CALL MCP` to invoke the server's tools — send Slack messages, read and write files, generate spreadsheet reports, or interact with any other MCP-compatible service. *** ## MCP Configuration ### MCP\_CONFIG Parameters MCP server command to execute (e.g., `npx`, `python`, `node`). Comma-separated server arguments (e.g., `-y, @modelcontextprotocol/server-filesystem`). MCP client identifier. Default: CorefluxBroker. *** ## Configuration Examples Enable LoT Actions to perform file operations: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MCPFilesystem WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "npx" WITH SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-filesystem, /data" WITH CLIENT_NAME "CorefluxFS" ``` Connect to a custom MCP server: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MCPCustom WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "python" WITH SERVER_ARGUMENTS "-m, my_mcp_server" WITH CLIENT_NAME "CorefluxCustom" ``` Run a Node.js MCP server: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MCPNode WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "node" WITH SERVER_ARGUMENTS "/opt/mcp/server.js, --config, /etc/mcp/config.json" WITH CLIENT_NAME "CorefluxNode" ``` *** ## MCP Server Examples The MCP ecosystem includes servers for various platforms and services. These examples show how to connect popular MCP servers to your Coreflux broker for IoT integration. Node.js-based MCP servers can be run directly using `npx`. These are the most common and include official Anthropic servers. | Server | LoT Configuration | IoT Use Case | | ------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- | | **Filesystem** | `SERVER_COMMAND "npx"` with `SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-filesystem, /data"` | Read sensor logs, manage config files | | **Google Drive** | `SERVER_COMMAND "npx"` with `SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-gdrive"` | Cloud backup of IoT data | | **Slack** | `SERVER_COMMAND "npx"` with `SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-slack"` | Send alerts to team channels | | **Memory** | `SERVER_COMMAND "npx"` with `SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-memory"` | Persist context across AI sessions | | **Fetch** | `SERVER_COMMAND "npx"` with `SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-fetch"` | Access external APIs and web data | | **Windows Control** | `SERVER_COMMAND "npx"` with `SERVER_ARGUMENTS "-y, mcp-control"` | Automate HMI/SCADA systems | **Example: Slack Alerts for IoT Events** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MCPSlack WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "npx" WITH SERVER_ARGUMENTS "-y, @modelcontextprotocol/server-slack" WITH CLIENT_NAME "CorefluxSlack" ``` Python-based MCP servers use `uvx` (the uv package runner). These are common for third-party integrations. | Server | LoT Configuration | IoT Use Case | | ----------- | ----------------------------------------------------------------- | --------------------------------- | | **Spotify** | `SERVER_COMMAND "uvx"` with `SERVER_ARGUMENTS "spotify-mcp"` | Smart home audio control | | **Excel** | `SERVER_COMMAND "uvx"` with `SERVER_ARGUMENTS "excel-mcp-server"` | Generate reports from sensor data | **Example: Excel Reports from Sensor Data** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MCPExcel WITH TYPE MCP ADD MCP_CONFIG WITH SERVER_COMMAND "uvx" WITH SERVER_ARGUMENTS "excel-mcp-server" WITH CLIENT_NAME "CorefluxExcel" ``` *** ## Calling MCP Tools Once an MCP route is defined, you can call its tools from within LoT Actions using the `CALL MCP` syntax. This enables AI-powered automation triggered by MQTT events. ### Basic Syntax The standard syntax to invoke an MCP tool from an Action: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL MCP "RouteName.ToolName" WITH (arg1 = value1, arg2 = value2) RETURN AS {result_variable} ``` | Component | Format | Description | | -------------- | -------------------------- | ------------------------------------------- | | **Route.Tool** | `"RouteName.ToolName"` | Route name and tool name separated by a dot | | **WITH** | `WITH (name = value, ...)` | Named arguments in parentheses | | **RETURN AS** | `RETURN AS {varname}` | Capture the result in a variable | ### Complete Example This Action monitors a temperature sensor and uses an MCP tool to send a Slack message when the temperature exceeds a threshold: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION TemperatureAlert ON TOPIC "sensors/+/temperature" DO SET "temp" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE) SET "sensor_id" WITH TOPIC POSITION 2 IF {temp} > 80 THEN CALL MCP "MCPSlack.send-message" WITH (channel = "alerts", message = "🔥 Sensor " + {sensor_id} + " at " + {temp} + "°C") RETURN AS {result} PUBLISH TOPIC "alerts/temperature/sent" WITH "Sensor: " + {sensor_id} + " Temp: " + {temp} + " Result: " + {result} END IF ``` ### Syntax Variations Some tools don't require arguments: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL MCP "MCPSystem.get-status" RETURN AS {status} ``` Pass multiple named parameters: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL MCP "MCPFilesystem.write-file" WITH (path = "/data/log.txt", content = {payload}, append = true) RETURN AS {write_result} ``` Build dynamic values using expressions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} CALL MCP "MCPSlack.send-message" WITH (message = "Device " + {device_id} + " reported: " + {value} + " at " + TIMESTAMP "UTC") RETURN AS {chat_result} ``` *** ## Tool Discovery When an MCP route connects to a server, Coreflux automatically discovers all available tools and publishes them to MQTT system topics. This allows you to dynamically query what tools are available. ### System Topics | Topic | Description | | --------------------------------------------------- | --------------------------------- | | `$SYS/Coreflux/Routes/{RouteName}/Tools` | Summary of all tools for a route | | `$SYS/Coreflux/Routes/{RouteName}/Tools/{ToolName}` | Detailed info for a specific tool | ### Tools Summary Payload Subscribe to the summary topic to see all available tools: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "route_name": "MCPSlack", "route_type": "MCP", "total_tools": 8, "tools": ["send-message", "list-channels", "get-channel-history", "add-reaction", ...], "timestamp": "2025-12-31T12:00:00.000Z" } ``` ### Individual Tool Payload Each tool publishes detailed metadata including parameters and usage examples: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "name": "send-message", "route_name": "MCPSlack", "tool_type": "MCP", "description": "Send a message to a Slack channel", "parameters": { "channel": { "type": "string", "required": true }, "message": { "type": "string", "required": true } }, "usage_example": "CALL MCP \"MCPSlack.send-message\" WITH (channel = {channel}, message = {message}) RETURN AS {result}", "capability": { "execution_count": 42, "average_latency_ms": 150.25, "reliability_percent": 98.5 }, "timestamp": "2025-12-31T12:00:00.000Z" } ``` ### Subscribing to Tool Discovery Use MQTT Explorer or any MQTT client to discover available tools: | What to Find | Topic Pattern | | ------------------------------- | -------------------------------------------------- | | All tools for a specific route | `$SYS/Coreflux/Routes/MCPSlack/Tools` | | Details for a specific tool | `$SYS/Coreflux/Routes/MCPSlack/Tools/send-message` | | All tools across all MCP routes | `$SYS/Coreflux/Routes/+/Tools/#` | Tool discovery topics are **retained messages**, so the information is immediately available when you subscribe—you don't need to wait for the next update. *** ## Use Cases Connect to Slack, email, or other messaging MCP servers to send alerts directly from LoT Actions. When a sensor crosses a threshold, the Action calls the messaging tool — no external middleware needed. Use filesystem MCP servers to read configuration files, write sensor logs, or manage data exports. LoT Actions can read a config file at startup or append readings to a log file on every event. Connect to Google Drive, Excel, or other cloud MCP servers to back up IoT data, generate reports from sensor readings, or sync configurations with cloud storage. Use Windows Control or custom MCP servers to automate HMI/SCADA interfaces, trigger external scripts, or interact with desktop applications as part of your IoT workflow. *** ## Best Practices * Limit MCP access to specific topics and actions * Use authentication for MCP server connections * Monitor MCP tool calls through logging * Implement rate limiting for MCP requests * Configure appropriate timeouts for MCP operations * Limit concurrent MCP connections if needed * Monitor memory usage of MCP server processes * Implement graceful fallbacks when the MCP server is unavailable * Log MCP communication errors for debugging * Set up alerts for repeated failures *** ## Troubleshooting * Verify SERVER\_COMMAND path is correct * Check SERVER\_ARGUMENTS syntax (comma-separated) * Ensure required packages are installed (npx, node, python) * Check system PATH includes required executables * Verify MCP server is running and responsive * Check network/firewall settings * Increase timeout values if server startup is slow * Check server logs for errors * Verify the requested tool is available * Check permissions for the operation * Review server logs for detailed error messages * Ensure broker has required access rights *** ## Next Steps Connect to external REST APIs. Add custom AI/ML logic with Python. # Media Routes Source: https://docs.coreflux.org/latest/lot-language/routes/media-routes Process barcodes and video streams with encoding, decoding, and streaming capabilities ## Media Routes Overview Media routes enable processing of visual data including barcode scanning and video streaming. These routes bridge the gap between image/video sources and your MQTT infrastructure. **Like adding eyes to your broker.** Media routes let your MQTT broker see and interpret visual data — scanning barcodes off a camera feed or streaming video to a dashboard — without any external processing code. *** ## Barcode Route The `BARCODE` route enables encoding and decoding of various barcode formats including QR codes, Code 128, EAN-13, and more. ### Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BarcodeProcessor WITH TYPE BARCODE ADD BARCODE_CONFIG WITH SUPPORTED_FORMATS "QR_CODE, CODE_128, EAN_13" ADD EVENT DecodeBarcode WITH SOURCE_TOPIC "barcode/scan" WITH DESTINATION_TOPIC "barcode/result" WITH MODE "DECODE" ``` ### Configuration Parameters Comma-separated barcode formats to support (e.g., `QR_CODE, CODE_128, EAN_13`). ### Event Parameters MQTT topic with barcode image (base64) or data to encode. MQTT topic to publish barcode results. Operation mode: `ENCODE` (create barcode) or `DECODE` (read barcode). Annotate decoded barcode location on image. Default: false. Topic to publish annotated image (for DECODE mode). ### Supported Formats | Format | Description | | ------------- | ------------------- | | `QR_CODE` | QR Code (2D) | | `CODE_128` | Code 128 (1D) | | `CODE_39` | Code 39 (1D) | | `EAN_13` | EAN-13 (1D retail) | | `EAN_8` | EAN-8 (1D retail) | | `UPC_A` | UPC-A (1D retail) | | `UPC_E` | UPC-E (1D retail) | | `DATA_MATRIX` | Data Matrix (2D) | | `PDF_417` | PDF417 (2D stacked) | ### Barcode Examples Scan barcodes from images: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BarcodeScanner WITH TYPE BARCODE ADD BARCODE_CONFIG WITH SUPPORTED_FORMATS "QR_CODE, CODE_128, EAN_13, UPC_A" ADD EVENT ScanBarcode WITH SOURCE_TOPIC "camera/frame" WITH DESTINATION_TOPIC "barcode/decoded" WITH MODE "DECODE" WITH ENABLE_ANNOTATION "true" WITH ANNOTATED_IMAGE_TOPIC "barcode/annotated" ``` **Input:** Base64-encoded image on `camera/frame` **Output:** JSON with decoded data on `barcode/decoded`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "format": "QR_CODE", "data": "https://example.com", "timestamp": "2024-01-15T10:30:00Z" } ``` Generate barcode images: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BarcodeGenerator WITH TYPE BARCODE ADD BARCODE_CONFIG WITH SUPPORTED_FORMATS "QR_CODE, CODE_128" ADD EVENT GenerateBarcode WITH SOURCE_TOPIC "barcode/generate" WITH DESTINATION_TOPIC "barcode/image" WITH MODE "ENCODE" ``` **Input:** JSON with data and format on `barcode/generate`: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "format": "QR_CODE", "data": "Product-12345", "width": 300, "height": 300 } ``` **Output:** Base64-encoded barcode image on `barcode/image` Industrial barcode scanning: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionScanner WITH TYPE BARCODE ADD BARCODE_CONFIG WITH SUPPORTED_FORMATS "CODE_128, DATA_MATRIX" ADD EVENT ScanProduct WITH SOURCE_TOPIC "line/camera/frame" WITH DESTINATION_TOPIC "line/product/scanned" WITH MODE "DECODE" ADD EVENT ScanSerial WITH SOURCE_TOPIC "line/serial/frame" WITH DESTINATION_TOPIC "line/serial/decoded" WITH MODE "DECODE" WITH ENABLE_ANNOTATION "true" WITH ANNOTATED_IMAGE_TOPIC "line/serial/annotated" ``` *** ## Video Input Route The `VIDEO_INPUT` route captures video frames from RTSP streams, webcams, or video files and publishes them to MQTT topics. ### Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE VideoCapture WITH TYPE VIDEO_INPUT ADD VIDEO_INPUT_CONFIG WITH SOURCE "rtsp://192.168.1.100:554/stream" WITH DESTINATION_TOPIC "video/frames" WITH FORMAT "JPEG" WITH FRAME_RATE '10' ``` ### Configuration Parameters Video source: RTSP URL, webcam identifier, or file path. MQTT topic to publish video frames. Output format: `JPEG`, `MJPEG`, `H264`, `H265`, `MP4`, `MPEGTS`. Frame rate for capture (1-60 fps). Frame width in pixels. Frame height in pixels. JPEG quality (1-100). ### Event Parameters MQTT topic to control video capture. Control command: `START`, `STOP`, or `CAPTURE `. ### Video Input Examples Capture from IP camera: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE IPCamera WITH TYPE VIDEO_INPUT ADD VIDEO_INPUT_CONFIG WITH SOURCE "rtsp://admin:password@192.168.1.100:554/stream1" WITH DESTINATION_TOPIC "camera/entrance/frames" WITH FORMAT "JPEG" WITH FRAME_RATE '5' WITH WIDTH '1280' WITH HEIGHT '720' WITH QUALITY '80' ADD EVENT CameraControl WITH SOURCE_TOPIC "camera/entrance/control" WITH QUERY "START" ``` Capture from local webcam: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Webcam WITH TYPE VIDEO_INPUT ADD VIDEO_INPUT_CONFIG WITH SOURCE "webcam:0" WITH DESTINATION_TOPIC "webcam/frames" WITH FORMAT "JPEG" WITH FRAME_RATE '15' WITH WIDTH '640' WITH HEIGHT '480' ADD EVENT WebcamControl WITH SOURCE_TOPIC "webcam/control" WITH QUERY "START" ``` Process a video file: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE VideoFile WITH TYPE VIDEO_INPUT ADD VIDEO_INPUT_CONFIG WITH SOURCE "/videos/sample.mp4" WITH DESTINATION_TOPIC "video/file/frames" WITH FORMAT "JPEG" WITH FRAME_RATE '30' ADD EVENT FileControl WITH SOURCE_TOPIC "video/file/control" WITH QUERY "START" ``` Low frame rate for motion detection: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MotionDetection WITH TYPE VIDEO_INPUT ADD VIDEO_INPUT_CONFIG WITH SOURCE "rtsp://192.168.1.50:554/stream" WITH DESTINATION_TOPIC "security/motion/frames" WITH FORMAT "JPEG" WITH FRAME_RATE '2' WITH WIDTH '640' WITH HEIGHT '480' WITH QUALITY '60' ADD EVENT MotionControl WITH SOURCE_TOPIC "security/motion/control" WITH QUERY "START" ``` *** ## Video Output Route The `VIDEO_OUTPUT` route streams video frames from MQTT to various output protocols. ### Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE VideoStreamer WITH TYPE VIDEO_OUTPUT ADD VIDEO_OUTPUT_CONFIG WITH SOURCE_TOPIC "video/frames" WITH OUTPUT_PROTOCOL "HTTP" WITH OUTPUT_PATH "/stream" ``` ### Configuration Parameters MQTT topic with video frames (base64). Output protocol: `HTTP`, `RTSP`, `UDP`, `TCP`. Output path or port for streaming. ### Event Parameters Control command: `START` or `STOP`. ### Video Output Examples Stream via HTTP (MJPEG): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE HTTPStream WITH TYPE VIDEO_OUTPUT ADD VIDEO_OUTPUT_CONFIG WITH SOURCE_TOPIC "camera/frames" WITH OUTPUT_PROTOCOL "HTTP" WITH OUTPUT_PATH ":8081/video" ADD EVENT StreamControl WITH QUERY "START" ``` Access stream at: `http://broker-ip:8081/video` Re-stream as RTSP: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE RTSPOutput WITH TYPE VIDEO_OUTPUT ADD VIDEO_OUTPUT_CONFIG WITH SOURCE_TOPIC "processed/frames" WITH OUTPUT_PROTOCOL "RTSP" WITH OUTPUT_PATH ":8554/stream" ADD EVENT RTSPControl WITH QUERY "START" ``` Access stream at: `rtsp://broker-ip:8554/stream` Multicast video stream: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE UDPStream WITH TYPE VIDEO_OUTPUT ADD VIDEO_OUTPUT_CONFIG WITH SOURCE_TOPIC "broadcast/frames" WITH OUTPUT_PROTOCOL "UDP" WITH OUTPUT_PATH "239.0.0.1:5000" ADD EVENT UDPControl WITH QUERY "START" ``` *** ## Complete Example: Vision System A complete vision system with camera input, barcode scanning, and video streaming: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} // Camera input DEFINE ROUTE InspectionCamera WITH TYPE VIDEO_INPUT ADD VIDEO_INPUT_CONFIG WITH SOURCE "rtsp://192.168.1.100:554/stream" WITH DESTINATION_TOPIC "inspection/camera/raw" WITH FORMAT "JPEG" WITH FRAME_RATE '10' WITH WIDTH '1920' WITH HEIGHT '1080' WITH QUALITY '90' ADD EVENT CameraControl WITH SOURCE_TOPIC "inspection/camera/control" WITH QUERY "START" // Barcode scanning DEFINE ROUTE ProductScanner WITH TYPE BARCODE ADD BARCODE_CONFIG WITH SUPPORTED_FORMATS "CODE_128, DATA_MATRIX, QR_CODE" ADD EVENT ScanProduct WITH SOURCE_TOPIC "inspection/camera/raw" WITH DESTINATION_TOPIC "inspection/barcode/result" WITH MODE "DECODE" WITH ENABLE_ANNOTATION "true" WITH ANNOTATED_IMAGE_TOPIC "inspection/camera/annotated" // Live stream for operators DEFINE ROUTE OperatorStream WITH TYPE VIDEO_OUTPUT ADD VIDEO_OUTPUT_CONFIG WITH SOURCE_TOPIC "inspection/camera/annotated" WITH OUTPUT_PROTOCOL "HTTP" WITH OUTPUT_PATH ":8080/inspection" ADD EVENT StreamControl WITH QUERY "START" ``` *** ## Best Practices Use lower frame rates for bandwidth efficiency: * **Monitoring**: 1-5 fps * **Motion detection**: 2-5 fps * **Barcode scanning**: 5-10 fps * **Live viewing**: 15-30 fps Balance quality and performance: * **Barcode scanning**: 640x480 to 1280x720 * **Monitoring**: 1280x720 * **High detail**: 1920x1080 JPEG format is ideal for MQTT transport: * Reasonable file sizes * Wide compatibility * Adjustable quality (60-90 recommended) Configure reconnection for RTSP streams and monitor connection status. *** ## Troubleshooting * Verify RTSP URL format and credentials * Check camera is accessible on network * Ensure port is not blocked by firewall * Try alternative RTSP paths (check camera documentation) * Ensure image quality is sufficient * Check barcode format is in SUPPORTED\_FORMATS * Verify barcode is properly visible in image * Try higher resolution capture * Reduce frame rate * Lower resolution * Decrease JPEG quality * Process fewer simultaneous streams * Use UDP for lowest latency * Reduce frame size and quality * Check network bandwidth * Use local network when possible *** ## Next Steps Connect to PLCs and industrial equipment. See complete route configuration examples. # Email Route Source: https://docs.coreflux.org/latest/lot-language/routes/notifications/email Send templated email notifications and alerts from MQTT messages over SMTP ## Email Overview The `EMAIL` route sends templated emails in response to MQTT messages. It's the simplest way to turn a broker event — a critical alarm, a daily report, a user notification — into a message that reaches a person's inbox. **Like an auto-reply rule in your mailbox.** When a message lands on a topic you're watching, the route fills in a template and sends the email for you — no manual step required. ### When to use it * Alert an operations team the moment a critical alarm fires. * Email a stakeholder a daily or shift report. * Notify an individual user using an address pulled from the message payload. *** Let's look at how to define an email route, starting with the most common scenarios. ## Quick Start Send an immediate alert to a fixed team address when a critical event arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CriticalAlerts WITH TYPE EMAIL ADD SMTP_CONFIG WITH HOST "smtp.gmail.com" WITH PORT '587' WITH USERNAME "alerts@company.com" WITH PASSWORD "app-password-here" WITH USE_TLS "true" ADD EVENT criticalNotify WITH SOURCE_TOPIC "alerts/critical/+" WITH TEMPLATE_PATH "/templates/critical_alert.html" WITH SUBJECT "CRITICAL: {value.json.alert_type}" WITH RECIPIENT "ops-team@company.com" ``` Send to an address taken from the message payload: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE UserNotifications WITH TYPE EMAIL ADD SMTP_CONFIG WITH HOST "smtp.office365.com" WITH PORT '587' WITH USERNAME "noreply@company.com" WITH PASSWORD "secure-password" WITH USE_TLS "true" ADD EVENT userNotify WITH SOURCE_TOPIC "users/+/notifications" WITH TEMPLATE_PATH "/templates/user_notification.html" WITH SUBJECT "Hello {value.json.username}" WITH RECIPIENT "{value.json.email}" ``` Embed images (a logo, a chart) directly in the email: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ReportEmail WITH TYPE EMAIL ADD SMTP_CONFIG WITH HOST "smtp.company.com" WITH PORT '587' WITH USERNAME "reports@company.com" WITH PASSWORD "password" WITH USE_TLS "true" ADD EVENT sendReport WITH SOURCE_TOPIC "reports/daily" WITH TEMPLATE_PATH "/templates/daily_report.html" WITH SUBJECT "Daily Report - {value.json.date}" WITH RECIPIENT "management@company.com" WITH EMBED_RESOURCES ADD "/images/logo.png" ADD "/images/chart.png" ``` For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833) instead of your regular password. Enable 2FA on your Google account first. *** ## SMTP Configuration The `SMTP_CONFIG` block tells the route which mail server to send through and how to authenticate. SMTP server address (e.g., `smtp.gmail.com`, `smtp.office365.com`). SMTP port (typically 587 for TLS, 465 for SSL). Enable TLS encryption. Recommended: true. Email account to send from. Email password or app-specific password. *** ## Event Configuration Each `ADD EVENT` block defines a trigger topic and the content of the email it sends. MQTT topic to trigger email sending. MQTT topic to publish send status. Path to HTML email template file. Email subject line (supports placeholders). Email recipient address (supports placeholders). ### Template Placeholders Subjects, recipients, and templates support placeholders that are filled in from the triggering message: | Placeholder | Description | | --------------------------- | --------------------------- | | `{value}` | Full JSON payload as string | | `{value.json.field}` | Single field from JSON | | `{value.json.nested.field}` | Nested field access | *** ## Troubleshooting * Verify SMTP credentials are correct * For Gmail, ensure you're using an App Password * Check firewall allows outbound SMTP connections * Verify recipient email address is valid * Double-check USERNAME and PASSWORD * Ensure 2FA is enabled if using app passwords * Some providers require enabling "less secure apps" access * Check for account lockouts due to failed attempts *** ## Next Steps Publish the MQTT events that trigger notifications. Store MQTT data in databases. # Routes Overview Source: https://docs.coreflux.org/latest/lot-language/routes/overview Connect your Coreflux broker to external systems, databases, industrial equipment, and other brokers using LoT routes ## What are Routes? Routes define connections between your Coreflux MQTT broker and external systems. They enable broker-to-broker communication, database storage, industrial protocol integration, REST API connectivity, email notifications, AI integration, and media processing - all without writing custom middleware. Routes extend your broker's capabilities by creating managed connections to external systems - all configured through LoT syntax. Architecture diagram showing the Coreflux MQTT broker at the center connected to databases, PLCs, REST APIs, cloud brokers, email, and AI through Routes ## Route Categories Configure MQTT bridges and Sparkplug roles. Connect MQTT topics to external event-streaming platforms. Send alerts and reports to people from MQTT events. Store MQTT data in PostgreSQL, MySQL, MongoDB, OpenSearch, Snowflake, and more. HTTP client/server for external API integration and webhook endpoints. Connect to PLCs via Modbus, Siemens S7, OPC UA, ADS, Allen-Bradley, and FINS. Connect to external tools and services through MCP. Add an autonomous AI assistant that can read topics and call tools. Barcode scanning and video streaming capabilities. Choose wait or drop when a route cannot keep up with incoming MQTT. This page covers the fundamentals of route configuration. Choose a specific route category above for detailed syntax and examples. *** ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WITH TYPE ADD WITH "" ADD MAPPING WITH SOURCE_TOPIC "" WITH DESTINATION_TOPIC "" ``` ### Components Unique identifier for the route. Use descriptive names like `CloudBridge`, `SensorDataIndexer`, `AlertEmailer`. The type of route (see Available Route Types below). Configuration blocks specific to the route type (e.g., `MQTT_CONFIG`, `MONGODB_CONFIG`, `REST_API_CONFIG`). Optional. Controls wait-or-drop behavior when this route cannot keep up with incoming MQTT. Works on every route type. See [Route Overload Configuration](./route-config). Defines topic mappings for data flow between source and destination. *** ## Available Route Types | Route Type | Description | | ---------------- | ------------------------------------------- | | `MQTT_BRIDGE` | Broker-to-broker topic synchronization | | `SPARKPLUG_B` | Sparkplug decoding and edge-node publishing | | `SPARKPLUG_HOST` | Sparkplug Primary Host Application | | Route Type | Description | | ---------- | --------------------------------------- | | `KAFKA` | Stream data both ways with Apache Kafka | | Route Type | Description | | ---------- | ---------------------------------------- | | `EMAIL` | Send templated emails from MQTT messages | | Route Type | Description | | -------------- | ------------------------------ | | `POSTGRESQL` | PostgreSQL database storage | | `MYSQL` | MySQL database storage | | `MARIADB` | MariaDB database storage | | `SQLSERVER` | SQL Server database storage | | `MONGODB` | MongoDB document database | | `OPENSEARCH` | OpenSearch indexing and search | | `CRATEDB` | CrateDB time-series database | | `SNOWFLAKE` | Snowflake cloud data warehouse | | `FILE_STORAGE` | CSV/JSON file storage | | Route Type | Description | | --------------- | --------------------------- | | `MODBUS_TCP` | Modbus TCP protocol | | `MODBUS_SERIAL` | Modbus RTU over RS-232/485 | | `SIEMENS_S7` | Siemens S7 PLCs | | `OPCUA` | OPC UA client | | `ADS` | Beckhoff TwinCAT ADS | | `ALLEN_BRADLEY` | Allen-Bradley/Rockwell PLCs | | `ETHERNETIP` | EtherNet/IP CIP protocol | | `FINS` | Omron FINS protocol | | Route Type | Description | | -------------- | ----------------------------------------- | | `REST_API` | REST API client/server | | `MQTT_TO_REST` | Forward MQTT messages to REST endpoints | | `MCP` | Model Context Protocol for external tools | | `AGENT` | Autonomous AI assistant with tool access | | `BARCODE` | Barcode encoding/decoding | | `VIDEO_INPUT` | Video capture | | `VIDEO_OUTPUT` | Video streaming | *** ## Quick Examples Send email notifications from MQTT events: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AlertEmail WITH TYPE EMAIL ADD SMTP_CONFIG WITH HOST "smtp.gmail.com" WITH PORT '587' WITH USERNAME "alerts@company.com" WITH PASSWORD "app-password" WITH USE_TLS "true" ADD EVENT criticalAlert WITH SOURCE_TOPIC "alerts/critical/+" WITH SUBJECT "Critical Alert: {value.json.type}" WITH RECIPIENT "team@company.com" ``` Read data from PLCs via Modbus: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlcReader WITH TYPE MODBUS_TCP ADD MODBUS_CONFIG WITH HOST "192.168.1.100" WITH PORT '502' WITH SLAVE_ID '1' ADD MAPPING Sensors WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "100" WITH ADDRESS_TYPE "HOLDING_REGISTER" WITH DATA_TYPE "FLOAT" WITH SOURCE_TOPIC "plc/temperature" ``` Connect to external REST APIs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WeatherService WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_CLIENT "true" WITH BASE_ADDRESS "https://api.weather.com" WITH USE_SSL "true" ADD EVENT GetWeather WITH SOURCE_TOPIC "weather/request" WITH DESTINATION_TOPIC "weather/response" WITH METHOD "GET" WITH ENDPOINT "/v1/current?city={value.json.city}" ``` Store MQTT data in PostgreSQL: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER "postgres.example.com" WITH PORT '5432' WITH DATABASE "iot_data" WITH USERNAME "iot_user" WITH PASSWORD "secure_password" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO readings (ts, sensor, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` Connect two brokers and sync topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CloudBridge WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "cloud.example.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "EdgeBridge" WITH USE_TLS "true" ADD MAPPING sensorSync WITH SOURCE_TOPIC "sensors/+/data" WITH DESTINATION_TOPIC "edge/sensors/+/data" WITH DIRECTION "out" ``` *** ## Understanding Route Configuration Routes share two common building blocks: **MQTT configuration** and **topic mappings**. You'll see these across most route types, so understanding them here saves time when setting up any specific route. ### Connecting to a Broker Routes that communicate with MQTT brokers — like bridges — need to know which broker to talk to. The simplest option is `BROKER SELF`, which tells the route to use your local Coreflux broker: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MQTT_CONFIG WITH BROKER SELF ``` When you need to reach a **remote** or **authenticated** broker (for example, a cloud broker or a second Coreflux instance on your network), provide the connection details explicitly: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MQTT_CONFIG WITH BROKER_ADDRESS "192.168.1.50" WITH BROKER_PORT '1883' WITH CLIENT_ID "MyRouteClient" WITH USERNAME "user" WITH PASSWORD "password" ``` Not every route type requires an `MQTT_CONFIG` block. Database routes, email routes, and REST routes have their own configuration sections. Check the specific route page for details. ### Controlling Data Flow with Mappings Mappings define **which MQTT topics** flow through a route and **in which direction**. For example, to push local sensor readings to a cloud broker: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD MAPPING sensorSync WITH SOURCE_TOPIC "sensors/+/temperature" WITH DESTINATION_TOPIC "cloud/sensors/+/temperature" WITH DIRECTION "out" ``` The `DIRECTION` parameter controls where data goes: | Direction | What it does | Example scenario | | --------- | --------------------------- | ---------------------------- | | `out` | Local broker to destination | Push edge data to the cloud | | `in` | Destination to local broker | Pull remote commands locally | | `both` | Bidirectional sync | Keep two brokers in sync | ### When a route cannot keep up Incoming MQTT can outpace the destination. Add `ADD ROUTE_CONFIG` on any route to choose whether that route waits or drops the newest message. Routes without the block use the broker default (`balanced`). See [Route Overload Configuration](./route-config). *** ## Route Status Monitoring Every route automatically publishes real-time health and status information to a dedicated MQTT topic. No additional configuration is required — the status topic is created as soon as the route is defined. Status updates are published to a `$SYS` topic and are available to any client subscribed to the broker. ### Status Topic The topic follows this pattern: ``` $SYS/Coreflux/Routes//status ``` For example, a route named `MyS7Route` publishes its status to `$SYS/Coreflux/Routes/MyS7Route/status`. ### Status Payload The status topic publishes a JSON object with connection health, message statistics, and timing information: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "name": "MyS7Route", "type": "SIEMENS_S7", "connection": "Connected", "health": "Green", "errors": { "total": 0, "consecutive": 0, "rate": 0 }, "messages": { "total": 4749, "succeeded": 4749, "failed": 0 }, "lastActivity": "2026-03-03T15:03:14.563Z", "lastSuccess": "2026-03-03T15:03:14.563Z", "lastError": null, "uptime": "00:08:01", "connectedSince": "2026-03-03T14:55:13.299Z", "timestamp": "2026-03-03T15:03:14.590Z" } ``` ### Field Reference | Field | Type | Description | | -------------------- | -------------- | ----------------------------------------------------------------------- | | `name` | string | The route name as defined in LoT | | `type` | string | The route type (e.g., `SIEMENS_S7`, `MQTT_BRIDGE`, `POSTGRESQL`) | | `connection` | string | Current connection state (`Connected`, `Disconnected`, etc.) | | `health` | string | Overall health indicator (`Green`, `Yellow`, `Red`) | | `errors.total` | integer | Total number of errors since the route started | | `errors.consecutive` | integer | Number of consecutive errors without a successful message | | `errors.rate` | number | Current error rate | | `messages.total` | integer | Total messages processed | | `messages.succeeded` | integer | Messages processed successfully | | `messages.failed` | integer | Messages that failed processing | | `lastActivity` | string | ISO 8601 timestamp of the last message (success or failure) | | `lastSuccess` | string | ISO 8601 timestamp of the last successful message | | `lastError` | string \| null | ISO 8601 timestamp of the last error, or `null` if no errors occurred | | `uptime` | string | Time the route has been running (`HH:MM:SS`) | | `connectedSince` | string | ISO 8601 timestamp of when the route established its current connection | | `timestamp` | string | ISO 8601 timestamp of when this status update was generated | ### Checking Route Status You can monitor any route's health by subscribing to its status topic. Use MQTT Explorer or any MQTT client to subscribe: ``` $SYS/Coreflux/Routes/+/status ``` This wildcard subscription shows status updates for **all** active routes at once. *** ## Use Cases Bridge local edge data to cloud brokers: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EdgeToCloud WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloud-provider.com" WITH BROKER_PORT '8883' WITH USE_TLS "true" ADD MAPPING allSensors WITH SOURCE_TOPIC "local/sensors/#" WITH DESTINATION_TOPIC "edge-01/sensors/#" WITH DIRECTION "out" ``` Read data from PLCs via multiple protocols: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SiemensPLC WITH TYPE SIEMENS_S7 ADD S7_CONFIG WITH IP "192.168.1.100" WITH CPU_TYPE "S71500" WITH RACK '0' WITH SLOT '1' ADD MAPPING ProcessData WITH EVERY 500 MILLISECONDS ADD TAG Temperature WITH ADDRESS "DB1.DBD0" WITH DATA_TYPE "REAL" WITH SOURCE_TOPIC "plc/temperature" ``` Store sensor data for analysis: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AnalyticsStore WITH TYPE OPENSEARCH ADD OPENSEARCH_CONFIG WITH BASE_URL "https://analytics.example.com:9200" WITH USERNAME "admin" WITH PASSWORD "password" WITH USE_SSL "true" ADD EVENT IndexReading WITH SOURCE_TOPIC "sensors/#" WITH QUERY "CLEAN:{index: sensor-data, body: {timestamp: {timestamp}, value: {value.json}}}" ``` Email stakeholders when critical events occur: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CriticalAlerts WITH TYPE EMAIL ADD SMTP_CONFIG WITH HOST "smtp.office365.com" WITH PORT '587' WITH USE_TLS "true" WITH USERNAME "alerts@company.com" WITH PASSWORD "password" ADD EVENT notify WITH SOURCE_TOPIC "alarms/critical/+" WITH SUBJECT "CRITICAL: {value.json.alarm_type}" WITH RECIPIENT "ops-team@company.com" ``` ## Next Steps Connect to PLCs and industrial equipment. Store MQTT data in databases. # REST API Routes Source: https://docs.coreflux.org/latest/lot-language/routes/rest-api-routes Connect to external REST APIs or expose HTTP endpoints from your broker with client/server modes ## REST API Overview The `REST_API` route enables HTTP communication in both directions: * **Client Mode** - Make HTTP requests to external APIs triggered by MQTT messages * **Server Mode** - Expose HTTP endpoints that accept data and publish it to MQTT topics REST API routes are ideal for integrating with web services, webhooks, cloud APIs, and creating HTTP-to-MQTT bridges. ## Basic Syntax ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE APIConnector WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS "https://api.example.com" WITH ENABLE_CLIENT "true" WITH USE_SSL "true" ADD EVENT GetData WITH SOURCE_TOPIC "api/requests/data" WITH DESTINATION_TOPIC "api/responses/data" WITH METHOD "GET" WITH ENDPOINT "/v1/data" ``` *** ## How REST API Routes Work A REST API route bridges the HTTP and MQTT worlds. It contains **events** that define what happens when data flows through—either from MQTT to HTTP (client mode) or from HTTP to MQTT (server mode). ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph ClientMode A[MQTT Message] -->|triggers| B[HTTP Request] B -->|response| C[MQTT Topic] end subgraph ServerMode D[HTTP Request] -->|triggers| E[MQTT Topic] end ``` | Component | Purpose | | ------------------- | --------------------------------------------------------------------- | | `REST_API_CONFIG` | Connection settings—base URL, authentication, SSL, timeouts | | `EVENT` | Defines a single HTTP operation with its trigger, method, and routing | | `SOURCE_TOPIC` | MQTT topic that triggers the HTTP request (client mode) | | `DESTINATION_TOPIC` | MQTT topic where results are published | | `ENDPOINT` | The HTTP path to call (can include placeholders) | *** ## REST API Configuration ### REST\_API\_CONFIG Parameters Base URL for HTTP requests (e.g., `https://api.example.com`). Enable HTTP client functionality. Default: false. Request timeout in seconds. Default: 30. Default content type. Default: application/json. Enable HTTP server functionality. Default: false. HTTP server port. Default: 8080. Basic authentication username. Basic authentication password. Enable SSL/TLS. Default: false. Ignore certificate validation errors. Default: false. Enable CORS headers. Default: false. Allowed CORS origins. Default: \* (all). *** ## Client Mode Use client mode when your broker needs to **call external APIs**. An MQTT message triggers the HTTP request, and the response is published back to MQTT. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR A["MQTT: weather/request
{city: 'London'}"] -->|triggers| B[REST API Route] B -->|GET /weather?q=London| C[External API] C -->|JSON response| B B -->|publishes| D["MQTT: weather/response
{temp: 15, ...}"] ``` ### Event Configuration MQTT topic that triggers the HTTP request. MQTT topic to publish HTTP response. HTTP method: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`. API endpoint path (supports placeholders). Request body for POST/PUT (supports placeholders). ### Placeholder Support Use placeholders in endpoints and bodies to insert dynamic values from the MQTT message: | Placeholder | Description | | -------------------- | -------------------------------- | | `{payload}` | Full MQTT payload as-is | | `{value.json}` | Parsed JSON payload | | `{value.json.field}` | Specific field from JSON payload | | `{source_topic}` | Original MQTT topic | | `{timestamp}` | Message timestamp | ### Client Examples Fetch data from an external API when an MQTT message arrives: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WeatherAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS "https://api.openweathermap.org" WITH ENABLE_CLIENT "true" WITH USE_SSL "true" WITH REQUEST_TIMEOUT '30' ADD EVENT GetWeather WITH SOURCE_TOPIC "weather/request" WITH DESTINATION_TOPIC "weather/response" WITH METHOD "GET" WITH ENDPOINT "/data/2.5/weather?q={value.json.city}&appid={value.json.apikey}" ``` When a message like `{"city": "London", "apikey": "abc123"}` arrives at `weather/request`, the route calls the API and publishes the weather data to `weather/response`. Send data to an external API: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE NotificationAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS "https://api.notification-service.com" WITH ENABLE_CLIENT "true" WITH USE_SSL "true" ADD EVENT SendNotification WITH SOURCE_TOPIC "notifications/send" WITH DESTINATION_TOPIC "notifications/result" WITH METHOD "POST" WITH ENDPOINT "/v1/notify" WITH HEADERS ADD "Content-Type" "application/json" ADD "Authorization" "Bearer {value.json.api_key}" WITH BODY "{payload}" ``` The entire MQTT payload is forwarded as the HTTP request body using `{payload}`. Use basic authentication for protected APIs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AuthenticatedAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS "https://secure-api.example.com" WITH ENABLE_CLIENT "true" WITH USERNAME "api_user" WITH PASSWORD "api_secret" WITH USE_SSL "true" ADD EVENT SecureRequest WITH SOURCE_TOPIC "secure/request" WITH DESTINATION_TOPIC "secure/response" WITH METHOD "GET" WITH ENDPOINT "/protected/data" ``` The username and password are sent as HTTP Basic Authentication headers automatically. *** ## Server Mode Use server mode when external systems need to **send data to your broker via HTTP**. The broker exposes an HTTP endpoint that accepts requests and publishes the data to MQTT topics. **Common use case:** Your web application, mobile app, or third-party service needs to push data into the MQTT broker without implementing an MQTT client. ### Why Use Server Mode? | Scenario | How Server Mode Helps | | ----------------------- | ---------------------------------------------------------- | | Web dashboards | Submit commands or data via simple HTTP POST | | Mobile apps | Send sensor data without MQTT libraries | | Webhooks | Receive callbacks from services like GitHub, Stripe, Slack | | Legacy systems | Integrate systems that only support HTTP | | Firewalled environments | Accept data through standard HTTP ports | ### Server Event Configuration The HTTP path to expose (e.g., `/api/sensors`). HTTP method to accept: `GET`, `POST`, `PUT`, `DELETE`. MQTT topic where incoming HTTP data is published. ### Server Examples Expose an endpoint for devices to submit readings via HTTP: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorHTTPBridge WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_SERVER "true" WITH SERVER_PORT '8080' WITH ENABLE_CORS "true" ADD EVENT ReceiveSensorData WITH ENDPOINT "/api/sensors" WITH METHOD "POST" WITH DESTINATION_TOPIC "sensors/from/http" ``` **Usage:** Send sensor data with a simple HTTP POST: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} curl -X POST http://broker-ip:8080/api/sensors \ -H "Content-Type: application/json" \ -d '{"sensor_id": "temp-01", "value": 25.5}' ``` The data is published to `sensors/from/http` and available to all MQTT subscribers. Expose different endpoints for different data types: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MultiEndpointAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_SERVER "true" WITH SERVER_PORT '8080' WITH ENABLE_CORS "true" ADD EVENT ReceiveTemperature WITH ENDPOINT "/api/temperature" WITH METHOD "POST" WITH DESTINATION_TOPIC "sensors/temperature/http" ADD EVENT ReceivePressure WITH ENDPOINT "/api/pressure" WITH METHOD "POST" WITH DESTINATION_TOPIC "sensors/pressure/http" ADD EVENT ReceiveCommands WITH ENDPOINT "/api/commands" WITH METHOD "POST" WITH DESTINATION_TOPIC "commands/from/http" ``` Each endpoint routes to a different MQTT topic for organized data handling. Accept webhooks from external services: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WebhookReceiver WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_SERVER "true" WITH SERVER_PORT '9000' ADD EVENT GitHubWebhook WITH ENDPOINT "/webhooks/github" WITH METHOD "POST" WITH DESTINATION_TOPIC "webhooks/github/events" ADD EVENT StripeWebhook WITH ENDPOINT "/webhooks/stripe" WITH METHOD "POST" WITH DESTINATION_TOPIC "webhooks/stripe/events" ``` Configure GitHub or Stripe to send webhooks to `http://your-broker:9000/webhooks/github`. Events appear on the corresponding MQTT topics. Enable cross-origin requests for browser-based applications: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WebAppAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_SERVER "true" WITH SERVER_PORT '3000' WITH ENABLE_CORS "true" WITH CORS_ORIGINS "https://app.example.com, https://admin.example.com" ADD EVENT SubmitData WITH ENDPOINT "/api/submit" WITH METHOD "POST" WITH DESTINATION_TOPIC "webapp/submissions" ``` Only requests from the specified origins are allowed. Use `"*"` for development (not recommended for production). *** ## Combined Client and Server A single route can operate in both modes—accepting HTTP requests AND calling external APIs: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE BidirectionalHTTP WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS "https://external-api.com" WITH ENABLE_CLIENT "true" WITH ENABLE_SERVER "true" WITH SERVER_PORT '8080' WITH USE_SSL "true" WITH ENABLE_CORS "true" // Server: Accept HTTP, publish to MQTT ADD EVENT ReceiveFromHTTP WITH ENDPOINT "/api/receive" WITH METHOD "POST" WITH DESTINATION_TOPIC "http/incoming" // Client: MQTT triggers external HTTP call ADD EVENT CallExternalAPI WITH SOURCE_TOPIC "http/outgoing" WITH DESTINATION_TOPIC "http/response" WITH METHOD "POST" WITH ENDPOINT "/api/data" ``` *** ## Complete Examples Full API for a web dashboard to read sensor data and send commands: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE DashboardAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_SERVER "true" WITH SERVER_PORT '8080' WITH ENABLE_CORS "true" WITH DEFAULT_CONTENT_TYPE "application/json" ADD EVENT PostSensorData WITH ENDPOINT "/api/sensors" WITH METHOD "POST" WITH DESTINATION_TOPIC "dashboard/sensors/incoming" ADD EVENT PostCommand WITH ENDPOINT "/api/commands" WITH METHOD "POST" WITH DESTINATION_TOPIC "commands/from/dashboard" ADD EVENT PostAlert WITH ENDPOINT "/api/alerts" WITH METHOD "POST" WITH DESTINATION_TOPIC "alerts/from/dashboard" ``` Integrate with an external CRM or ERP system: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CRMIntegration WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS "https://api.crm-system.com" WITH ENABLE_CLIENT "true" WITH USE_SSL "true" WITH REQUEST_TIMEOUT '60' ADD EVENT CreateRecord WITH SOURCE_TOPIC "crm/create" WITH DESTINATION_TOPIC "crm/created" WITH METHOD "POST" WITH ENDPOINT "/v2/records" WITH HEADERS ADD "Authorization" "Bearer {value.json.token}" ADD "Content-Type" "application/json" WITH BODY "{value.json.data}" ADD EVENT UpdateRecord WITH SOURCE_TOPIC "crm/update" WITH DESTINATION_TOPIC "crm/updated" WITH METHOD "PUT" WITH ENDPOINT "/v2/records/{value.json.id}" WITH BODY "{value.json.data}" ADD EVENT DeleteRecord WITH SOURCE_TOPIC "crm/delete" WITH DESTINATION_TOPIC "crm/deleted" WITH METHOD "DELETE" WITH ENDPOINT "/v2/records/{value.json.id}" ``` *** ## Troubleshooting * Verify BASE\_ADDRESS is correct * Check firewall allows outbound connections * Increase REQUEST\_TIMEOUT if API is slow * Verify network connectivity * Ensure USE\_SSL matches the API requirements * For self-signed certificates, set IGNORE\_CERT\_ERRORS (not recommended for production) * Verify certificate chain is complete * Verify USERNAME and PASSWORD are correct * Check Authorization header format * Ensure API key is valid and not expired * Enable CORS on the server: `WITH ENABLE_CORS "true"` * Configure specific allowed origins * Check browser console for specific CORS error messages * Verify DESTINATION\_TOPIC is configured * Check MQTT broker connectivity * Monitor broker logs for errors *** ## Best Practices Always use SSL/TLS for external API connections: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH USE_SSL "true" ``` Configure timeouts based on expected API response times: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH REQUEST_TIMEOUT '30' ``` Subscribe to response topics to monitor API call results and handle failures in your Actions. When exposing HTTP server endpoints: * Use authentication where needed * Limit CORS origins to specific domains * Consider rate limiting at the network level Configure different base URLs for development and production environments. *** ## Next Steps Stream MQTT messages through Kafka. See complete route configuration examples. # Route Overload Configuration Source: https://docs.coreflux.org/latest/lot-language/routes/route-config Choose wait or drop with ADD ROUTE_CONFIG when a LoT route cannot keep up with incoming MQTT ## When MQTT outruns the destination Incoming MQTT can outpace a database insert, a cloud bridge, or a PLC write. `ADD ROUTE_CONFIG` lets **each route** choose whether to wait or to drop the newest message — without crowding out every other route. **Like a ticket window with a limited line.** People already being served always finish. When the line is full you can wait for a spot, wait a short time then turn the newest arrival away, or turn them away immediately. ### When to use it * Leave the default (`balanced`) on most routes. * Use `lossless` when a path must not lose messages (audit, compliance) and you accept that the publisher may wait. * Use `realtime` when high-rate telemetry must never stall the client that published it. * Set `OVERLOAD_CAPACITY` to cap one noisy route so it cannot crowd out others. `ADD ROUTE_CONFIG` is the only LoT block that works on **every** route type. Place it next to that type's own config (`SQL_CONFIG`, `SOURCE_CONFIG`, `MODBUS_CONFIG`, and so on). Order among `ADD` blocks does not matter. *** Let's look at how to set it, starting with the three common contracts. ## Quick Start Use `lossless` on a forwarding path that must keep every message. The publisher waits until this route has room — it never drops. ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CloudBridge WITH TYPE MQTT_BRIDGE ADD ROUTE_CONFIG WITH OVERLOAD_POLICY "lossless" ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "broker.example.com" WITH BROKER_PORT '8883' WITH USE_TLS "true" ADD MAPPING auditForward WITH SOURCE_TOPIC "audit/#" WITH DESTINATION_TOPIC "cloud/audit/#" WITH DIRECTION "out" ``` Use `realtime` on high-rate telemetry. If the route is full, the arriving message is dropped immediately and the publishing client is not delayed. ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorDB WITH TYPE POSTGRESQL ADD ROUTE_CONFIG WITH OVERLOAD_POLICY "realtime" ADD SQL_CONFIG WITH SERVER GET ENV "PG_HOST" WITH DATABASE "factory" WITH USERNAME GET ENV "PG_USER" WITH PASSWORD GET SECRET "PG_PASSWORD" ADD EVENT StoreReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO readings (ts, value) VALUES (NOW(), '{value.json}')" ``` Cap how many of **this route's** messages may be in flight. Extra messages from this route drop even while other routes still have room. ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE NoisySensors WITH TYPE MQTT_BRIDGE ADD ROUTE_CONFIG WITH OVERLOAD_CAPACITY 5000 ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "telemetry.example.com" WITH BROKER_PORT '1883' ADD MAPPING sensorsOut WITH SOURCE_TOPIC "sensors/#" WITH DESTINATION_TOPIC "remote/sensors/#" WITH DIRECTION "out" ``` *** ## Settings All three settings are optional. An absent `ADD ROUTE_CONFIG` block inherits the broker default entirely. ```lot wrap focus={3-6} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CloudBridge WITH TYPE MQTT_BRIDGE ADD ROUTE_CONFIG WITH OVERLOAD_POLICY "balanced" WITH OVERLOAD_TIMEOUT 200 WITH OVERLOAD_CAPACITY 20000 ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "broker.example.com" WITH BROKER_PORT '8883' WITH USE_TLS "true" ADD MAPPING sensorSync WITH SOURCE_TOPIC "sensors/#" WITH DESTINATION_TOPIC "cloud/sensors/#" WITH DIRECTION "out" ``` | Setting | Required | Default | Description | | ------------------- | -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OVERLOAD_POLICY` | No | `balanced` | `lossless` waits until there is room and never drops. `balanced` waits up to `OVERLOAD_TIMEOUT`, then drops the **arriving** message. `realtime` never waits — it drops immediately when the route is full. | | `OVERLOAD_TIMEOUT` | No | `50` (ms) | Wait budget for `balanced` only. Ignored for `lossless` (waits indefinitely) and `realtime` (never waits). Must be zero or greater. | | `OVERLOAD_CAPACITY` | No | unbounded | Maximum number of **this route's** messages allowed in flight at once. Once reached, further messages from this route drop even if other routes still have room. Must be greater than zero. | A message already accepted for a route is never evicted. Only the arriving message can be dropped. That keeps sequences such as Sparkplug birth then data self-consistent under load. Do not set `OVERLOAD_CAPACITY` together with `OVERLOAD_POLICY "lossless"`. A capacity cap drops overflow; `lossless` never drops. The broker logs an error naming the route, **ignores the capacity**, and runs the route as lossless. The same conflict applies if the route inherits `lossless` from the broker default and then declares a capacity. *** ## Defaults Each setting resolves on its own: 1. **No `ADD ROUTE_CONFIG` block** — the route inherits the broker default for policy, timeout, and capacity. 2. **A block is present but a setting is omitted** — that setting does not copy the broker's timeout or capacity. `OVERLOAD_POLICY` inherits the broker default. `OVERLOAD_TIMEOUT` and `OVERLOAD_CAPACITY` use that policy's own default (`50` ms for `balanced`, unbounded capacity). 3. **An invalid value** (unknown policy name, negative timeout, non-positive capacity) does not stop the route loading. The broker logs an error and uses that field's default. Broker-wide defaults are environment variables, read once at process start. Change them and **restart** the broker. A route's own `ADD ROUTE_CONFIG` always overrides these for that route. | Variable | Default | Effect | | ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `COREFLUX_ROUTE_OVERLOAD_POLICY` | `balanced` | Default for `OVERLOAD_POLICY` (`lossless`, `balanced`, or `realtime`). An unrecognized value logs an error and falls back to `balanced`. | | `COREFLUX_ROUTE_CHANNEL_FULL_TIMEOUT_MS` | `50` | Default wait budget in milliseconds for `balanced`. Ignored when the resolved policy is `lossless` or `realtime`. A negative value logs an error and falls back to `50`. | | `COREFLUX_ROUTE_CHANNEL_CAPACITY` | `100000` | Shared ceiling on how many route updates the broker will hold at once. Below the ceiling, matching messages are accepted. At the ceiling, the arriving message's wait-or-drop policy applies. Values below `1024` are raised to `1024`. Unset, non-numeric, zero, or negative values fall back to `100000`. | `OVERLOAD_CAPACITY` on a route is a **private** budget for that route. The shared ceiling is separate: one route can hit its own cap and start dropping while other routes still have room. See [Broker Configuration](/latest/mqtt-broker/configuration#performance-and-back-pressure) for how these sit alongside other broker limits. *** ## What happens under load When a route is waiting, that wait can delay **the publishing client's own publish** — only that client, and only up to the wait budget. Other connected clients are not stalled by this route. The broker logs two different saturation conditions (each at most once every five seconds): * The shared route queue is full — every route waiting behind it is affected. * A single route is over the `OVERLOAD_CAPACITY` it declared for itself — the shared queue may still have room. If two routes could match the same topic (for example one exact mapping and another `sensors/#`), the broker applies the **stricter** wait-or-drop choice among those candidates. A `lossless` route on an overlapping pattern can make a `realtime` route wait, even if only the `realtime` route would have handled the message. The broker never drops a message because a more lenient route asked to drop it. If mixed policies on overlapping wildcards wait or drop more than you expect, check the patterns first — that behavior is by design. *** ## Troubleshooting That route's own `OVERLOAD_CAPACITY` is exhausted. Look for the broker warning that a route is over its in-flight budget, not the warning that the shared queue is full. Working as designed — `lossless` waits until there is room. Switch to `balanced` with a timeout if you would rather drop than stall, or cap a noisy route with `OVERLOAD_CAPACITY` so it drops its own overflow instead. It is declared alongside `OVERLOAD_POLICY "lossless"` (or the broker default is `lossless`). Check the broker log for the lossless/capacity conflict — capacity is ignored, not applied. Another route's overlapping wildcard pattern resolved a stricter policy for that topic. See the overlapping-pattern warning above. The resolved policy is `realtime`. That is expected — `realtime` drops without waiting. *** ## Best practices * Start with the default `balanced` policy. Change a route only when you have a reason (must not lose, must not stall, or must not crowd others). * Use `lossless` sparingly. It protects delivery at the cost of delaying the publisher that caused the backlog. * Put `OVERLOAD_CAPACITY` on the noisy route, not on the critical one — the critical route can stay unbounded while the noisy route drops its own overflow. * Prefer exact topic mappings when two routes would otherwise share a broad `+` / `#` pattern with different policies. * Invalid settings never fail the route silently: read the broker log after deploy if behavior does not match the block you wrote. *** ## Next Steps Define routes, mappings, and status topics. Set broker-wide route overload defaults and other limits. # Kafka Route Source: https://docs.coreflux.org/latest/lot-language/routes/streaming/kafka Stream data both ways between your Coreflux broker and Apache Kafka clusters, including Confluent Cloud, AWS MSK, Aiven, and self-managed brokers ## Kafka Overview The `KAFKA` route connects your Coreflux broker to an Apache Kafka cluster and moves messages in both directions. A single route can **produce** (MQTT → Kafka) and **consume** (Kafka → MQTT) at the same time, so device telemetry can flow into your data pipeline while commands and setpoints flow back to the field. **Like a conveyor belt between two warehouses.** Your MQTT broker handles fast-moving device traffic; Kafka is the long-term, replayable record. The Kafka route is the belt that carries boxes (messages) between them in whichever direction you point it. ### When to use it * Feed real-time MQTT telemetry into a Kafka-based analytics or data pipeline. * Bring Kafka events (commands, setpoints, alarms) back into the broker for field devices. * Bridge edge MQTT traffic to a managed cloud platform like Confluent Cloud or AWS MSK. ### Key capabilities | Capability | What it means for you | | ----------------------- | ------------------------------------------------------------------------------------------ | | **Bidirectional** | Produce and consume in one route — no separate definitions needed | | **Secure** | Supports `PLAINTEXT`, `SSL`, `SASL_PLAINTEXT`, and `SASL_SSL` | | **Reliable delivery** | Per-message offset commit on the consume path (at-least-once) | | **Message keys** | Route records to partitions by topic segment or JSON field for ordered, per-device streams | | **Tunable throughput** | Configurable batching, compression, and acknowledgement levels | | **Cloud & self-hosted** | Confluent Cloud, AWS MSK, Aiven, Redpanda, and self-managed clusters | *** Let's look at how a Kafka route is defined, starting with the three common directions. ## Quick Start Send every sensor reading from the broker into a Kafka topic, keyed by device: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorProducer WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-sensors" WITH SECURITY_PROTOCOL "PLAINTEXT" ADD EVENT SensorsToKafka WITH SOURCE_TOPIC "factory/sensors/+" WITH DESTINATION_TOPIC "factory.sensors.raw" WITH DIRECTION "out" WITH QUERY "{topic.3}" ``` A message on `factory/sensors/pump-01` becomes a Kafka record on `factory.sensors.raw` with key `pump-01`. Forward Kafka commands into the MQTT broker: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CommandConsumer WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-commands" WITH SECURITY_PROTOCOL "PLAINTEXT" WITH AUTO_OFFSET_RESET "Latest" ADD EVENT CommandsFromKafka WITH SOURCE_TOPIC "factory.commands" WITH DESTINATION_TOPIC "factory/commands/in" WITH DIRECTION "in" ``` A Kafka record on `factory.commands` is published to the MQTT topic `factory/commands/in`. One route handling both directions — telemetry out, commands in: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-plant" WITH SECURITY_PROTOCOL "PLAINTEXT" WITH AUTO_OFFSET_RESET "Earliest" ADD EVENT SensorsToKafka WITH SOURCE_TOPIC "plant/sensors/+" WITH DESTINATION_TOPIC "plant.sensors.raw" WITH DIRECTION "out" ADD EVENT CommandsFromKafka WITH SOURCE_TOPIC "plant.commands" WITH DESTINATION_TOPIC "plant/commands/in" WITH DIRECTION "in" ``` The Quick Start examples use `PLAINTEXT` for clarity. For any cluster reachable over a network — especially cloud services — use `SASL_SSL` and load credentials from environment variables and secrets. See [Security & Authentication](#security--authentication). *** ## How Kafka Compares to MQTT Kafka and MQTT solve different problems. Coreflux bridges the two so each does what it's best at: MQTT for fast device traffic, Kafka for durable, replayable streams. | Feature | Kafka | MQTT | | ---------------------------- | --------------------------------- | ----------------------------- | | **Delivery model** | Pull (consumer fetches) | Push (broker delivers) | | **Retention** | Configurable (hours to forever) | Typically none | | **Ordering** | Per-partition | Per-topic | | **Fan-out / load balancing** | Built-in consumer groups | Shared subscriptions | | **Throughput** | Millions of messages/sec | Thousands of messages/sec | | **Typical use** | Data pipelines, analytics, replay | IoT telemetry, device control | A few Kafka terms appear throughout this page: | Term | Description | | --------------------- | ----------------------------------------------------------------------------------- | | **Bootstrap servers** | The initial broker addresses the route contacts to discover the rest of the cluster | | **Topic** | A named, partitioned log of records | | **Partition** | An ordered sub-stream within a topic — the unit of ordering and parallelism | | **Consumer group** | A set of consumers that divide a topic's partitions between them | | **Offset** | A record's position within a partition, committed once it has been processed | *** ## Supported Kafka Services The Kafka route works with any Kafka-compatible cluster, cloud-managed or self-hosted. | Service | Security Protocol | Notes | | -------------------------------- | ----------------- | ------------------------------------------ | | **Confluent Cloud** | `SASL_SSL` | `PLAIN` or `SCRAM` mechanism | | **Amazon MSK** | `SASL_SSL` | IAM or SCRAM auth | | **Aiven for Kafka** | `SASL_SSL` | SCRAM-SHA-512 typical | | **Azure Event Hubs (Kafka API)** | `SASL_SSL` | `PLAIN` with connection string as password | | **Redpanda Cloud** | `SASL_SSL` | Kafka-compatible | | Cluster | Notes | | ----------------------- | ----------------------------------------- | | **Apache Kafka** (2.0+) | All security protocols supported | | **Confluent Platform** | Same configuration as Apache Kafka | | **Redpanda** | Drop-in Kafka-compatible cluster | | **KRaft mode** | Kafka without ZooKeeper — fully supported | *** ## Connection Configuration Every Kafka route starts with a `KAFKA_CONFIG` block describing the cluster, security, and throughput settings. Only `BROKER_ADDRESS` is required, but production routes should always set a `GROUP_ID` and use a secure protocol. ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-plant" ADD EVENT SensorsToKafka WITH SOURCE_TOPIC "plant/sensors/+" WITH DESTINATION_TOPIC "plant.sensors.raw" WITH DIRECTION "out" ``` ### Connection & Security Bootstrap servers, e.g. `localhost:9092` or `host1:9092,host2:9092`. List two or three brokers in production so the route survives an individual broker restart. Consumer group name. **Always set this in production.** If omitted, a random group is generated on every startup, causing the consumer to replay all retained messages from scratch after each restart. Where to start reading when no committed offset exists: `Earliest` (from the beginning) or `Latest` (new messages only). Leave `false` for per-message commit (at-least-once delivery). Set `true` to additionally enable periodic background commits. `PLAINTEXT`, `SSL`, `SASL_PLAINTEXT`, or `SASL_SSL`. Required with SASL: `PLAIN`, `SCRAM-SHA-256`, or `SCRAM-SHA-512`. SASL username. Supports `GET ENV`. SASL password. Use `GET SECRET` to keep it out of your LoT definition. Path to the CA certificate (for `SSL` or `SASL_SSL`). Path to the client certificate (mutual TLS). Path to the client key (required with a client certificate). Key or keystore password. Producer durability: `0` (no acknowledgement), `1` (leader only), or `all` (all in-sync replicas). How long the producer waits to fill a batch before sending. Higher values mean fewer, larger requests (better throughput) at the cost of a little added latency. Maximum producer batch size in bytes. Default is 1 MB. Producer compression: `none`, `gzip`, `snappy`, `lz4`, or `zstd`. Most effective on larger batches — pair with `LINGER_MS`. Maximum bytes returned per consumer fetch. Default is 50 MB. Maximum KB pre-fetched per partition into the consumer buffer (64 MB by default). Lower it to cap memory use on high-volume topics. How long the route waits for in-flight produced messages to drain when stopping. *** ## Security & Authentication Choose the security protocol that matches your broker's listener. For anything beyond a local development cluster, use `SASL_SSL`. | `SECURITY_PROTOCOL` | TLS | SASL | Typical use | | ------------------- | --- | ---- | --------------------------------------- | | `PLAINTEXT` | No | No | Local development | | `SSL` | Yes | No | TLS-only clusters | | `SASL_PLAINTEXT` | No | Yes | Internal test environments | | `SASL_SSL` | Yes | Yes | Confluent Cloud, MSK, Aiven, production | No encryption or authentication — local clusters only: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE LocalKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS "localhost:9092" WITH GROUP_ID "coreflux-dev" WITH SECURITY_PROTOCOL "PLAINTEXT" ADD EVENT SensorsToKafka WITH SOURCE_TOPIC "dev/sensors/+" WITH DESTINATION_TOPIC "dev.sensors" WITH DIRECTION "out" ``` Encrypted transport without SASL credentials: ```lot wrap focus={5-6} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TlsKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS "kafka.example.com:9093" WITH GROUP_ID "coreflux-tls" WITH SECURITY_PROTOCOL "SSL" WITH SSL_CA_LOCATION "/etc/ssl/certs/kafka-ca.pem" ADD EVENT SensorsToKafka WITH SOURCE_TOPIC "plant/sensors/+" WITH DESTINATION_TOPIC "plant.sensors" WITH DIRECTION "out" ``` Encrypted transport with SCRAM authentication — the recommended production setup: ```lot wrap focus={5-10} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-prod" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" WITH SSL_CA_LOCATION "/etc/ssl/certs/ca.pem" ADD EVENT SensorsToKafka WITH SOURCE_TOPIC "plant/sensors/+" WITH DESTINATION_TOPIC "plant.sensors" WITH DIRECTION "out" ``` Store credentials with `GET SECRET` and non-sensitive values like hostnames with `GET ENV`. See [Environment Variables and Secrets](/latest/mqtt-broker/secrets-and-env) for setup across Windows, Linux, Docker, and Kubernetes. *** ## Events and Direction Each `ADD EVENT` block maps one MQTT topic to one Kafka topic and sets the direction of flow. Think of the **broker** as the source side and **Kafka** as the destination side. | `DIRECTION` | `SOURCE_TOPIC` | `DESTINATION_TOPIC` | Data flow | | ----------- | -------------------------------- | ------------------------------- | ------------ | | `"out"` | Broker MQTT topic (wildcards OK) | Kafka topic to produce to | MQTT → Kafka | | `"in"` | Kafka topic to subscribe to | Broker MQTT topic to publish to | Kafka → MQTT | ### Event Parameters Broker MQTT topic when `DIRECTION` is `"out"`; Kafka topic to subscribe to when `DIRECTION` is `"in"`. Kafka topic when `DIRECTION` is `"out"`; broker MQTT publish topic when `DIRECTION` is `"in"`. `"out"` (MQTT → Kafka) or `"in"` (Kafka → MQTT). The Kafka record **key** for partitioning (produce events only). See [Message Keys and Partitioning](#message-keys-and-partitioning). Produce on a timer instead of on each incoming message. *** ## Message Keys and Partitioning Kafka sends records with the same key to the same partition, which preserves **order per key**. On produce events, set the key with `WITH QUERY`. * **With a key** (`WITH QUERY`): same key → same partition → ordering preserved per key (for example, all readings from one device stay in order). * **Without a key**: records spread across all partitions for higher throughput, with no per-key ordering guarantee. * **Consume events** join the consumer group and have partitions assigned automatically — set a stable `GROUP_ID` so offsets survive restarts. ### Key Placeholders The `WITH QUERY` value is resolved before each record is sent. Supported placeholders: | Placeholder | Description | Example result | | ---------------------- | ----------------------------------------- | --------------------------------------------------- | | `{topic.N}` | Nth segment (1-based) of the source topic | `pump-01` from `plant/sensors/pump-01/reading` | | `{topic}` | Full source topic string | `plant/sensors/pump-01/reading` | | `{payload}` | Message payload as a string | `42.5` | | `{payload.json}` | Full payload parsed as JSON | — | | `{payload.json.field}` | A JSON field from the payload | `pump-01` from `{"device_id":"pump-01","value":42}` | | `{timestamp}` | ISO 8601 timestamp | `2026-06-12T10:30:45.123Z` | | `{env.NAME}` | Environment variable value | value of `NAME` | | `{secret.NAME}` | Secret value | value of `NAME` | ### Key from a topic segment Use a wildcard position from the source topic as the key — here the device ID in the third segment: ```lot wrap focus={10} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorsByDevice WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-sensors" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" ADD EVENT SensorsByDevice WITH SOURCE_TOPIC "plant/sensors/+/reading" WITH DESTINATION_TOPIC "plant.sensors.raw" WITH DIRECTION "out" WITH QUERY "{topic.3}" ``` A message on `plant/sensors/pump-01/reading` gets the key `pump-01`. ### Key from a JSON field When the identifier lives inside the payload, pull it from a JSON field instead: ```lot wrap focus={13} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SensorsByPayloadId WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-sensors" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" ADD EVENT SensorsByPayloadId WITH SOURCE_TOPIC "plant/sensors/+/reading" WITH DESTINATION_TOPIC "plant.sensors.raw" WITH DIRECTION "out" WITH QUERY "{payload.json.device_id}" ``` For a payload of `{"device_id":"pump-01","value":42}`, the record key becomes `pump-01`. *** ## Complete Examples Full production route with TLS, SCRAM authentication, telemetry out, and alarms in: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-production" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" WITH SSL_CA_LOCATION "/etc/ssl/certs/ca.pem" WITH ACKS "all" WITH AUTO_OFFSET_RESET "Earliest" ADD EVENT TelemetryOut WITH SOURCE_TOPIC "production/+/telemetry" WITH DESTINATION_TOPIC "production.telemetry" WITH DIRECTION "out" WITH QUERY "{topic.2}" ADD EVENT AlarmsIn WITH SOURCE_TOPIC "production.alarms" WITH DESTINATION_TOPIC "production/alarms/incoming" WITH DIRECTION "in" ``` Optimized for sustained high-rate telemetry with batching and compression: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE TelemetryKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-telemetry" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" WITH ACKS "1" WITH LINGER_MS "50" WITH COMPRESSION_TYPE "lz4" WITH FLUSH_TIMEOUT_MS "10000" ADD EVENT DeviceTelemetry WITH SOURCE_TOPIC "devices/+/telemetry" WITH DESTINATION_TOPIC "iot.telemetry" WITH DIRECTION "out" WITH QUERY "{topic.2}" ``` `ACKS "1"` avoids waiting for every replica on non-critical telemetry, `LINGER_MS "50"` batches bursts into fewer requests, and `lz4` compression cuts bandwidth with negligible CPU cost. Route different asset types to different Kafka topics, keyed by a JSON field: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AssetKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "KAFKA_BOOTSTRAP" WITH GROUP_ID "coreflux-assets" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" ADD EVENT PumpsOut WITH SOURCE_TOPIC "assets/pumps/+/data" WITH DESTINATION_TOPIC "assets.pumps" WITH DIRECTION "out" WITH QUERY "{payload.json.asset_id}" ADD EVENT MotorsOut WITH SOURCE_TOPIC "assets/motors/+/data" WITH DESTINATION_TOPIC "assets.motors" WITH DIRECTION "out" WITH QUERY "{payload.json.asset_id}" ADD EVENT SetpointsIn WITH SOURCE_TOPIC "assets.setpoints" WITH DESTINATION_TOPIC "assets/setpoints/incoming" WITH DIRECTION "in" ``` Connect to Confluent Cloud using SASL/PLAIN with an API key and secret: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ConfluentCloud WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS GET ENV "CONFLUENT_BOOTSTRAP" WITH GROUP_ID "coreflux-cloud" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "PLAIN" WITH SASL_USERNAME GET ENV "CONFLUENT_API_KEY" WITH SASL_PASSWORD GET SECRET "CONFLUENT_API_SECRET" WITH ACKS "all" WITH AUTO_OFFSET_RESET "Latest" ADD EVENT TelemetryToCloud WITH SOURCE_TOPIC "factory/+/telemetry" WITH DESTINATION_TOPIC "factory.telemetry" WITH DIRECTION "out" WITH QUERY "{topic.2}" ``` `CONFLUENT_BOOTSTRAP` is the `pkc-xxxx.region.confluent.cloud:9092` address from your cluster settings; the API key and secret come from the Confluent Cloud credentials page. List multiple bootstrap brokers so the route survives a single broker restart: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE HaKafka WITH TYPE KAFKA ADD KAFKA_CONFIG WITH BROKER_ADDRESS "kafka-1:9092,kafka-2:9092,kafka-3:9092" WITH GROUP_ID "coreflux-ha" WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" WITH SASL_USERNAME GET ENV "KAFKA_USER" WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" WITH ACKS "all" ADD EVENT TelemetryOut WITH SOURCE_TOPIC "factory/sensors/+" WITH DESTINATION_TOPIC "factory.sensors" WITH DIRECTION "out" ``` *** ## Performance Tuning A few settings control the trade-off between throughput, latency, durability, and memory. Tune them to match each stream. ### Durability vs. throughput (produce) `ACKS` decides how many replicas must confirm each record before it counts as sent: | `ACKS` | Behavior | Use for | | ------ | -------------------------------------------------------------- | ------------------------------------------------ | | `all` | Wait for all in-sync replicas (most durable, lower throughput) | Mission-critical data | | `1` | Wait for the partition leader only | High-rate telemetry | | `0` | Don't wait at all | Fire-and-forget metrics where loss is acceptable | ### Batching and compression (produce) Under burst load, many produces are in flight at once and Kafka groups them into shared batches. These settings shape that behavior: | Setting | Effect | | ------------------------ | --------------------------------------------------------- | | `LINGER_MS "0"` | Send immediately — lowest latency, smallest batches | | `LINGER_MS "50"` | Wait up to 50 ms to fill a batch — fewer, larger requests | | `COMPRESSION_TYPE "lz4"` | Compress batches — lower bandwidth at negligible CPU cost | | `BATCH_SIZE "2097152"` | Allow 2 MB batches — more messages per request | ### Consume throughput Each consumed message is committed individually for at-least-once delivery, which adds a short round-trip per message. For very high-rate consume topics, use one Kafka route per logical stream rather than stacking many consume events on a single route. ### Memory use (consume) The route pre-fetches messages into a buffer while it processes. On high-volume topics with many partitions this buffer can grow large. Cap it with `QUEUED_MAX_MESSAGES_KB` (for example `"4096"` for 4 MB per partition) and bound fetch responses with `FETCH_MAX_BYTES`. ### Watch for consume backpressure When a consume route publishes into the broker, a busy MQTT subsystem (many subscribers, large payloads) can slow the consumer down. If you pair a high-rate consume topic with many subscribers, monitor consumer group lag to catch this early. *** ## Best Practices A fixed group ID lets offsets survive restarts. Without one, a new random group is created on every startup and the consumer replays the entire topic. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH GROUP_ID "coreflux-plant-v1" ``` Never hardcode passwords in a route definition. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SASL_PASSWORD GET SECRET "KAFKA_PASSWORD" ``` Reserve `PLAINTEXT` for local development; encrypt and authenticate everything else. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SECURITY_PROTOCOL "SASL_SSL" WITH SASL_MECHANISM "SCRAM-SHA-512" ``` Choose where a brand-new consumer starts reading. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH AUTO_OFFSET_RESET "Earliest" // replay history WITH AUTO_OFFSET_RESET "Latest" // new messages only ``` Use a message key so all records from one device land on the same partition, in order. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH QUERY "{topic.2}" ``` A single broker address is a single point of failure; list several for resilience. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH BROKER_ADDRESS "kafka-1:9092,kafka-2:9092,kafka-3:9092" ``` Use your Kafka management UI (such as Confluent Control Center or Redpanda Console) or `kafka-consumer-groups --describe --group ` to watch for growing lag, which signals publish backpressure. *** ## Troubleshooting Verify a route's connection at any time by publishing the following to `$SYS/Coreflux/Command` from any MQTT client: ``` -checkRouteConnection PlantKafka ``` The route validates the cluster connection and, when consume events are present, the consumer group join — catching configuration problems early. * Confirm the bootstrap address and port are correct. * Make sure `SECURITY_PROTOCOL` matches the broker's listener. * Check that the port (default `9092`) isn't blocked by a firewall. * Verify network reachability to the broker host. The bootstrap connection can succeed while the cluster's advertised addresses point somewhere unreachable. This is common when Kafka runs in Docker. Make sure the broker advertises an address the Coreflux host can actually reach: | Where Coreflux runs | `BROKER_ADDRESS` | | ---------------------------- | ---------------- | | On the host | `localhost:9092` | | Same Docker network as Kafka | `kafka:9092` | * Confirm `SECURITY_PROTOCOL` matches the listener (e.g. `SASL_SSL`, not `SASL_PLAINTEXT`). * Make sure `SASL_MECHANISM` matches what the broker requires. * Check that credentials are correct and the env/secret names resolve. * Set `AUTO_OFFSET_RESET "Earliest"` if messages were produced before the consumer first started. * Set a stable `GROUP_ID` so offsets are remembered across restarts. * Give each route its own `GROUP_ID` unless you intentionally want them to share partitions. | Symptom | Likely cause | Fix | | ----------------------------- | ---------------------------------- | ------------------------------------------------- | | Produce slower than expected | `ACKS "all"` on a high-rate stream | Use `ACKS "1"` for telemetry | | High bandwidth usage | No compression | Add `COMPRESSION_TYPE "lz4"` and `LINGER_MS "50"` | | High memory on consume routes | Large prefetch buffer | Set `QUEUED_MAX_MESSAGES_KB "4096"` | | Growing consumer lag | MQTT publish backpressure | Reduce subscriber load on the destination topics | | Messages lost on shutdown | Flush timeout too short | Raise `FLUSH_TIMEOUT_MS` | *** ## Next Steps Configure MQTT bridges and Sparkplug roles. Persist MQTT data in databases for analytics. # System Routes Source: https://docs.coreflux.org/latest/lot-language/routes/system-routes Configure MQTT bridges and Sparkplug roles with Coreflux System Routes ## Build the broker topology around your data System Routes define how your Coreflux broker participates in a wider MQTT system. Use them to bridge selected topics to another broker, or make Coreflux consume and produce Sparkplug B traffic. **Think of System Routes as the roads around the broker.** Bridges connect MQTT brokers, and Sparkplug routes define how industrial MQTT traffic enters and leaves the system. For **high-availability Coreflux clustering** (multi-node broker failover), see [High-Availability Clustering](/latest/mqtt-broker/clustering). That feature is broker configuration, not a System Route. ### When to use System Routes Use this category when you need to: * synchronize topics with another MQTT broker; * decode Sparkplug B payloads into readable JSON; * publish as a Sparkplug edge node; or * make Coreflux the Sparkplug Primary Host Application. ## Choose a System Route Synchronize selected MQTT topics with another broker. Decode Sparkplug traffic or publish Coreflux metrics as an edge node. Make Coreflux the Primary Host for a Sparkplug namespace. *** Choose the route by the role Coreflux needs to play. ## Compare System Routes | Route | Coreflux role | Connects to | Best for | | ---------------- | ------------------------ | -------------------------- | --------------------------------------------------------------- | | `MQTT_BRIDGE` | MQTT bridge | Another MQTT broker | Topic synchronization and edge-to-cloud links | | `SPARKPLUG_B` | Decoder and/or edge node | A Sparkplug MQTT namespace | Translating Sparkplug to JSON or publishing Sparkplug telemetry | | `SPARKPLUG_HOST` | Primary Host Application | Sparkplug edge nodes | Namespace authority, commands, and automatic rebirth requests | A `SPARKPLUG_HOST` route and a `SPARKPLUG_B` route in `DECODE` or `BOTH` mode cannot run together on the same broker. Both consume the complete `spBv1.0/#` namespace. You can combine one Host route with separate `ENCODE`-only routes. *** ## Next Steps Compare decode, edge-node, and Primary Host responsibilities. Run multiple Coreflux brokers as a fault-tolerant cluster. # MQTT Bridge Route Source: https://docs.coreflux.org/latest/lot-language/routes/system/mqtt-bridge Connect two MQTT brokers for edge-to-cloud synchronization, multi-site connectivity, and backup systems ## MQTT Bridge Overview The `MQTT_BRIDGE` route connects your Coreflux broker to another MQTT broker and keeps selected topics in sync between them. Use it for edge-to-cloud synchronization, linking multiple sites, or mirroring data to a backup broker. **Like call forwarding for messages.** You decide which topics get forwarded and in which direction, and the bridge relays every matching message between the two brokers automatically. ### When to use it * Push local sensor data from an edge device up to a cloud broker. * Pull commands or configuration from a central broker down to remote sites. * Keep two brokers continuously in sync for redundancy. *** Let's look at how to define a bridge, starting with the most common scenarios. ## Quick Start Forward local sensor data up to a cloud broker over TLS: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EdgeToCloud WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloud-provider.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "EdgeDevice001" WITH USERNAME "edge_user" WITH PASSWORD "secure_password" WITH USE_TLS "true" ADD MAPPING sensorData WITH SOURCE_TOPIC "sensors/+/data" WITH DESTINATION_TOPIC "edge-001/sensors/+/data" WITH DIRECTION "out" ``` Pull commands from a cloud broker down to the local broker: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE CloudCommands WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloud-provider.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "EdgeDevice001-Commands" WITH USE_TLS "true" ADD MAPPING commands WITH SOURCE_TOPIC "local/commands/#" WITH DESTINATION_TOPIC "edge-001/commands/#" WITH DIRECTION "in" ``` Keep two brokers fully in sync in both directions: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE FullSync WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "partner-broker.example.com" WITH BROKER_PORT '1883' WITH CLIENT_ID "SyncClient" ADD MAPPING bidirectionalSync WITH SOURCE_TOPIC "shared/data/#" WITH DESTINATION_TOPIC "partner/data/#" WITH DIRECTION "both" ``` *** ## Connection Configuration A bridge has two endpoints: a `SOURCE_CONFIG` (usually your local broker) and a `DESTINATION_CONFIG` (the remote broker). Use `WITH BROKER SELF` for the local Coreflux broker, or provide explicit connection details for a remote one. IP address or hostname of the broker. Use `SELF` for the local broker. MQTT port (typically 1883 for unencrypted, 8883 for TLS). Unique client identifier for the bridge connection. Authentication username. Authentication password. Enable TLS encryption. Default: false. Allow untrusted certificates. Default: false. Path to CA certificate file. Path to client certificate for mTLS. Client certificate password. Number of reconnection attempts. Default: 5. *** ## Mapping Configuration Each `ADD MAPPING` block defines which topics are bridged and in which direction. Topic pattern to subscribe to on the source broker. Supports wildcards. Topic to publish to on the destination broker. Data flow direction: `out` (source→dest), `in` (dest→source), or `both`. *** ## Secure mTLS Bridge For production links between sites, encrypt both endpoints and authenticate with mutual TLS: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SecureBridge WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER_ADDRESS "192.168.1.10" WITH BROKER_PORT '8883' WITH CLIENT_ID "SecureSource" WITH USE_TLS "true" WITH SERVER_CA_CERT_PATH "/certs/ca.pem" WITH CLIENT_CERT_PATH "/certs/client.pem" WITH CLIENT_CERT_PASS "cert_password" ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "secure.cloud.com" WITH BROKER_PORT '8883' WITH CLIENT_ID "SecureDest" WITH USE_TLS "true" WITH ALLOW_UNTRUSTED_CERTS "false" ADD MAPPING secureMapping WITH SOURCE_TOPIC "secure/+/data" WITH DESTINATION_TOPIC "cloud/+/data" WITH DIRECTION "out" ``` *** ## Best Practices Always enable TLS when connecting to external brokers: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH USE_TLS "true" ``` Each bridge connection needs a unique CLIENT\_ID to avoid conflicts: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH CLIENT_ID "EdgeDevice001-SensorBridge" ``` Use `WITH BROKER SELF` during development: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD SOURCE_CONFIG WITH BROKER SELF ``` Store passwords securely and use app-specific passwords where available. Configure appropriate retry settings: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH RECONNECTION_RETRIES '10' ``` *** ## Troubleshooting * Verify IP address and port are correct * Check firewall allows outbound connections on the configured port * Ensure remote broker is running and accessible * Try increasing `RECONNECTION_RETRIES` * Verify certificate paths are correct * Ensure certificates are valid and not expired * Check certificate chain is complete * For testing, use `ALLOW_UNTRUSTED_CERTS "true"` (not recommended for production) * Verify SOURCE\_TOPIC and DESTINATION\_TOPIC are correct * Check DIRECTION setting matches your expected data flow * Ensure CLIENT\_ID is unique across all connections * Check broker logs for subscription errors *** ## Next Steps Compare bridging and Sparkplug roles. Publish and consume structured industrial MQTT data. # Sparkplug B Route Source: https://docs.coreflux.org/latest/lot-language/routes/system/sparkplug-b Decode Sparkplug B traffic to JSON or publish Coreflux MQTT topics as a Sparkplug edge node ## Give industrial MQTT data a shared structure The `SPARKPLUG_B` route connects Coreflux to a Sparkplug B namespace. It can decode binary Sparkplug payloads into readable JSON, publish local MQTT topics as a Sparkplug edge node, or run both directions in one route. **Sparkplug is a shared vocabulary for industrial MQTT.** MQTT carries the messages; Sparkplug defines how devices announce themselves, report values, and signal whether they are online. ### When to use it Use this route when you need to: * consume `spBv1.0/#` traffic in LoT Actions, dashboards, or storage Routes; * publish Coreflux topics as `NBIRTH`, `NDATA`, and `NDEATH`; * group edge-node metrics into Sparkplug devices; * accept `NCMD` or `DCMD` writes for selected metrics; or * gate edge-node publishing on an external Primary Host. Use the separate [Sparkplug Host Route](./sparkplug-host) when Coreflux must be the Primary Host Application. For a **custom** plant `.proto` (not Sparkplug), use [Protobuf Models](/latest/lot-language/models/protobuf) instead of this route. *** Start by choosing the direction in which Coreflux participates. ## Quick Start Decode every Sparkplug B publisher on the local broker into readable MQTT topics: ```lot wrap focus={3} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantDecoder WITH TYPE SPARKPLUG_B ADD SPARKPLUG_CONFIG WITH MODE "DECODE" ``` This mode listens to all `spBv1.0/#` traffic. You do not need a Group ID or Edge Node ID. Publish a local temperature topic as a Sparkplug edge-node metric: ```lot wrap focus={3-5,6-8} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Line1Edge WITH TYPE SPARKPLUG_B ADD SPARKPLUG_CONFIG WITH MODE "ENCODE" WITH GROUP_ID "Factory" WITH EDGE_NODE_ID "Line1" ADD METRIC "Temperature" WITH DATA_TYPE "Float" WITH SOURCE_TOPIC "factory/line1/temperature" ``` Coreflux publishes a birth certificate when the route connects and sends data when the source topic changes. Decode the namespace while publishing Coreflux metrics as a gateway edge node: ```lot wrap focus={3-5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantGateway WITH TYPE SPARKPLUG_B ADD SPARKPLUG_CONFIG WITH MODE "BOTH" WITH GROUP_ID "Factory" WITH EDGE_NODE_ID "CorefluxGateway" ADD METRIC "Uptime" WITH DATA_TYPE "Int64" WITH SOURCE_TOPIC "gateway/uptime" ``` The decode and encode paths operate independently inside the same route. *** ## Choose the Coreflux role | Mode | Coreflux role | Inbound behavior | Outbound behavior | | -------- | ------------------------ | ------------------------------------------ | ----------------------------------------- | | `DECODE` | Translator and consumer | Decodes all `spBv1.0/#` traffic | None | | `ENCODE` | Sparkplug edge node | Handles commands for writable metrics | Publishes birth, data, and death messages | | `BOTH` | Translator and edge node | Decodes the namespace and handles commands | Publishes Coreflux metrics | `MODE` is the canonical setting. `DIRECTION` remains accepted as a deprecated alias with the same values. Only one namespace consumer can run per broker. Deploy either one `SPARKPLUG_B` route in `DECODE` or `BOTH` mode, or one `SPARKPLUG_HOST` route. Multiple `ENCODE`-only routes are allowed. ## Configure the Sparkplug connection The `SPARKPLUG_CONFIG` block controls the route mode, edge-node identity, target MQTT broker, and optional Primary Host gating. | Setting | Required | Default | Description | | ----------------- | ------------------- | ----------- | ------------------------------------------------------------------ | | `MODE` | No | `DECODE` | `DECODE`, `ENCODE`, or `BOTH` | | `GROUP_ID` | For `ENCODE`/`BOTH` | — | Group identity Coreflux publishes on the Sparkplug wire | | `EDGE_NODE_ID` | For `ENCODE`/`BOTH` | — | Unique edge-node identity within the group | | `PRIMARY_HOST_ID` | No | — | External Primary Host whose retained `STATE` gates edge publishing | | `SERVER` | No | `localhost` | MQTT broker used by the encode path | | `PORT` | No | `1883` | Target MQTT broker port | | `USERNAME` | No | — | Target broker username | | `PASSWORD` | No | — | Target broker password; use `GET SECRET` | | `USE_TLS` | No | `false` | Enables TLS for the target broker connection | `GROUP_ID` and `EDGE_NODE_ID` do not filter decoded traffic. The decode path always consumes the complete `spBv1.0/#` namespace. ## Declare edge-node metrics In `ENCODE` or `BOTH` mode, add node-level metrics directly to the route and device-level metrics inside `ADD DEVICE`. | Setting | Required | Description | | ------------------------------------ | ---------------------- | -------------------------------------------------- | | `DATA_TYPE` | Yes | Sparkplug datatype for the metric | | `SOURCE_TOPIC` | Yes | Local MQTT topic whose value Coreflux publishes | | `WRITE_TOPIC` or `DESTINATION_TOPIC` | For command write-back | Local topic that receives an inbound command value | | `WRITABLE` | No | Set to `"true"` to accept `NCMD` or `DCMD` writes | This complete route publishes a node metric and a writable device metric to a remote broker: ```lot wrap focus={10-15} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE Line1Edge WITH TYPE SPARKPLUG_B ADD SPARKPLUG_CONFIG WITH MODE "ENCODE" WITH GROUP_ID "Factory" WITH EDGE_NODE_ID "Line1" WITH SERVER GET ENV "SPARKPLUG_BROKER" WITH PORT 8883 WITH USERNAME GET ENV "SPARKPLUG_USER" WITH PASSWORD GET SECRET "SPARKPLUG_PASSWORD" WITH USE_TLS true ADD METRIC "Uptime" WITH DATA_TYPE "Int64" WITH SOURCE_TOPIC "factory/line1/uptime" ADD DEVICE "Press01" ADD METRIC "Setpoint" WITH DATA_TYPE "Float" WITH SOURCE_TOPIC "factory/line1/press01/setpoint" WITH WRITE_TOPIC "factory/line1/press01/setpoint/write" WITH WRITABLE "true" ``` Supported scalar and array datatypes include signed and unsigned integers, `Float`, `Double`, `Boolean`, `String`, `Text`, `UUID`, `DateTime`, and `Bytes`. `DataSet` and `Template` values can be decoded but not encoded. ## Understand the message lifecycle When the encode path connects, Coreflux: 1. publishes `NBIRTH`, including `bdSeq` and `Node Control/Rebirth`; 2. publishes one `DBIRTH` for each declared device; 3. publishes `NDATA` or `DDATA` when a source topic changes; 4. republishes births after a `Node Control/Rebirth` command; and 5. publishes death messages on a graceful stop or delivers `NDEATH` through the MQTT Will after a crash. Writable node and device metrics accept inbound `NCMD` and `DCMD` values. Coreflux republishes each accepted value to its local write topic. ### Gate publishing on a Primary Host Set `PRIMARY_HOST_ID` when an external Primary Host must be online before the edge node publishes: ```lot wrap focus={6} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE GatedEdge WITH TYPE SPARKPLUG_B ADD SPARKPLUG_CONFIG WITH MODE "ENCODE" WITH GROUP_ID "Factory" WITH EDGE_NODE_ID "Line1" WITH PRIMARY_HOST_ID "PlantHost" ADD METRIC "Temperature" WITH DATA_TYPE "Float" WITH SOURCE_TOPIC "factory/line1/temperature" ``` The route watches the retained `spBv1.0/STATE/PlantHost` topic. It defers births and suppresses data until the host reports `online:true`, then publishes fresh births and resumes data. Primary Host gating does not buffer values. Data produced while the host is offline is dropped, and multi-server Primary Host failover is not supported. *** ## Consume decoded topics The decode path converts the fixed Sparkplug wire namespace into a JSON topic tree: ```text wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} spBv1.0//// → sparkplug/decoded//// spBv1.0/// → sparkplug/decoded/// ``` Each decoded metric contains its canonical name, value, datatype, timestamp, and quality: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "name": "Inputs/Temperature", "value": 24.6, "type": "Double", "timestamp": 1717160400123, "quality": "GOOD" } ``` The route also publishes: | Topic suffix | Retained | Purpose | | ------------ | -------- | ------------------------------------------------ | | `/` | No | Latest decoded metric event | | `/$status` | Yes | Node or device online state from BIRTH and DEATH | | `/$error` | No | Decode or alias-resolution error | Metric names containing `/` create deeper topic levels. The `name` field in the JSON payload remains authoritative. ## Verify the route Publish the connection check command to `$SYS/Coreflux/Command`: ```text wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -checkRouteConnection PlantDecoder ``` Confirm `$SYS/Coreflux/Routes/PlantDecoder/status` reports the route as connected or active. Use any MQTT client to confirm messages arrive under `spBv1.0/#`. Subscribe to `sparkplug/decoded/#`. After a BIRTH message, named metric topics and retained `$status` topics appear. ## Troubleshooting Confirm the route is active and that a Sparkplug B publisher is sending valid protobuf payloads under `spBv1.0/#`. Decoded output appears under `sparkplug/decoded/#`. Alias-only DATA arrived before its matching BIRTH. Wait for or request a rebirth and monitor `$error` for `alias_map_unavailable`. If `PRIMARY_HOST_ID` is configured, confirm the matching host publishes retained `{"online":true}` state on the same target broker configured by `SERVER` and `PORT`. Confirm the metric has `WRITABLE "true"` and a `WRITE_TOPIC` or `DESTINATION_TOPIC`. Read-only metrics reject inbound commands. ## Best Practices * Give every edge node a unique `GROUP_ID` and `EDGE_NODE_ID` pair. * Build automations on `sparkplug/decoded/#`, not on opaque protobuf topics. * Use retained `$status` topics for availability checks. * Load broker credentials with `GET ENV` and `GET SECRET`. * Monitor `$error` and route status for repeated alias or decode failures. *** ## Next Steps Make Coreflux the Primary Host and issue commands to edge nodes. Persist readable Sparkplug metrics in a database. # Sparkplug Host Route Source: https://docs.coreflux.org/latest/lot-language/routes/system/sparkplug-host Make Coreflux the Sparkplug B Primary Host Application for namespace state, commands, decoding, and rebirth control ## Make Coreflux the namespace authority The `SPARKPLUG_HOST` route makes Coreflux the Sparkplug B Primary Host Application. It publishes authoritative host state, decodes the complete Sparkplug namespace, issues commands to edge nodes, and requests rebirth when it detects missing aliases or sequence gaps. **The Primary Host is the control room for a Sparkplug namespace.** Edge nodes report their catalogues and values; the host watches their health and sends commands back. ### When to use it Use this route when Coreflux must: * publish retained `STATE` as the namespace's Primary Host; * consume and decode all edge-node traffic; * issue `NCMD` and `DCMD` commands from LoT; * detect sequence gaps and request a fresh BIRTH; or * prevent two hosts from asserting the same Host ID. Use [Sparkplug B Route](./sparkplug-b) instead when Coreflux only needs to decode traffic or publish as an edge node. *** Start with the broker where the Sparkplug edge nodes publish. ## Quick Start Make Coreflux the Primary Host for edge nodes connected to its local broker: ```lot wrap focus={3-4} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantHost WITH TYPE SPARKPLUG_HOST ADD SPARKPLUG_HOST_CONFIG WITH SERVER "localhost" WITH HOST_ID "CorefluxHost" ``` Coreflux publishes retained state at `spBv1.0/STATE/CorefluxHost` and decodes the local namespace. Connect the Host route to the broker already used by plant edge nodes: ```lot wrap focus={3-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantHost WITH TYPE SPARKPLUG_HOST ADD SPARKPLUG_HOST_CONFIG WITH SERVER "plant-broker.local" WITH PORT 8883 WITH HOST_ID "CorefluxHost" WITH HOST_ID_CONFLICT "REFUSE" WITH USE_TLS true WITH USERNAME GET ENV "SPARKPLUG_USER" WITH PASSWORD GET SECRET "SPARKPLUG_PASSWORD" ``` Decoded metrics are republished on the local Coreflux broker under `sparkplug/decoded/#`. Add an explicit command target that works even before Coreflux sees the edge node's BIRTH: ```lot wrap focus={6-9} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE PlantHost WITH TYPE SPARKPLUG_HOST ADD SPARKPLUG_HOST_CONFIG WITH SERVER "plant-broker.local" WITH HOST_ID "CorefluxHost" WITH HOST_ID_CONFLICT "REFUSE" ADD METRIC "Setpoint" WITH DATA_TYPE "Float" WITH TARGET "Factory/Line1/Press01" WITH COMMAND_TOPIC "commands/line1/press01/setpoint" ``` Publishing a value to the command topic sends a typed `DCMD` to `Press01`. *** ## Understand the Host role A Host route connects two MQTT contexts: | Context | Purpose | | ------------------------------- | ----------------------------------------------------------------- | | Target broker (`SERVER`/`PORT`) | Carries `spBv1.0/#`, retained host `STATE`, and outbound commands | | Local Coreflux broker | Receives `sparkplug/decoded/#` JSON and local command triggers | The target and local brokers can be the same broker. If they differ, configure `SERVER` to the broker used by the Sparkplug edge nodes. A `SPARKPLUG_HOST` route consumes the complete Sparkplug namespace. Do not run it alongside a `SPARKPLUG_B` route in `DECODE` or `BOTH` mode on the same broker. You can run separate `SPARKPLUG_B` routes in `ENCODE` mode. ## Configure the Primary Host | Setting | Required | Default | Description | | ------------------ | -------- | ----------- | ------------------------------------------------------------------------------------ | | `HOST_ID` | Yes | — | Identity published in `spBv1.0/STATE/` | | `HOST_ID_CONFLICT` | No | `REFUSE` | `REFUSE` backs off when another host appears to own the ID; `WARN` proceeds and logs | | `SERVER` | No | `localhost` | Target broker where edge nodes publish | | `PORT` | No | `1883` | Target broker port | | `USE_TLS` | No | `false` | Enables TLS for the target broker | | `USERNAME` | No | — | Target broker username | | `PASSWORD` | No | — | Target broker password; use `GET SECRET` | There is no `MODE` setting: `SPARKPLUG_HOST` defines the role. ### Prevent Host ID conflicts With the default `HOST_ID_CONFLICT "REFUSE"`, Coreflux watches the configured state topic before asserting the role. If it sees a foreign `online:true` state for the same Host ID, it disconnects and stands down for that session. Use `WARN` only when you know that the other state is stale and Coreflux should proceed. ## Follow the STATE lifecycle The Host route publishes retained state with QoS 1: 1. Before connecting, it registers an MQTT Will with `online:false`. 2. After connecting, it publishes `online:true` using the same session timestamp. 3. On graceful stop, it publishes `online:false`. 4. After a crash, the target broker delivers the registered Will. 5. After reconnecting, it publishes a new offline-to-online session state. The payload follows this shape: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "online": true, "timestamp": 1717160400123 } ``` Edge nodes configured with the matching `PRIMARY_HOST_ID` use this state to decide when to publish. *** ## Issue commands to edge nodes The Host route supports two command methods. ### Use the convention topic tree Publish a value on the local broker without declaring command metrics: ```text wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sparkplug/command/// sparkplug/command//// ``` The node form sends `NCMD`; the device form sends `DCMD`. Coreflux uses the datatype learned from the target's BIRTH catalogue. This Action forwards a UI value to a node-level setpoint: ```lot wrap focus={3} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SetLine1Setpoint ON TOPIC "ui/line1/setpoint" DO PUBLISH TOPIC "sparkplug/command/Factory/Line1/Setpoint" WITH PAYLOAD ``` ### Declare a command overlay Use `ADD METRIC` when you need a friendly trigger topic, an explicit datatype, or commands before the first BIRTH: | Setting | Required | Description | | --------------- | -------- | ------------------------------------------------------------------- | | `DATA_TYPE` | Yes | Sparkplug datatype used to encode the command | | `TARGET` | Yes | `/` for `NCMD` or `//` for `DCMD` | | `COMMAND_TOPIC` | Yes | Local MQTT topic that triggers the command | The declared datatype takes precedence over a learned catalogue. If neither source provides a datatype, Coreflux drops the command instead of guessing. ## Decode and repair the namespace The Host route feeds the same readable topic tree as a `SPARKPLUG_B` decode route: ```text wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} spBv1.0///[/] → sparkplug/decoded//[/]/ ``` It also monitors sequence integrity. When alias-only DATA arrives before BIRTH or a node's `seq` skips a value, Coreflux rate-limits and sends a node-level `Node Control/Rebirth` request. | Output | Purpose | | -------------------------------- | ------------------------------------ | | `sparkplug/decoded/.../` | Decoded metric JSON | | `sparkplug/decoded/.../$status` | Retained node or device availability | | `sparkplug/decoded/.../$error` | Decode and alias-resolution failures | *** ## Verify the Host route Publish the following command to `$SYS/Coreflux/Command`: ```text wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -checkRouteConnection PlantHost ``` Confirm `$SYS/Coreflux/Routes/PlantHost/status` reports the target connection as active. Use any MQTT client connected to the target broker and subscribe to `spBv1.0/STATE/CorefluxHost`. Confirm the retained payload reports `online:true`. Subscribe to `sparkplug/decoded/#` on the local Coreflux broker, then connect or rebirth an edge node. Publish a correctly typed value to a convention or declared command topic and confirm the edge node receives `NCMD` or `DCMD`. ## Troubleshooting Confirm `SERVER`, `PORT`, credentials, and TLS settings point to the broker used by the edge nodes. Check route status for a `HOST_ID_CONFLICT` refusal. Confirm edge nodes publish valid Sparkplug B traffic to the target broker and that no `SPARKPLUG_B` decode consumer conflicts with this Host route. Use the correct convention topic or declared `COMMAND_TOPIC`. If the edge node has not published BIRTH, declare the command with an explicit `DATA_TYPE`. Persistent alias or sequence gaps usually indicate an unstable edge node. Monitor decoded `$error` topics and the route status. ## Limitations * The route connects to one target `SERVER` and `PORT`; multi-server Primary Host failover is not supported. * The route does not provide durable store-and-forward replay. * `DataSet` and `Template` metrics decode to JSON but cannot be sent as commands. ## Best Practices * Keep `HOST_ID_CONFLICT` set to `REFUSE` unless you intentionally take ownership. * Match every edge node's `PRIMARY_HOST_ID` to the Host route's `HOST_ID`. * Point `SERVER` at the broker where the edge nodes actually publish. * Prefer declared command overlays for production automations. * Load credentials with `GET ENV` and `GET SECRET`. *** ## Next Steps Decode traffic or publish Coreflux metrics as an edge node. Automate decoded metrics and Sparkplug command topics. # RULE Overview Source: https://docs.coreflux.org/latest/lot-language/rules/overview Define access control, permissions, and governance rules for your MQTT infrastructure ## Securing Your Broker Rules define **who can do what** in your Coreflux broker. They control access to system operations, topic publishing/subscribing, and administrative functions—ensuring only authorized users can perform sensitive actions. **Think of Rules like a security guard at a building entrance.** The guard checks your badge (permissions or group) and decides if you're allowed through the door (the operation). Rules do the same for every broker operation. *** ## When to Use Rules | Scenario | Rule Type | | ------------------------------------------- | ---------------------------------------- | | Restrict who can create/delete users | User Management Rules | | Control who deploys Actions, Models, Routes | Entity Management Rules | | Limit access to system configuration | System Configuration Rules | | Secure MQTT topic access by user or group | Publish/Subscribe Rules + [RBAC](./rbac) | | Protect administrative `$SYS` topics | System Topic Rules | For groups, precedence (**lower priority number wins**), default rules, and Visu panel access, see [RBAC with LoT Rules](./rbac). *** ## In This Section * [Rules Syntax](./syntax) — Conditions, scopes, and patterns * [RBAC with LoT Rules](./rbac) — Groups, precedence, cookbook, Visu access *** ## How to Deploy a Rule Rules can be deployed in two ways: through a **LoT Notebook** (recommended) or via **MQTT commands**. Custom topic and management ACL rules (`-addRule`) require a **Growth or Enterprise** license. Shipped default rules always apply. Write your rule definition in a code cell and run it—the extension detects `DEFINE RULE` and sends the proper command to the broker. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE Zone1Access WITH PRIORITY 100 FOR Subscribe TO TOPIC "zone1/#" IF USER IN GROUP "zone1-staff" THEN ALLOW ELSE DENY ``` Click the **Run** button. The notebook recognizes the LoT code and deploys it instantly. This approach is ideal for: * Developing and testing rules interactively * Documenting your security configuration alongside the code * Sharing rule sets with your team as `.lotnb` files Deploy rules by publishing to the broker's command topic using any MQTT client. **Topic:** `$SYS/Coreflux/Command` **Payload:** ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addRule DEFINE RULE Zone1Access WITH PRIORITY 100 FOR Subscribe TO TOPIC "zone1/#" IF USER IN GROUP "zone1-staff" THEN ALLOW ELSE DENY ``` Subscribe to `$SYS/Coreflux/Command/Output` to receive the response confirming deployment. *** ## Removing Rules Remove a rule by name using the `-removeRule` command. Publish to `$SYS/Coreflux/Command`: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removeRule Zone1Access ``` You can also use the **Coreflux Entities** panel in VS Code to view and remove deployed rules. Locked built-in system rules cannot be removed. *** ## Updating Rules To update an existing rule, deploy a new rule with the **same name**. The new definition replaces the existing one. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Original rule DEFINE RULE Zone1Access WITH PRIORITY 100 FOR Subscribe TO TOPIC "zone1/#" IF USER IN GROUP "zone1-staff" THEN ALLOW ELSE DENY -- Updated rule (same name, also allow supervisors) DEFINE RULE Zone1Access WITH PRIORITY 100 FOR Subscribe TO TOPIC "zone1/#" IF USER IN GROUP "zone1-staff" OR USER IN GROUP "supervisors" THEN ALLOW ELSE DENY ``` *** ## Viewing Active Rules To see all deployed rules, subscribe to the rules system topic: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SUBSCRIBE "$SYS/Coreflux/Rules/#" ``` Or use the **Coreflux Entities** panel in VS Code if you have the LoT Notebooks extension installed. *** ## Default Rules Reference Coreflux ships two tiers of defaults: | Tier | Behavior | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | **Locked system rules** | Management gates and `$SYS` access in the reserved band (`0–99`) — cannot be removed or overridden | | **Seed catch-alls** | Priority `1 000 000` allow-all for connect, publish, and subscribe so clients keep working; the weakest band, so any custom rule overrides them | | **Root backstop** | `root` can always recover command and rule management access | Custom rules use priorities from `100` to `1 000 000`. **Lower priority number wins**; ties deny-wins; no match means deny. Details: [RBAC with LoT Rules](./rbac). *** ## Next Steps Groups, precedence, cookbook, and Visu panel access. Conditions, scopes, and complete rule patterns. # RBAC with LoT Rules Source: https://docs.coreflux.org/latest/lot-language/rules/rbac Role-based access control with user groups, LoT Rules, permission flags, and Visu panel access ## Control Who Can Do What Role-based access control (RBAC) in Coreflux combines **user groups**, **LoT Rules**, **permission flags**, and optional **Visu panel** sharing so you can lock down topics, management operations, and dashboards without external ACL servers. **Like badge groups in a factory.** Operators get a badge that opens production doors; supervisors get a badge that also opens the emergency-stop cabinet. Groups name the badges; Rules decide which doors each badge opens. ### When to Use This Guide | Goal | Use | | ------------------------------------------- | ------------------------------------------------- | | Restrict MQTT publish/subscribe by role | Groups + `DEFINE RULE` with `USER IN GROUP` | | Gate who can create users, routes, or rules | Permission flags + management scopes | | Hide or lock Visu panels/controls | `WITH SHARE TO GROUP` / `WITH INTERACT FOR GROUP` | | Understand which rule wins | Precedence model below | Adding custom topic or management ACL rules (`-addRule`) requires a **Growth or Enterprise** license. Shipped default rules always apply. Group membership commands and permission flags are available on all tiers. *** Write rules with correct layout and conditions in [Rules Syntax](./syntax), then return here for groups, precedence, and operator patterns. ## User Groups Each broker user can belong to one or more named groups. Group names are **case-insensitive** at evaluation time (`"Operators"` and `"operators"` match the same group). Prefer kebab-case or lowercase identifiers without spaces (`zone1-staff`, not `Zone 1 Staff`). Groups are created by naming them — assign a user to a group and reference that name in rules or Visu clauses. ### Manage Groups via MQTT Publish to `$SYS/Coreflux/Command` from a session authorized for user management (`AllowedUserManagement`, `AllowedSystemConfiguration`, or `root`). The requesting identity is the **authenticated MQTT session** — do not pass a requester in the payload. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addUserToGroup alice operators -removeUserFromGroup alice operators -listUserGroups alice ``` Responses arrive on `$SYS/Coreflux/Command/Output`. See [Broker Commands](/latest/mqtt-broker/commands) for the full command set. *** ## Rule Precedence **Lower priority number wins.** This is the same convention as BACnet write priority and ISA-18.2 alarm priority: the lower the number, the stronger the statement. The very strongest numbers are reserved for the broker's own rules, so `100` is the strongest priority you can write. For a given operation (and topic, for topic operations), the broker considers only the rules with the **lowest** priority number among those that match. Within that winning band: * If **any** matching rule evaluates to **DENY**, the operation is denied (**deny-wins**) * Otherwise, if a rule allows, the operation is allowed * If **no** rule matches at all, the operation is **denied** (default-deny) Weaker (higher-numbered) rules are consulted only where no stronger rule matches the topic. | Priority | Use case | | ------------- | --------------------------------------------------------------------------------------------------------------- | | `0–99` | **Reserved** for locked system rules — rejected for user/project rules | | `100` | The strongest rule you can write — use it for the most specific case | | `100–999 999` | The user band. Layer exceptions by stepping *down* toward `100` as rules get more specific | | `1 000 000` | Shipped permissive catch-alls (`AllowPublishTopic` / `AllowSubscribeTopic` / `AllowConnect`) — the weakest band | | `> 1 000 000` | **Rejected** — weaker than the catch-alls, so the rule could never apply | Because the catch-alls sit at the weakest priority `1 000 000`, **any** rule you add in the user band overrides them for its topic (or, for Connect, for the whole connection). You do not need to remove or reprioritise a built-in to deny (or further allow) a topic. **Start broad rules high — you can only carve out downwards.** An exception needs a *lower* number than the rule it overrides, and `100` is the floor of the user band. Give a namespace-wide rule a large number (`1000`, or higher if you expect several layers) and keep `100` for the most specific case. A broad rule placed at `100` leaves no room to carve out later without renumbering what is already deployed. Prefer distinct priorities, or combine conditions with `OR` in one rule. Same-priority ties resolve **deny-wins**. ### Layout requirements The LoT lexer is indentation-sensitive (one level = 4 spaces): * Keep the full header on **one line**: `DEFINE RULE Name WITH PRIORITY n FOR …` * Put each `IF` / `ELSE` / `ALLOW` / `DENY` on its own line (a single-line `THEN ALLOW ELSE DENY` body is rejected) * Omitting `ELSE` is valid: `THEN DENY` allows everyone else, and `THEN ALLOW` denies everyone else. An explicit `ELSE` is still the clearer form. * Rule names are bare identifiers — **no quotes** around the rule name ```lot wrap focus={2-5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE Zone1Access WITH PRIORITY 100 FOR Subscribe TO TOPIC "zone1/#" IF USER IN GROUP "zone1-staff" THEN ALLOW ELSE DENY ``` *** ## Quick Start Only members of `operators` may publish commands under `machines/+/cmd`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE OperatorControl WITH PRIORITY 100 FOR Publish TO TOPIC "machines/+/cmd" IF USER IN GROUP "operators" THEN ALLOW ELSE DENY ``` Supervisors or operators may subscribe to production topics: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE SupOrOp WITH PRIORITY 100 FOR Subscribe TO TOPIC "production/#" IF USER IN GROUP "supervisors" OR USER IN GROUP "operators" THEN ALLOW ELSE DENY ``` Deny a config subtree at a coarse priority, then carve out one leaf for supervisors at a stronger (lower) one: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DenyConfig WITH PRIORITY 1000 FOR Publish TO TOPIC "site/config/#" DENY DEFINE RULE AllowSetpt WITH PRIORITY 100 FOR Publish TO TOPIC "site/config/setpoint" IF USER IN GROUP "supervisors" THEN ALLOW ELSE DENY ``` Deploy with `-addRule ` on `$SYS/Coreflux/Command`, or run the cell in a [LoT Notebook](/latest/quick-start/vscode). Remove with `-removeRule `. *** ## Access-Control Cookbook ### Deny a whole subtree ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DenyRawArea WITH PRIORITY 100 FOR Publish TO TOPIC "factory/raw/#" DENY ``` Beats the weakest-band publish catch-all (priority `1 000 000`) for `factory/raw/#`; other topics still fall through to the default allow. ### Restrict a topic to named users ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE ControlWriters WITH PRIORITY 100 FOR Publish TO TOPIC "line1/control/#" IF USER IS "operator1" OR USER IS "operator2" THEN ALLOW ELSE DENY ``` ### Gate by permission flag ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DiagnosticsReaders WITH PRIORITY 100 FOR Subscribe TO TOPIC "diagnostics/#" IF USER HAS AllowedSystemConfiguration OR USER IS "root" THEN ALLOW ELSE DENY ``` ### Switch a namespace toward default-deny Blanket-deny at a coarse priority, then allow only what you intend at a stronger (lower) one: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DenyAllPub WITH PRIORITY 1000 FOR Publish TO TOPIC "#" DENY DEFINE RULE AllowApp WITH PRIORITY 100 FOR Publish TO TOPIC "app/#" ALLOW ``` Alternatively, remove the removable publish seed (`-removeRule AllowPublishTopic`) so unmatched publishes fall through to default-deny. Locked system rules and the root backstop still protect recovery. Restore the three shipped seeds later with [`-restoreRules`](/latest/mqtt-broker/commands#restoring-seeded-rules). Do **not** expect `-removeRule AllowConnect` alone to lock CONNECT — see [Connection admission](#connection-admission). ### Precedence walk-through The table reads top-down in precedence order — the first band that matches decides: | Rule | Priority | Topic | Decision | | ------------------------------ | --------- | ----------------- | -------- | | `AllowSensors` | 100 | `plant/sensors/#` | ALLOW | | `DenyArea` | 1 000 | `plant/#` | DENY | | `AllowPublishTopic` (built-in) | 1 000 000 | `#` | ALLOW | * Publish to `office/x` → only the catch-all matches → **ALLOW** * Publish to `plant/motor` → strongest match is `DenyArea` (1 000) → **DENY** * Publish to `plant/sensors/t1` → strongest match is `AllowSensors` (100) → **ALLOW** *** ## Permission Flags vs Groups | Mechanism | What it is for | | -------------------- | ------------------------------------------------------------------ | | **Permission flags** | Coarse system access — checked with `USER HAS` | | **Groups** | Fine-grained topic and panel access — checked with `USER IN GROUP` | | Flag | Gates | | ---------------------------- | --------------------------------------------------------------- | | `AllowedSystemConfiguration` | System config, creating routes/models/actions, many admin paths | | `AllowedUserManagement` | User CRUD and group membership commands | | `AllowedLogManagement` | Log access | Set a flag via MQTT: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -changeUserSettings alice AllowedUserManagement true ``` In Coreflux HUB User Management, **Full Access** maps to `AllowedSystemConfiguration`; **User Management** and **Log Management** map to the matching flags. Manage groups with the MQTT commands above (see also [User Management](/hub/system/user-management)). | Scenario | Prefer | | ------------------------------------- | ------------------------- | | Can this user create LoT actions? | Permission flag | | Can this user see Zone 1 data/panels? | Group + rule / Visu share | | Can this user manage other users? | `AllowedUserManagement` | `USER IN GROUP`, `USER IS` / `USER EQUALS`, and `USER HAS` also work inside LoT Action bodies (for topic-triggered actions). Scheduled actions (`ON EVERY` / `ON START`) have no triggering user — group checks return false there. *** ## Default Rules and System Access ### Locked system rules Management gates (`CommandCall`, user/rule/route/action management, and so on) and `$SYS` access rules are **locked** built-ins in the reserved band (`0–99`) — the strongest priorities on the number line. They cannot be removed, cannot be overridden by a same-named rule, and outrank every user rule (which are floored at `100`), so no rule you write can revoke admin access and lock the broker. The band is split the same way user rules are — more specific means a lower number: base locked rules sit at `10`, and more-specific `$SYS` carve-outs at `1`. The flip side of that protection: a custom rule on a management scope, or on `$SYS/#` subscribe, never decides either — the locked rule always forms the winning band. Adjust those with permission flags instead. See [Operation Scopes](./syntax#operation-scopes). ### Seed catch-alls Three permissive defaults sit at priority `1 000 000` — the weakest possible band — so MQTT clients keep working out of the box while losing to every rule an operator writes: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE AllowPublishTopic WITH PRIORITY 1000000 FOR Publish TO TOPIC "#" ALLOW DEFINE RULE AllowSubscribeTopic WITH PRIORITY 1000000 FOR Subscribe TO TOPIC "#" ALLOW DEFINE RULE AllowConnect WITH PRIORITY 1000000 FOR Connect ALLOW ``` These are the only defaults an operator may remove or replace via MQTT. To change a seed catch-all's body, `-removeRule` it and then `-addRule` your replacement — rule names must be unique. Project merges skip every built-in name — including these seeds — so redefine them with MQTT commands, not project files. To put the three seeds back (including resetting an override of those names to the shipped ALLOW body), run [`-restoreRules`](/latest/mqtt-broker/commands#restoring-seeded-rules). That command requires `root` or `AllowedSystemConfiguration` and does not require an RBAC license. Locked built-ins and your other custom rules are left alone. Loading or unloading a project keeps locked built-ins and operator-authored rules. Only rules the broker attributes to the outgoing project are removed. ### Connection admission A rule written `FOR Connect` decides whether the broker accepts an MQTT CONNECT after authentication succeeds. Connect rules take no `TO TOPIC` clause. Any user-band Connect rule (`100`–`999 999`) outranks `AllowConnect`. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DenyGuestConnect WITH PRIORITY 100 FOR Connect IF USER IS "guest" THEN DENY ``` A rule set with **no** `Connect` rule at all leaves CONNECT ungated — that is how upgrades from brokers that had no Connect operation keep accepting clients. `-removeRule AllowConnect` without adding a replacement therefore does **not** lock the door. To control who may connect, add an explicit `FOR Connect` policy. `root` remains admitted by the anti-lockout backstop. ### Rejected priorities `-addRule` and project rule merges reject both ends of the number line, and report the reason per rule: | Priority | Result | | ------------- | ------------------------------------------------------------------------------------------------------------ | | `0–99` | **Rejected** — nothing user-defined may tie with or outrank a locked system rule | | `100–999 999` | Accepted — the user band | | `1 000 000` | Accepted, so you can re-add a replaced catch-all in its own band | | `> 1 000 000` | **Rejected** — weaker than the catch-alls, so the rule would never be consulted and would silently fail open | ### Root anti-lockout Independently of all rules, `root` can always connect, run commands, manage rules, and reach `$SYS/Coreflux/Command` and `$SYS/Coreflux/Command/Output`. ### Upgrade note **Review any custom rule that used the `1–99` range.** That band is now reserved for locked system rules, so such rules are **clamped to `100`** when the broker loads them (each clamp is logged with its old and new priority). Clamping keeps them in force and keeps them from outranking the locked safety rules, but several rules that used to be distinct can land on `100` together — where ties resolve **deny-wins**. Re-space them deliberately across the user band, putting the *more specific* rule at the *lower* number. Locked built-ins are rewritten to their canonical definitions and priorities on every start, and a seed catch-all you kept has its body preserved but its priority normalized to `1 000 000`. A seed you deliberately removed stays removed. Fresh brokers also ship `AllowConnect` at `1 000 000`; see [Connection admission](#connection-admission). *** ## Visu Panel RBAC LoTV panels can restrict who sees content and who can interact with controls. ### Visibility ```lot wrap focus={4-5} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL "Zone1Control" WITH TITLE "Zone 1 Control" WITH VISIBILITY SHARED WITH SHARE TO GROUP "zone1-staff" WITH SHARE TO GROUP "supervisors" WITH STATE PUBLISHED ``` Published shared panels generate Layer 1 subscribe rules so only listed groups (or users via `WITH SHARE TO USER`) receive the panel. Others do not see it in `$Visu/Catalog`. ### Interaction | Keyword | Who can interact | | ------------------------------- | --------------------------------- | | `WITH INTERACT PUBLIC` | Any client who can view the panel | | `WITH INTERACT FOR GROUP ""` | Members of group `` | | `WITH INTERACT FOR USER ""` | Named user `` | | *(omitted)* | View-only — no Interact topic | Stack multiple `FOR GROUP` lines or multiple `FOR USER` lines. Do **not** mix `FOR GROUP` and `FOR USER` on the same component — only the last scope is enforced. Put named users into a group when both are needed. Denial modes: `WITH INTERACT_DENY SILENT` (default) or `WITH INTERACT_DENY NOTIFY` (publishes to `$Visu/Session//Denied`). ### Two-layer enforcement | Layer | What | | ------- | ----------------------------------------------------------------------- | | Layer 1 | MQTT subscribe/publish to `$Visu` topics from panel visibility | | Layer 2 | Runtime check of `WITH INTERACT FOR GROUP` / `FOR USER` per interaction | *** ## Best Practices * Start from the shipped defaults; every rule you add already overrides the catch-alls for its topic * Give namespace-wide rules a coarse number (`1000` or higher) and reserve `100` for the most specific carve-outs * Prefer groups over hard-coded usernames for topic and panel access * Keep rule headers on one line; use consistent 4-space indentation * Keep custom priorities inside the user band (`100`–`1 000 000`) * Document who owns each group name in your operations runbook Never attempt to override locked system rules or rely on removing `root` protections. The reserved priority band and root backstop exist so the broker remains recoverable. *** ## Next Steps Conditions, operation scopes, and complete rule patterns. Group, user, and rule management command reference. # RULE Syntax Source: https://docs.coreflux.org/latest/lot-language/rules/syntax Complete reference for LoT rule syntax, conditions, operation scopes, and access control patterns ## Write Your Broker's Access Policy Every broker operation—creating users, deploying Actions, publishing to topics—passes through Rules before it executes. This page is the complete syntax reference for writing those rules: the conditions you can check, the scopes you can target, and the patterns that tie them together. **Like writing house rules on a whiteboard.** Each rule says "IF you are on the approved list, THEN you may enter the room—ELSE the door stays locked." LoT rule syntax works the same way: you name the rule, pick the door (scope), and write the condition. ### When You Need This Reference | Scenario | You'll use | | -------------------------------------------------- | ------------------------------------------------ | | Restricting who can create or delete users | User Management scopes | | Controlling who deploys Actions, Models, or Routes | Entity Management scopes | | Locking down MQTT topic access per user or group | Publish / Subscribe scopes with `TO TOPIC` | | Protecting `$SYS` command topics | System topic scopes | | Building role-based access across teams | Groups with `USER IN GROUP` — see [RBAC](./rbac) | **Deploying rules:** In a [LoT Notebook](/latest/quick-start/vscode), write the rule and run the cell. For MQTT clients, use `-addRule` ([broker commands](/latest/mqtt-broker/commands#rules)). Custom ACL rules require a Growth or Enterprise license. *** ## Rule Structure Every rule follows this structure: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE WITH PRIORITY FOR [TO TOPIC ""] IF THEN ALLOW ELSE DENY ``` | Component | Description | | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | | `RuleName` | Bare identifier (letters, digits, underscores) — **do not quote** the name | | `PRIORITY` | Integer `100`–`1 000 000`; **lower number wins** among matching rules (see [Priority and Evaluation](#priority-and-evaluation)) | | `Scope` | The operation type this rule controls | | `TO TOPIC` | Optional topic pattern for Publish/Subscribe (and `$SYS` variants) | | `condition` | Logic that determines access (user, group, permission flags) | Keep the entire header on **one line**. Indent the body one level (4 spaces); nest `ALLOW`/`DENY` under `IF`/`ELSE` another level. A single-line `THEN ALLOW ELSE DENY` body is rejected. Unconditional forms are valid too: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE OpenPublic WITH PRIORITY 100 FOR Subscribe TO TOPIC "public/#" ALLOW ``` *** ## Conditions Rules evaluate conditions to decide whether to ALLOW or DENY an operation. ### User Identity Checks ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} USER IS "root" USER EQUALS "alice" ``` ### Group Membership ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} USER IN GROUP "operators" USER IN GROUP "zone1-staff" ``` Group lookup is case-insensitive. See [RBAC with LoT Rules](./rbac) for group management commands and patterns. ### Permission Flags ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} USER HAS AllowedUserManagement USER HAS AllowedSystemConfiguration USER HAS AllowedLogManagement ``` These are the three broker permission flags. Prefer groups for fine-grained topic access rather than inventing custom flag names. ### Combining Conditions ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} IF USER HAS AllowedUserManagement OR USER IS "root" THEN ALLOW IF USER IN GROUP "supervisors" AND USER EQUALS "alice" THEN ALLOW ``` Omitting `ELSE` is valid. `THEN DENY` allows everyone else; `THEN ALLOW` denies everyone else — the same as writing the opposite decision in an `ELSE` branch. An explicit `ELSE` is still the clearer form. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DenyDashboardPublish WITH PRIORITY 120 FOR Publish TO TOPIC "factory/#" IF USER IS "dashboard" THEN DENY ``` *** ## Operation Scopes Rules control different categories of operations. They split into two groups that behave very differently in practice. **Topic scopes** — `Publish`, `Subscribe`, `PublishSys`, `SubscribeSys` — are the scopes you write rules for. The only built-ins that compete with you are the permissive catch-alls in the weakest band, so your rules decide. **Connect** — `FOR Connect` decides whether the broker accepts an MQTT CONNECT after authentication. It takes no `TO TOPIC` clause. See [Connection admission](./rbac#connection-admission). **Management scopes** — everything below — are already covered by locked built-in rules in the reserved band (`0–99`) that key off [permission flags](#permission-flags). Because your rules are floored at `100` and only the lowest-numbered matching band decides, a rule you write on a management scope is **never** the deciding band. Do not try to tighten or loosen a management gate with a custom rule. The locked built-in always wins, and `-addRule` rejects both a priority below `100` and any attempt to reuse a built-in rule name. Change who can perform these operations by setting **permission flags** with `-changeUserSettings` instead. The tables below name each scope and the flags its locked built-in accepts. In every case `root` is also accepted. ### User Management | Scope | Controls | Built-in gate accepts | | ------------------------------ | -------------------------------------------- | ----------------------------------------------------- | | `UserManagementCreation` | Creating new users | `AllowedUserManagement`, `AllowedSystemConfiguration` | | `UserManagementRemove` | Deleting users | `AllowedUserManagement`, `AllowedSystemConfiguration` | | `UserManagementUpdate` | Modifying user settings and group membership | `AllowedUserManagement`, `AllowedSystemConfiguration` | | `UserManagementPasswordChange` | Changing user passwords | `AllowedUserManagement`, `AllowedSystemConfiguration` | ### Entity Management | Scope | Controls | Built-in gate accepts | | -------------------------- | ------------------------------ | ------------------------------------- | | `ActionManagementCreation` | Creating Actions and panels | `AllowedSystemConfiguration`, `admin` | | `ActionManagementRemove` | Deleting Actions and panels | `AllowedSystemConfiguration`, `admin` | | `ActionManagementRun` | Manually running Actions | `AllowedSystemConfiguration`, `admin` | | `ModelManagementCreation` | Creating Models | `AllowedSystemConfiguration`, `admin` | | `ModelManagementRemove` | Deleting Models | `AllowedSystemConfiguration`, `admin` | | `RouteManagementCreation` | Creating Routes | `AllowedSystemConfiguration`, `admin` | | `RouteManagementRemove` | Deleting Routes | `AllowedSystemConfiguration`, `admin` | | `RuleManagementCreation` | Creating Rules (`-addRule`) | `AllowedSystemConfiguration` | | `RuleManagementRemove` | Deleting Rules (`-removeRule`) | `AllowedSystemConfiguration` | ### System Operations | Scope | Controls | Built-in gate accepts | | --------------------- | --------------------------------------------------- | -------------------------------------------------------------- | | `SystemConfiguration` | Changing broker configuration | `AllowedSystemConfiguration` | | `ShellCommand` | Executing shell commands | `AllowedSystemConfiguration` | | `CommandCall` | Calling broker commands via `$SYS/Coreflux/Command` | `AllowedUserManagement`, `AllowedSystemConfiguration`, `admin` | ### Log Management | Scope | Controls | Built-in gate accepts | | ----------------------- | -------------------- | ---------------------------------------------------- | | `LogManagementCreation` | Creating log traces | `AllowedLogManagement`, `AllowedSystemConfiguration` | | `LogManagementRemove` | Deleting log traces | `AllowedLogManagement`, `AllowedSystemConfiguration` | | `LogManagementUpdate` | Modifying log traces | `AllowedLogManagement`, `AllowedSystemConfiguration` | *** ## Topic Access Rules ### Standard Topics Use `Publish` and `Subscribe` with `TO TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE OperatorPublish WITH PRIORITY 100 FOR Publish TO TOPIC "machines/+/cmd" IF USER IN GROUP "operators" THEN ALLOW ELSE DENY ``` The broker ships catch-alls at the weakest priority (`1 000 000`) that allow all connect, publish, and subscribe. Any rule you add in the user band already outranks them for its topic — see [RBAC](./rbac). ### System Topics (`$SYS`) `$SYS` topics use the `PublishSys` and `SubscribeSys` scopes. Most of this namespace is already locked down for you: | Path | Coverage | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `SubscribeSys` on `$SYS/#` | Locked built-in — admins only. Cannot be tightened or loosened by a custom rule | | `PublishSys` on the broker's own command and management topics | Locked built-ins — flag-gated | | `PublishSys` on any other `$SYS` subtree | No built-in, so **default-deny**. A rule you write is the only candidate and decides | Because the locked `$SYS` rules sit in the reserved band, a custom rule for `$SYS/#` subscribe never decides. Write `PublishSys` rules only for subtrees the built-ins do not already cover: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE AppStatusWriters WITH PRIORITY 100 FOR PublishSys TO TOPIC "$SYS/MyApp/#" IF USER IN GROUP "app-services" THEN ALLOW ELSE DENY ``` System topics (`$SYS/#`) contain sensitive broker information and command interfaces. Rely on the locked built-ins rather than writing your own `$SYS` subscribe rules, and never grant `$SYS` access to untrusted clients. *** ## Permission Flags | Permission Flag | Grants Access To | | ---------------------------- | -------------------------------------------------------- | | `AllowedUserManagement` | User creation, removal, updates, group membership | | `AllowedSystemConfiguration` | System config, many entity management paths, shell/admin | | `AllowedLogManagement` | Log trace creation, removal, updates, read | Assign flags with `-changeUserSettings` or in [Coreflux HUB User Management](/hub/system/user-management) (Full Access → `AllowedSystemConfiguration`). *** ## Priority and Evaluation **Lower priority number wins** — the same convention as BACnet write priority and ISA-18.2 alarm priority. Among rules that match the operation (and topic), only the lowest-numbered band decides: 1. If any rule in that band evaluates to **DENY** → deny (**deny-wins**) 2. Else if any allows → allow 3. If **no** rule matches → **deny** (default-deny) ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Strongest band for this topic (100) decides first DEFINE RULE AllowSensors WITH PRIORITY 100 FOR Publish TO TOPIC "plant/sensors/#" ALLOW DEFINE RULE DenyPlant WITH PRIORITY 1000 FOR Publish TO TOPIC "plant/#" DENY ``` | Priority band | Guideline | | ------------- | --------------------------------------------------------------------- | | `0–99` | Reserved for locked system rules — rejected for your rules | | `100` | The strongest rule you can write — keep it for the most specific case | | `100–999 999` | The user band; step *down* toward `100` as rules get more specific | | `1 000 000` | Shipped connect/publish/subscribe catch-alls — the weakest band | | `> 1 000 000` | Rejected — weaker than the catch-alls, so the rule could never apply | Use a **lower** priority number than the rule you intend to override. Because the seed catch-alls sit at `1 000 000`, any rule in the user band is already enough to restrict a subtree — start broad rules at `1000` or higher so you have room to carve out exceptions later. For cookbook patterns, Visu RBAC, and default-rule tiers, see [RBAC with LoT Rules](./rbac). *** ## Complete Examples Operators and supervisors share a plant namespace; only supervisors may emergency-stop: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE Zone1Cmd WITH PRIORITY 1000 FOR Publish TO TOPIC "zone1/+/cmd" IF USER IN GROUP "operators" OR USER IN GROUP "supervisors" THEN ALLOW ELSE DENY DEFINE RULE EmergencyStop WITH PRIORITY 100 FOR Publish TO TOPIC "zone1/emergency/stop" IF USER IN GROUP "supervisors" THEN ALLOW ELSE DENY ``` Confine device accounts to their own tree, and deny them everything else with a coarse fallback: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE DenyDeviceElsewhere WITH PRIORITY 1000 FOR Publish TO TOPIC "#" IF USER IN GROUP "devices" THEN DENY ELSE ALLOW DEFINE RULE DevicePublish WITH PRIORITY 100 FOR Publish TO TOPIC "devices/#" IF USER IN GROUP "devices" THEN ALLOW ELSE DENY ``` Device accounts need no rule to keep them off `$SYS/Coreflux/Command` — the locked built-in already requires a permission flag, so simply leave all three flags unset on those users. Separate publish access by group: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE RULE EngineeringPublish WITH PRIORITY 100 FOR Publish TO TOPIC "engineering/#" IF USER IN GROUP "engineering" THEN ALLOW ELSE DENY DEFINE RULE OperationsPublish WITH PRIORITY 100 FOR Publish TO TOPIC "operations/#" IF USER IN GROUP "operations" THEN ALLOW ELSE DENY DEFINE RULE SharedTopicAccess WITH PRIORITY 100 FOR Publish TO TOPIC "shared/#" IF USER IN GROUP "engineering" OR USER IN GROUP "operations" THEN ALLOW ELSE DENY ``` *** ## Best Practices Use `USER IN GROUP` for data and panel access. Reserve permission flags for system-level capabilities. You do not need to remove `AllowPublishTopic` / `AllowSubscribeTopic` / `AllowConnect` to deny a subtree (or a connection). They sit in the weakest band (`1 000 000`), so any rule you write for that pattern already wins. Exceptions need a *lower* number than the rule they override, and `100` is the floor. Place namespace-wide rules at `1000` or higher and keep `100` for the most specific case, so you never have to renumber deployed rules. Rely on the locked built-ins — they already gate `$SYS` on permission flags and cannot be overridden. Keep those flags off device and operator accounts rather than writing your own `$SYS` rules. Prefer `DenyRawArea` or `OperatorControl` over `Rule1`. *** ## Next Steps Groups, precedence deep-dive, cookbook, and Visu access. Deploy rules and manage groups via MQTT commands. # Alarms and monitoring Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/alarms-and-monitoring Threshold crossings, device and route liveness, and optional email notifications. ## Reacting to Threshold Crossings Most alarm logic boils down to "if a value crosses a threshold, alert someone." LoT does this in a handful of lines: read the current value, read the threshold from config, compare, publish if exceeded. **Like a kitchen oven timer.** It does one thing, watches one number, and beeps when the number's wrong. Quiet the rest of the time. ### When to Reach for This Any time you need to detect that something has gone outside an acceptable range — temperature too high, tank too full, vibration too strong, fill level too low, pollution above a limit. This is one of the first patterns most projects need. ### The Simple Version A CO2 monitor in a conference room: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION HighCO2Alarm ON TOPIC "sensors/co2" DO SET "current" WITH PAYLOAD AS DOUBLE SET "max" WITH (GET TOPIC "config/co2_max" AS DOUBLE) IF {current} > {max} THEN PUBLISH TOPIC "alerts/co2_high" WITH ("CO2 too high: " + {current}) ``` That's it. Three things to notice: 1. **The threshold lives in `config/`, not in the Action.** A facilities manager can change it at runtime without redeploying anything. If the limit shifts from 1000 ppm to 1200 ppm, just publish the new value to `config/co2_max` and the next reading uses the new limit. 2. **The alert topic is separate from the data topic.** Anyone subscribing to `alerts/#` gets every alert in the system, regardless of source. Notifiers, on-call systems, and [dashboard `alert` and `indicator` components](/hub/dashboards/components/display) all use the same channel — the visualization end of an alarm pipeline. 3. **No state tracking in this version.** The Action fires every time a reading exceeds the threshold, so you'll get repeated alerts while CO2 stays high. If you have downstream notification infrastructure that handles deduplication, that's fine. If you don't, see the state-tracking variant below. ### Optional: Standardize the Alarm Format with a Model Plain text alerts work, but as your system grows you'll want consistent structure — every alarm in the same JSON shape so dashboards and databases can parse them uniformly. Define a `COLLAPSED` Model and use `PUBLISH MODEL` instead of `PUBLISH TOPIC`. (`COLLAPSED` means the Model doesn't publish on its own — it only fires when something explicitly calls `PUBLISH MODEL`. See the [Models reference](/latest/lot-language/models/overview) for the full options.) ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL Alarm COLLAPSED ADD STRING "type" ADD STRING "severity" ADD DOUBLE "value" ADD DOUBLE "threshold" ADD STRING "timestamp" DEFINE ACTION HighCO2Alarm ON TOPIC "sensors/co2" DO SET "current" WITH PAYLOAD AS DOUBLE SET "max" WITH (GET TOPIC "config/co2_max" AS DOUBLE) IF {current} > {max} THEN PUBLISH MODEL Alarm TO "alerts/co2_high" WITH type = "CO2_HIGH" severity = "WARNING" value = {current} threshold = {max} timestamp = TIMESTAMP "UTC" ``` Now every alarm — whether it's high CO2, low water level, or door left open — publishes the same JSON shape. A single database table, dashboard panel, or notification rule can handle all of them. This pairs especially well with [model inheritance](/latest/lot-language/usage-patterns/data-layer#standardizing-data-across-device-types). ### Optional: Deduplicate with State Tracking If you don't have a downstream notifier that deduplicates — or you want a clean audit trail of "alarm started" and "alarm cleared" events — track the previous state in `state/` and only publish on transitions. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION HighCO2Alarm ON TOPIC "sensors/co2" DO SET "current" WITH PAYLOAD AS DOUBLE SET "max" WITH (GET TOPIC "config/co2_max" AS DOUBLE) SET "previous" WITH (GET TOPIC "state/co2_alarm") IF {current} > {max} THEN IF {previous} != "active" THEN PUBLISH TOPIC "alerts/co2_high" WITH ("CO2 too high: " + {current}) KEEP TOPIC "state/co2_alarm" WITH "active" ELSE IF {previous} EQUALS "active" THEN PUBLISH TOPIC "alerts/co2_normal" WITH ("CO2 back to normal: " + {current}) KEEP TOPIC "state/co2_alarm" WITH "normal" ``` What's happening: * The action reads three things: current value, threshold, and the previous alarm state stored in `state/co2_alarm`. * If CO2 is over the threshold **and** the alarm wasn't already active, it fires a "high" alert and marks the state active. Subsequent over-threshold readings while still active produce no extra alerts. * If CO2 is back below threshold **and** the alarm was active, it fires a "normal" alert and marks the state normal. The `state/` topic uses `KEEP TOPIC` (not `PUBLISH TOPIC`) so it's internal to the alarm logic — retained on the broker, but not noise on the wire. ### Optional: Send the Alarm by Email For alarms that need to reach humans directly, add an `EMAIL` route that subscribes to your alert topics and sends a templated message via SMTP. The route does all the work — your Action just publishes to the alert topic as before. (Full reference: [Email Route](/latest/lot-language/routes/notifications/email).) ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AlertEmail WITH TYPE EMAIL ADD SMTP_CONFIG WITH HOST GET ENV "SMTP_HOST" WITH PORT '587' WITH USE_TLS "true" WITH USERNAME GET ENV "SMTP_USER" WITH PASSWORD GET SECRET "SMTP_PASSWORD" ADD EVENT NotifyOnHighCO2 WITH SOURCE_TOPIC "alerts/co2_high" WITH SUBJECT "CO2 alarm: {value.json.value} ppm" WITH RECIPIENT "facilities@acmecorp.com" WITH TEMPLATE_PATH "/templates/alarm_email.html" ``` How it fits together: 1. The threshold Action publishes a structured alarm to `alerts/co2_high` (using the Model variant from above). 2. The email route is subscribed to that topic and fires automatically whenever a new alarm appears. 3. Placeholders like `{value.json.value}` pull fields out of the JSON payload to build the email subject and body. Credentials come from `ENV` and `SECRET` (see [Keeping Credentials Out of Your Code](/latest/lot-language/usage-patterns/integrations#keeping-credentials-out-of-your-code)). Pair with the **state tracking** variant if you want only one email per alarm transition rather than one per reading. *** ## Knowing When a Device or Route Goes Offline Two different things can go silent in a Coreflux system, and you want to know about both. **Devices** are the sensors, machines, and gateways out in the world. They go offline when their network drops, their battery dies, or they crash. The signal that something's wrong is the *absence of incoming messages*. **Routes** are the broker's own connections to databases, cloud brokers, REST APIs, and other external systems. They go offline when credentials change, the remote system is unreachable, or the network between them breaks. The signal here is the *absence of an outbound connection*, which the broker tracks itself using [SYS topics](/latest/mqtt-broker/sys-topics) (the built-in SYS hierarchy). Both need monitoring. The data sources are different, so the patterns are different. **Like a roll call at the start of class.** Everyone present says "here" — students through the door (devices), staff through the office intercom (broker SYS topics). After a beat of silence on a name, the teacher knows who's missing. ### When to Reach for This Whenever uptime matters: critical infrastructure, paid telemetry, anything you'd want to dispatch a technician for. Skip it for opportunistic devices where occasional silence is normal (mobile phones, ad-hoc sensors). For routes, this is almost always worth doing — a silent integration is a silent data loss. ### Approach 1: Heartbeats for Devices Each device publishes a timestamp to its own status topic at a regular interval. Then a scheduled Action checks for any device whose last timestamp is too old. If your devices already send data regularly, you can stamp the heartbeat directly from the Action that processes their readings: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION RecordHeartbeat ON TOPIC "sensors/+/+" DO SET "device_id" WITH TOPIC POSITION 2 KEEP TOPIC "state/" + {device_id} + "/last_seen" WITH TIMESTAMP "UTC" ``` Every time any sensor sends any data, this Action stamps a `last_seen` timestamp for that device. The wildcard means it covers every sensor automatically, and `KEEP TOPIC` retains the value internally without broadcasting it. A scheduled Action then wakes up periodically, compares each timestamp to "now," and publishes an alert for anything stale. Time arithmetic is awkward in LoT, so this is a natural place to call a small Python helper (see [Running Python from a LoT Action](/latest/lot-language/usage-patterns/reusable-logic#running-python-from-a-lot-action)): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CheckDeviceLiveness ON EVERY 60 SECONDS DO CALL PYTHON "Liveness.check_all" WITH (300) RETURN AS {result} SET "offline_count" WITH (GET JSON "count" IN {result} AS INT) IF {offline_count} > 0 THEN PUBLISH MODEL Alarm TO "alerts/devices/offline" WITH type = "DEVICES_OFFLINE" severity = "WARNING" value = {offline_count} threshold = 0 timestamp = TIMESTAMP "UTC" ``` The Python function reads every `state/+/last_seen` topic, compares to the current time, and returns the count and list of devices whose last\_seen is older than 300 seconds. ### Approach 2: SYS topics for Routes The broker exposes a live view of itself through [SYS topics](/latest/mqtt-broker/sys-topics) — a built-in dashboard in MQTT form. For monitoring route health, read the JSON array published on the topic below. It lists every client currently connected to the broker, with timestamps and connection details. Routes that maintain MQTT connections (bridges, REST endpoints in client mode, anything with a connected client identity) appear in this list. When one drops, it disappears from the array. ``` $SYS/Coreflux/Comms/Clients ``` The pattern: keep an "expected routes" list in a config topic, subscribe to that clients topic, and have an Action that flags any expected route that isn't in the live connection list. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CheckRoutesOnline ON EVERY 60 SECONDS DO SET "expected" WITH (GET TOPIC "config/expected_routes") SET "clients" WITH (GET TOPIC "$SYS/Coreflux/Comms/Clients") CALL PYTHON "RouteMonitor.find_missing" WITH ({expected}, {clients}) RETURN AS {result} SET "missing_count" WITH (GET JSON "count" IN {result} AS INT) IF {missing_count} > 0 THEN SET "missing_list" WITH (GET JSON "list" IN {result} AS STRING) PUBLISH MODEL Alarm TO "alerts/routes/offline" WITH type = "ROUTES_OFFLINE" severity = "CRITICAL" value = {missing_count} threshold = 0 timestamp = TIMESTAMP "UTC" PUBLISH TOPIC "system/offline_routes" WITH {missing_list} ``` The Python helper does the actual list comparison — straightforward in Python, fiddly in LoT: ```python wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # /scripts/RouteMonitor.py import json def find_missing(expected_csv, clients_json): expected = [name.strip() for name in expected_csv.split(",") if name.strip()] try: clients = json.loads(clients_json) connected_ids = {c.get("Id", "") for c in clients} except (json.JSONDecodeError, TypeError): connected_ids = set() missing = [name for name in expected if name not in connected_ids] return {"count": len(missing), "list": ",".join(missing)} ``` What's happening: 1. The expected route list lives in a config topic — for example, you'd publish `"DatabaseRoute,CloudBridge,WeatherAPI"` to `config/expected_routes`. Operators can update it without redeploying anything. 2. The Action reads the live JSON from `GET TOPIC` on the path above and hands both lists to Python. 3. Python parses the connections, computes the set difference, and returns the count plus a comma-separated list of missing routes. 4. If anything is missing, the Action publishes a critical alarm and a list of which routes are down. ### Combining the Two Approaches A complete monitoring layer runs both Actions side by side: heartbeats catch devices that have stopped reporting even though their network looks fine, and the SYS clients-topic check catches routes that have lost their integration to the outside world. Both publish into the same `alerts/` branch, so a single subscriber — a dashboard, a notification rule, an [email route](#optional-send-the-alarm-by-email) — gives you a unified "what's not working right now" view. Pair either Action with the [state tracking variant](#optional-deduplicate-with-state-tracking) to avoid alert spam: one alert when something disappears, one when it comes back, silence in between. *** ## Next Steps Send alerts and reports to people from MQTT events. Built-in broker topics for monitoring clients and comms. # Anti-patterns Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/anti-patterns Common LoT mistakes and proven alternatives — for authors and reviewers. ## Common LoT mistakes These are the most frequent mistakes in LoT development. Avoid them whether you're writing new code or reviewing existing output. | Anti-Pattern | Why It's Bad | Do This Instead | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Omitting type casts in math | Implicit type handling leads to silent errors | Always cast: `PAYLOAD AS DOUBLE`, `GET TOPIC ... AS DOUBLE` — see [Operations](/latest/lot-language/actions/operations) | | Triggering models on timestamps | Timestamps update every tick — model fires constantly | Trigger on the primary data field: `AS TRIGGER` on value, not timestamp — see [Schema definition](/latest/lot-language/models/schema-definition) | | Inconsistent naming across models | `sensorID` in one model, `sensor_id` in another | Standardize on `snake_case` for all field names — see [Naming conventions](./naming-conventions) | | Using Python for native LoT tasks | Python adds overhead for simple publish/get/if operations | Use Python only for math libraries, ML, API calls, or complex parsing | | Handing out permission flags to open up `$SYS/#` | `$SYS` carries broker commands and sensitive data, and its access is gated by permission flags — a flag granted for convenience opens the whole namespace | Leave the three flags unset on device and operator accounts; the broker's locked `$SYS` rules then deny them. Custom Rules cannot tighten `$SYS/#` further — see [Rules syntax](/latest/lot-language/rules/syntax#system-topics-sys) | | Generic entity names | `Action1`, `Rule1`, `MyRoute` are meaningless in a system with 50 entities | Name by purpose: `MonitorPressure`, `ProtectSysTopics`, `SensorDatabase` | | `PUBLISH` for internal state | Broadcasts data that only your own Actions need | Use `KEEP TOPIC` for internal state; `PUBLISH` for external subscribers — see [Writing reusable logic](./reusable-logic#internal-state-keep-vs-publish) | | Monolithic Actions | One Action doing 15 things is hard to test and debug | Split into callable Actions with `INPUT`/`OUTPUT` — see [One Action, One Job](./reusable-logic#one-action-one-job) | *** ## Next Steps Entity, variable, and topic naming standards. Callables, wildcards, and KEEP vs PUBLISH patterns. # Designing your data layer Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/data-layer Topic trees (UNS), payload formats (scalar vs JSON), Models, and inheritance — patterns for how data is shaped and addressed. ## Designing Your Topic Tree Your topic tree is the backbone of every Coreflux project. It decides how Actions trigger, how Models publish, how Routes map data into databases, and how dashboards subscribe. Get it right early and the system stays clean as it grows. Get it wrong and you'll be untangling spaghetti for years. A **Unified Namespace (UNS)** is the idea that every piece of data in your system has one predictable address — a single MQTT topic — that any consumer can subscribe to without knowing or caring where the data physically came from. The concept applies to any domain: a factory floor, an office tower, a solar park, a city's traffic lights, or a fleet of delivery trucks. **Like a postal address that works anywhere in the world.** "Country / State / City / Street / Building / Apartment" lets a letter find any home, even one the postman has never visited. A UNS does the same for your data — a fresh subscriber can read or write to any device without ever being told it exists. ### When to Reach for This Always — but the deeper hierarchy patterns earn their keep once you have more than a handful of devices, more than one site, or more than one team subscribing to the data. Even a single-device hobby project benefits from a consistent two-level structure. ### Common Principles Across Every UNS Whatever your domain, a good hierarchy answers three questions: 1. **Who owns this data?** Top levels usually identify the organization or operator. 2. **Where does it physically live?** Middle levels mirror the real-world layout — sites, buildings, zones, machines. 3. **What is it?** The leaves identify the actual measurement, status, or event. The deeper you go, the more specific the topic. Subscribers can pick exactly the level they care about and ignore the rest. ### Functional namespaces Organize topics into functional namespaces that reflect the data lifecycle. These prefixes work alongside any domain hierarchy (ISA-95, buildings, fleets): | Namespace | Purpose | Example Topics | | ------------ | -------------------------------------------- | ---------------------------------------------------- | | `sensors/` | Raw incoming sensor data | `sensors/temp001/raw`, `sensors/+/temperature` | | `processed/` | Transformed or enriched data | `processed/temp001/fahrenheit`, `processed/+/status` | | `alerts/` | Threshold violations and notifications | `alerts/critical/pump03`, `alerts/+/temperature` | | `config/` | Configuration values read by Actions | `config/setpoint`, `config/max_temperature` | | `state/` | Internal persistent state (used with `KEEP`) | `state/counter`, `state/last_run` | | `cache/` | Cached values for quick reference | `cache/temp001/last_reading` | | `system/` | Heartbeats, health checks, diagnostics | `system/heartbeat`, `system/status` | | `commands/` | Inbound instructions from external systems | `commands/devices/+/restart` | ### Designing for Your Domain Different domains use different hierarchy patterns. Here are five common ones — pick the closest match to yours and adapt. #### Manufacturing — ISA-95 For factories, the industry standard is **ISA-95**, which divides operations from the enterprise level down to individual machines. The full MQTT path is a single string; the folder view below shows how each **topic level** lines up with **ISA-95** (Enterprise → Site → Area → Line / Cell → Device → **Metric** at the leaf). `acme/dallas/packaging/line1/filler01/temperature` ``` acme/ enterprise └── dallas/ site └── packaging/ area └── line1/ line / cell └── filler01/ device └── temperature metric ``` The ISA-95 levels: **Enterprise / Site / Area / Line / Cell / Device / Metric**. Use it for any project where you have a corporate hierarchy that maps onto a physical plant layout. #### Smart Buildings Buildings have a natural physical hierarchy: campus → building → floor → zone → device. The same operator prefix can branch like a folder tree: ``` acmecorp/ └── headquarters/ ├── floor3/ │ └── conference_a/ │ ├── thermostat/ │ │ └── temperature │ └── lighting/ │ └── state └── garage/ └── level_b1/ └── sensors/ └── co2 ``` Subscribe to `acmecorp/headquarters/floor3/#` to see every reading on the third floor. Subscribe to `acmecorp/+/+/+/thermostat/temperature` to compare temperatures across every conference room in the company. #### Energy and Solar Parks Energy operators typically organize by site, then by physical infrastructure (arrays, strings, inverters). One site can group weather separately from the array: ``` solarop/ └── lisbon-park/ ├── array-a/ │ └── string-3/ │ └── inverter-01/ │ ├── power │ └── voltage └── weather/ └── irradiance windop/ └── north-sea-cluster/ └── turbine-12/ └── rotor_speed ``` A maintenance team subscribes to `solarop/lisbon-park/#` for one park. A fleet operations center subscribes to `solarop/+/+/+/+/power` to monitor every inverter across every park. #### Smart Cities Cities organize by district, then by asset type (since asset categories are the meaningful grouping, not departments): ``` lisboa/ ├── baixa/ │ ├── traffic_lights/ │ │ └── tl-042/ │ │ └── state │ └── parking/ │ └── sensor-117/ │ └── occupied └── parqueeduardo/ └── air_quality/ └── aq-03/ └── pm25 porto/ └── centro/ └── waste_bins/ └── bin-218/ └── fill_level ``` A transport team subscribes to `lisboa/+/traffic_lights/#`. An air quality dashboard subscribes to `+/+/air_quality/#` to see every monitor in every city. #### Vehicle Fleets Fleets organize by operator, region, vehicle type, and individual vehicle: ``` deliveryco/ └── iberia/ ├── vans/ │ └── v-2840/ │ ├── location │ └── fuel_level └── refrigerated/ └── r-118/ └── temperature ``` A regional dispatcher subscribes to `deliveryco/iberia/#`. A predictive-maintenance system subscribes to `+/+/+/+/engine/diagnostic_codes` across every fleet. ### Optional: Data Classification Many UNS designs add a classification segment after the device, splitting **measurements**, **state**, **events**, and **alarms** into separate branches under the same device: ``` acmecorp/ └── headquarters/ └── floor3/ └── conference_a/ └── thermostat/ ├── metrics/ │ └── temperature ├── state/ │ └── mode ├── alarms/ │ └── sensor_fault └── events/ └── setpoint_changed ``` This makes it trivial to subscribe to "all alarms in the building" (`acmecorp/headquarters/+/+/+/alarms/#`) without picking up a flood of ordinary readings. Use it when your project has clearly different *kinds* of data per device. ### Don't Over-Engineer Small Projects A single-room weather station or a hobby solar setup doesn't need six levels of hierarchy. Even a flat `sensors//` is fine until you have enough scale to need more. Pick a structure that fits today's complexity, and leave room to grow. *** ## Choosing Data Formats Your topic tree answers **where** data lives. The next decision is **what shape** each message carries — a single value on the payload, a JSON object, structured JSON produced by a **Model**, or **binary protobuf**. These choices are independent: you can mix scalar topics, JSON ingress, Model output, and protobuf edge topics in the same project. **Like labels on shipping boxes.** The address (topic) tells you which shelf it belongs on; the box contents (payload) tell you what's inside. Sometimes one number on the label is enough; sometimes you need a packing list (JSON); sometimes you standardize the box size (Model) so every downstream handler knows what to expect. ### Scalar payloads (value only) Use a **plain payload** — one number, string, or boolean with no JSON wrapper — when the **topic name already identifies** the measurement and you only ever send one value per message. | Best for | Example topic | Payload | In Actions | | -------------------------------------------------------- | ---------------------------------------- | ------------- | ------------------- | | PLC registers, simple sensors, Modbus/OPC-UA mapped tags | `acme/dallas/line1/filler01/temperature` | `23.5` | `PAYLOAD AS DOUBLE` | | Status flags, on/off | `.../running` | `true` or `1` | `PAYLOAD AS BOOL` | | Commands with a single argument | `commands/device/restart` | `now` | `PAYLOAD AS STRING` | ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CheckHighTemperature ON TOPIC "acme/dallas/+/+/+/temperature" DO SET "temp" WITH PAYLOAD AS DOUBLE IF {temp} > 80 THEN PUBLISH TOPIC "alerts/high_temperature" WITH {temp} ``` **Practices:** * Put meaning in the **topic path** (site, device, metric) so subscribers do not have to parse the body. * Prefer scalar leaves under `processed/` or your UNS metric level when data is already normalized at the edge. * Avoid cramming multiple measurements into one topic without JSON — use separate topics or switch to JSON. ### JSON payloads (structured body) Use **JSON** when one MQTT message carries **several fields**, nested data, or metadata (timestamps, units, quality flags) that do not belong in the topic string. | Best for | Example topic | Payload shape | | -------------------------------------- | ----------------------------- | ----------------------------------------------------- | | Devices, gateways, REST/webhook Routes | `sensors/temp001/telemetry` | `{"temperature":23.5,"humidity":65,"unit":"celsius"}` | | Partner APIs with variable schemas | `integrations/partner/events` | Nested objects and arrays | In **Actions**, extract fields with [`GET JSON`](/latest/lot-language/actions/operations#get-json). For nested paths, missing keys, and publishing cleaned JSON from an Action, see **[Working with JSON](/latest/lot-language/usage-patterns/working-with-json)** — that page is the hands-on guide; this section is about when to choose JSON at the data-layer level. **Practices:** * Keep **raw JSON** on ingress topics (`sensors/`, `integrations/`) and publish **normalized** data to `processed/` (scalar or Model output) so dashboards and databases see a stable shape. * Always cast types in Actions (`AS DOUBLE`, `AS STRING`) — do not rely on implicit conversion. * If many consumers need the same JSON schema, promote the shape with a Model instead of rebuilding JSON manually in every Action. ### Protobuf payloads (binary body) Use **protobuf** when a gateway or SCADA system already speaks a `.proto` on MQTT. Decode with [`GET PROTO`](/latest/lot-language/actions/operations#get-proto) and emit bytes with `PUBLISH MODEL` on a `WITH FORMAT PROTOBUF` model. Tag topics in the middle can stay JSON. Guide: [Protobuf Models](/latest/lot-language/models/protobuf). ### Models (typed, consistent output) A **Model** defines the JSON schema your broker publishes — field names, types, and triggers. Use Models when **multiple subscribers** (Routes, dashboards, other Actions) must see the **same structure** every time. | Approach | When to use | How it publishes | | ---------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | **Triggered Model** (`WITH TOPIC` + `AS TRIGGER`) | One device type, automatic publish when a source field updates | Broker builds JSON when the trigger field changes | | **COLLAPSED Model** + `PUBLISH MODEL` from an Action | You control timing, destination, or merge JSON + scalar sources | Action decides when and where to publish | | **Base + `FROM` inheritance** | Families of devices share most fields | See [Standardizing data across device types](#standardizing-data-across-device-types) below | Triggered example — raw scalar in, structured JSON out: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorReading WITH TOPIC "acme/dallas/line1/filler01/formatted" ADD STRING "device_id" WITH "filler01" ADD DOUBLE "temperature" WITH TOPIC "acme/dallas/line1/filler01/temperature" AS TRIGGER ADD STRING "unit" WITH "celsius" ADD STRING "timestamp" WITH TIMESTAMP "UTC" ``` Action-driven example — JSON in, COLLAPSED Model out (full walkthrough in [Working with JSON](/latest/lot-language/usage-patterns/working-with-json#end-to-end-extract-json-and-publish-a-model)): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ProcessedSensorReading COLLAPSED ADD STRING "device_id" ADD DOUBLE "temperature" ADD STRING "unit" ADD STRING "timestamp" DEFINE ACTION NormalizeTelemetry ON TOPIC "sensors/+/telemetry" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) PUBLISH MODEL ProcessedSensorReading TO "processed/" + {sensor_id} WITH device_id = {sensor_id} temperature = {temp} unit = "celsius" timestamp = TIMESTAMP "UTC" ``` **Practices:** * Use **scalar topics** for simple ingestion; use **Models** for the contract your analytics and APIs depend on. * Do not hand-build JSON strings in Actions when `PUBLISH MODEL` can do it — fewer quoting bugs and consistent field names. * Mark the **primary measurement** as `AS TRIGGER`, not timestamps or metadata ([schema definition](/latest/lot-language/models/schema-definition)). * Exploding incoming JSON into per-field topics (Models overview) helps when other Models or Actions need single fields without `GET JSON` on every step — see [Models overview](/latest/lot-language/models/overview). ### How the three formats work together A common pipeline: 1. **Ingress** — device sends JSON to `sensors/+/telemetry` (or scalar to `.../temperature`). 2. **Action** — optional `GET JSON`, validation, unit conversion. 3. **Model** — publishes stable JSON to `processed/+/formatted` (or triggered Model from scalar sources). 4. **Route** — stores `processed/#` or specific leaves in a database. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR device[Device or Route] rawTopic[Raw topic scalar or JSON] action[Action optional] model[Model] processed[processed/ topics] consumers[Dashboards Routes Actions] device --> rawTopic rawTopic --> action rawTopic --> model action --> model model --> processed processed --> consumers ``` | If your payload is… | Prefer | Avoid | | ------------------------------ | ------------------------------ | -------------------------------------------------------------- | | One number per topic | Scalar + topic semantics | JSON with a single `"value"` field unless the device forces it | | Multiple fields per message | JSON ingress + Action or Model | Parsing JSON with string splits in Actions | | Same schema for many consumers | Model on `processed/` | Copy-pasting JSON templates in five Actions | | Variant device types | Base Model + `FROM` | Separate unrelated schemas per device | *** ## Standardizing Data Across Device Types When several devices in your system share most of their data fields but each has a few extras, define the shared fields in a **base Model**, then extend it for each variant. A LoT Model is essentially a typed schema that publishes structured JSON onto a topic. Inheritance works exactly like in object-oriented programming — the base holds what's common, the children add what's specific. Full reference: [Model Inheritance](/latest/lot-language/models/model-inheritance). **Like a base recipe for bread dough.** Sourdough, focaccia, and pizza all start from the same base — they just add their own toppings. Improve the base recipe, and every variant gets the improvement automatically. ### When to Reach for This When your system has more than one *kind* of device that mostly produces the same data — pumps and chillers, vans and trucks, conference rooms and labs. The break-even point is around three variants: at two it's still tempting to copy-paste, at three the inheritance pays off, at five it's the only sane way. ### The Pattern A smart-building example: every room in the building has temperature, humidity, and occupancy sensors. But conference rooms also need to track presentation equipment, and labs need extra safety monitoring. Start with the shared base — it's `COLLAPSED`, meaning it doesn't publish on its own and exists only to be inherited: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BaseRoom COLLAPSED ADD STRING "room_id" ADD DOUBLE "temperature" ADD DOUBLE "humidity" ADD INT "occupancy" ADD STRING "status" ADD STRING "timestamp" ``` A standard office uses the base directly. Specialized rooms extend it using `FROM`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ConferenceRoom FROM BaseRoom ADD BOOL "projector_on" ADD BOOL "video_call_active" ADD INT "scheduled_meetings" DEFINE MODEL Lab FROM BaseRoom ADD BOOL "fume_hood_running" ADD DOUBLE "co2_ppm" ADD STRING "hazmat_status" ``` `ConferenceRoom` automatically has all six fields from `BaseRoom` plus three of its own. `Lab` gets the six base fields plus three lab-specific ones. If you later add a `last_cleaned` field to `BaseRoom`, every child Model gains it without any edits. **Two rules of thumb for designing the base:** * Put a field in the base only if **every variant** truly needs it. Identifiers, timestamps, status, and core measurements belong in the base. Type-specific fields belong in the children. * Don't put `AS TRIGGER` on a base Model field. The base exists to be inherited — it's the children that decide when and how they publish, either through their own trigger or via `PUBLISH MODEL` from an Action. The payoff: every team consuming room data — facilities dashboards, energy management, AI agents — sees a consistent shape. They can rely on `room_id`, `temperature`, and `occupancy` being there for any room, and only need to handle the extra fields for variants they actually care about. The same pattern fits anywhere you have a family of related devices: `BaseInverter` with grid-tied and hybrid variants in solar, `BaseSensor` with temperature, vibration, and pressure variants in industrial, `BaseVehicle` with van, truck, and refrigerated variants in fleets. For JSON-heavy ingress before you standardize with Models, see [Choosing data formats](#choosing-data-formats) and [Working with JSON](/latest/lot-language/usage-patterns/working-with-json). *** ## Next Steps GET JSON patterns, nested payloads, and PUBLISH MODEL workflows. Deep dive on `FROM`, base models, and extending schemas. # Connecting to the outside world Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/integrations REST server and client Routes, database KPIs, and ENV/Secrets for integrations. ## Letting Web Apps Send Data to MQTT Sometimes the data you need isn't coming from a sensor — it's coming from a web app, a mobile app, a partner's webhook, or a system that only speaks HTTP. Coreflux's `REST_API` route can run in **server mode**, exposing an HTTP endpoint on the broker itself. When something POSTs to that endpoint, the route publishes the body to an MQTT topic, just like a sensor message. **Like leaving a drop box at the front door.** Anyone with the right key can drop a package in, and once it's inside, it's handled the same way as everything else. ### When to Reach for This * A web dashboard or mobile app needs to push commands or readings without implementing an MQTT client * A third-party service (Stripe, GitHub, Slack, your CRM, an IoT vendor's cloud) supports HTTP webhooks but not MQTT * A legacy system can call HTTP endpoints but doesn't speak MQTT * You're behind a firewall that allows HTTP but not MQTT ### The Pattern ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WebhookIngest WITH TYPE REST_API ADD REST_API_CONFIG WITH ENABLE_SERVER "true" WITH SERVER_PORT '8080' WITH USERNAME GET ENV "WEBHOOK_USER" WITH PASSWORD GET SECRET "WEBHOOK_TOKEN" WITH ENABLE_CORS "true" ADD EVENT ReceiveSensorReading WITH ENDPOINT "/api/sensors" WITH METHOD "POST" WITH DESTINATION_TOPIC "sensors/from/http" ``` How it works: 1. The route opens an HTTP server on port 8080. 2. When something POSTs to `http://your-broker:8080/api/sensors` with the right Basic Auth credentials, the body of the request is published to `sensors/from/http`. 3. From there, any Action subscribed to that topic processes it like a normal MQTT message — type-cast, normalize, publish into your UNS, store in the database. A web app sending a reading would do something like: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} curl -X POST http://broker.example.com:8080/api/sensors \ -u "$WEBHOOK_USER:$WEBHOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sensor_id": "temp-01", "value": 25.5}' ``` And an Action on the broker normalizes it into the UNS: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION RouteWebhookReading ON TOPIC "sensors/from/http" DO SET "sensor_id" WITH (GET JSON "sensor_id" IN PAYLOAD AS STRING) SET "value" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE) PUBLISH TOPIC "acmecorp/headquarters/floor3/sensors/" + {sensor_id} + "/temperature" WITH {value} ``` That's the whole pattern. The web app doesn't need an MQTT library, the broker doesn't need an external middleware service, and the credentials sit safely in `ENV` and `SECRET`. Use the same approach for receiving CRM events, payment notifications, AI service callbacks, or anything else the outside world wants to push to you. *** ## Pulling Data from External APIs The mirror image of webhook ingestion: when *you* need to call out to a third-party API. The same `REST_API` route in **client mode** turns an MQTT message into an outbound HTTP request, then publishes the response back to MQTT. Combined with `GET ENV` and `GET SECRET`, the credentials never appear in the route definition. **Like a vending machine.** Drop the right token in (an MQTT request), and the answer pops out a slot below (an MQTT response). ### When to Reach for This Whenever a Coreflux system needs information that lives outside it: weather forecasts for HVAC control, exchange rates for billing, geocoding for a fleet, traffic data for routing, AI/ML inference, public data feeds. ### The Pattern Weather data is useful in nearly every domain — solar parks need irradiance forecasts, smart buildings adjust HVAC for outdoor temperature, fleets reroute around storms, agriculture schedules irrigation. Here's a route that calls a weather API. The base URL and credentials come from environment variables and secrets, so the same route works in dev and prod: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE WeatherAPI WITH TYPE REST_API ADD REST_API_CONFIG WITH BASE_ADDRESS GET ENV "WEATHER_API_URL" WITH ENABLE_CLIENT "true" WITH USE_SSL "true" WITH USERNAME GET ENV "WEATHER_API_USER" WITH PASSWORD GET SECRET "WEATHER_API_KEY" WITH REQUEST_TIMEOUT '30' ADD EVENT GetCurrentWeather WITH SOURCE_TOPIC "weather/request" WITH DESTINATION_TOPIC "weather/response" WITH METHOD "GET" WITH ENDPOINT "/v1/current?location={value.json.location}" ``` How it works: 1. Some Action publishes a JSON message like `{"location": "Lisbon"}` to `weather/request`. 2. The route picks it up, builds the URL with the location filled in, and fires an authenticated HTTP GET. 3. The API responds with the weather data. 4. The route publishes that response to `weather/response`. Trigger it from any Action — say, every fifteen minutes: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION FetchWeather ON EVERY 15 MINUTES DO PUBLISH TOPIC "weather/request" WITH '{"location":"Lisbon"}' ``` Then handle the response in another Action that subscribes to `weather/response`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION StoreWeather ON TOPIC "weather/response" DO SET "temp" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) SET "humidity" WITH (GET JSON "humidity" IN PAYLOAD AS DOUBLE) PUBLISH TOPIC "processed/weather/temperature" WITH {temp} PUBLISH TOPIC "processed/weather/humidity" WITH {humidity} ``` The HTTP credentials sit encrypted in `secrets.json`, the API URL sits in `.env`, and the Route definition stays portable across deployments. If the API key rotates, you update the secret and nothing else changes. *** ## Live KPIs from a Database Database routes are not just for inserting data. The same `EVENT` syntax supports `SELECT` queries — either triggered by an MQTT message or run on a fixed schedule. This is how you turn a historian into a live KPI feed: one query, repeated automatically, results published as ordinary MQTT messages that any consumer can subscribe to. **Like a live scoreboard at a stadium.** The board updates every minute with fresh totals, and everyone in the stadium sees the same numbers without having to do the math themselves. ### When to Reach for This When you need rolling KPIs (averages, counts, peaks) that consumers should subscribe to like any other topic, or when you need on-demand historical lookups (last hour of readings for a device) triggered by an MQTT request. Skip it for one-off ad-hoc queries — for those, just query the database directly. ### Scheduled Queries: Live Aggregates A common need in solar operations: publish the **average power output of the last 60 seconds** every minute, for every inverter, with no polling code anywhere. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE InverterKpis WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER GET ENV "DB_HOST" WITH PORT GET ENV "DB_PORT" WITH DATABASE "energy_data" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ADD EVENT AvgPowerLast60s WITH EVERY 60 SECONDS WITH DESTINATION_TOPIC "kpi/inverters/avg_power_60s" WITH QUERY "SELECT inverter_id, AVG(power_kw) AS avg_power FROM inverter_readings WHERE ts > NOW() - INTERVAL '60 seconds' GROUP BY inverter_id" ``` What happens here: 1. Every 60 seconds, the route automatically runs the SQL query against your historian. 2. The result set is serialized as JSON and published to `kpi/inverters/avg_power_60s`. 3. Anyone listening — a dashboard, an Action, an AI agent — sees a fresh KPI every minute as a regular MQTT message. No polling code, no glue script, no scheduler to maintain. The SQL is the schedule. ### On-Demand Queries: Historical Lookups For queries that should run when *asked* rather than on a clock, swap `WITH EVERY` for `WITH SOURCE_TOPIC`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT GetInverterHistory WITH SOURCE_TOPIC "kpi/request/history" WITH DESTINATION_TOPIC "kpi/result/history" WITH QUERY "SELECT ts, power_kw FROM inverter_readings WHERE inverter_id = '{payload}' AND ts > NOW() - INTERVAL '1 hour' ORDER BY ts" ``` Now publishing `inv-01` to `kpi/request/history` returns the last hour of readings on `kpi/result/history`. Operators, AI agents, or other systems can pull historical context on demand. **The pattern at a glance:** raw data lives in the database, KPIs and historical lookups are exposed as MQTT topics. Whether you're tracking solar inverters, traffic light timings, or refrigerated truck temperatures, your dashboards and downstream consumers don't care where the data physically lives — they just subscribe to topics like everything else. *** ## Keeping Credentials Out of Your Code Never hardcode database passwords, API keys, or broker credentials in your code. Coreflux gives you two runtime stores — one for ordinary configuration, one for secrets that need to be encrypted. **Like the difference between leaving your keys taped to the front door versus keeping them in a safe.** ENV is the wallet you carry openly (which city, which port, which feature flag). Secrets is the safe you only open when you need it (passwords, API keys, tokens). ### When to Reach for This The first time you deploy outside your laptop — or earlier if you're committing your notebook to git from day one. Get the habit before you have any real credentials in flight; retrofitting `GET SECRET` into a working system is more painful than starting with it. ### The Two Operations * `GET ENV "NAME"` reads from a managed `.env` file. Use it for non-sensitive configuration: hostnames, ports, region tags, feature flags. Safe to log. * `GET SECRET "NAME"` reads from `secrets.json`, which is encrypted at rest with AES-256-GCM. Use it for anything sensitive: passwords, API keys, tokens. Never logged. Both can be referenced directly inside Route configurations, which is exactly where you need them most: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ProductionDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER GET ENV "DB_HOST" WITH PORT GET ENV "DB_PORT" WITH DATABASE GET ENV "DB_NAME" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" WITH USE_SSL "true" ADD EVENT StoreSensorReading WITH SOURCE_TOPIC "sensors/+/reading" WITH QUERY "INSERT INTO readings (ts, sensor_id, value) VALUES (NOW(), '{sensor_id}', '{value.json}')" ``` The same Route definition now works in development, staging, and production — only the `.env` file and `secrets.json` differ between environments. To register a secret, run this once from a notebook or the broker console: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP SECRET "DB_PASSWORD" WITH "your-actual-password" ``` After that, the password lives encrypted on the broker. Your route definitions, your git history, your AI assistant transcripts, and your logs all stay clean. *** ## Next Steps Client and server mode, endpoints, and authentication. `.env`, `secrets.json`, and secure deployment. # Naming conventions Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/naming-conventions PascalCase entities, snake_case variables and fields, and topic naming basics for maintainable LoT systems. ## Why naming matters Consistent naming is the single most impactful practice for maintainable LoT systems. These conventions apply to all LoT entities and keep notebooks readable as your project grows. **Like street names in a city.** Predictable patterns let anyone find an address without a map — and let AI assistants generate code that matches your project. *** ## Entity names All LoT entities use **PascalCase**. Names should be descriptive and purpose-driven. | Entity | Convention | Good Examples | Bad Examples | | ---------------- | ----------------------------- | ---------------------------------------------------------------- | ------------------------------------- | | Actions | PascalCase, verb-first | `ProcessTemperature`, `MonitorPressure`, `SendDailyReport` | `temp_process`, `action1`, `myAction` | | Models | PascalCase, noun-based | `SensorReading`, `EquipmentStatus`, `ProductionRecord` | `sensor_model`, `Model1`, `data` | | Rules | PascalCase, descriptive scope | `AllowAdminActions`, `ProtectSysTopics`, `RestrictDevicePublish` | `Rule1`, `myRule`, `newRule` | | Routes | PascalCase, destination-based | `CloudBridge`, `SensorDatabase`, `AlertEmail` | `route1`, `my_route`, `dbRoute` | | Callable Actions | PascalCase, function-like | `CalculateAverage`, `ConvertCelsiusToFahrenheit` | `calc`, `helper`, `util` | *** ## Variable names Variables use **snake\_case** inside double quotes for `SET` declarations, and **curly braces** for references. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessSensorData ON TOPIC "sensors/+/raw" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "raw_value" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE) SET "converted_value" WITH ({raw_value} * 1.8 + 32) PUBLISH TOPIC "processed/" + {sensor_id} + "/fahrenheit" WITH {converted_value} ``` | Context | Convention | Examples | | ------------------- | ----------------------------- | ---------------------------------------------------- | | Declaration (`SET`) | `snake_case` in double quotes | `"sensor_id"`, `"raw_value"`, `"cycle_time"` | | Reference | Curly braces | `{sensor_id}`, `{raw_value}`, `{cycle_time}` | | Model fields | `snake_case` in double quotes | `"equipment_id"`, `"runtime_hours"`, `"last_update"` | | Action inputs | `snake_case` after keyword | `INPUT value AS DOUBLE`, `INPUT threshold AS DOUBLE` | *** ## Topic names Topics use **lowercase with forward-slash separators**. Multi-word segments use **snake\_case**. Whenever possible, follow a **Unified Namespace (UNS)** format for topic naming. This enables readability and scalability as systems grow. | Pattern | Example | Use Case | | --------------------------- | ----------------------------------------------------------------- | ---------------------- | | `domain/entity/attribute` | `sensors/temperature/value` | General data | | `domain/instance/attribute` | `sensors/temp001/raw` | Instance-specific data | | `domain/category/instance` | `alerts/critical/pump03` | Categorized events | | Prefixed namespaces | `processed/`, `alerts/`, `config/`, `cache/`, `state/`, `system/` | Functional separation | For hierarchy design, functional namespaces, and domain-specific UNS patterns, see [Designing your data layer](./data-layer). *** ## Next Steps Topic trees, payload formats, and model inheritance. Common mistakes and what to do instead. # Production architecture Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/production-architecture End-to-end ingestion and edge-to-cloud bridges — synthesis patterns that pull everything together. ## From Device to Database, End to End Putting the patterns together, a full ingestion pipeline has four stages, each one a single LoT entity. This is the canonical recipe for "get data from a real device, normalize it, and store it for analytics." **Like a kitchen production line.** The delivery comes in (sensor), the chef cleans and preps (Action), the dish is plated to the same standard every time (Model), and the waiter takes it to the table (Database). ### When to Reach for This The first time you need persistent, queryable history of your data — usually somewhere between "it works on my laptop" and "we need to show this to the customer." It's also the natural foundation for [Live KPIs from a Database](/latest/lot-language/usage-patterns/integrations#live-kpis-from-a-database) and any reporting or analytics layer you'll add later. ### The Worked Example A smart-building HVAC chiller, communicating via Modbus, normalized into a unified schema, and stored in PostgreSQL. #### Stage 1 — Industrial Route Ingests from the Device Pick the route type that matches your protocol: `MODBUS` for HVAC controllers, `OPCUA` for PLCs, `S7` for Siemens, `ALLEN_BRADLEY` for Rockwell, `BACNET` for building automation. Map each register or tag to a UNS topic. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ChillerGateway WITH TYPE MODBUS ADD MODBUS_CONFIG WITH HOST GET ENV "CHILLER_HOST" WITH PORT '502' WITH UNIT_ID '1' ADD MAPPING coolingPower WITH REGISTER '40001' WITH DATA_TYPE "FLOAT32" WITH DESTINATION_TOPIC "acmecorp/headquarters/floor3/hvac/chiller-01/metrics/cooling_power" ``` #### Stage 2 — Action Processes and Normalizes Type-cast, scale, validate, then call a Model to produce structured output. A wildcard trigger means the same Action handles every chiller in every building. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION NormalizeChillerReading ON TOPIC "acmecorp/+/+/hvac/+/metrics/cooling_power" DO SET "building" WITH TOPIC POSITION 2 SET "floor" WITH TOPIC POSITION 3 SET "device_id" WITH TOPIC POSITION 5 SET "raw_kw" WITH PAYLOAD AS DOUBLE PUBLISH MODEL ChillerReading TO "acmecorp/" + {building} + "/" + {floor} + "/hvac/" + {device_id} + "/processed" WITH device_id = {device_id} building = {building} cooling_power_kw = {raw_kw} timestamp = TIMESTAMP "UTC" ``` #### Stage 3 — Model Standardizes the Schema One JSON shape for every chiller in every building. Add fields here once, every consumer downstream sees them. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL ChillerReading COLLAPSED ADD STRING "device_id" ADD STRING "building" ADD DOUBLE "cooling_power_kw" ADD STRING "timestamp" ``` #### Stage 4 — Database Route Persists A single insert per published reading. Credentials come from `ENV` and `SECRET` so the route is portable between environments. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE ChillerHistorian WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER GET ENV "DB_HOST" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" WITH DATABASE "building_data" ADD EVENT StoreChillerReading WITH SOURCE_TOPIC "acmecorp/+/+/hvac/+/processed" WITH QUERY "INSERT INTO chiller_readings (ts, device_id, building, cooling_power_kw) VALUES ('{value.json.timestamp}', '{value.json.device_id}', '{value.json.building}', {value.json.cooling_power_kw})" ``` Four entities, one pipeline. Every chiller in every building flows the same way. Adding a new chiller means adding a Mapping in the gateway route — nothing else changes. Adding a new field to the schema means adding it to `ChillerReading` and updating the SQL — all the Actions stay untouched. The same four-stage recipe fits any vertical: a solar inverter via Modbus, a traffic light controller via REST, a delivery van via cellular MQTT. Different devices, different protocols, same shape. *** ## Splitting Edge from Cloud with a Bridge In a typical production setup, the **edge broker** runs on-site and handles all the high-frequency, device-facing work — every reading, every command, every alarm. The **cloud broker** sits centrally and only sees what the business needs to see — minute aggregates, alerts, cross-site rollups. An `MQTT_BRIDGE` route is what selectively forwards data between the two. **Like a local newspaper sending a daily summary to head office.** The full story stays in the newsroom; only the headlines and totals make the trip to corporate. Cheaper, safer, faster. ### When to Reach for This | Reason | What it gives you | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Bandwidth and cost** | Raw sensor data at 1 Hz from 100 devices is a lot. Aggregating to one summary per minute can shrink uplink traffic by 99% — and your cloud bill with it. | | **Edge processing speed** | Local alarms react in milliseconds without waiting for a cloud round-trip. Even if the WAN goes down, the edge keeps running. | | **Resilience** | If the cloud connection drops, the edge buffers and reconnects automatically. Operations never stop because of a flaky uplink. | | **Data and security boundary** | Sensitive raw data never leaves the site. Only the summarized, business-relevant information crosses the boundary. | | **Multi-site rollups** | The cloud sees every site through the same shape (`lisbon-park/...`, `madrid-park/...`), enabling cross-site analytics and dashboards without changing edge logic. | This is the right pattern when you have **multiple sites**, **strict bandwidth or cost limits**, **regulatory or security requirements** to keep raw data local, or **devices that must keep operating during cloud outages**. For a single-site project with a fast and cheap connection, a single broker is simpler. ### What's Where: A Concrete Example Imagine an edge broker at a solar park with multiple inverters. Each inverter publishes voltage, current, and power readings several times a second. Here's what each broker actually contains: | Topic on **edge broker** (at the park) | What it is | Goes to cloud? | | -------------------------------------- | -------------------------------------- | -------------- | | `inverters/inv-01/voltage` | Raw reading every second | ❌ Stays local | | `inverters/inv-01/current` | Raw reading every second | ❌ Stays local | | `inverters/inv-01/power` | Raw reading every second | ❌ Stays local | | `inverters/inv-02/power` | Raw reading from another inverter | ❌ Stays local | | `state/inv-01/last_seen` | Internal state via `KEEP TOPIC` | ❌ Stays local | | `aggregates/inv-01/1min` | Computed every 60 seconds by an Action | ✅ Forwarded | | `aggregates/inv-02/1min` | Same, for the second inverter | ✅ Forwarded | | `alerts/inv-01/low_output` | Threshold alarm | ✅ Forwarded | | Topic on **cloud broker** (after bridge) | Source | | ---------------------------------------- | ------------------------------- | | `lisbon-park/aggregates/inv-01/1min` | Forwarded from the Lisbon edge | | `lisbon-park/aggregates/inv-02/1min` | Forwarded from the Lisbon edge | | `lisbon-park/alerts/inv-01/low_output` | Forwarded from the Lisbon edge | | `madrid-park/aggregates/inv-A/1min` | Forwarded from a different site | | ... | (one prefix per park) | The cloud sees a clean, aggregated, multi-site view. The edge keeps everything raw, locally and privately. ### Step 1: Create the Aggregate at the Edge An Action on the edge computes a summary every minute, per inverter. This is what gets sent up. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PublishMinuteAggregate ON EVERY 60 SECONDS DO SET "inverter_id" WITH "inv-01" SET "avg_power" WITH (GET TOPIC "cache/" + {inverter_id} + "/power_avg" AS DOUBLE) SET "peak_power" WITH (GET TOPIC "cache/" + {inverter_id} + "/power_peak" AS DOUBLE) PUBLISH MODEL InverterAggregate TO "aggregates/" + {inverter_id} + "/1min" WITH inverter_id = {inverter_id} avg_power_kw = {avg_power} peak_power_kw = {peak_power} timestamp = TIMESTAMP "UTC" ``` (The `cache/` topics get filled by other Actions that consume the raw `inverters/` stream — kept internal to the edge using `KEEP TOPIC`.) ### Step 2: Define the Bridge Three topic mappings. Aggregates and alerts go up; commands come down. Everything else stays local. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE EdgeToCloud WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS GET ENV "CLOUD_BROKER_HOST" WITH BROKER_PORT '8883' WITH CLIENT_ID GET ENV "EDGE_SITE_ID" WITH USERNAME GET ENV "CLOUD_BROKER_USER" WITH PASSWORD GET SECRET "CLOUD_BROKER_PASSWORD" WITH USE_TLS "true" ADD MAPPING aggregateUplink WITH SOURCE_TOPIC "aggregates/#" WITH DESTINATION_TOPIC "lisbon-park/aggregates/#" WITH DIRECTION "out" ADD MAPPING alertUplink WITH SOURCE_TOPIC "alerts/#" WITH DESTINATION_TOPIC "lisbon-park/alerts/#" WITH DIRECTION "out" ADD MAPPING commandsDownlink WITH SOURCE_TOPIC "lisbon-park/commands/#" WITH DESTINATION_TOPIC "commands/#" WITH DIRECTION "in" ``` The site prefix (`lisbon-park`) is added on the cloud side, so multiple parks — or buildings, or cities, or fleet regions — can coexist on the same cloud broker without topic collisions. ### Complementary Patterns on the Cloud Once the cloud is receiving aggregates and alerts as MQTT topics, you can compose more patterns on top of it: * **Cloud database route for central analytics.** Add a PostgreSQL or CrateDB route on the cloud broker that subscribes to `+/aggregates/#` and inserts into a multi-site table. Now you have one historian with data from every site, queryable for trends, capacity planning, and cross-park benchmarks. The exact same `EVENT ... QUERY` pattern from the [Live KPIs from a Database](/latest/lot-language/usage-patterns/integrations#live-kpis-from-a-database) section works here. * **Cloud dashboards.** Coreflux HUB Dashboards on the cloud broker subscribe to `+/aggregates/#` and `+/alerts/#` to give corporate or operations teams a unified view of every site. * **Cross-site KPIs.** A scheduled `EVENT` on the cloud's database can publish company-wide KPIs (`SELECT AVG(...) GROUP BY site_id`) as MQTT topics, just like a single-site KPI — but rolled up across the whole portfolio. * **AI / MCP routes on the cloud.** Plug an `AGENT` route into the cloud broker so an AI assistant can answer questions about the entire fleet without ever needing to touch the edge. The bridge is the boundary; everything above it is business; everything below it is operations. Each side can evolve independently. *** ## Next Steps Historians, inserts, and scheduled queries across database types. REST, KPIs, and credentials from this guide. # Project organization Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/project-organization Keeping LoT notebooks clean, readable, and reviewable — single-file vs multi-file layouts. ## Keeping Notebooks Clean and Readable A well-organized notebook is living documentation. Six months from now, a teammate (or you) should be able to open the file and immediately understand what every cell does and why. **Like a well-kept recipe binder.** Each chapter has a clear theme, every recipe has a one-line description above the ingredients, and you can find anything in seconds because nothing's jumbled together. ### When to Reach for This From day one, regardless of project size. Good notebook discipline is cheap to start and expensive to retrofit — by the time your single notebook is hard to navigate, splitting it up means re-reading every cell to figure out where it belongs. ### Single File or Multiple Files? There's no single right answer — it's a project preference. Both approaches are valid, and both can produce clean, maintainable systems. | Approach | Best for | Pros | Trade-offs | | ------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | **Single file** | Small projects, demos, single-concern systems, getting started | Easy to share and review, everything in one place, simple to deploy as a unit | Long files take longer to navigate, harder to deploy parts selectively, awkward for teams editing in parallel | | **Multiple files in a folder** | Larger projects, multi-concern systems, team collaboration | Easy to find specific concerns, parallel work without conflicts, selective deployment per file | More files to track, requires a clear naming convention | A small smart-thermostat demo or a single-greenhouse irrigation controller probably belongs in one notebook. A multi-site solar park monitoring platform with industrial routes, normalization, alarms, and storage usually wants the folder layout. When in doubt, start single-file and split when it stops feeling comfortable. The discipline below applies to both approaches. ### Cell Discipline (Applies Either Way) A few habits make notebooks self-explanatory regardless of how many files you have: * **One markdown cell before every LoT cell.** State what the next cell does, why it exists, and what topics it reads or publishes. The markdown cell is the contract; the LoT cell is the implementation. * **One definition per LoT cell.** One Action, one Model, or one Route per cell — never bundled. This makes selective re-deployment trivial and keeps execution traces readable. * **Section headers as standalone markdown cells.** Use `##` headers to break content into logical sections (`## Inverter Models`, `## Aggregation Actions`, `## Alarm Logic`). VS Code's outline view turns these into a clickable table of contents within the file. * **An intro markdown cell at the top.** What's in this file, what other notebooks (if any) it depends on, what topics it owns. ### Single-File Layout When everything lives in one notebook, lean on section headers to keep it navigable: ``` my-thermostat-demo.lotnb ├── # Smart Thermostat Demo (intro markdown cell) ├── ## Models │ └── (LoT cells: each Model in its own cell, with markdown above) ├── ## Routes │ └── (LoT cells: each Route in its own cell) ├── ## Processing Actions │ └── (LoT cells: one Action per cell) └── ## Alarm Actions └── (LoT cells: alarm logic with markdown context) ``` Read top to bottom: it's a self-contained tour of your system. ### Multi-File Layout For larger projects, one notebook per concern. Numbering reflects the deploy order — define Models before the Actions that publish them, define Routes before the Actions that trigger them. ``` my-solar-park/ ├── 00_overview.lotnb // What this project does, who it's for ├── 01_models.lotnb // All Model definitions ├── 02_routes_ingestion.lotnb // Industrial routes, REST clients ├── 03_actions_processing.lotnb // Normalization, calculations ├── 04_actions_alarms.lotnb // Threshold detection, alerts ├── 05_routes_storage.lotnb // Database and bridge routes ├── 06_rules.lotnb // Access control ├── AGENTS.md // AI assistant conventions └── README.md // How to deploy ``` Inside each file, the same cell discipline applies: section headers, markdown before each LoT cell, one definition per cell. ### What This Looks Like in Practice A snippet from a Models notebook (or the `## Models` section of a single-file project): > ## Inverter Models > > Standard schemas for every inverter in the fleet. The base model `BaseInverter` is the contract — every inverter variant inherits from it. Used by the processing actions and stored by the historian route. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL BaseInverter COLLAPSED ADD STRING "inverter_id" ADD DOUBLE "power_kw" ADD DOUBLE "voltage" ADD STRING "timestamp" ``` > Hybrid inverter variant — adds battery state for sites with on-site storage. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL HybridInverter FROM BaseInverter ADD DOUBLE "battery_soc" ADD DOUBLE "battery_power_kw" ``` Each LoT cell has a markdown cell right above it explaining the *why*. Six months from now, you'll thank past-you. ### Treat Notebooks Like Code `.lotnb` files are text — diff them, branch them, review them. Commit your notebooks to git alongside your `AGENTS.md` and `README.md`. When a teammate joins, the project's git repository is everything they need to understand and reproduce the system. Pair the notebooks with an `AGENTS.md` at the project root so AI assistants generate code that matches your conventions automatically. See [AI-Assisted Development Best Practices](/ai/best-practices) for the template. *** ## Next Steps Installing and running the VS Code extension. Conventions for `AGENTS.md` and AI-generated LoT. # Writing reusable logic Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/reusable-logic Callable actions, wildcards, counters, and Python — techniques for Actions that scale and stay maintainable. ## Building a Library of Reusable Logic Any Action without a trigger clause is a **callable** — a reusable function with typed inputs and outputs. Build a small library of utility callables for the calculations and transformations your project does repeatedly, then call them from your trigger-based Actions. **Like saving a custom button on a calculator.** You don't rebuild the multiplication function every time you need to multiply — you press the button. Callables are your custom buttons, named after exactly what they do. ### When to Reach for This The moment you write the same expression a second time, or any time a logic block deserves its own name to make the parent Action readable. Also useful when several Actions need to share a calculation that might evolve over time — fix it in the callable, and every caller is updated. ### The Pattern A callable has three parts: declared inputs, the logic that runs, and declared outputs to return: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CalculatePercentage INPUT value AS DOUBLE INPUT total AS DOUBLE DO SET "pct" WITH ({value} / {total} * 100) RETURN OUTPUT pct ``` Supported `INPUT` types are `STRING`, `INT`, `DOUBLE`, `BOOL`, and `JSON`. A callable can return one or many `OUTPUT` values — the caller names the return variables in the order they're declared. Here's a callable that returns three values from one calculation: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION AnalyzeReading INPUT current AS DOUBLE INPUT threshold AS DOUBLE DO SET "is_above" WITH ({current} > {threshold}) SET "difference" WITH ({current} - {threshold}) SET "percent_of_max" WITH ({current} / {threshold} * 100) RETURN OUTPUT is_above OUTPUT difference OUTPUT percent_of_max ``` Now a regular trigger-based Action can use both callables instead of duplicating the math itself. Whether you're tracking water tanks on a farm, fuel storage at a depot, or chemical reservoirs at a plant — the logic is the same: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessTankLevel ON TOPIC "tanks/+/level" DO SET "tank_id" WITH TOPIC POSITION 2 SET "level" WITH PAYLOAD AS DOUBLE SET "capacity" WITH (GET TOPIC "config/" + {tank_id} + "/capacity" AS DOUBLE) CALL ACTION CalculatePercentage WITH value = {level}, total = {capacity} RETURN fill_pct CALL ACTION AnalyzeReading WITH current = {level}, threshold = ({capacity} * 0.9) RETURN above, diff, pct_of_max PUBLISH TOPIC "processed/" + {tank_id} + "/fill_percent" WITH {fill_pct} PUBLISH TOPIC "processed/" + {tank_id} + "/over_threshold" WITH {above} ``` The main Action stays focused on *what to do* (read level, publish results); the callables hold *how to calculate*. If the percentage formula changes, you fix it in one place. *** ## One Action, One Job Each Action should do **one thing well**. Break complex logic into callable Actions with `INPUT`/`OUTPUT`: Separate reusable logic into callable Actions, then compose them in a main Action: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ConvertCelsiusToFahrenheit INPUT celsius AS DOUBLE DO SET "fahrenheit" WITH ({celsius} * 9 / 5 + 32) RETURN OUTPUT fahrenheit DEFINE ACTION ProcessTemperatureReading ON TOPIC "sensors/+/celsius" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp_c" WITH PAYLOAD AS DOUBLE CALL ACTION ConvertCelsiusToFahrenheit WITH celsius = {temp_c} RETURN temp_f PUBLISH TOPIC "sensors/" + {sensor_id} + "/fahrenheit" WITH {temp_f} ``` All logic crammed into one Action, no reuse: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION DoEverything ON TOPIC "sensors/+/celsius" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temp_c" WITH PAYLOAD AS DOUBLE SET "temp_f" WITH ({temp_c} * 9 / 5 + 32) SET "temp_k" WITH ({temp_c} + 273.15) SET "max" WITH (GET TOPIC "config/max_temperature" AS DOUBLE) SET "pct" WITH ({temp_c} / {max} * 100) IF {temp_c} > {max} THEN PUBLISH TOPIC "alerts/" + {sensor_id} WITH "High" PUBLISH TOPIC "sensors/" + {sensor_id} + "/fahrenheit" WITH {temp_f} PUBLISH TOPIC "sensors/" + {sensor_id} + "/kelvin" WITH {temp_k} PUBLISH TOPIC "sensors/" + {sensor_id} + "/capacity" WITH {pct} ``` *** ## Internal State: KEEP vs PUBLISH Use `KEEP TOPIC` for internal persistent state and `PUBLISH TOPIC` for external broadcast. Never use `PUBLISH` for values only your system reads: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION PersistentCounter ON EVERY 10 SECONDS DO SET "current" WITH (GET TOPIC "state/counter") IF {current} == EMPTY THEN KEEP TOPIC "state/counter" WITH 1 ELSE KEEP TOPIC "state/counter" WITH ({current} + 1) PUBLISH TOPIC "stats/count" WITH (GET TOPIC "state/counter") ``` | Operation | Use When | | --------------- | ----------------------------------------------------------------------- | | `PUBLISH TOPIC` | Other systems or subscribers need to see the value | | `KEEP TOPIC` | Only your own Actions read the value (internal state, caches, counters) | *** ## One Action for Many Devices The moment you find yourself about to copy-paste an Action and change one device ID, stop. Use a **wildcard** — the MQTT character `+` matches exactly one topic level, so `sensors/+/power` matches `sensors/inv-01/power`, `sensors/inv-47/power`, and any other topic with that shape. The broker fires your Action once per matching message, and `TOPIC POSITION` lets you read which device the message came from. ### Wildcard guidelines | Wildcard | Meaning | Where to Use | | -------- | -------------------------- | -------------------------------------------------------- | | `+` | Matches exactly one level | Action triggers, Model topics, Route mappings | | `#` | Matches one or more levels | Route mappings, Rule topic patterns, broad subscriptions | Use `+` in **Actions and Models** for precise instance matching. Use `#` in **Routes and Rules** for broad coverage. **Like writing a form letter that says "Dear customer."** One letter, delivered to thousands, and each recipient feels it's addressed to them. ### When to Reach for This The moment you have two or more devices that should be processed the same way. Wildcards are also the foundation that makes [Models that publish for every device](/latest/lot-language/usage-patterns/data-layer#standardizing-data-across-device-types) work — and they let new devices join your system without any code changes. ### The Pattern Imagine a solar park with a hundred inverters, each publishing power readings. One Action handles all of them: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessInverterPower ON TOPIC "inverters/+/power" DO SET "inverter_id" WITH TOPIC POSITION 2 SET "power_w" WITH PAYLOAD AS DOUBLE SET "power_kw" WITH ({power_w} / 1000) PUBLISH TOPIC "processed/" + {inverter_id} + "/power_kw" WITH {power_kw} KEEP TOPIC "cache/" + {inverter_id} + "/last_seen" WITH TIMESTAMP "UTC" ``` **On `KEEP TOPIC` in this example.** The last-seen line uses [`KEEP TOPIC`](/latest/lot-language/actions/operations) instead of `PUBLISH TOPIC`. `PUBLISH` broadcasts a fresh message to every subscriber; `KEEP` stores a value on the broker without broadcasting. Use `KEEP` for internal state other Actions read later — counters, last-seen timestamps, alarm flags — not for data every dashboard must see live. Walk through what's happening: 1. The trigger `inverters/+/power` matches `inverters/inv-01/power`, `inverters/inv-47/power`, and any other topic that fits. 2. `TOPIC POSITION 2` reads whatever the `+` matched — so on one trigger it's `inv-01`, on the next it's `inv-47`. Position counting is 1-based and starts at the topic root. 3. The published outputs use that value to build per-inverter topics, so each inverter gets its own processed reading and last-seen cache. This is called **wildcard context inheritance**: the same `+` value flows through every `GET TOPIC` and `PUBLISH TOPIC` inside the Action, so you don't have to parse the topic string yourself. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessWithContext ON TOPIC "sensors/+/temperature" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "humidity" WITH (GET TOPIC "sensors/+/humidity" AS DOUBLE) PUBLISH TOPIC "processed/" + {sensor_id} + "/combined" WITH {humidity} ``` If triggered by `sensors/temp001/temperature`, the `GET TOPIC` reads from `sensors/temp001/humidity` — the wildcard resolves to the same instance. A single Action like this can replace fifty hand-written copies — one for every device. When a new inverter comes online, you don't deploy new code; the existing Action just starts handling it. The same approach works equally well for hotel rooms (`rooms/+/temperature`), parking sensors (`parking/+/occupied`), or delivery vans (`vans/+/fuel_level`). Anywhere you have many devices doing the same thing, one wildcard Action covers them all. *** ## Counting Things Over Time Plenty of real-world questions are counting questions. How many cycles has this machine completed today? How many vehicles entered the garage this hour? How many error events has this device emitted this week? LoT has no built-in counter type, but a counter is just a number you keep on a topic and increment whenever something happens. **Like a tally on a bar napkin.** Each time something happens, you make another mark. The napkin keeps the count even after you walk away. ### When to Reach for This Whenever you need a value that **persists across messages** — not just within a single Action run. Production cycles, error totals, visitor counts, button presses, alarms-this-shift, anything that should keep counting whether you reset the system or not. ### The Pattern The trick is two-part: on the very first run there's no value yet, so you initialize to zero. On every run after that, you read the current value, add one, and write it back. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CountCycles ON TOPIC "machines/cycle_complete" DO IF GET TOPIC "counters/cycles" == EMPTY THEN PUBLISH TOPIC "counters/cycles" WITH 0 ELSE PUBLISH TOPIC "counters/cycles" WITH (GET TOPIC "counters/cycles" + 1) ``` What's happening: 1. Every time a `cycle_complete` message arrives, the Action runs. 2. The first time ever, `counters/cycles` doesn't exist yet, so `GET TOPIC` returns `EMPTY`. The Action creates the counter at zero. 3. Every subsequent run reads the current value, adds one, and publishes the new total. The counter survives broker restarts as long as the topic is retained, and any dashboard or other Action can subscribe to `counters/cycles` to see the live count. ### Counters Per Device Combine with a [wildcard](#one-action-for-many-devices) to keep one counter per device: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION CountCyclesPerMachine ON TOPIC "machines/+/cycle_complete" DO SET "machine_id" WITH TOPIC POSITION 2 IF GET TOPIC "counters/" + {machine_id} + "/cycles" == EMPTY THEN PUBLISH TOPIC "counters/" + {machine_id} + "/cycles" WITH 0 ELSE PUBLISH TOPIC "counters/" + {machine_id} + "/cycles" WITH (GET TOPIC "counters/" + {machine_id} + "/cycles" + 1) ``` Now `counters/machine-01/cycles`, `counters/machine-02/cycles`, and so on each track their own machine independently — and one Action handles all of them. ### Resetting on a Schedule For shift counters or daily totals, use a scheduled Action to reset: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ResetDailyCounter ON EVERY 24 HOURS DO PUBLISH TOPIC "counters/cycles" WITH 0 ``` Pair it with a scheduled SQL insert to archive the previous total before resetting, and you've got a daily history with two short Actions. *** ## Running Python from a LoT Action LoT is great for the operations that 90% of IoT logic actually needs — math, conditionals, JSON extraction, MQTT publishes. But sometimes you need real programming: regex parsing, statistical analysis, calling a Python library, complex validation rules. For those cases, [`CALL PYTHON`](/latest/lot-language/actions/python-integration) lets a LoT Action invoke a Python function and use the result inline. **Like calling a specialist into a routine job.** The general contractor handles the plumbing, framing, and electrical — but when there's something only a specialist can do, they make a phone call. ### When to Stay in LoT vs. Go to Python **Stay in LoT for** simple math, string concatenation, basic conditionals, MQTT operations, JSON field extraction, route triggering, scheduled or topic-triggered logic. Anything LoT does natively, do natively — Python adds overhead and complexity. **Reach for Python when** you need: * Regex matching or advanced string parsing * Statistical or scientific calculations (averages over windows, anomaly detection, ML inference) * Library functions: `numpy`, `pandas`, `scipy`, `scikit-learn`, custom company libraries * Complex validation rules with many conditional branches * JSON restructuring beyond simple field extraction The rule of thumb: **if you can do it in LoT, do it in LoT.** Python is a tool for capabilities LoT genuinely lacks, not a fallback when LoT is unfamiliar. ### When to Reach for This When your Action has hit the limits of LoT's expressiveness — typically around regex, statistics, ML, or library calls — and the work isn't worth wrapping in an external service. ### The Pattern A Python script that validates a complex sensor payload using regex and a custom rule: ```python wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # /scripts/Validator.py import re def validate_serial(serial): pattern = r'^[A-Z]{2}-\d{6}-[A-Z0-9]{4}$' if re.match(pattern, serial): return {"valid": True, "error": None} return {"valid": False, "error": "Serial format invalid"} ``` A LoT Action that uses it: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ValidateAndStore ON TOPIC "sensors/registration" DO SET "serial" WITH (GET JSON "serial" IN PAYLOAD AS STRING) CALL PYTHON "Validator.validate_serial" WITH ({serial}) RETURN AS {result} SET "valid" WITH (GET JSON "valid" IN {result} AS BOOL) IF {valid} EQUALS TRUE THEN PUBLISH TOPIC "registry/valid" WITH PAYLOAD ELSE SET "err" WITH (GET JSON "error" IN {result} AS STRING) PUBLISH TOPIC "registry/rejected" WITH ("Rejected: " + {err}) ``` What's happening: 1. The Action receives a sensor registration with a serial number. 2. It hands the serial to the Python function, which uses regex (something LoT can't do natively) to validate the format. 3. The function returns a dictionary, which LoT receives as a JSON object. 4. The Action extracts the `valid` and `error` fields with `GET JSON` and routes the message accordingly. ### Practical Tips * **Keep Python functions fast** (under \~100ms). They run inline in your Action and block message processing while they execute. For heavy computation, consider an external service. * **Always handle errors.** Wrap the Python in `try/except` and return structured results — `{"success": false, "error": "..."}` — so the LoT side can branch on it cleanly. * **Return JSON-friendly values.** Dictionaries, lists, strings, numbers, booleans. Custom objects won't serialize. * **Convert types explicitly.** Python receives parameters as strings — `float(value)`, `int(count)` at the top of your function. The full reference is at [Python Integration](/latest/lot-language/actions/python-integration). *** ## Next Steps Reusable logic, on-demand scripts, and `CALL ACTION`. `CALL PYTHON` syntax, deployment, and best practices. # Working with JSON Source: https://docs.coreflux.org/latest/lot-language/usage-patterns/working-with-json Read JSON fields with GET JSON in Actions, handle nested payloads and missing keys, and publish structured output through Models. ## Overview Most MQTT payloads in IoT systems are JSON — from devices, HTTP webhooks, industrial gateways, and partner APIs. In Actions you **read** fields with `GET JSON` and **write** structured output with [`PUBLISH MODEL`](/latest/lot-language/models/publish-model). Use raw `PAYLOAD AS DOUBLE` only when the message is a single value with no structure. For **binary protobuf**, use [`GET PROTO` and protobuf models](/latest/lot-language/models/protobuf) instead of `GET JSON`. For when to choose scalar topics, JSON ingress, or Models at the architecture level, see [Choosing data formats](/latest/lot-language/usage-patterns/data-layer#choosing-data-formats) on the data layer page. **Like fishing one specific item out of a packed grocery bag.** You don't unpack the whole bag — you reach in, grab the field by name, and leave the rest where it is. For the full `GET JSON` syntax table and edge cases in the operations reference, see [Operations — GET JSON](/latest/lot-language/actions/operations#get-json). *** ## When to Use This Any time an incoming MQTT message is a JSON object. Typical sources: * Sensors and PLCs publishing JSON over MQTT * REST or webhook Routes that forward JSON bodies to topics * Cloud or SCADA integrations bridged into the broker *** ## Reading JSON Fields [`GET JSON`](/latest/lot-language/actions/operations#get-json) takes a field name (or a dotted path), the source (almost always `PAYLOAD`), and a target type. It works inside any expression that needs a value: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessReading ON TOPIC "sensors/reading" DO SET "device_id" WITH (GET JSON "id" IN PAYLOAD AS STRING) SET "temperature" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) SET "active" WITH (GET JSON "enabled" IN PAYLOAD AS BOOL) PUBLISH TOPIC "processed/" + {device_id} + "/temperature" WITH {temperature} ``` Supported target types are `STRING`, `DOUBLE`, `INT`, and `BOOL`. The conversion happens at extraction time, so by the time the value is in your variable it is already typed correctly for comparisons and math. *** ## Nested Paths For nested objects, use dot notation. For arrays, use bracket notation. Mix them freely: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ProcessNested ON TOPIC "sensors/payload" DO SET "temp" WITH (GET JSON "data.sensors.temperature" IN PAYLOAD AS DOUBLE) SET "first_reading" WITH (GET JSON "readings[0].value" IN PAYLOAD AS DOUBLE) SET "second_id" WITH (GET JSON "readings[1].sensor_id" IN PAYLOAD AS STRING) ``` For a payload like: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "data": { "sensors": { "temperature": 23.5 } }, "readings": [ { "sensor_id": "S1", "value": 100 }, { "sensor_id": "S2", "value": 200 } ] } ``` `{temp}` becomes `23.5`, `{first_reading}` becomes `100`, `{second_id}` becomes `"S2"`. *** ## Handling Missing Fields If a field is absent, `GET JSON` returns a type-appropriate default: empty string for `STRING`, `0` for numeric types, `FALSE` for `BOOL`. For optional fields, check explicitly with `EMPTY`: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} SET "battery" WITH (GET JSON "battery_level" IN PAYLOAD AS DOUBLE) IF {battery} == EMPTY THEN PUBLISH TOPIC "alerts/missing_field" WITH "battery_level not reported" ELSE PUBLISH TOPIC "processed/battery" WITH {battery} ``` This pattern matters when you consume data from third parties — webhooks and partner APIs often omit fields rather than send nulls. *** ## End-to-End: Extract JSON and Publish a Model To publish structured JSON without hand-building strings, define a **COLLAPSED** Model with typed fields and use `PUBLISH MODEL ... TO ... WITH` from an Action. The broker assembles the JSON for you. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE MODEL SensorDataRecord COLLAPSED ADD STRING "sensor_id" ADD STRING "sensor_type" ADD DOUBLE "temperature" ADD DOUBLE "pressure" ADD STRING "timestamp" ADD STRING "quality_status" DEFINE ACTION ProcessSensorJSON ON TOPIC "sensors/+/json_data" DO SET "sensor_id" WITH TOPIC POSITION 2 SET "temperature_val" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE) SET "pressure_val" WITH (GET JSON "pressure" IN PAYLOAD AS DOUBLE) SET "sensor_type" WITH (GET JSON "type" IN PAYLOAD AS STRING) SET "quality" WITH (GET JSON "quality_status" IN PAYLOAD AS STRING) PUBLISH MODEL SensorDataRecord TO "sensors/processed/" + {sensor_id} WITH sensor_id = {sensor_id} sensor_type = {sensor_type} temperature = {temperature_val} pressure = {pressure_val} timestamp = TIMESTAMP "UTC" quality_status = {quality} ``` | Step | What Happens | | ---- | ---------------------------------------------------------------------------------- | | 1 | Action triggers when any sensor publishes JSON to `sensors/+/json_data` | | 2 | `GET JSON` extracts each field with the correct type | | 3 | `PUBLISH MODEL` builds a clean JSON object and publishes it to the processed topic | See [Publish Model](/latest/lot-language/models/publish-model) for more on COLLAPSED models and field mapping. *** ## Next Steps Topic trees, scalar vs JSON payloads, and when to use Models. Full syntax reference for GET JSON and related operations. # High-Availability Clustering Source: https://docs.coreflux.org/latest/mqtt-broker/clustering Run multiple Coreflux MQTT Broker nodes as a fault-tolerant cluster with leader election, durable QoS replication, and automatic failover ## Keep messaging available when a node fails A single broker is a single point of failure. High-availability clustering runs several Coreflux MQTT Broker nodes as one system: if one node stops, the others keep serving clients. Clients can connect to any healthy node. Important messages (QoS 1 and 2) are copied across nodes so a crash of a minority of the cluster cannot erase a message the broker already accepted. **Like a team with a captain and shared playbook.** One node leads changes; the others stay ready. If the captain drops out, the team elects a new one and keeps playing — without rewriting every client's connection plan. ### When to use clustering Use clustering when you need: * continuous MQTT service if one broker stops or is restarted; * important publishes (QoS 1/2) to survive if some of the nodes crash after the broker has accepted them; or * a fixed or elastic set of broker nodes that should act as one cluster. Prefer **odd** node counts (**3** or **5**) so a clear majority can decide who leads. Clustering requires a license that grants the cluster feature. If you enable clustering without that license, the broker logs a warning and runs standalone. Clustering is **broker configuration**, not a LoT System Route. For topic sync with another MQTT broker (including non-Coreflux), use an [MQTT Bridge](/latest/lot-language/routes/system/mqtt-bridge) instead. *** Configure each node with the same cluster identity and discovery settings, then deploy the same LoT project to every node through your normal workflow. ## Quick Start Add a `Cluster` section to the broker configuration (or matching environment variables — env vars override the file). On **every** node, create a dedicated MQTT user for inter-node traffic and set `DataPlaneUsername` to that username so nodes can exchange topic data. Use fixed peer addresses when automatic discovery is unavailable (cloud, most Kubernetes networks, multi-subnet LANs): ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Cluster": { "Enabled": true, "ClusterId": "factory-floor", "NodeName": "broker-a", "Discovery": "seeds", "Seeds": ["broker-a:9200", "broker-b:9200", "broker-c:9200"], "SharedSecret": "a-strong-secret", "DataPlaneUsername": "cluster" } } ``` Auto-discover peers on a shared local network (default discovery mode): ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Cluster": { "Enabled": true, "ClusterId": "lab-cluster", "NodeName": "broker-1", "Discovery": "multicast", "SharedSecret": "a-strong-secret", "DataPlaneUsername": "cluster" } } ``` Discover peers via a headless Service DNS name; better for elastic pods than a static address list: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Cluster": { "Enabled": true, "ClusterId": "k8s-brokers", "NodeName": "broker-0", "Discovery": "kubernetes", "KubernetesServiceDns": "coreflux-broker-headless.default.svc.cluster.local", "KubernetesPeerPort": 9200, "SharedSecret": "a-strong-secret", "DataPlaneUsername": "cluster" } } ``` *** ## Concepts operators need | Plane | What it does for you | | ----------------- | --------------------------------------------------------------------------------------- | | **Control plane** | How nodes find each other, who leads, and which node may accept LoT **writes** | | **Data plane** | Moves MQTT topic traffic between nodes so subscribers on any node can receive publishes | * **One leader.** Runtime LoT **write** commands run only on the current leader. Followers reject writes and tell you which node is the leader. Read-only and node-local commands (queries, diagnostics, license/instance key) still run locally. * **LoT is not copied between nodes.** Each node loads its own LoT from disk / GitOps. A write applied on the leader changes **only that node** until you distribute the same project files to the others. Point runtime tooling at the leader, and treat your project repository as the source of truth for all nodes. * **Only matching nodes join.** Nodes must share the same cluster identity and join secret so unrelated brokers do not join by accident. (See `ClusterId` and `SharedSecret` in the configuration reference.) * **Session failover needs durability.** Enable [MQTT state durability](/latest/mqtt-broker/durability) so a client that reconnects to another node can get its subscriptions back. Messages queued for offline clients stay on the node that hosted them (see [Limitations](#limitations)). *** ## Configuration reference ### Core options | Config field | Env var | Default | Description | | ---------------------- | -------------------------------------- | ---------------------- | ----------------------------------------------------------------- | | `Enabled` | `COREFLUX_CLUSTER_ENABLED` | `false` | Master switch | | `ClusterId` | `COREFLUX_CLUSTER_ID` | `coreflux-cluster` | Logical cluster identity | | `NodeName` | `COREFLUX_CLUSTER_NODE_NAME` | hostname | This node's identity | | `BindIP` | `COREFLUX_CLUSTER_BIND_IP` | `0.0.0.0` | Control-plane listener bind address | | `Port` | `COREFLUX_CLUSTER_PORT` | `9200` | Control-plane TCP port | | `AdvertisedHost` | `COREFLUX_CLUSTER_ADVERTISED_HOST` | `NodeName` | Address peers use to reach this node | | `AdvertisedPort` | `COREFLUX_CLUSTER_ADVERTISED_PORT` | `Port` | Port peers use to reach this node | | `Discovery` | `COREFLUX_CLUSTER_DISCOVERY` | `multicast` | `multicast`, `seeds`, or `kubernetes` | | `Seeds` | `COREFLUX_CLUSTER_SEEDS` | `[]` | Comma/semicolon list of `host:port` | | `MulticastGroup` | `COREFLUX_CLUSTER_MULTICAST_GROUP` | `239.63.114.71` | Multicast group | | `MulticastPort` | `COREFLUX_CLUSTER_MULTICAST_PORT` | `9201` | Multicast announce port | | `KubernetesServiceDns` | `COREFLUX_CLUSTER_K8S_SERVICE_DNS` | — | Headless Service DNS name | | `KubernetesPeerPort` | `COREFLUX_CLUSTER_K8S_PEER_PORT` | — | Peer port for Kubernetes discovery | | `SharedSecret` | `COREFLUX_CLUSTER_SHARED_SECRET` | — | Join secret (discovery filtering and data-plane integrity) | | `DataPlaneEnabled` | `COREFLUX_CLUSTER_DATAPLANE_ENABLED` | `true` | Exchange MQTT traffic between nodes | | `DataPlaneMqttPort` | `COREFLUX_CLUSTER_DATAPLANE_MQTT_PORT` | broker MQTT port | MQTT port used between nodes | | `DataPlaneUsername` | `COREFLUX_CLUSTER_DATAPLANE_USERNAME` | — | MQTT username for inter-node links (password from the user store) | | `UseTls` | `COREFLUX_CLUSTER_TLS` | `false` | TLS on inter-node MQTT links | | `Topology` | `COREFLUX_CLUSTER_TOPOLOGY` | `mesh` | `mesh` (recommended) or `ring` (complete only for ≤ 3 nodes) | | `DataDir` | `COREFLUX_CLUSTER_DATA_DIR` | `{CFBasePath}/cluster` | Cluster state directory | The data plane starts only when `DataPlaneEnabled` is true **and** `DataPlaneUsername` names a user that exists in the broker MQTT user store on that node. ### Replication options | Config field | Env var | Default | Description | | -------------------------- | --------------------------------------------- | ---------------------- | ---------------------------------------------------------------- | | `ReplicationEnabled` | `COREFLUX_CLUSTER_REPLICATION_ENABLED` | `true` | Require quorum durability before acknowledging QoS ≥ 1 publishes | | `ReplicationFactor` | `COREFLUX_CLUSTER_REPLICATION_FACTOR` | `0` (= auto min(3, N)) | Replica-set size per partition | | `PartitionCount` | `COREFLUX_CLUSTER_PARTITION_COUNT` | `32` | Logical partitions (pick once; changing re-hashes keys) | | `ReplicationTimeoutMs` | `COREFLUX_CLUSTER_REPLICATION_TIMEOUT_MS` | `5000` | Quorum wait before rejecting a publish | | `ReplicationReplaySeconds` | `COREFLUX_CLUSTER_REPLICATION_REPLAY_SECONDS` | `300` | Failover replay window (0 = no replay) | *** ## Ports | Port | Protocol | Purpose | | ---------------- | -------- | ---------------------------------------------- | | `9200` | TCP | Control plane (leader election and membership) | | `9201` | UDP | Multicast discovery (multicast mode only) | | Broker MQTT port | TCP | Inter-node MQTT data plane | Open these between cluster members in your firewall or network policies. *** ## Delivery guarantees | QoS | What clustering adds | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **0** | Best-effort delivery to peers; may be lost if a target node is down | | **1** | The broker accepts the publish only after a **majority** of the replica set has stored it; other nodes receive it reliably; after failover it may be delivered again (at-least-once) | | **2** | Same majority check before the broker accepts the publish in normal operation; across failover, treat delivery as **at-least-once** and design consumers to ignore duplicates | **Summary:** once the broker has accepted a QoS 1 or 2 publish, that message is stored on a majority of nodes and can be replayed if ownership moves after a failure. If the cluster is split and this side no longer has a majority, it **rejects** those publishes instead of accepting messages it cannot keep safe. *** ## Operations ### Bootstrap and failover * A node that sees an existing leader joins as a follower. * If no leader is visible and peers exist, the cluster elects a leader and adds members as they are discovered. * When the leader is lost, a new one is elected quickly (typically within a fraction of a second). Survivors stop contacting confirmed-dead peers and bring returning nodes back into the mesh automatically. * With **seeds** discovery, a stopped node can remain a configured member (marked unavailable) while majority is computed over the configured list. Prefer **kubernetes** discovery when instances come and go often. ### Observability | Signal | What it shows | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | `GET /cluster` | Full status: role, leadership, members, data-plane health, replication, and related fields | | `GET /info` | Lightweight `cluster` summary | | Prometheus `/metrics` | Cluster, partition, replication, and related series (`coreflux_cluster_*`, `coreflux_replication_*`, and related) | | Retained `$SYS` topics | `$SYS/Coreflux/Cluster/{Role,IsLeader,Leader,Term,Members,Status}` (refreshed periodically) | Use MQTT Explorer or any MQTT client to subscribe to the `$SYS/Coreflux/Cluster/#` topics, or call `GET /cluster` on each node's operations HTTP port. *** ## Limitations **LoT is not replicated between nodes.** Distribute the same project/configuration to every node. Runtime edits on the leader do not appear on followers until you provision them. **Retained messages are node-local.** They survive a restart on that node but are not a cluster-wide retained store. A subscriber on another node may not see a retain set elsewhere. **Subscriptions can move; offline queues do not.** A client reconnecting with a persistent session to another node can regain its subscriptions when [durability](/latest/mqtt-broker/durability) is enabled. Queued QoS 1/2 messages for offline clients stay on the node that hosted them until that node returns. **QoS 2 across failover is at-least-once.** Design consumers to tolerate duplicates after a node failure or replay. *** ## Next Steps Persist retained messages, sessions, and queued QoS across restarts — required for session failover. Ports, TLS, listeners, and other broker settings for each node. # Broker Commands Source: https://docs.coreflux.org/latest/mqtt-broker/commands Publish commands to the Coreflux MQTT broker at runtime and parse the JSON replies ## Control the Broker Over MQTT Instead of editing files and restarting, you publish a command string to a system topic and the broker applies the change immediately: users, LoT entities, Routes, projects, secrets, and more. **Like a remote control for the broker.** You send a message to a reserved topic; the broker answers on a matching output topic. No SSH and no restart. ### When to use broker commands * Deploy or remove Actions, Models, Routes, and Rules without downtime * Run an Action on demand, or inspect what is loaded * Manage users, groups, environment variables, and secrets * Load LoT notebook projects and check their status * Drive the same verbs from [Coreflux HUB Command Console](/hub/system/command-console) or any MQTT client On a [cluster](/latest/mqtt-broker/clustering), send **write** commands (add/remove/load) to the **leader**. Read-only and license commands still run on the node you are connected to. *** Subscribe first, then publish. Replies are always JSON on a single output topic. ## Quick Start Subscribe to `$SYS/Coreflux/Command/Output`, then publish the command. Use QoS 1 on both the command and the output subscription. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addAction DEFINE ACTION Heartbeat ON EVERY 10 SECONDS DO PUBLISH TOPIC "system/heartbeat" WITH TIMESTAMP "UTC" ``` The broker replies with a JSON envelope. `success` is `true` when the Action was registered: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": true, "command": "AddAction", "message": "Action 'Heartbeat' added successfully.", "entityType": "Action", "entityName": "Heartbeat", "data": {} } ``` List commands put the structured result in `data`. `message` stays a short human summary. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -listRoutes -requestId routes-1 ``` ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": true, "command": "ListRoutes", "requestId": "routes-1", "message": "Listed 1 route(s).", "data": [ { "name": "CloudBridge", "type": "MQTT_BRIDGE", "connection": "Connected", "health": "Green" } ] } ``` Every client shares `$SYS/Coreflux/Command/Output`. Pass `-requestId` so you can match the reply to the request you sent. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -listProjects -requestId demo-1 ``` Read `requestId` on the envelope root. Omit the flag when you only have one command in flight. ## How Commands Work | Role | Topic | Notes | | ------------------------- | ------------------------------ | ------------------------------------------------------- | | **Publish commands** | `$SYS/Coreflux/Command` | UTF-8 plain string payload (not a JSON wrapper) | | **Subscribe for replies** | `$SYS/Coreflux/Command/Output` | Always this topic — independent of flags in the command | ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -commandName [-requestId ] ``` | Flag | Required | Purpose | | ------------ | -------- | ------------------------------------------------------------------------------------- | | `-requestId` | No | Client correlation id; echoed as `requestId` in the JSON reply. Alias: `--request-id` | Authorization uses the **MQTT session username** of the publishing client. Do not send `-requestUser` in the payload. Replies always go to `$SYS/Coreflux/Command/Output`. Do not subscribe to `$SYS/Coreflux/CommandResponse`. Custom reply routing (`-bt`) and `$SYS/Coreflux/Cloud/Command` are not supported. ## Response Envelope Every reply on `$SYS/Coreflux/Command/Output` is one JSON object. List and query payloads live in `data`. `data` is **always present**: `{}` when there is nothing structured to return, or `[]` for an empty list. | Field | Type | Description | | ------------ | --------------- | ------------------------------------------------------------------------------------- | | `success` | boolean | Whether the command itself succeeded | | `command` | string | Command name (`AddRoute`, `ListProjects`, …). Unknown verbs keep the raw `-verb` form | | `message` | string | Human-readable summary | | `entityType` | string? | Entity kind for deploy, remove, and run commands | | `entityName` | string? | Entity name when applicable | | `requestId` | string? | Echo of `-requestId` when you sent one | | `data` | object or array | Structured result. Always present | | `errors` | object\[]? | Present when `success` is `false` | | `warnings` | string\[]? | Non-fatal warnings (for example a project load that skipped one entity) | ### How to parse a reply 1. Parse the payload as JSON (always a single object). 2. Read `success`, `command`, and optional `requestId` from the root. 3. Read structured results from `data`. 4. Read human text from `message` and failures from `errors[]`. ### Error objects | Field | Description | | ----------------- | -------------------------------------------- | | `message` | Error text for that entry | | `line` / `column` | 1-based source location for LoT parse errors | | `sourceLines` | Map of line number to source text | | `suggestions` | Parser hints when available | Parse failure example: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": false, "command": "AddAction", "message": "Unexpected token 'WITH' at line 4", "entityType": "Action", "entityName": "BadAction", "data": {}, "errors": [ { "message": "Unexpected token 'WITH' at line 4", "line": 4, "column": 12, "sourceLines": { "4": "DO PUBLISH TOPIC \"x\"" } } ] } ``` ### Global failures **Permission denied** (command accepted at MQTT, then blocked by RBAC): ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": false, "command": "AddRoute", "message": "Permission denied: You are not allowed to execute -addRoute.", "data": {}, "errors": [ { "message": "Permission denied: You are not allowed to execute -addRoute." } ] } ``` **Unknown command:** ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": false, "command": "-notARealCommand", "message": "Unknown command: -notARealCommand", "data": {}, "errors": [{ "message": "Unknown command: -notARealCommand" }] } ``` If the session lacks `CommandCall`, the publish to `$SYS/Coreflux/Command` can be rejected before any Output message. Handle both MQTT-level denial and JSON `success: false`. See [RBAC with LoT Rules](/latest/lot-language/rules/rbac). *** ## Command Index Most verbs also accept a dashed alias (`-addRoute` / `--add-route`). The table lists the MQTT form you publish. | Area | Commands | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Users** | `-addUser`, `-removeUser`, `-changeUserPassword`, `-changeUserSettings`, `-addUserToGroup`, `-removeUserFromGroup`, `-listUserGroups` | | **Rules** | `-addRule`, `-removeRule`, `-removeAllProjectRules`, `-restoreRules` | | **Models** | `-addModel`, `-removeModel`, `-removeAllModels` | | **Actions** | `-addAction`, `-removeAction`, `-runAction`, `-removeAllActions`, `-actionTrace` | | **Routes** | `-addRoute`, `-removeRoute`, `-removeAllRoutes`, `-listRoutes`, `-routeCode`, `-checkRouteConnection`, `-routeCertificates`, `-listTemplates`, `-connectionStatus`, `-discoverAIProviders` | | **Diagnostics** | `-lotDiagnostic`, `-getPendingStatus` | | **Python** | `-addPython`, `-removePython`, `-installPythonPackage`, `-listPythonPackages`, `-removeAllPythonScripts` | | **Panels** | `-addPanel` (`-addVisu`), `-removePanel`, `-listPanels`, `-panelCode`, `-updateVisuState`, `-addVisuFile`, `-refreshVisu`, `-addTheme`, `-removeTheme`, `-removeAllThemes`, `-listThemes` | | **Projects** | `-addProject`, `-removeProject`, `-loadProject`, `-unloadProject`, `-listProjects`, `-listEntities`, `-getProject`, `-duplicateProject`, `-projectCheckout`, `-projectPull`, `-projectPush`, `-projectStatus`, `-projectManifest` | | **Env & secrets** | `-setEnv`, `-removeEnv`, `-listEnv`, `-setSecret`, `-removeSecret`, `-listSecrets` | | **License** | `-showInstanceKey`, `-loadLicense`, `-installLicense` | | **Audit** | `-auditStatus`, `-auditQuery`, `-auditFilter`, `-decryptAudit`, `-auditExport` | | **System** | `-addTraceLog`, `-listTraceLogs`, `-removeTraceLog`, `-removeAllTraceLogs`, `-updateData`, `-h` | `-updateData` refreshes broker `$SYS` statistics and **does not** publish a reply on `$SYS/Coreflux/Command/Output`. *** ## User Management Create a user, then assign groups for `USER IN GROUP` in LoT Rules. Group commands require user-management permission on the **authenticated MQTT session**. See [RBAC with LoT Rules](/latest/lot-language/rules/rbac). ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addUser operator SecurePass123! -removeUser operator -changeUserPassword operator NewSecurePass456! -changeUserSettings alice AllowedUserManagement true -addUserToGroup alice operators -removeUserFromGroup alice operators -listUserGroups alice ``` `-listUserGroups` returns `{ username, groups, count }` in `data`. Permission flags for `-changeUserSettings` include `AllowedUserManagement`, `AllowedSystemConfiguration`, and `AllowedLogManagement`. *** ## Rules Rules define access control. See [Rules Overview](/latest/lot-language/rules/overview), [Rules Syntax](/latest/lot-language/rules/syntax), and [RBAC](/latest/lot-language/rules/rbac). **Lower priority number wins**. `-addRule` accepts priorities from `100` to `1 000 000` only; custom ACL rules require Growth or Enterprise. Rules are LoT code and can also be deployed from a [LoT Notebook](/latest/quick-start/vscode) by running the cell that contains the definition. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addRule DEFINE RULE RestrictSensorPublish WITH PRIORITY 100 FOR Publish TO TOPIC "sensors/#" IF USER IN GROUP "operators" THEN ALLOW ELSE DENY ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removeRule RestrictSensorPublish ``` Locked built-in system rules cannot be removed. Seed catch-alls (`AllowPublishTopic` / `AllowSubscribeTopic` / `AllowConnect`) may be removed to switch that operation toward default-deny — except Connect, where removing `AllowConnect` without a replacement leaves CONNECT ungated. See [RBAC](/latest/lot-language/rules/rbac#seed-catch-alls). `-restoreRules` puts `AllowPublishTopic`, `AllowSubscribeTopic`, and `AllowConnect` back to their shipped ALLOW definitions. Missing seeds are re-added; overrides of those names are reset; locked built-ins and your other custom rules are left alone. Requires `root` or `AllowedSystemConfiguration`. No arguments. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -restoreRules ``` `-removeAllProjectRules` removes permission rules **owned by the active project**. Built-in rules and operator-authored custom rules stay in place. *** ## Models ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addModel DEFINE MODEL TemperatureSensor WITH TOPIC "sensors/+/temperature" ADD FIELD temperature WITH TYPE Float ADD FIELD unit WITH TYPE String ADD FIELD timestamp WITH TYPE DateTime ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removeModel TemperatureSensor -removeAllModels ``` `-removeAllModels` removes every Model on the broker. *** ## Actions ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addAction DEFINE ACTION Heartbeat ON EVERY 10 SECONDS DO PUBLISH TOPIC "system/heartbeat" WITH TIMESTAMP "UTC" ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addAction DEFINE ACTION EchoMessage ON TOPIC "input/data" DO PUBLISH TOPIC "output/echo" WITH PAYLOAD ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removeAction Heartbeat -removeAllActions ``` ### Run an Action on demand `-runAction` runs an Action immediately, regardless of its trigger. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -runAction Heartbeat ``` Success means the command **dispatched** the Action. For this form, a failure inside the Action body is not in the command reply — watch `$SYS/Coreflux/Actions//Trace` (if tracing is on) or `$SYS/Coreflux/Actions//Error`. `data.traceId` identifies this execution. **Callable Actions** (those that declare `INPUT`) accept a JSON object of parameters: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -runAction SetZoneSetpoint {"zone":"f2-north","target":21.5} ``` The broker rejects a payload on a non-callable Action, invalid JSON, or a non-object JSON value. **Topic-triggered Actions** (`ON TOPIC` / `ON CHANGE`) accept trigger context instead of `INPUT` bindings when the JSON object contains a `topic` key. The Action runs **synchronously**; `data` includes `status`, `published`, `durationMs`, `error`, and `traceId`. You do not need to arm `-actionTrace` for this form. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -runAction TemperatureMonitor {"topic":"sensors/zone1/temperature","payload":{"value":72}} ``` | `data.status` | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------- | | `success` | Finished; `published` lists messages it emitted | | `error` | The Action threw; `error` has the message. Envelope `success` stays `true` — the *command* worked | | `no_trace_captured` | Ran, but no trace was produced | The `topic` value must match the Action's trigger pattern (including wildcards). This `topic` form does not apply to callables — their JSON object is always `INPUT` bindings, even if it contains a `topic` key. See [Callable ACTIONs](/latest/lot-language/actions/callable-actions) for `INPUT` / `CALL ACTION`. ### Action tracing ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -actionTrace Heartbeat -actionTrace all -actionTrace status -actionTrace off -actionTrace remove Heartbeat ``` Traces publish to `$SYS/Coreflux/Actions//Trace`. Arming auto-expires; `data.expiresUtc` tells you when. A `{ "topic": … }` `-runAction` arms tracing for that one call and disarms afterward, unless a live `-actionTrace` session was already on. *** ## Routes ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addRoute DEFINE ROUTE CloudBridge WITH TYPE MQTT_BRIDGE ADD SOURCE_CONFIG WITH BROKER SELF ADD DESTINATION_CONFIG WITH BROKER_ADDRESS "iot.cloudprovider.com" WITH BROKER_PORT '8883' WITH USE_TLS true ADD MAPPING sensorData WITH SOURCE_TOPIC "sensors/#" WITH DESTINATION_TOPIC "factory1/sensors/#" WITH DIRECTION "out" ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removeRoute CloudBridge -removeAllRoutes -listRoutes -routeCode POSTGRESQL -listTemplates -checkRouteConnection CloudBridge -connectionStatus ``` `-listRoutes` places entries in `data` (`name`, `type`, `connection`, `health`). Unmonitored routes report `"Unknown"` for connection and health. `-checkRouteConnection` tests the named Route. Details such as `routeType` and `connectionTime` are in `data`. What “connected” means depends on the Route type — see the Route page (for example [OPC UA Client](/latest/lot-language/routes/industrial/opcua-client) or [Modbus TCP Server](/latest/lot-language/routes/industrial/modbus-tcp-server)). `-routeCode` returns template LoT for that Route type in `data`. `-listTemplates` lists available templates. `-routeCertificates` manages PKI for Routes that support certificate stores (OPC UA client and server). The argument is `cert64:` plus base64 of a minified JSON request (`stores`, `list`, `inspect`, `trust`, `delete`, `export`, `import`). Operation details are in `data`. See [OPC UA Client — Certificate Management](/latest/lot-language/routes/industrial/opcua-client#certificate-management). ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -routeCertificates cert64: ``` `-discoverAIProviders` probes configured LLM providers for reachability and models. API keys and host URLs come from secrets and environment variables (`OPENAI_API_KEY`, `MISTRAL_API_KEY`, `ANTHROPIC_API_KEY`, `OLLAMA_HOST`). An optional JSON object can override those values for the probe. Results are also published retained on `$SYS/Coreflux/AI/Providers`. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -discoverAIProviders ``` *** ## Diagnostics ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -lotDiagnostic action Heartbeat -getPendingStatus ``` `-lotDiagnostic` takes an entity kind (`model`, `action`, `route`, or `rule`) and the entity name. *** ## Environment Variables and Secrets See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for storage, substitution in LoT, and security notes. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -setEnv DB_HOST=postgres.example.com -removeEnv OLD_CONFIG -listEnv -setSecret DB_PASSWORD=my_secure_password -removeSecret OLD_API_KEY -listSecrets ``` `-listEnv` returns name/value pairs in `data`. `-listSecrets` returns **names only** — secret values never appear in command output. *** ## Python Integration ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addPython Calculator # Script Name: Calculator def add(a, b): return a + b ``` Python scripts must start with `# Script Name: YourScriptName`. That name is what you pass to `CALL PYTHON` from a LoT Action. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removePython Calculator -installPythonPackage numpy -listPythonPackages -removeAllPythonScripts ``` *** ## Panels and Themes Deploy dashboards written as `DEFINE PANEL`. `-addPanel` and `-addVisu` are the same command. See [Defining Panels](/latest/lot-language/panels/defining-panels). ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addPanel DEFINE PANEL LineOverview WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line Overview" WITH LAYOUT "grid" COLUMNS 3 ADD COMPONENT "Temperature" WITH TYPE "gauge" SET RANGE FROM 0 TO 100 BIND VALUE TO TOPIC "sensor/temperature" ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -removePanel LineOverview -listPanels -panelCode LineOverview -updateVisuState LineOverview PUBLISHED -refreshVisu ``` `-updateVisuState` takes `DRAFT`, `PUBLISHED`, or `ARCHIVED`. `-addVisuFile lot64:` deploys every `DEFINE PANEL` block from a `.lot` file encoded as base64. Theme commands: `-addTheme` (full `DEFINE THEME` body), `-removeTheme`, `-listThemes`, `-removeAllThemes`. `-listPanels` and `-listThemes` also publish catalog data on `$SYS/Coreflux/VoT/Panels/` and `$SYS/Coreflux/VoT/Themes/`. *** ## Projects A project is a folder of `.lotnb` files under the broker data path. See [Project organization](/latest/lot-language/usage-patterns/project-organization). `-loadProject` and `-unloadProject` remove only entities the broker attributes to that project. Actions, Models, Routes, Python scripts, and panels you added with `-addAction` (and similar) while no project was active stay running. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -listProjects -addProject https://git.example.com/factory-iot.git main -addProject zip64: -loadProject factory-iot -unloadProject -removeProject factory-iot -listEntities -getProject factory-iot -duplicateProject factory-iot factory-iot-copy -projectCheckout factory-iot main -projectPull factory-iot -projectPush factory-iot -projectStatus factory-iot -projectManifest factory-iot ``` Names may contain spaces or hyphens (`-loadProject Traceability System` or `-loadProject "Traceability System"`). `/` is not allowed. `-listProjects` returns `name`, `active`, git metadata, and related fields in `data`. `-loadProject` returns `{ project, entities, counts }` in `data`. `counts.actions` lists **user-defined** Actions only (not Route-internal event Actions). Built-in permission rules are omitted. Per-entity failures appear in `warnings[]`; `success` can still be `true` when the load finished with partial errors. The last loaded project is remembered across restarts unless you pass `--project` at startup. See [Startup Options](/latest/mqtt-broker/startup-options#loading-a-project-at-startup). `-unloadProject` tears down owned runtime entities and returns the broker to standalone mode. The folder stays on disk. `-removeProject` also deletes the folder. `-getProject` exports a zip (base64) on `$SYS/Coreflux/Projects//download`. `-projectManifest` returns hashes of stored files and declared entities so clients can compare a working copy without downloading the zip. The same JSON is published retained on `$SYS/Coreflux/Projects/{name}/manifest`. *** ## License ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -showInstanceKey -loadLicense /path/to/file.license -installLicense ``` `-showInstanceKey` returns the instance key in `message`. `-installLicense` takes the license file text (the same content you would read from a `.license` file), which is the usual path from HUB and other UIs. *** ## Audit ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -auditStatus -auditQuery 2026-08-26 -auditQuery 2026-08-26 -auditFilter operationName=UserLogin -decryptAudit 2026-08-26 ``` `-auditQuery` takes a date (`YYYY-MM-DD`). Add `-auditFilter field=value` in the same payload to narrow results. `-decryptAudit` decrypts that day's log; pair with `-auditExport ` to write JSON to a file. Decryption requires the audit key configured on the broker (`COREFLUX_AUDIT_KEY`). *** ## Trace Logging Capture broker log lines and republish matches to an MQTT topic. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -addTraceLog level=Information,lifetime=3600,messageContains=sensor,topic=logs/sensors -listTraceLogs -removeTraceLog logs/sensors -removeAllTraceLogs ``` | Parameter | Description | | ----------------- | --------------------------------------------------- | | `level` | `Debug`, `Information`, `Warning`, `Error`, `Fatal` | | `lifetime` | Seconds until the trace point expires | | `messageContains` | Only logs whose message contains this substring | | `topic` | MQTT topic where matching logs are published | *** ## Command Authorization Not every user can run every command. Management gates such as `CommandCall` and per-entity creation flags are defined in LoT Rules. See [Rules overview](/latest/lot-language/rules/overview) and [RBAC](/latest/lot-language/rules/rbac). Configure access control before any untrusted client can publish to `$SYS/Coreflux/Command`. *** ## Next Steps Subscribe to command output, entity code, and broker telemetry. Run the same commands from Coreflux HUB without a separate MQTT client. # Broker Configuration Source: https://docs.coreflux.org/latest/mqtt-broker/configuration Complete guide to configuring the Coreflux MQTT Broker - ports, security, TLS, and deployment options Configure your Coreflux MQTT Broker to match your deployment requirements. This guide covers network settings, security options, TLS certificates, and deployment-specific configurations. Configuration changes require a broker restart to take effect. Plan configuration updates during maintenance windows for production systems. To keep retained messages, persistent sessions, and queued QoS 1/2 across restarts, configure the separate [`Durability`](/latest/mqtt-broker/durability) section (or `COREFLUX_DURABILITY_*` environment variables). *** ## Configuration Overview The broker configuration is stored as a JSON object. The structure below shows every supported property (the file is encrypted on disk; this is an illustrative representation): ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Name": "Coreflux DataHub", "Port": 1883, "TlsPort": 8883, "WebsocketPort": 5000, "WebsocketPortTls": 443, "BindIPForMQTT": "0.0.0.0", "BindIPForMQTTwithTls": "0.0.0.0", "BindIpForWebSockets": "0.0.0.0", "BindIpForWebSocketsTls": "0.0.0.0", "AnonymousLogin": false, "CleanSession": false, "SessionExpiryInterval": 0, "MaxPendingMessagesPerClient": 20000, "FullDebugLog": false, "CloudLogging": false, "ResendRetainTopics": false, "ClientCertificationValidation": false, "CheckCertificateChainValidation": false, "CheckCertificateRevokationList": false, "ServerCertificatePath": "", "ServerCertificateKeyPath": "", "ServerCertificatePassword": "", "RootCACertificatePath": "", "ClientCertificateSourcePath": "", "ClientCertificateRevocationListPath": "", "ProjectPath": "", "UsersFile": "", "EnvsPath": "", "SecretsPath": "" } ``` ### First-Run Defaults When no config file exists, the broker creates one with these values: | Property | Default | | --------------------------------- | ---------------------------- | | `Name` | `"Coreflux DataHub"` | | `Port` | `1883` | | `TlsPort` | `8883` | | `WebsocketPort` | `5000` | | `WebsocketPortTls` | `443` | | Bind IPs (MQTT / TLS / WebSocket) | `"0.0.0.0"` (all interfaces) | | `AnonymousLogin` | `true` | | `FullDebugLog` | `false` | | `ClientCertificationValidation` | `false` | | `MaxPendingMessagesPerClient` | `20000` | | `CleanSession` | `false` | | `SessionExpiryInterval` | `0` | On a brand-new install the broker creates the config with `AnonymousLogin: true`. Set it to `false` and configure user credentials before exposing the broker to any untrusted network. *** ## Network Settings ### Ports The broker listens on multiple ports for different connection types: Standard MQTT port for unencrypted connections. This is the default port used by most MQTT clients. MQTT port for TLS-encrypted connections. Requires valid server certificate configuration. Port for MQTT over WebSocket connections. Used by browser-based clients and applications that cannot use raw TCP. Port for MQTT over WebSocket with TLS encryption. Commonly set to 443 to work through corporate firewalls. All ports must be between 1 and 65535. Ensure chosen ports are not already in use by other services. ### IP Bindings Control which network interfaces the broker listens on: IP address to bind for standard MQTT connections. Use `0.0.0.0` to listen on all interfaces, or a specific IP to restrict access. IP address to bind for TLS-encrypted MQTT connections. IP address to bind for WebSocket connections. IP address to bind for WebSocket connections with TLS. #### Example: Restrict to Local Network ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "BindIPForMQTT": "192.168.1.100", "BindIPForMQTTwithTls": "192.168.1.100", "BindIpForWebSockets": "192.168.1.100", "BindIpForWebSocketsTls": "192.168.1.100" } ``` #### Example: Listen on All Interfaces ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "BindIPForMQTT": "0.0.0.0", "BindIPForMQTTwithTls": "0.0.0.0", "BindIpForWebSockets": "0.0.0.0", "BindIpForWebSocketsTls": "0.0.0.0" } ``` *** ## Security Settings ### Authentication When `true`, clients may connect **without** a valid username/password, and unknown usernames are treated as anonymous. When `false`, only registered users in the users store can connect. The class default is `false`, but on a brand-new install the broker writes the first-run config with `AnonymousLogin: true`. Enforced at runtime in `ValidateConnectionAsync`, which rejects unauthenticated connects when `false` and the user is not found. Never leave `AnonymousLogin` enabled in production environments. Always require authentication for broker access. ### TLS Server Certificate Configure TLS encryption for secure client connections: Path to the server certificate file (PEM or PFX format). Required to enable the TLS listeners — MQTTS and WSS endpoints are only started when a server certificate is loaded successfully. If set but the file is missing, a warning is logged and the broker may still start without TLS. Path to the **private key** file when the certificate and key are in separate PEM files. Leave empty if the key is embedded in the certificate file. Password for an encrypted private key or PFX bundle. If `ServerCertificatePath` is set but this is empty, a warning is logged at startup. Path to the root CA certificate for certificate chain validation. #### Example: TLS Configuration ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "ServerCertificatePath": "/certs/server.crt", "ServerCertificateKeyPath": "/certs/server.key", "ServerCertificatePassword": "your-certificate-password", "RootCACertificatePath": "/certs/ca.crt" } ``` ### Mutual TLS (mTLS) For environments requiring client certificate authentication: Directory or source containing the **trusted client CA / client certificate** material used during mTLS validation. If set but the path does not exist, a warning is logged at startup. Enable client certificate validation. When `true`, TLS connections **require** a client certificate (`ClientCertificateMode.RequireCertificate`). When `false`, client certificates are optional (`AllowCertificate`). Path to the Certificate Revocation List (CRL) file for checking revoked certificates. Enable CRL checking for client certificates. Enable full certificate chain validation for client certificates. #### Example: mTLS Configuration ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "ServerCertificatePath": "/certs/server.crt", "ServerCertificateKeyPath": "/certs/server.key", "ServerCertificatePassword": "your-password", "ClientCertificateSourcePath": "/certs/trusted-clients/", "ClientCertificationValidation": true, "CheckCertificateChainValidation": true, "CheckCertificateRevokationList": true, "ClientCertificateRevocationListPath": "/certs/crl.pem" } ``` mTLS is recommended for high-security environments where you need to verify client identity at the transport layer, in addition to username/password authentication. *** ## Sessions and MQTT Behavior **Broker policy** (not the client CONNECT flag). * `false` — allow persistent sessions; honour each client's Clean Session / Clean Start flag. * `true` — **require** clean sessions only; reject clients that connect with a persistent session (returns `NotAuthorized`). Wired in via `ValidateConnectionAsync` (reject policy) and `WithPersistentSessions(!CleanSession)` at startup. **Naming note:** `CleanSession = true` on the broker means "force clean sessions from clients", not "enable clean session". **Clean Session enforcement** — per the MQTT 5.0 spec, a broker with `CleanSession = true` rejects any client connecting with `CleanSession = false` (persistent session). Make sure your clients connect with a clean session when this policy is enabled. **MQTT 5 only** — maximum session lifetime cap, in seconds. Defines how long the broker holds a session after a client disconnects. (Previously hardcoded to 10 minutes; now configurable.) * `0` — no cap; the client's CONNECT value is used as-is. * `> 0` — reject persistent connects where the client requests **more** than this many seconds. * `4294967295` (`uint.MaxValue`) — allow "never expire" client requests. Applied in `MqttService.ApplyConfiguredSessionExpiry` during connection validation. **MQTT 3.1.1:** ignored — persistence is controlled by `CleanSession` + `WithPersistentSessions` only. **Important:** Coreflux has **no** server-builder API for session expiry. Clients that need MQTT 5 persistence must set `SessionExpiryInterval > 0` in their CONNECT packet. When `true`, the broker maintains a buffer of retained messages and re-publishes them in specific situations: on new subscriptions (copies retained messages into an internal buffer) and via periodic `LogRetainedObjects` publishing. Useful when clients need retained messages re-delivered after reconnect or subscription changes. *** ## Performance and Back-Pressure Maximum number of **pending outbound messages** queued per connected client before Coreflux drops messages (especially QoS 0). Protects broker memory when a client is slow to read. Wired in via `WithMaxPendingMessagesPerClient` at startup. `MaxPendingMessagesPerClient` must be between `1` and `100000`. A value outside this range prevents the broker from starting. Per-route wait-or-drop when a LoT route cannot keep up is configured in LoT with `ADD ROUTE_CONFIG`. These environment variables set the broker-wide defaults (a restart is required to change them): | Variable | Default | Effect | | ---------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `COREFLUX_ROUTE_OVERLOAD_POLICY` | `balanced` | Default for `OVERLOAD_POLICY`: `lossless` (wait forever, never drop), `balanced` (wait up to the timeout, then drop the arriving message), or `realtime` (drop immediately). | | `COREFLUX_ROUTE_CHANNEL_FULL_TIMEOUT_MS` | `50` | Default wait budget in milliseconds for `balanced`. | | `COREFLUX_ROUTE_CHANNEL_CAPACITY` | `100000` | Shared ceiling on how many route updates the broker holds at once. Values below `1024` are raised to `1024`. | See [Route Overload Configuration](/latest/lot-language/routes/route-config) for the LoT block, per-route overrides, and what happens under load. *** ## Deployment and Data Paths These paths control where the broker stores its state. `ProjectPath` is the "base" that the other three resolve against. Overrides the broker **data root** (`CFBasePath`) so all state (`bin`, `log`, `stats`, config, projects, users, envs, secrets) lives under this path instead of the default install directory. Typical for Kubernetes volumes or custom install layouts. If not set, the broker uses its install directory. **Precedence:** `COREFLUX_DATA_PATH` (if set) wins over this field. `CONFIG_PATH` sets the initial data directory (used by Docker images) but a non-empty `ProjectPath` can still replace it unless `COREFLUX_DATA_PATH` is set. The `-p` / `--path` flag is applied late — prefer env vars. Path to the file holding the broker's user accounts (the username/password credentials used when `AnonymousLogin` is `false`). When set and valid, users load from this **plaintext JSON** array instead of the encrypted user store, and runtime user changes are written back to this file. Directory containing the `.env` file for environment variables the broker (and LOT logic) can reference at runtime — used to externalize deployment-specific values instead of hardcoding them in config. Resolves to `{EnvsPath}/.env`. Falls back to `CFBasePath` when empty. See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for usage. Directory containing `secrets.json` — the secrets store for sensitive values (tokens, keys, credentials) kept separate from the main config so they aren't sitting in plain config JSON. Used by routes and LOT `GET SECRET`. Resolves to `{SecretsPath}/secrets.json`. Falls back to `CFBasePath` when empty. See [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env) for usage. *** ## Logging Enable detailed debug logging: connection validation details, every publish/subscription intercept, TCP health noise, etc. Useful for troubleshooting but may impact performance in production. Only enable `FullDebugLog` for troubleshooting. Debug logging generates significant log volume and may impact broker performance. When `true`, switches logging to **stdout-only structured JSON** (no rolling file sink). Intended for Docker/Kubernetes where log aggregators scrape container output. The `COREFLUX_CLOUD_LOGGING=true` environment variable also enables cloud logging. *** ## Additional Settings A human-readable name for this DataHub / broker instance. Used for identification in logs and UI; does not affect MQTT protocol behaviour. *** ## Applying Configuration ### Via MQTT Topic Publish a configuration JSON to the system topic using any MQTT client (such as MQTT Explorer). You can send a **partial update** — only the fields you include are changed; everything else is left as-is: | Field | Value | | --------- | ---------------------------------------------------- | | **Topic** | `$SYS/Coreflux/Config/New` | | **Auth** | Requires a user with system configuration permission | **Payload:** Full or partial configuration JSON object ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} {"Name":"ProductionBroker","Port":1883,"TlsPort":8883,"AnonymousLogin":false} ``` The runtime handler currently persists only a subset of fields (`AnonymousLogin`, bind IPs, ports, `FullDebugLog`). After publishing, you must restart the broker for changes to take effect — a restart is required for listener, session, and certificate changes to take full effect. ### Configuration Validation The broker validates configuration on load. The following checks are **fatal** — an invalid value prevents the broker from starting: | Check | Requirement | | ----------------------------- | ---------------------------- | | Port values | Must be between 1 and 65535 | | IP addresses | Must be valid IPv4 addresses | | `MaxPendingMessagesPerClient` | Must be between 1 and 100000 | Certificate-related issues are **non-fatal**: they log a warning at startup and the broker continues without TLS rather than refusing to start. | Condition | Behaviour | | ----------------------------------------------------------------- | ----------------------------------------- | | `ServerCertificatePath` set but file missing | Warning logged; TLS listeners not started | | `ServerCertificatePath` set but `ServerCertificatePassword` empty | Warning logged | | `ClientCertificateSourcePath` set but path does not exist | Warning logged | ### Related Environment Variables These process environment variables override or complement the JSON file. Restart the broker after changing them. Details and examples: [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env). | Variable | Effect | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `COREFLUX_DATA_PATH` | Data directory, used as-is; takes precedence over `CONFIG_PATH` and `ProjectPath` | | `CONFIG_PATH` | Data directory if `COREFLUX_DATA_PATH` is unset (Docker images set `/etc/project`) | | `COREFLUX_CLOUD_LOGGING` | Enables stdout JSON logging (same intent as `CloudLogging`) | | `COREFLUX_LOG_LEVEL` | Log verbosity (`Verbose` … `Fatal`) | | `COREFLUX_SECRET_KEY` | Base64 32-byte key for encrypted secrets | | `COREFLUX_AUDIT_KEY` | Base64 32-byte key for audit log encryption | | `COREFLUX_OPS_ENABLED` | Enable ops HTTP on `COREFLUX_OPS_PORT` (default `9100`) | | `COREFLUX_OPS_PORT` | Ops listener port | | `COREFLUX_OPS_BIND_IP` | Ops bind address | | `COREFLUX_ROUTE_OVERLOAD_POLICY` | Broker-wide default for route `OVERLOAD_POLICY`. See [Route Overload Configuration](/latest/lot-language/routes/route-config). | | `COREFLUX_ROUTE_CHANNEL_FULL_TIMEOUT_MS` | Broker-wide default wait budget (ms) for `balanced` routes | | `COREFLUX_ROUTE_CHANNEL_CAPACITY` | Shared ceiling on queued route updates (default `100000`) | | `COREFLUX_DURABILITY_*` | See [MQTT State Durability](/latest/mqtt-broker/durability) | | `COREFLUX_CLUSTER_*` | See [Clustering](/latest/mqtt-broker/clustering) | | `COREFLUX_DISABLE_PYTHON` | Skip in-process Python when `true` | *** ## Docker Configuration When running Coreflux in Docker, configure the broker using environment variables or mounted configuration files. ### Using Environment Variables ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} docker run --pull=always -d \ --name coreflux_broker \ -p 1883:1883 \ -p 5000:5000 \ -p 9100:9100 \ -v project-volume:/etc/project \ -v /path/to/certs:/certs \ coreflux/coreflux-mqtt-broker:latest ``` ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} version: '3.8' services: coreflux: image: coreflux/coreflux-mqtt-broker:latest container_name: coreflux ports: - "1883:1883" - "8883:8883" - "5000:5000" - "443:443" volumes: - ./config:/config - ./certs:/certs restart: unless-stopped ``` ### Mounting Configuration Mount your configuration file to the container: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} docker run -d \ -v /path/to/config.json:/app/config.json \ -p 1883:1883 \ coreflux/coreflux-mqtt-broker:latest ``` ### TLS with Docker When using TLS in Docker, ensure certificate paths in the configuration match the mounted paths inside the container: ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} version: '3.8' services: coreflux: image: coreflux/coreflux-mqtt-broker:latest ports: - "1883:1883" - "8883:8883" volumes: - ./certs/server.crt:/certs/server.crt:ro - ./certs/server.key:/certs/server.key:ro - ./certs/ca.crt:/certs/ca.crt:ro ``` Configuration JSON should reference the container paths: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "ServerCertificatePath": "/certs/server.crt", "ServerCertificateKeyPath": "/certs/server.key", "RootCACertificatePath": "/certs/ca.crt" } ``` *** ## Configuration Examples ### Development Environment Minimal configuration for local development: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Name": "DevBroker", "Port": 1883, "AnonymousLogin": true, "FullDebugLog": true, "BindIPForMQTT": "127.0.0.1" } ``` ### Production Environment Secure configuration for production deployment: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Name": "ProductionBroker", "Port": 1883, "TlsPort": 8883, "WebsocketPort": 5000, "WebsocketPortTls": 443, "AnonymousLogin": false, "FullDebugLog": false, "BindIPForMQTT": "0.0.0.0", "BindIPForMQTTwithTls": "0.0.0.0", "BindIpForWebSockets": "0.0.0.0", "BindIpForWebSocketsTls": "0.0.0.0", "ServerCertificatePath": "/certs/server.crt", "ServerCertificateKeyPath": "/certs/server.key", "ServerCertificatePassword": "secure-password", "RootCACertificatePath": "/certs/ca.crt" } ``` ### High-Security Environment Configuration with mTLS for maximum security: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Name": "SecureBroker", "Port": 1883, "TlsPort": 8883, "AnonymousLogin": false, "FullDebugLog": false, "BindIPForMQTT": "0.0.0.0", "BindIPForMQTTwithTls": "0.0.0.0", "ServerCertificatePath": "/certs/server.crt", "ServerCertificateKeyPath": "/certs/server.key", "ServerCertificatePassword": "secure-password", "RootCACertificatePath": "/certs/ca.crt", "ClientCertificateSourcePath": "/certs/trusted-clients/", "ClientCertificationValidation": true, "CheckCertificateChainValidation": true, "CheckCertificateRevokationList": true, "ClientCertificateRevocationListPath": "/certs/crl.pem" } ``` *** ## Next Steps Learn how to manage the broker via MQTT commands. Monitor broker status through system topics. # MQTT State Durability Source: https://docs.coreflux.org/latest/mqtt-broker/durability Persist retained messages, sessions, and queued QoS 1/2 across broker restarts with the durability write-ahead log ## Keep MQTT state across restarts By default, retained messages, persistent sessions, and queued QoS 1/2 traffic live in memory. A broker restart clears them. **Durability** writes that state to a local write-ahead log (WAL) so after a restart, clients can reconnect and pick up where they left off — their subscriptions, retained values, and any messages that were still waiting to be delivered. **Like a notebook next to a whiteboard.** The whiteboard is fast working memory; the notebook is what you still have after the room goes dark. Durability is the notebook for MQTT state that must survive a restart. ### When to enable durability Enable durability when you need: * retained values to survive a broker restart; * persistent sessions and their subscriptions restored after reboot; or * queued QoS 1/2 messages re-offered to clients that reconnect with a persistent session. Leave it off on constrained edge devices (slow flash, tight write budgets) or when restart persistence is not required. Durability is a **broker-level** setting. It works on a standalone broker and on every cluster node. [High-availability clustering](/latest/mqtt-broker/clustering) builds on it for session failover — enable durability when you need subscriptions to follow a client that reconnects to another node. *** Configure the `Durability` section (or `COREFLUX_DURABILITY_*` environment variables), restart the broker, and verify retained or session state after a controlled restart. ## Quick Start Add a `Durability` block to the broker configuration, or set environment variables (env vars override the file). Restart the broker for changes to take effect. Opt in with the default flush policy (group commit every 50 ms): ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Durability": { "Enabled": true, "FsyncPolicy": "interval", "FsyncIntervalMs": 50, "Codec": "json" } } ``` Flush every append when you need the smallest power-loss window on a single node: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Durability": { "Enabled": true, "FsyncPolicy": "always", "Codec": "json" } } ``` Tune without editing the config file (useful in containers): ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} export COREFLUX_DURABILITY_ENABLED=true export COREFLUX_DURABILITY_FSYNC=interval export COREFLUX_DURABILITY_FSYNC_INTERVAL_MS=50 export COREFLUX_DURABILITY_CODEC=json # optional: export COREFLUX_DURABILITY_DATA_DIR=/var/lib/coreflux/mqttwal ``` *** ## What is persisted | State | Survives restart? | Notes | | --------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------- | | **Retained messages** | Yes | Reloaded on boot on that node | | **Persistent sessions + subscriptions** | Yes | A client that reconnects with a persistent session gets its subscriptions back | | **Queued QoS 1/2 (unacked)** | Yes | Re-offered on session restore (**at-least-once** — a message acked at crash time may be redelivered) | QoS 0 traffic is not made durable by this layer — MQTT treats it as best-effort. *** ## Configuration reference | Config field | Env var | Default | Description | | ------------------------ | --------------------------------------- | --------------------- | ---------------------------------------------------------- | | `Enabled` | `COREFLUX_DURABILITY_ENABLED` | `false` | Master switch — opt in for restart durability | | `FsyncPolicy` | `COREFLUX_DURABILITY_FSYNC` | `interval` | `interval` (group commit) or `always` (flush every append) | | `FsyncIntervalMs` | `COREFLUX_DURABILITY_FSYNC_INTERVAL_MS` | `50` | Flush interval in ms when policy is `interval` | | `Codec` | `COREFLUX_DURABILITY_CODEC` | `json` | Log encoding: `json` (readable) or `binary` (compact) | | `DataDir` | `COREFLUX_DURABILITY_DATA_DIR` | `{data path}/mqttwal` | Root directory for durable MQTT state | | `MaxSegmentBytes` | — | `8388608` (8 MiB) | Segment size that triggers rotation | | `CheckpointEveryAppends` | — | `4096` | Appends between checkpoints that truncate older segments | Accept `1` / `true` (case-insensitive) for `COREFLUX_DURABILITY_ENABLED`. ### Fsync policy | Policy | Behavior | Trade-off | | -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `interval` (default) | Appends buffer and flush together on a timer | Bounded loss window on **power failure** only; process crashes still keep what the OS already had | | `always` | Flush to disk on every append | Strongest single-node guarantee; highest I/O cost (costly on eMMC/SD edge devices) | ### Codec * **`json` (default)** — human-readable records; easier to inspect during debugging. * **`binary`** — compact encoding; smaller on disk and typically faster to encode/decode on busy brokers. Existing JSON segments still recover; new records append in binary. Switching back from binary to `json` for inspection of already-written binary segments is not supported — stay on `json` if you need text tooling on the log. In a cluster, the same codec setting also applies to the replication logs used for quorum durability. *** ## Durability and clustering Durability and clustering solve different loss modes: | Concern | What protects you | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | **Single-node restart** | Durability WAL on that broker | | **Minority node crash after an acknowledged QoS ≥ 1 publish** | Cluster [quorum replication](/latest/mqtt-broker/clustering#delivery-guarantees) | | **Client reconnects to another node** | Session takeover — requires **`Durability.Enabled`** so subscriptions can hydrate on the new node | **Offline queues stay on the node that hosted them.** Subscriptions can follow a client after failover (with durability enabled), but queued QoS 1/2 messages for offline clients do not migrate. See [clustering limitations](/latest/mqtt-broker/clustering#limitations). *** ## Observability Prometheus `/metrics` exposes durability and WAL series, including: | Metric family | What it shows | | ----------------------------- | ------------------------------------------------------------------------------- | | `coreflux_durability_enabled` | Whether durability is on | | `coreflux_wal_*` | Appends, bytes written, fsyncs, last fsync latency | | `coreflux_durable_*` | Counts for retained messages, sessions, in-flight messages, and in-flight drops | Use these alongside your usual broker health checks after enabling durability or changing fsync policy. *** ## Limitations **Durability is opt-in.** The default is `Enabled: false`. Without it, retained messages, sessions, and queued QoS 1/2 are lost on restart, and cluster session failover cannot restore subscriptions on another node. **Enabling durability reduces QoS 1 throughput by about 20%.** The write-ahead log adds per-message work on the publish path. QoS 0 is unaffected. Leave durability off when you need maximum QoS 1 throughput and do not need restart persistence or cluster session failover. **At-least-once after crash.** A QoS 1/2 message acknowledged at the exact moment of failure may be redelivered after restore. Design consumers to tolerate duplicates. **Retained messages remain node-local.** Durability restores retains on the same node after restart; it does not create a cluster-wide retained store. See [clustering limitations](/latest/mqtt-broker/clustering#limitations). *** ## Next Steps Run multiple brokers with quorum replication and session failover on top of durability. Ports, TLS, listeners, and other broker settings. # MQTT Broker Source: https://docs.coreflux.org/latest/mqtt-broker/overview Introduction to the Coreflux MQTT Broker - the core component of your IoT platform. ## Why Use the Coreflux MQTT Broker? Your IoT devices need to communicate with each other and with your applications. Without a central hub, you'd need to manage complex point-to-point connections between every device—impossible to scale and maintain. **Think of the broker as a smart post office.** Devices "mail" messages to topics (like addresses), and the broker delivers them to everyone who "subscribed" to that address. No device needs to know who else is listening. The Coreflux MQTT Broker solves this by providing: * **Reliable message delivery** between any number of devices * **Built-in data processing** with LoT (Language of Things) * **Security and access control** without external services * **Edge computing** that reduces cloud dependency *** ## How Does an MQTT Broker Work? An MQTT broker sits at the center of all communication. Instead of devices talking directly to each other, they all talk to the broker—and the broker handles delivery. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart TB subgraph Publishers A[Temperature Sensor] B[Pressure Sensor] C[Motion Detector] end subgraph Broker D[Coreflux MQTT Broker] end subgraph Subscribers E[Dashboard App] F[Alert System] G[Database Logger] end A -->|"PUBLISH sensors/temp"| D B -->|"PUBLISH sensors/pressure"| D C -->|"PUBLISH sensors/motion"| D D -->|"DELIVER"| E D -->|"DELIVER"| F D -->|"DELIVER"| G ``` ### The Publish-Subscribe Pattern MQTT uses a **publish-subscribe** (pub/sub) model: 1. **Publishers** send messages to **topics** (like `sensors/temperature`) 2. **Subscribers** register interest in topics they care about 3. The **broker** receives all messages and delivers them to matching subscribers This decouples senders from receivers—a sensor doesn't need to know (or care) who's listening to its data. ### What the Broker Handles | Function | What It Does | | ------------------------- | -------------------------------------------------------------------------- | | **Message Routing** | Receives messages from publishers, matches topics, delivers to subscribers | | **Connection Management** | Maintains client connections, handles reconnects, manages session state | | **Quality of Service** | Ensures delivery guarantees (at-most-once, at-least-once, exactly-once) | | **Message Retention** | Optionally stores the last message on a topic for new subscribers | | **Security** | Authenticates clients and enforces access permissions | *** ## The Coreflux Advantage Standard MQTT brokers route messages—that's it. The Coreflux MQTT Broker goes further with **built-in intelligence** using the LoT (Language of Things) runtime: ### Beyond Message Routing * **[LoT Actions](/latest/lot-language/actions/overview)** — React to messages automatically. When a temperature exceeds a threshold, trigger an alert without external code. * **[LoT Models](/latest/lot-language/models/overview)** — Transform and structure data in real-time. Convert raw sensor values into formatted JSON with timestamps and metadata. * **[LoT Routes](/latest/lot-language/routes/overview)** — Connect to external systems. Bridge to other brokers, store data in databases, call REST APIs—all configured in LoT. * **[LoT Rules](/latest/lot-language/rules/overview)** — Control access with fine-grained permissions. Define who can publish, subscribe, or manage the broker. ### Processing at the Edge Instead of sending all data to the cloud for processing, the Coreflux broker processes data **where it's generated**: * **Lower latency** — Local actions execute in milliseconds, no round-trip to the cloud * **Reduced bandwidth** — Only filtered or aggregated data leaves the edge * **Offline resilience** — Critical logic keeps running even if the internet drops * **Stronger privacy** — Sensitive data stays on-premises by default *** ## Key Benefits MQTT's lightweight protocol is designed for constrained devices and limited bandwidth. The Coreflux broker optimizes message delivery for challenging network conditions—perfect for industrial environments, remote sites, or battery-powered devices. **Coreflux enhancement:** Built-in message compression and intelligent batching reduce bandwidth by up to 70% compared to raw MQTT. IoT security is non-negotiable. The Coreflux broker includes TLS encryption, certificate-based authentication, and username/password auth out of the box. **Coreflux enhancement:** LoT Rules provide fine-grained access control—define exactly who can publish, subscribe, or manage each topic pattern. Raw sensor data is rarely useful as-is. It needs filtering, transformation, and enrichment before applications can use it. **Coreflux enhancement:** LoT Models and Actions process data in real-time at the edge. No external ETL pipelines or stream processors required. IoT systems don't exist in isolation. They connect to databases, APIs, dashboards, and other brokers. **Coreflux enhancement:** LoT Routes provide native integration with SQL databases, REST APIs, industrial protocols (Modbus, OPC-UA, S7), and other MQTT brokers. *** ## Coreflux MQTT Broker as a Platform The Coreflux MQTT Broker is more than a message router — it is a complete IoT platform running at the edge: | Layer | What You Get | | ------------------ | ---------------------------------------------------------------------------------------------------------------- | | **MQTT Messaging** | Reliable pub/sub communication with QoS guarantees, session management, and retained messages | | **Security** | TLS encryption, client authentication, and fine-grained topic-level access control via LoT Rules | | **LoT Runtime** | Built-in automation, data transformation, external integrations, and access policies — no external code required | | **Administration** | Configuration, logging, metrics, and maintenance through MQTT-native management topics | *** ## Next Steps Learn the fundamentals of MQTT messaging. Install and configure your Coreflux broker. # MQTT Protocol Source: https://docs.coreflux.org/latest/mqtt-broker/protocol Understanding the MQTT protocol - the foundation of IoT communication. ## The Language of IoT MQTT (Message Queuing Telemetry Transport) is a lightweight, publish-subscribe messaging protocol designed for constrained devices and low-bandwidth, high-latency networks. It has become the de facto standard for IoT communication. Before diving into Coreflux features like LoT Actions and Models, you need to understand how MQTT works. Every Coreflux capability builds on these fundamentals—topics, subscriptions, QoS, and message patterns. **Think of MQTT like a radio station.** Publishers broadcast on a frequency (topic), and anyone who tunes in (subscribes) hears the message. The broker is the transmission tower that makes it all work. MQTT is designed to be simple, lightweight, and easy to implement. A minimal MQTT control message can be as small as 2 bytes, making it ideal for resource-constrained devices. *** ## The Publish-Subscribe Pattern Unlike traditional request-response protocols (like HTTP), MQTT uses a publish-subscribe (pub/sub) pattern. This decouples the sender (publisher) from the receiver (subscriber), allowing for more flexible and scalable architectures. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR subgraph Publishers A[Temperature Sensor] B[Humidity Sensor] end subgraph Broker C[Coreflux MQTT Broker] end subgraph Subscribers D[Mobile App] E[Dashboard] F[Database] end A -->|"PUBLISH
sensors/temp"| C B -->|"PUBLISH
sensors/humidity"| C C -->|"DELIVER"| D C -->|"DELIVER"| E C -->|"DELIVER"| F ``` ### How It Works A client publishes a message to a specific topic on the broker. The broker receives the message and checks which clients are subscribed to that topic. The broker forwards the message to all subscribers of that topic. *** ## Topics Topics are the routing mechanism in MQTT. They are UTF-8 strings that the broker uses to filter messages for each connected client. ### Topic Structure Topics are hierarchical, using forward slashes (`/`) as level separators. This hierarchy is powerful for organizing data—especially in industrial settings using patterns like the Unified Namespace (UNS). ``` building/floor1/room101/temperature building/floor1/room101/humidity building/floor2/room201/temperature ``` ### Topic Tree Example A well-designed topic tree organizes your entire system logically: ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} flowchart LR A[enterprise] --> B[site] --> C[area] --> D[line] --> E[cell] --> F[device] --> G[measurement] ``` Here's how this maps to real topics: ``` enterprise/ ├── factory-munich/ │ ├── production/ │ │ ├── line-1/ │ │ │ ├── cnc-machine-01/ │ │ │ │ ├── temperature │ │ │ │ ├── vibration │ │ │ │ └── status │ │ │ └── robot-arm-02/ │ │ │ ├── position │ │ │ └── status │ │ └── line-2/ │ │ └── ... │ └── quality/ │ └── ... └── factory-berlin/ └── ... ``` Design your topic hierarchy carefully. A well-structured topic tree—often called a Unified Namespace (UNS)—makes it easier to subscribe to related data, manage access control, and scale your system. ### Topic Best Practices | Practice | Example | Description | | -------------------------- | ---------------------------------- | --------------------------------- | | Use hierarchical structure | `factory/line1/machine1/status` | Organize topics logically | | Be specific | `sensors/temp/celsius` | Avoid overly generic topics | | Use lowercase | `home/living-room/light` | Consistent naming convention | | Avoid leading slashes | `home/kitchen` not `/home/kitchen` | Leading slash creates empty level | *** ## Wildcards MQTT supports two wildcard characters for subscribing to multiple topics at once. The `+` wildcard matches **exactly one** topic level. **Subscription:** `building/floor1/+/temperature` **Matches:** `building/floor1/room101/temperature`, `building/floor1/room102/temperature`, `building/floor1/lobby/temperature` **Does NOT match:** `building/floor2/room201/temperature` (different floor), `building/floor1/room101/humidity` (different measurement), `building/floor1/west/room101/temperature` (extra level) The `#` wildcard matches **any number of levels** and must be at the end. **Subscription:** `building/floor1/#` **Matches:** `building/floor1/room101/temperature`, `building/floor1/room101/humidity`, `building/floor1/room102/status`, `building/floor1/lobby/motion/detected` **Does NOT match:** `building/floor2/room201/temperature` (different floor), `building/basement/storage/humidity` (different path) Use wildcards carefully. Subscribing to `#` alone would receive ALL messages on the broker, which could overwhelm your client and create security risks. *** ## Quality of Service (QoS) MQTT defines three levels of Quality of Service for message delivery. Choose based on your reliability requirements and network conditions. ### QoS 0 — At Most Once **Fire and forget.** The message is sent once with no acknowledgment. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sequenceDiagram participant P as Publisher participant B as Broker participant S as Subscriber P->>B: PUBLISH B->>S: PUBLISH ``` | Aspect | Detail | | ------------------ | ------------------------------------------------------------ | | **Delivery** | At most once (may be lost) | | **Acknowledgment** | None | | **Performance** | Fastest | | **Use when** | Frequent sensor readings where occasional loss is acceptable | **Best for:** High-frequency telemetry data like temperature readings every second. Missing one reading doesn't matter when the next arrives immediately. ### QoS 1 — At Least Once **Acknowledged delivery.** The sender stores the message until the broker confirms receipt. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sequenceDiagram participant P as Publisher participant B as Broker participant S as Subscriber P->>B: PUBLISH B->>S: PUBLISH B->>P: PUBACK ``` | Aspect | Detail | | ------------------ | ----------------------------------------------------------- | | **Delivery** | At least once (may duplicate) | | **Acknowledgment** | PUBACK from broker | | **Performance** | Moderate | | **Use when** | Important data where your application can handle duplicates | **Best for:** Event logs, alerts, or status updates. If the same alert arrives twice, your system should deduplicate it. ### QoS 2 — Exactly Once **Assured delivery.** A four-way handshake ensures the message arrives exactly once. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sequenceDiagram participant P as Publisher participant B as Broker P->>B: PUBLISH B->>P: PUBREC P->>B: PUBREL B->>P: PUBCOMP ``` | Aspect | Detail | | ------------------ | --------------------------------------------- | | **Delivery** | Exactly once (guaranteed, no duplicates) | | **Acknowledgment** | PUBREC → PUBREL → PUBCOMP handshake | | **Performance** | Slowest | | **Use when** | Critical data where duplicates cause problems | **Best for:** Financial transactions, billing events, or commands where duplicate execution would be harmful (e.g., "dispense medication"). *** ## Retained Messages When a message is published with the retain flag set, the broker stores the last message for that topic. Any new subscriber immediately receives this retained message upon subscribing. Retained messages are useful for status topics. A new client connecting can immediately know the current state without waiting for the next update. **Example use cases:** * Device status (online/offline) * Current configuration values * Last known sensor readings *** ## Last Will and Testament (LWT) The Last Will and Testament feature allows a client to specify a message that the broker should publish if the client disconnects unexpectedly. This is essential for detecting device failures. ### How LWT Works ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sequenceDiagram participant D as Device participant B as Broker participant M as Monitoring System D->>B: CONNECT with LWT Note right of B: LWT stored:
"devices/sensor-01/status" = "offline" D->>B: PUBLISH status = "online" B->>M: status = "online" Note over D: Device crashes or
loses network B->>M: LWT triggered:
status = "offline" ``` When connecting, the client specifies its LWT message, topic, QoS, and retain flag. The broker holds the LWT message as long as the client stays connected. If the client disconnects without sending a proper DISCONNECT packet (crash, network loss), the broker publishes the LWT message. ### LWT Configuration Example A typical pattern for device status monitoring: | Setting | Value | Purpose | | --------------- | -------------------------- | -------------------------------------- | | **LWT Topic** | `devices/sensor-01/status` | Where to publish if device disconnects | | **LWT Message** | `offline` | What to publish | | **LWT Retain** | `true` | Keep message for new subscribers | | **LWT QoS** | `1` | Ensure delivery to monitoring systems | When the device connects, it publishes `online` to the same topic (retained). If it crashes, the broker publishes `offline`. Monitoring systems always know the current state. *** ## Clean Sessions MQTT supports two session types: | Session Type | Clean Session Flag | Behavior | | ---------------------- | ------------------ | ---------------------------------------------------------------------------------- | | **Clean Session** | `true` | No state is stored between connections. Subscriptions are discarded on disconnect. | | **Persistent Session** | `false` | Broker stores subscriptions and queued messages (QoS 1/2) for the client. | Persistent sessions are useful for mobile devices or intermittent connections where you don't want to miss messages during brief disconnections. *** ## Keep Alive The keep-alive mechanism ensures the connection remains active and detects dead connections quickly. ```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sequenceDiagram participant C as Client participant B as Broker C->>B: CONNECT (keep-alive: 60s) Note over C,B: Normal data exchange C->>B: PUBLISH B->>C: PUBACK Note over C,B: No data to send...
approaching keep-alive timeout C->>B: PINGREQ B->>C: PINGRESP Note over C,B: Connection confirmed alive C->>B: PINGREQ Note over B: No PINGRESP for 1.5x keep-alive Note over B: Connection declared dead,
LWT published ``` ### How It Works | Step | Description | | --------------- | ----------------------------------------------------------------------------------------------- | | **1. Connect** | Client specifies a keep-alive interval (in seconds) when connecting | | **2. Activity** | Client must send a control packet within each keep-alive period | | **3. Ping** | If no data is being sent, the client sends PINGREQ | | **4. Response** | The broker responds with PINGRESP | | **5. Timeout** | If no communication occurs within 1.5x the keep-alive period, the connection is considered dead | Choose keep-alive values based on your network. Shorter intervals (15-30s) detect failures faster but use more bandwidth. Longer intervals (60-120s) are better for mobile networks with variable latency. *** ## Next Steps Learn how to control the Coreflux broker via MQTT commands. Explore system topics for monitoring and control. # Environment Variables & Secrets Source: https://docs.coreflux.org/latest/mqtt-broker/secrets-and-env Manage configuration, credentials, and sensitive data securely with environment variables and encrypted secrets ## Stop Hardcoding Credentials Every IoT deployment needs connection strings, API keys, and passwords -- but embedding them directly in your LoT code or route definitions creates security risks and makes your configuration impossible to reuse across environments. The Coreflux Broker solves this with built-in environment variable and secrets management. * **Environment variables** store non-sensitive configuration (hostnames, ports, URLs) in a plain-text `.env` file * **Secrets** store sensitive credentials (passwords, API keys, tokens) encrypted at rest with **AES-256-GCM** Both are accessible from LoT Actions, Models, Routes, and broker CLI commands using a unified syntax -- no external tools or services required. **Think of it like a vault and a notebook.** Environment variables are the notebook -- quick to read, easy to update, visible if someone opens the file. Secrets are the vault -- locked with a key, encrypted on disk, only decrypted when the broker needs them at runtime. Environment variables and secrets management is available from **Coreflux Broker v1.9.3** and above. ## When to Use Each | Scenario | Use | Why | | ------------------------- | ------------------------ | -------------------------------------------------- | | Database host, port, name | **Environment variable** | Non-sensitive, changes per environment | | API base URLs | **Environment variable** | Configuration that varies between dev/staging/prod | | Feature flags | **Environment variable** | Simple on/off toggles | | Database passwords | **Secret** | Must not be readable on disk | | API keys and tokens | **Secret** | Sensitive credentials | | TLS passphrases | **Secret** | Security-critical values | *** ## Config Root The broker picks its **data directory** (config root) in this order — **first match wins**: 1. `COREFLUX_DATA_PATH` (used as-is) 2. `CONFIG_PATH` (used as-is; this is what official Docker images set) 3. `-p` / `--path` (used as-is, applied later in startup — prefer the env vars) 4. Default: `{install directory}/Coreflux` When an env var is set, **`/Coreflux` is not appended**. With `CONFIG_PATH=/etc/project`, files live at `/etc/project/.env`, not `/etc/project/Coreflux/.env`. The `/Coreflux` suffix is only added in the default case (no env var and no `-p`). | Install | Typical data directory | | ------------------------- | --------------------------------------------------------------------------- | | Docker / Compose | `/etc/project` (`CONFIG_PATH` in the image; mount your volume here) | | Linux APT/DNF | `/var/lib/coreflux` (`COREFLUX_DATA_PATH` in `coreflux-broker.service`) | | Windows / portable binary | `{folder with the executable}/Coreflux` unless you set `COREFLUX_DATA_PATH` | ### File Layout Environment and secrets files live in the data directory: ``` {data directory}/ .env # Plain-text environment variables (key=value) secrets.json # AES-256-GCM encrypted secrets secret.key # Optional: encryption key (base64, 32 bytes) bin/ # Internal broker data (managed automatically) log/ # Log files stats/ # Statistics ``` *** ## Broker process environment variables These names are read by the **broker process** (Docker `environment:`, systemd `Environment=`, Kubernetes `env:`). They are not the same as values you store in `.env` for `GET ENV` — though a name set in the process environment is still visible to `GET ENV` through the fallback chain. Restart the broker after changing them. ### Data path and logging | Variable | Default | Description | | ------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `COREFLUX_DATA_PATH` | `{install}/Coreflux` | Data directory, used as-is. Wins over `CONFIG_PATH` and over the `ProjectPath` config field. | | `CONFIG_PATH` | — | Same role as `COREFLUX_DATA_PATH` if that variable is unset. Official images set this to `/etc/project`. A non-empty `ProjectPath` in broker JSON can still override it unless `COREFLUX_DATA_PATH` is set. | | `COREFLUX_CLOUD_LOGGING` | auto in containers | `true` / `1` forces stdout JSON logs. Also on when the process runs in a container. | | `COREFLUX_LOG_LEVEL` | `Information` | Serilog level (`Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`). | ### Secrets and audit | Variable | Default | Description | | --------------------- | ----------- | ----------------------------------------------------------------------------------------------- | | `COREFLUX_SECRET_KEY` | derived key | Base64-encoded 32-byte key for `secrets.json`. Preferred in Docker/Kubernetes. | | `COREFLUX_AUDIT_KEY` | derived key | Base64-encoded 32-byte key for encrypted audit logs (or use `audit.key` in the data directory). | ### Operational HTTP (`/health`, `/ready`, `/info`, `/metrics`) | Variable | Default | Description | | ---------------------- | --------- | -------------------------------------------------------------- | | `COREFLUX_OPS_ENABLED` | `true` | Set `false` / `0` to disable the ops listener. | | `COREFLUX_OPS_PORT` | `9100` | Ops TCP port. Do not publish this port to the public internet. | | `COREFLUX_OPS_BIND_IP` | `0.0.0.0` | Ops bind address. Use `127.0.0.1` to keep probes on localhost. | Env vars override the matching fields in broker JSON (`OpsEnabled`, `OpsPort`, `OpsBindIP`). ### Routes, durability, clustering, Python | Variable | Description | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `COREFLUX_ROUTE_OVERLOAD_POLICY` | Broker-wide default `lossless`, `balanced`, or `realtime`. See [Route Overload Configuration](/latest/lot-language/routes/route-config). | | `COREFLUX_ROUTE_CHANNEL_FULL_TIMEOUT_MS` | Wait budget (ms) for `balanced` (default `50`). | | `COREFLUX_ROUTE_CHANNEL_CAPACITY` | Shared queue ceiling (default `100000`). | | `COREFLUX_DURABILITY_*` | Restart persistence for retained messages and sessions. See [MQTT State Durability](/latest/mqtt-broker/durability). | | `COREFLUX_CLUSTER_*` | High-availability clustering. See [Clustering](/latest/mqtt-broker/clustering). | | `COREFLUX_DISABLE_PYTHON` | `true` skips in-process Python (`CALL PYTHON`). | *** ## Environment Variables ### Storage Environment variables are stored in plain text in `{data directory}/.env` using standard key=value format: ```env wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DB_HOST=postgres.example.com DB_PORT=5432 API_BASE_URL=https://api.example.com MQTT_BRIDGE_HOST=remote-broker.company.com ``` ### Resolution Order When the broker resolves an environment variable (via `GET ENV`, route config, or CLI), it checks multiple sources in this order: | Priority | Source | Description | | -------- | --------------------- | ----------------------------------------------------------- | | 1 | `.env` file (managed) | Set via `-setEnv` or `KEEP ENV`. Persisted to disk. | | 2 | Process environment | Docker `environment:`, Kubernetes `env:`, or shell exports. | | 3 | User environment | OS user-level environment variables (non-Docker). | | 4 | Machine environment | OS machine-level environment variables (non-Docker). | If not found in any source, an empty string is returned. This fallback chain means Docker `environment:` variables work even without a `.env` file -- the broker picks them up from the process environment automatically. *** ## Secrets ### How Secrets Work Secrets are encrypted at rest using **AES-256-GCM** and stored in `{data directory}/secrets.json`. Each secret entry contains the initialization vector, the encrypted value with authentication tag, and a creation timestamp: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "DB_PASSWORD": { "IV": "base64...", "Value": "base64(ciphertext):base64(tag)", "Created": "2026-02-08T12:00:00Z" } } ``` Secret values are only decrypted at runtime when accessed via `GET SECRET` or route configuration. They are never logged or displayed -- even the `-listSecrets` command only shows secret names, never values. ### Encryption Key The broker resolves the encryption key in this order: | Priority | Source | Notes | | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | 1 | `COREFLUX_SECRET_KEY` env var | Best for Docker/Kubernetes. Base64-encoded, 32 bytes. | | 2 | `{data directory}/secret.key` file | Good for mounted volumes. Base64-encoded, 32 bytes. | | 3 | Derived from machine GUID | Auto-generated fallback. Machine-specific -- secrets become unreadable if moved to a different machine. | #### Generating a Key Use OpenSSL to generate a cryptographically secure 32-byte key: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} openssl rand -base64 32 # Example output: K7x2mN8pQ1rT5uW3yB6dF9hJ0kL4nP7sV2xZ8cG1eA= ``` If you use the derived key fallback (no explicit key), your secrets are tied to the specific machine. Moving the `secrets.json` file to another machine or container will make the secrets unreadable. Always use an explicit key for portable deployments. *** ## Using in LoT Environment variables and secrets can be read, written, and deleted from within LoT Actions, Models, and route configurations. ### Reading Values Use `GET ENV` to read an environment variable and `GET SECRET` to read an encrypted secret (decrypted at runtime): ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Read configuration from environment SET "myHost" WITH (GET ENV "DB_HOST") SET "myPort" WITH (GET ENV "DB_PORT") -- Read credentials from encrypted secrets SET "dbPass" WITH (GET SECRET "DB_PASSWORD") SET "apiKey" WITH (GET SECRET "API_KEY") ``` ### Persisting Values Use `KEEP ENV` to save a new environment variable to the `.env` file, and `KEEP SECRET` to encrypt and store a new secret: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Save an environment variable (persisted to .env) KEEP ENV "LAST_RUN" WITH TIMESTAMP "UTC" -- Save an encrypted secret (persisted to secrets.json) KEEP SECRET "API_KEY" WITH "sk-abc123" ``` ### Deleting Values Use `DELETE ENV` and `DELETE SECRET` to remove values: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} -- Remove an environment variable DELETE ENV "OLD_CONFIG" -- Remove a secret DELETE SECRET "OLD_API_KEY" ``` ### Complete Example This action reads database configuration from environment variables and credentials from secrets on broker startup, then reports the status: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION ConnectToDatabase ON START DO -- Read connection settings from environment SET "host" WITH (GET ENV "DB_HOST") SET "port" WITH (GET ENV "DB_PORT") SET "dbName" WITH (GET ENV "DB_NAME") -- Read credentials from encrypted secrets SET "user" WITH (GET SECRET "DB_USER") SET "pass" WITH (GET SECRET "DB_PASSWORD") IF {host} == EMPTY THEN PUBLISH TOPIC "system/errors" WITH "DB_HOST not configured" ELSE PUBLISH TOPIC "system/status" WITH "Database configured: " + {host} + ":" + {port} -- Track when we last initialized KEEP ENV "LAST_DB_INIT" WITH TIMESTAMP "UTC" ``` *** ## Using in Routes Environment variables and secrets can be referenced directly in route definitions, keeping connection details out of your LoT code. ### Standard Syntax Use `GET ENV` and `GET SECRET` in route configuration blocks to inject values at runtime: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE MyDB WITH TYPE POSTGRESQL ADD SQL_CONFIG WITH SERVER GET ENV "DB_HOST" WITH PORT 5432 WITH DATABASE GET ENV "DB_NAME" WITH USERNAME GET ENV "DB_USER" WITH PASSWORD GET SECRET "DB_PASSWORD" ``` ### Legacy Prefix Syntax The `ENV:` and `SECRET:` prefix syntax is also supported for backward compatibility in route `ADD CONFIG` sections: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} WITH SERVER ENV:DB_HOST WITH PASSWORD SECRET:DB_PASSWORD ``` ### Event Query Placeholders In route event queries (e.g. `WITH QUERY`), use curly-brace placeholders that are resolved at execution time: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} ADD EVENT StoreReading WITH SOURCE_TOPIC "sensors/#" WITH QUERY "INSERT INTO readings (host, value) VALUES ('{env.DB_HOST}', '{payload}')" ``` | Placeholder | Resolves To | | --------------- | -------------------------- | | `{env.NAME}` | Environment variable value | | `{secret.NAME}` | Decrypted secret value | *** ## CLI Commands Manage environment variables and secrets at runtime by publishing commands to `$SYS/Coreflux/Command` using any MQTT client (such as MQTT Explorer). ### Environment Variable Commands | Command | Description | | -------------------- | ------------------------------------------------------ | | `-setEnv NAME=value` | Set an environment variable (persisted to `.env` file) | | `-removeEnv NAME` | Remove an environment variable | | `-listEnv` | List all managed environment variables | Publish these messages to `$SYS/Coreflux/Command` to manage environment variables: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Set an environment variable -setEnv DB_HOST=postgres.example.com # List all environment variables -listEnv # Remove an environment variable -removeEnv OLD_CONFIG ``` ### Secret Commands | Command | Description | | ----------------------- | -------------------------------------------------------- | | `-setSecret NAME=value` | Set a secret (encrypted and persisted to `secrets.json`) | | `-removeSecret NAME` | Remove a secret | | `-listSecrets` | List secret names (values are never shown) | Publish these messages to `$SYS/Coreflux/Command` to manage secrets: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Set a secret -setSecret DB_PASSWORD=my_secure_password # List secret names -listSecrets # Remove a secret -removeSecret OLD_API_KEY ``` Subscribe to `$SYS/Coreflux/Command/Output` to receive confirmation responses from commands. *** ## Deployment Set up environment variables and secrets across different deployment targets. On Windows, the data directory defaults to the broker's executable folder + `\Coreflux` unless `COREFLUX_DATA_PATH` is set (used as-is). If the broker is at `C:\Coreflux\CorefluxMQTTBroker.exe`, the default data directory is `C:\Coreflux\Coreflux\`. To pin a custom path, set a machine environment variable (no extra `\Coreflux` is appended): ```powershell wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [Environment]::SetEnvironmentVariable("COREFLUX_DATA_PATH", "C:\CorefluxData", "Machine") ``` Files then live directly under `C:\CorefluxData\`. Create a `.env` file in the data directory: ```powershell wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} @" DB_HOST=postgres.example.com DB_PORT=5432 API_BASE_URL=https://api.example.com "@ | Out-File -FilePath "C:\CorefluxData\.env" -Encoding UTF8 ``` Generate and save a `secret.key` file for encrypting secrets: ```powershell wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} openssl rand -base64 32 > "C:\CorefluxData\secret.key" ``` Start the broker, then set secrets using any MQTT client (such as MQTT Explorer). Publish to `$SYS/Coreflux/Command`: ``` -setSecret DB_PASSWORD=my_secure_password ``` Subscribe to `$SYS/Coreflux/Command/Output` to confirm the secret was stored. APT/DNF installs already set `COREFLUX_DATA_PATH=/var/lib/coreflux` on `coreflux-broker.service`. Put `.env` and `secret.key` there. Write your environment variables and restrict permissions: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sudo tee /var/lib/coreflux/.env << 'EOF' DB_HOST=postgres.example.com DB_PORT=5432 API_BASE_URL=https://api.example.com EOF sudo chown coreflux:coreflux /var/lib/coreflux/.env sudo chmod 600 /var/lib/coreflux/.env ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} openssl rand -base64 32 | sudo tee /var/lib/coreflux/secret.key >/dev/null sudo chown coreflux:coreflux /var/lib/coreflux/secret.key sudo chmod 600 /var/lib/coreflux/secret.key ``` ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sudo systemctl restart coreflux-broker ``` Using any MQTT client, publish to `$SYS/Coreflux/Command`: ``` -setSecret DB_PASSWORD=my_secure_password ``` Verify with `-listSecrets` -- the secret name should appear in the output. ### Option 1: Mount a Config Volume (Recommended) Pre-create the config files on the host and mount them into the container. **1. Prepare the host directory:** Create the config directory, generate an encryption key, and write your environment variables: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} mkdir -p ./coreflux-data # Generate encryption key openssl rand -base64 32 > ./coreflux-data/secret.key # Create .env file cat > ./coreflux-data/.env << 'EOF' DB_HOST=postgres.example.com DB_PORT=5432 API_BASE_URL=https://api.example.com EOF ``` **2. Docker Compose file:** Mount the host directory at `/etc/project` (the image's `CONFIG_PATH`): ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} services: coreflux-broker: image: coreflux/coreflux-mqtt-broker:latest container_name: coreflux-broker ports: - "1883:1883" - "8883:8883" - "5000:5000" - "9100:9100" volumes: - ./coreflux-data:/etc/project restart: unless-stopped ``` The broker resolves: * `CONFIG_PATH=/etc/project` (baked into the image) — data directory is `/etc/project` * `.env` from `/etc/project/.env` (your `./coreflux-data/.env`) * `secret.key` from `/etc/project/secret.key` * All broker state persists in the mounted directory **3. Set secrets after the broker starts:** Using any MQTT client, publish to `$SYS/Coreflux/Command`: ``` -setSecret DB_PASSWORD=my_secure_password ``` ### Option 2: Pass Env Vars via Docker (No .env File) If you prefer not to manage a `.env` file, pass variables directly through Docker's `environment:`. The resolution fallback to process environment picks them up: ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} services: coreflux-broker: image: coreflux/coreflux-mqtt-broker:latest ports: - "1883:1883" - "9100:9100" environment: - DB_HOST=postgres.example.com - DB_PORT=5432 - API_BASE_URL=https://api.example.com - COREFLUX_SECRET_KEY=K7x2mN8pQ1rT5uW3yB6dF9hJ0kL4nP7sV2xZ8cG1eA= volumes: - coreflux-data:/etc/project volumes: coreflux-data: ``` Variables set this way: * Are available to LoT via `GET ENV "DB_HOST"` * Are available to routes via `GET ENV "DB_HOST"` and legacy `ENV:DB_HOST` * Are **not** persisted to `.env` (they exist only in the process environment) * Will be lost if the container restarts without the same `environment:` block Secrets still need a volume for `secrets.json` persistence, even when passing the encryption key via `COREFLUX_SECRET_KEY`. Use Kubernetes ConfigMaps for environment variables and Kubernetes Secrets for the encryption key. **1. ConfigMap for environment variables:** Store non-sensitive configuration in a ConfigMap: ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} apiVersion: v1 kind: ConfigMap metadata: name: coreflux-env data: DB_HOST: "postgres.cluster.local" DB_PORT: "5432" API_BASE_URL: "https://api.example.com" ``` **2. Kubernetes Secret for the encryption key:** Store the encryption key in a Kubernetes Secret (base64-encoded): ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} apiVersion: v1 kind: Secret metadata: name: coreflux-secrets type: Opaque data: COREFLUX_SECRET_KEY: SzdsMm1OOHBRMXJUNXVXM3lCNmRGOWhKMGtMNG5QN3NWMnhaOGNHMWVBPQ== ``` **3. Deployment:** Inject the ConfigMap as environment variables via `envFrom`, and the encryption key from the Kubernetes Secret: ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} apiVersion: apps/v1 kind: Deployment metadata: name: coreflux-broker spec: replicas: 1 selector: matchLabels: app: coreflux-broker template: metadata: labels: app: coreflux-broker spec: containers: - name: broker image: coreflux/coreflux-mqtt-broker:latest ports: - containerPort: 1883 - containerPort: 5000 - containerPort: 9100 env: - name: CONFIG_PATH value: "/etc/project" - name: COREFLUX_SECRET_KEY valueFrom: secretKeyRef: name: coreflux-secrets key: COREFLUX_SECRET_KEY envFrom: - configMapRef: name: coreflux-env volumeMounts: - name: data mountPath: /etc/project volumes: - name: data persistentVolumeClaim: claimName: coreflux-pvc ``` **4. PersistentVolumeClaim:** Provide persistent storage for broker data, secrets, and configuration: ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} apiVersion: v1 kind: PersistentVolumeClaim metadata: name: coreflux-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi ``` Apply all resources: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} kubectl apply -f coreflux-configmap.yaml kubectl apply -f coreflux-secret.yaml kubectl apply -f coreflux-pvc.yaml kubectl apply -f coreflux-deployment.yaml ``` ConfigMap values are injected as process environment variables via `envFrom`, so `GET ENV` picks them up through the fallback chain. The `COREFLUX_SECRET_KEY` from the Kubernetes Secret enables portable secret encryption. *** ## Best Practices Never rely on the derived key fallback for production deployments. Always provide a `COREFLUX_SECRET_KEY` environment variable or a `secret.key` file. The derived key is machine-specific -- if you migrate, scale, or recreate a container, your secrets become unreadable. On Linux, restrict access to sensitive files to the broker's service user only: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} chmod 600 /var/lib/coreflux/.env chmod 600 /var/lib/coreflux/secret.key chmod 600 /var/lib/coreflux/secrets.json ``` Even for values that seem static (like a database port), use `GET ENV` so you can change configuration without redeploying LoT code. This makes your Actions and Routes portable across dev, staging, and production. Always mount a persistent volume at the data directory (`/etc/project` in Docker, `/var/lib/coreflux` on Linux packages). Without a volume, `.env`, `secrets.json`, and broker state are lost when the container is recreated. Keep `secret.key`, `secrets.json`, and `.env` files out of Git. Add them to `.gitignore` and use your deployment pipeline to inject them at runtime. *** ## Resolution Reference ### Where Each Syntax Reads From | Syntax | Where It Works | Resolved By | | ------------------- | -------------------------------- | -------------------------------------- | | `GET ENV "NAME"` | Actions, Models, Route configs | `.env` file, then process env fallback | | `GET SECRET "NAME"` | Actions, Models, Route configs | Decrypts from `secrets.json` | | `ENV:NAME` | Route `ADD CONFIG` sections only | Same as `GET ENV` | | `SECRET:NAME` | Route `ADD CONFIG` sections only | Same as `GET SECRET` | | `{env.NAME}` | Event queries (`WITH QUERY`) | Replaced at event execution time | | `{secret.NAME}` | Event queries (`WITH QUERY`) | Replaced at event execution time | ### Encryption Key Resolution Order | Priority | Source | Notes | | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------ | | 1 | `COREFLUX_SECRET_KEY` env var | Best for Docker/Kubernetes. Base64-encoded, 32 bytes. | | 2 | `{data directory}/secret.key` file | Good for mounted volumes. Base64-encoded, 32 bytes. | | 3 | Derived from machine GUID | Auto-generated fallback. Machine-specific -- secrets won't decrypt on a different machine. | ### GetEnv Resolution Order | Priority | Source | Notes | | -------- | --------------------- | ----------------------------------------------------------- | | 1 | `.env` file (managed) | Set via `-setEnv` or `KEEP ENV`. Persisted to disk. | | 2 | Process environment | Docker `environment:`, Kubernetes `env:`, or shell exports. | | 3 | User environment | OS user-level env vars (non-Docker). | | 4 | Machine environment | OS machine-level env vars (non-Docker). | *** ## Next Steps Full reference for all broker CLI commands including env and secret management. See GET ENV and GET SECRET in the complete LoT operations reference. # Startup Options & Deployment Source: https://docs.coreflux.org/latest/mqtt-broker/startup-options Start the broker with CLI flags, projects, and packages — Docker, Linux systemd, and Windows services ## Configure the broker before it starts Pass configuration at process start so containers, packages, and CI pipelines come up the same way every time. Typical jobs: * Point the data directory at a persistent volume * Import broker or user JSON on first boot * Load a LoT notebook project from git or disk * Install a license file before MQTT accepts connections **Think of startup options as a recipe card.** You tell the broker where its data lives and which project to load; it applies that recipe and then stays up as a service or container. For install commands (Docker, APT, DNF, Windows), start with [Installation](/latest/quick-start/installation). For `COREFLUX_*` variables, secrets, and `.env` files, see [Environment Variables & Secrets](/latest/mqtt-broker/secrets-and-env). *** ## Command-line options ### Configuration | Option | Long form | Description | | ------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `-c` | `--config` | Path to broker configuration JSON. Applied only when no broker config exists yet, unless you also pass `--config-force`. | | `-cf` | `--config-force` | Overwrite existing broker configuration with `--config`. | | `-u` | `--users` | Path to users JSON. Applied only when no users file exists yet, unless you also pass `--users-force`. | | `-uf` | `--users-force` | Overwrite existing users with `--users`. | | `-p` | `--path` | Data directory (same role as `COREFLUX_DATA_PATH`). Prefer the environment variable so the path is set **before** `--config` is applied. | Set `COREFLUX_DATA_PATH` or `CONFIG_PATH` in the process environment, then start the binary. `-p` / `--path` is applied late in startup, so `--config` can write into the default data directory instead of the path you intended. ### Projects and licenses Load LoT (Language of Things) from a **project** (git URL, local folder, or a project already stored on this broker). That is the supported way to ship Actions, Models, Routes, and Rules at boot. | Option | Long form | Description | | ------------------ | --------------------- | --------------------------------------------------------------------------------------------- | | | `--project` | Load a project at startup (git URL, local path, or existing project name). | | | `--branch` | Git branch or tag to check out with `--project`. | | `-loadLicense` | `--load-license` | Copy a license file into the data directory so the broker can validate it on start. | | `-showInstanceKey` | `--show-instance-key` | Print the instance key to stdout and exit (needed to request a license). Does not start MQTT. | A project loaded with `--project` (or `-loadProject` at runtime) is remembered across restarts. On the next start, if you do **not** pass `--project`, the broker re-activates that project. `-unloadProject` clears it. `--project` always wins over the remembered project. ### Other options | Option | Long form | Description | | ------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `-I` | `--install` | Register a Windows service or a Linux systemd unit for a **portable** (zip/tarball) binary. Skip this when you installed from APT/DNF — the package already ships `coreflux-broker.service`. | | `-h` | `--help` | Display help. | *** ## Basic usage ### Starting with configuration files ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Start with broker configuration ./CorefluxMQTTBroker --config /path/to/broker-config.json # Start with users configuration ./CorefluxMQTTBroker --users /path/to/users.json # Start with both ./CorefluxMQTTBroker \ --config /path/to/broker-config.json \ --users /path/to/users.json ``` ```powershell wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Start with broker configuration .\CorefluxMQTTBroker.exe --config C:\config\broker-config.json # Start with users configuration .\CorefluxMQTTBroker.exe --users C:\config\users.json # Start with both .\CorefluxMQTTBroker.exe ` --config C:\config\broker-config.json ` --users C:\config\users.json ``` ### Force overwrite existing configuration By default, if configuration already exists in the data directory, startup JSON is ignored. Use force flags to overwrite: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # Force overwrite broker config ./CorefluxMQTTBroker --config /path/to/broker-config.json --config-force # Force overwrite users config ./CorefluxMQTTBroker --users /path/to/users.json --users-force # Force overwrite both (short form) ./CorefluxMQTTBroker -c /path/to/broker-config.json -cf -u /path/to/users.json -uf ``` Force flags overwrite existing configurations. Back up your current configuration before using them. ### Loading a project at startup ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} # From git URL ./CorefluxMQTTBroker --project https://github.com/org/my-iot-project.git --branch production # From local path ./CorefluxMQTTBroker --project /path/to/my-project # By project name (already stored on this broker) ./CorefluxMQTTBroker --project my-iot-project ``` Package Actions, Models, Routes, and Rules in a LoT notebook project and load it with `--project`. That is the supported bootstrap path for LoT at startup. *** ## Configuration files ### Broker configuration (JSON) The broker configuration file defines MQTT service settings: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Name": "MyCorefluxBroker", "Port": 1883, "TlsPort": 8883, "WebsocketPort": 5000, "WebsocketPortTls": 5443, "BindIPForMQTT": "0.0.0.0", "BindIPForMQTTwithTls": "0.0.0.0", "BindIpForWebSockets": "0.0.0.0", "BindIpForWebSocketsTls": "0.0.0.0", "AnonymousLogin": false, "FullDebugLog": false, "ServerCertificatePath": "/path/to/certificate.pfx", "ServerCertificatePassword": "your-certificate-password", "ResendRetainTopics": false } ``` For every supported field (ports, TLS/mTLS, sessions, performance, data paths, ops, durability), see [Broker Configuration](/latest/mqtt-broker/configuration). ### Users configuration (JSON) The users configuration file defines MQTT users and their permissions: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [ { "UserName": "root", "Password": "your-secure-password", "AllowedBaseTopic": "#", "AllowedSystemConfiguration": true, "AllowedUserManagement": true, "AllowedLogManagement": true }, { "UserName": "device-user", "Password": "device-password", "AllowedBaseTopic": "devices/", "AllowedSystemConfiguration": false, "AllowedUserManagement": false, "AllowedLogManagement": false } ] ``` | Property | Type | Description | | ---------------------------- | ------ | --------------------------------------------- | | `UserName` | string | MQTT username | | `Password` | string | MQTT password | | `AllowedBaseTopic` | string | Topic access restriction (`#` for all topics) | | `AllowedSystemConfiguration` | bool | Allow system configuration changes | | `AllowedUserManagement` | bool | Allow user management operations | | `AllowedLogManagement` | bool | Allow log management operations | *** ## Container deployment Official images use `CONFIG_PATH=/etc/project`. Persist that directory so configuration, secrets, and projects survive image updates. The published image is `coreflux/coreflux-mqtt-broker`. Same pattern as [Installation](/latest/quick-start/installation): named volume at `/etc/project`, MQTT on `1883`, WebSocket on `5000`, ops on `9100`. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} docker run --pull=always -d --name coreflux_broker \ -p 1883:1883 -p 5000:5000 -p 9100:9100 \ -v project-volume:/etc/project \ coreflux/coreflux-mqtt-broker:latest \ --project https://github.com/org/my-iot-project.git --branch main ``` Import JSON on first boot by mounting files and passing `--config` / `--users` (add `--config-force` / `--users-force` only when you intend to replace existing state): ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} docker run --pull=always -d --name coreflux_broker \ -p 1883:1883 -p 5000:5000 -p 9100:9100 \ -v project-volume:/etc/project \ -v /host/config:/config:ro \ coreflux/coreflux-mqtt-broker:latest \ --config /config/broker-config.json \ --users /config/users.json ``` Probe liveness with `curl http://localhost:9100/health`. The image has no shell, so do not use `exec` healthchecks. Use the Compose file from [Installation](/latest/quick-start/installation). To load a project, add `command` on the broker service: ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} services: broker: image: coreflux/coreflux-mqtt-broker:latest ports: - "1883:1883" - "5000:5000" - "9100:9100" volumes: - broker-data:/etc/project command: - "--project" - "https://github.com/org/my-iot-project.git" - "--branch" - "main" restart: unless-stopped volumes: broker-data: ``` The image already sets `CONFIG_PATH=/etc/project`. Override `COREFLUX_DATA_PATH` only if you change the mount path. Mount a PVC at `/etc/project`. Use HTTP probes on port `9100` (the image has no shell). ```yaml wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} apiVersion: apps/v1 kind: Deployment metadata: name: coreflux-broker spec: replicas: 1 selector: matchLabels: app: coreflux-broker template: metadata: labels: app: coreflux-broker spec: containers: - name: broker image: coreflux/coreflux-mqtt-broker:latest args: - "--project" - "https://github.com/org/my-iot-project.git" - "--branch" - "production" ports: - containerPort: 1883 name: mqtt - containerPort: 5000 name: websocket - containerPort: 9100 name: ops env: - name: CONFIG_PATH value: "/etc/project" volumeMounts: - name: data mountPath: /etc/project livenessProbe: httpGet: path: /health port: 9100 initialDelaySeconds: 5 periodSeconds: 15 readinessProbe: httpGet: path: /ready port: 9100 initialDelaySeconds: 5 periodSeconds: 10 volumes: - name: data persistentVolumeClaim: claimName: coreflux-data ``` *** ## Service installation Prefer the install method from [Installation](/latest/quick-start/installation). Use `--install` only for portable binaries. APT and DNF packages already register **`coreflux-broker.service`**, install under `/opt/coreflux-broker`, and set `COREFLUX_DATA_PATH=/var/lib/coreflux`. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sudo systemctl status coreflux-broker sudo systemctl restart coreflux-broker sudo journalctl -u coreflux-broker -f ``` To add startup flags (for example `--project`), override the unit: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sudo systemctl edit coreflux-broker ``` ```ini wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [Service] ExecStart= ExecStart=/opt/coreflux-broker/CorefluxMQTTBroker --project https://github.com/org/my-iot-project.git --branch main ``` Then `sudo systemctl daemon-reload && sudo systemctl restart coreflux-broker`. [Installation](/latest/quick-start/installation) registers the service with `sc create`. For a portable zip, you can instead run as Administrator: ```powershell wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} .\CorefluxMQTTBroker.exe --install net start CorefluxMQTTBroker Get-Service CorefluxMQTTBroker ``` To apply JSON once before the service owns the process, run the broker in the foreground with `--config` / `--users`, stop it, then install or start the service. For a tarball (not APT/DNF), run as root: ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sudo ./CorefluxMQTTBroker --install ``` That writes a systemd unit. For production, still prefer [packages.coreflux.org](https://packages.coreflux.org) so upgrades and `COREFLUX_DATA_PATH` stay consistent. *** ## Best practices Use `COREFLUX_DATA_PATH` or `CONFIG_PATH` so `--config` and `--users` land in the same directory the broker uses at runtime. Docker images already set `CONFIG_PATH=/etc/project`. `-cf` and `-uf` overwrite existing configuration. Back up first. Load Actions, Models, Routes, and Rules with `--project`, not with ad-hoc `.lot` file flags at process start. Always mount a volume at `/etc/project` in Docker and Kubernetes. Without it, configuration, secrets, and projects disappear when the container is recreated. *** ## Troubleshooting 1. Confirm `COREFLUX_DATA_PATH` / `CONFIG_PATH` matches the volume you mounted. 2. If files already exist in that directory, add `--config-force` / `--users-force` only if you intend to replace them. 3. Check logs for validation errors. ```bash wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} sudo lsof -i :1883 ``` On Windows: `netstat -ano | findstr :1883` Stop the other listener or change the broker port in configuration. The package service user is `coreflux`, with state under `/var/lib/coreflux`. The service user needs read access to certificates and write access to the data directory. *** ## Next Steps Ports, TLS, sessions, ops, and every JSON field. Data path, COREFLUX\_\* variables, .env, and encrypted secrets. # System ($SYS) Topics Source: https://docs.coreflux.org/latest/mqtt-broker/sys-topics Monitor the broker, read configuration, and control Coreflux over MQTT system topics ## See the Broker From MQTT You do not need a separate monitoring stack to know what the broker is doing. Subscribe to `$SYS` topics and you get version, license, connected clients, LoT source, route health, and project state as MQTT messages — the same way you already consume sensor data. **Think of `$SYS` as the broker's dashboard in topic form.** Subscribe like any other MQTT topic and watch live status, without opening a separate UI. ### When to use this | Goal | Start here | | --------------------------------------------------- | ----------------------------------------------------------------------- | | Confirm the broker is up and which build is running | `$SYS/Coreflux/Version` | | Send management commands | `$SYS/Coreflux/Command` and `Command/Output` | | Inspect or back up LoT source | `$SYS/Coreflux/Actions/#`, `Models/#`, `Routes/#`, `Rules/#`, `Visus/#` | | Watch route health | `$SYS/Coreflux/Routes/+/status` | | See which project is loaded | `$SYS/Coreflux/Projects/active` | Topics that start with `$` are not included when you subscribe to `#` at the root. Subscribe to `$SYS/#` (or a more specific `$SYS/…` filter) explicitly. *** The tables below are the map; later sections explain payloads and who may publish or subscribe. ## Topic Hierarchy ``` $SYS/ ├── Coreflux/ │ ├── Command # Publish commands │ ├── Command/Output # JSON command replies │ ├── Config # Live configuration (includes Python status) │ ├── Config/New # Publish a config update │ ├── Config/New/Result # Result of a config update │ ├── Config/Users # User list (passwords masked) │ ├── Version # Broker version │ ├── License # License and usage │ ├── Resources/ # Memory totals │ ├── Comms/ │ │ ├── Sessions # MQTT sessions │ │ ├── Clients # Connected clients │ │ ├── Logins # Login events │ │ └── PacketTrace/… # Optional packet traces │ ├── Actions/ # Action LoT │ ├── Models/ # Model LoT │ ├── Routes/ # Route LoT │ ├── Rules/ # Rule LoT │ ├── Visus/ # Panel LoT │ ├── Projects/ # Project list, active, manifest, download │ ├── Entities/ownership # Project vs ad-hoc origin │ ├── Cluster/ # Role, leader, members (when clustering) │ ├── Python/Scripts # Registered Python scripts │ └── AI/Providers # Discovered LLM providers └── / # e.g. Linux-x64, Windows-x64 ├── Python/Info └── Comms/TCP|UDP/… ``` ### Read-only (broker → client) | Topic | Content | | ------------------------------------ | -------------------------------------------------------- | | `$SYS/Coreflux/Version` | Version JSON (**retained**) | | `$SYS/Coreflux/License` | License, tier, limits, current usage (**retained**) | | `$SYS/Coreflux/Config` | Live configuration JSON, including Python backend status | | `$SYS/Coreflux/Config/Users` | Users with passwords masked; includes `Groups` | | `$SYS/Coreflux/Config/New/Result` | Outcome of the last publish to `Config/New` | | `$SYS/Coreflux/Comms/Sessions` | Active sessions | | `$SYS/Coreflux/Comms/Clients` | Connected clients | | `$SYS/Coreflux/Comms/Logins` | Login event lines | | `$SYS/Coreflux/Actions/{name}` | Action LoT (**retained**) | | `$SYS/Coreflux/Models/{name}` | Model LoT (**retained**) | | `$SYS/Coreflux/Routes/{name}` | Route LoT (**retained**) | | `$SYS/Coreflux/Routes/{name}/status` | Route health JSON | | `$SYS/Coreflux/Rules/{name}` | Rule LoT | | `$SYS/Coreflux/Visus/{name}` | Panel source (**retained**) | | `$SYS/Coreflux/Projects/list` | Stored projects (**retained**) | | `$SYS/Coreflux/Projects/active` | Loaded project (**retained**) | | `$SYS/Coreflux/Entities/ownership` | Running entities → project origin (**retained**) | ### Writable (client → broker) | Topic | Purpose | | ---------------------------------------------------- | ------------------------------------------------------------------- | | `$SYS/Coreflux/Command` | Run [broker commands](/latest/mqtt-broker/commands) | | `$SYS/Coreflux/Config/New` | Apply a configuration JSON (restart still required for most fields) | | `$SYS/Coreflux/Actions/{name}/Run` | Run a callable Action (panels use this) | | `$SYS/Coreflux/Actions/{name}/Invoke` | Same as `/Run` (legacy alias) | | `$SYS/Coreflux/Routes/{name}/Events/{event}/Trigger` | Fire a named route event from a panel | *** ## Command Topics Send a command and read the reply over MQTT. Subscribe to the output topic **before** you publish, or you can miss a fast reply. Using any MQTT client (such as MQTT Explorer), subscribe to `$SYS/Coreflux/Command/Output`. Publish the command string to `$SYS/Coreflux/Command`. Add `-requestId ` when several commands may be in flight. Every reply is one JSON object on `Command/Output`. Match `requestId` when you sent one. ### `$SYS/Coreflux/Command` **Direction:** Client → Broker | Field | Value | | ----------- | ----------------------------------------------------------------------------------------------------------------- | | **Topic** | `$SYS/Coreflux/Command` | | **Payload** | `-addAction DEFINE ACTION Heartbeat ON EVERY 10 SECONDS DO PUBLISH TOPIC "system/heartbeat" WITH TIMESTAMP "UTC"` | | **Auth** | Session allowed for `CommandCall` (`root` or the matching RBAC permission flags) | ### `$SYS/Coreflux/Command/Output` **Direction:** Broker → Client Replies always use this JSON envelope. Structured list/query results are in `data`. `data` is always present (`{}` when there is nothing to return, or `[]` for an empty list). ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "success": true, "command": "AddAction", "message": "Action 'Heartbeat' added successfully.", "entityType": "Action", "entityName": "Heartbeat", "data": {} } ``` | Field | Description | | ----------- | ----------------------------------------------- | | `success` | Whether the command succeeded | | `command` | Internal command name (for example `AddAction`) | | `message` | Human-readable summary | | `requestId` | Echo of `-requestId` when you sent one | | `data` | Structured payload for list/query commands | | `errors` | Present when `success` is `false` | The broker does **not** publish to `$SYS/Coreflux/CommandResponse`. The full command list is on [Broker Commands](/latest/mqtt-broker/commands). *** ## LoT Entity Topics Each running Action, Model, Route, Rule, and panel publishes its LoT (or panel source) on a topic named after that entity. Subscribe to retrieve what is actually deployed — useful for backup, debugging, and Coreflux HUB. When you remove an entity, the broker publishes an **empty retained** payload on that topic (MQTT tombstone) so subscribers do not keep a stale definition. ### `$SYS/Coreflux/Actions/{ActionName}` **Payload:** the complete Action definition (**retained**). Subscribe with MQTT Explorer or any MQTT client: | Field | Value | | --------- | --------------------------------- | | **Topic** | `$SYS/Coreflux/Actions/Heartbeat` | **Payload received:** ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION Heartbeat ON EVERY 10 SECONDS DO PUBLISH TOPIC "system/heartbeat" WITH TIMESTAMP "UTC" ``` Related Action topics: | Topic | Content | | ------------------------------------------ | ----------------------------------------------------------------------------------- | | `$SYS/Coreflux/Actions/{name}/Description` | Optional description (**retained**) | | `$SYS/Coreflux/Actions/{name}/Error` | Last Action error (**retained** when set) | | `$SYS/Coreflux/Actions/{name}/Trace` | Execution traces when tracing is enabled (not retained; expire after a few minutes) | | `$SYS/Coreflux/Actions/{name}/Run` | **Publish** here to run a callable Action | | `$SYS/Coreflux/Actions/{name}/Invoke` | Same as `/Run` | ### `$SYS/Coreflux/Models/{ModelName}` Model LoT (**retained**). ### `$SYS/Coreflux/Routes/{RouteName}` Route LoT (**retained**). Extra subtopics appear depending on the route type: | Topic | Content | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `$SYS/Coreflux/Routes/{name}/status` | Health and counters. See [Route Status Monitoring](/latest/lot-language/routes/overview#route-status-monitoring) | | `$SYS/Coreflux/Routes/{name}/Certificates` | Certificate-store summary for cert-managed routes (**retained**) | | `$SYS/Coreflux/Routes/{name}/Tools` | MCP tool summary for MCP routes | | `$SYS/Coreflux/Routes/{name}/Tools/{tool}` | One MCP tool (schema and capability stats) | | `$SYS/Coreflux/Routes/{name}/Events/{event}/Trigger` | **Publish** to fire that route event | ### `$SYS/Coreflux/Rules/{RuleName}` Rule LoT source. ### `$SYS/Coreflux/Visus/{PanelName}` Panel source text (**retained**). #### Subscribe to all entities | Entity type | Topic pattern | | ----------- | ------------------------- | | All Actions | `$SYS/Coreflux/Actions/#` | | All Models | `$SYS/Coreflux/Models/#` | | All Routes | `$SYS/Coreflux/Routes/#` | | All Rules | `$SYS/Coreflux/Rules/#` | | All panels | `$SYS/Coreflux/Visus/#` | *** ## Broker Information ### `$SYS/Coreflux/Version` Broker version and build (**retained**). ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "version": { "major": 2, "minor": 14, "patch": 0, "branch": "release", "os": "Linux-x64", "stage": "stable", "timestamp": "2026-08-26T10:00:00Z", "notes": "" } } ``` The `os` field is the same prefix used under `$SYS/{OS}/…`. ### `$SYS/Coreflux/License` License status, tier, feature flags, limits, and current artifact usage (**retained**). Unlicensed or trial brokers still publish this topic so you can see `Tier`, `IsTrial`, and `CurrentUsage`. ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "LicensedTo": "N/A", "ExpiresUtc": null, "IsActive": false, "IsTrial": true, "TrialDaysRemaining": 14.0, "Tier": "Community", "InstanceKey": "", "Features": { "EnableAdvancedRoutes": false, "EnableRBAC": false }, "CurrentUsage": { "ArtifactCount": 3, "Actions": { "Regular": 2, "RouteEvents": 0, "PythonScripts": 0 }, "Models": 0, "Rules": 12, "Routes": 1, "Panels": 0 } } ``` ### `$SYS/Coreflux/Resources` | Topic | Description | | ----------------------------------------- | ------------------------------ | | `$SYS/Coreflux/Resources/MemoryInfoTotal` | Total available memory (bytes) | | `$SYS/Coreflux/Resources/MemoryInfoUsage` | Current memory usage (bytes) | *** ## Configuration Topics ### `$SYS/Coreflux/Config` The live broker configuration as JSON. The broker adds a `Python` object so you can see whether scripts run locally or on the runner sidecar without also subscribing to `$SYS/{OS}/Python/Info`. ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Name": "Coreflux DataHub", "Port": 1883, "TlsPort": 8883, "WebsocketPort": 5000, "WebsocketPortTls": 443, "BindIPForMQTT": "0.0.0.0", "AnonymousLogin": false, "Python": { "Available": true, "UsingEmbeddedRuntime": false, "UsingRunner": false, "Version": "3.11.2", "Backend": "python.net", "IsolationLevel": "none" } } ``` To change configuration, publish JSON to `$SYS/Coreflux/Config/New`. Most fields still need a restart. See [Broker Configuration](/latest/mqtt-broker/configuration). ### `$SYS/Coreflux/Config/New` and `…/Result` Publish a **partial** configuration object to `$SYS/Coreflux/Config/New`. The broker writes `$SYS/Coreflux/Config/New/Result` with success or validation failure text. ### `$SYS/Coreflux/Config/Users` Configured users with passwords masked. `Groups` is the list used by `USER IN GROUP` in Rules. ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [ { "UserName": "root", "Password": "********", "AllowedBaseTopic": "", "AllowedSystemConfiguration": true, "AllowedUserManagement": false, "AllowedLogManagement": false, "Groups": [] } ] ``` | Field | Description | | ---------------------------- | --------------------------------------------------------- | | `AllowedBaseTopic` | Optional topic prefix restriction | | `AllowedSystemConfiguration` | Can change broker configuration and most management gates | | `AllowedUserManagement` | Can manage users | | `AllowedLogManagement` | Can manage log traces | | `Groups` | Group names for [RBAC](/latest/lot-language/rules/rbac) | *** ## Connection Monitoring ### `$SYS/Coreflux/Comms/Sessions` Active MQTT sessions. ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [ { "CreatedTimestamp": "2026-08-26T09:01:36.5008146Z", "DisconnectedTimestamp": null, "ExpiryInterval": 0, "Id": "mqtt-explorer-274f0040", "Items": {}, "PendingApplicationMessagesCount": 0 } ] ``` ### `$SYS/Coreflux/Comms/Clients` Connected clients, including bytes and packet counts. ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [ { "BytesReceived": 173, "BytesSent": 3943557, "ConnectedTimestamp": "2026-08-26T09:01:36.5008189Z", "Endpoint": "172.17.0.1:52256", "Id": "mqtt-explorer-274f0040", "ProtocolVersion": 4, "ReceivedApplicationMessagesCount": 16070, "SentApplicationMessagesCount": 0, "Session": { "Id": "mqtt-explorer-274f0040", "PendingApplicationMessagesCount": 0 } } ] ``` | Field | Description | | ----------------------------- | -------------------------------------- | | `BytesReceived` / `BytesSent` | Totals for this connection | | `Endpoint` | Client IP and port | | `Id` | MQTT client ID | | `ProtocolVersion` | `3` = MQTT 3.1, `4` = 3.1.1, `5` = 5.0 | ### `$SYS/Coreflux/Comms/Logins` One line per login attempt (password always masked): ``` ClientId = lotvstudio-a6kjtbkwp, Endpoint = 172.17.0.1:53454, Username = root, Password = ******, CleanSession = True, ReasonCode = Success ``` Lifecycle Actions (`ON CONNECT` / `ON DISCONNECT`) are a better place for automation; this topic is for observability. See [Action triggers](/latest/lot-language/actions/events). ### `$SYS/Coreflux/Comms/PacketTrace/{clientId}/In` and `/Out` Optional raw packet traces started with `-mqttPacketTrace`. Use only while debugging a specific client. *** ## Project Topics Retained so Coreflux HUB and other clients get the last project state on subscribe, without sending `-listProjects` first. See [Project Commands](/latest/mqtt-broker/commands#project-commands). | Topic | Content | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `$SYS/Coreflux/Projects/list` | Stored projects and which one is active (**retained**) | | `$SYS/Coreflux/Projects/active` | Currently loaded project (**retained**; empty in standalone mode) | | `$SYS/Coreflux/Projects/{name}/status` | Git status for that project (**retained**) | | `$SYS/Coreflux/Projects/{name}/manifest` | SHA-256 of stored file bytes, plus entities declared in `.lotnb` cells (**retained**). Same JSON as `-projectManifest`. Cleared on `-removeProject` | | `$SYS/Coreflux/Entities/ownership` | Map of `type:name` → `{ project, file, cell }` for project-owned entities (**retained**). Absence of a key means the entity is ad-hoc | Export payloads on `$SYS/Coreflux/Projects/{name}/download` are **not** retained (one-shot zip transfer). Under default rules, `$SYS/Coreflux/Projects/#` is allowed for the same users who may use `-listProjects` (`AllowedUserManagement`, `AllowedSystemConfiguration`, `root`, `admin`) — not every MQTT user. ## Platform Topics These topics are prefixed with the operating system identifier (for example `Linux-x64`, `Windows-x64`). Read `os` from `$SYS/Coreflux/Version`. ### `$SYS/{OS}/Python/Info` Python availability and version on that host. ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} { "Available": true, "Version": "3.11.2 (main, Apr 28 2025, 14:11:48) [GCC 12.2.0]", "Scripts": [] } ``` Individual scripts also appear at `$SYS/{OS}/Python/Scripts/{scriptName}`. ### TCP and UDP listeners | Topic | Content | | --------------------------------------- | ----------------------------------------------- | | `$SYS/{OS}/Comms/TCP/Listeners` | Addresses and ports the process is listening on | | `$SYS/{OS}/Comms/TCP/ActiveConnections` | Established TCP connections | | `$SYS/{OS}/Comms/UDP/Listeners` | UDP listeners | | `$SYS/{OS}/Comms/UDP/ActiveConnections` | Active UDP associations | Example TCP listeners payload: ```json wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} [ {"Address": "0.0.0.0", "Port": "1883"}, {"Address": "0.0.0.0", "Port": "80"} ] ``` *** ## Access Control `$SYS` uses the `SubscribeSys` and `PublishSys` rule scopes. **Locked built-in rules** already decide who may subscribe to `$SYS/#`, publish commands, and update config. A custom `SubscribeSys` rule on `$SYS/#` does not override those gates. | Who | Typical access | | --------------------------------------------- | ------------------------------------------------------------------------------------- | | `root`, `admin`, `AllowedSystemConfiguration` | Subscribe to `$SYS/#` | | `CommandCall` gates | Publish to `$SYS/Coreflux/Command` and subscribe to `Command/Output` | | Same roles as `-listProjects` | `$SYS/Coreflux/Projects/#` | | Any connected client | Publish to `$SYS/Coreflux/Actions/+/Run` and `/Invoke` (panels call Actions this way) | See [Rules syntax](/latest/lot-language/rules/syntax#system-topics-sys) and [RBAC](/latest/lot-language/rules/rbac) for flags and locked rules. Publish access to `$SYS/Coreflux/Command` lets a client run administrative commands. Keep that topic on management users only. `/Run` and `/Invoke` are allowed more widely so dashboards can call Actions — restrict those Actions themselves if they are sensitive. *** ## Next Steps Change ports, TLS, and other broker settings Full command list published to `$SYS/Coreflux/Command` # Best Practices & Patterns Source: https://docs.coreflux.org/latest/quick-start/best-practices Overview and table of contents for best practices—topic design, reusable logic, alarms, integrations, production architecture, and notebooks. ## After Hello World, What's Next? Once you've followed [Getting Started](/latest/quick-start/getting-started) and have Coreflux, VS Code, and your first Action running, the question becomes *how to grow*: naming topics, handling JSON, scaling Actions, wiring integrations, and shipping systems that stay maintainable. Experienced teams reuse the same patterns across factories, buildings, energy sites, and fleets—only the topics and payloads change. **Like learning chords before writing songs.** Your first Action proves the broker runs your logic; patterns teach you how dozens of Actions, Models, and Routes fit together without turning into spaghetti. ## How Are Best Practices & Patterns Organized Chapters below live under **Best Practices and Usage Patterns** in the sidebar. Pick one based on what you're building — each page focus on a set of tips for a given topic. Start with **Naming conventions** and finish with **Anti-patterns** when reviewing code. PascalCase entities, snake\_case variables, and topic naming basics. Topic trees (UNS), scalar vs JSON payloads, Models, and inheritance — how data is shaped and addressed. GET JSON in Actions, nested payloads, and publishing structured output through Models. Callable actions, wildcards, counting over time, and Python — techniques for the Action you are writing now; logic that scales and stays maintainable. Threshold crossings and device or route offline detection; optional email routes alongside alarms. REST server and client, live KPIs from a database, and ENV / Secrets — Routes that bridge to non-MQTT systems. End-to-end device-to-database ingestion and edge–cloud bridges — synthesis patterns that build on everything before. Keeping notebooks clean and readable — single-file vs multi-file layouts and shared discipline at every stage. Common LoT mistakes and what to do instead. *** ## Next Steps Building blocks and how Actions, Models, Routes, and Rules fit together. Deep dive on `FROM` and base models. New to the language? **[Introduction to LoT](/latest/lot-language/introduction)** links Actions, Models, Routes, and Rules from one page. # Build Your First Dashboard Source: https://docs.coreflux.org/latest/quick-start/building-dashboards From a topic in the Data Viewer to a live, shareable panel — in about ten minutes. The finished Line 3 Shift Lead panel with a temperature gauge, a live temperature trend chart, a pump toggle, and a Start Line button By the end of this guide you'll have a live monitoring panel with a gauge, a live trend chart, a control, and an action wired to it — open and shareable on a second screen. Everything runs on data you generate yourself, so you can follow along even on a fresh broker. **Like assembling a control room one instrument at a time.** You'll bolt on one widget per step, watch it come alive immediately, and ship the finished console at the end. So your widgets have something to show, deploy a small **LoT Action** that publishes a changing temperature and machine status every few seconds. Open the **LoT Editor** in the HUB, paste this, and deploy: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ACTION SimulateSensorData ON EVERY 3 SECONDS DO IF GET TOPIC "sim/counter" == EMPTY THEN KEEP TOPIC "sim/counter" WITH 0 SET "n" WITH (GET TOPIC "sim/counter" AS INT) SET "n" WITH ({n} + 10) IF {n} > 140 THEN SET "n" WITH 0 KEEP TOPIC "sim/counter" WITH {n} PUBLISH TOPIC "sensor/temperature" WITH {n} IF {n} > 100 THEN PUBLISH TOPIC "machine/status" WITH "stopped" ELSE PUBLISH TOPIC "machine/status" WITH "running" ``` This ramps `sensor/temperature` from 0 to 140 and flips `machine/status` between `running` and `stopped` — enough to move a gauge past its threshold and draw a live trend line. **Prefer to drive it by hand?** Skip the Action and instead publish manually in the [Data Viewer](/hub/mqtt/data-viewer): send `sensor/temperature` a number like `82`, and `machine/status` the text `running` or `stopped`. Open the **Data Viewer** and confirm `sensor/temperature` is updating with a numeric payload. Note the payload shape: a plain number binds directly; a JSON object would need `JSON_PATH` later. Coreflux HUB Data Viewer showing the publish panel on the left and the topic tree with the sensor and machine topics Open **Dashboards**, click **Create new panel**, and start from this skeleton. The Dashboard Manager opens the panel definition next to its live result: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Line3ShiftLead WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line 3 — Shift Lead" WITH LAYOUT "grid" COLUMNS 2 ``` Add a **gauge** bound to `sensor/temperature`, with a range, a unit, and a warning threshold. The panel so far — the new gauge component is highlighted: ```lot wrap focus={7-11} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Line3ShiftLead WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line 3 — Shift Lead" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 150 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD THRESHOLD AT 100 WITH COLOR "warning" ``` Save — the needle starts moving immediately. That's the live binding at work. A radial temperature gauge with a 0 to 150 range reading a live value of 82 degrees The gauge shows *now*; a chart shows *the last few minutes*. Open the **AI Assistant** and ask: > "Add a live chart to my Line 3 panel that trends `sensor/temperature` over time." Review what it added in the Dashboard Manager — the panel now reads: ```lot wrap focus={13-16} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Line3ShiftLead WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line 3 — Shift Lead" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 150 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD THRESHOLD AT 100 WITH COLOR "warning" ADD COMPONENT "TempTrend" WITH TYPE "realtime-chart" SET LABEL "Temperature Trend" SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ``` The `realtime-chart` accumulates every published value into a trend — with the simulator from step 1 running, the line starts drawing itself within seconds. Add a **toggle** that tracks the live pump state — it flips the moment the bound topic changes. Publish `true` or `false` to `zone1/pump/state` in the Data Viewer to see it move: ```lot wrap focus={18-20} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Line3ShiftLead WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line 3 — Shift Lead" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 150 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD THRESHOLD AT 100 WITH COLOR "warning" ADD COMPONENT "TempTrend" WITH TYPE "realtime-chart" SET LABEL "Temperature Trend" SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD COMPONENT "PumpToggle" WITH TYPE "toggle" SET LABEL "Pump" BIND STATE TO TOPIC "zone1/pump/state" ``` Events and actions are what make a panel *do* things, not just show them. Add a **button** that publishes a start command when clicked: ```lot wrap focus={22-25} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Line3ShiftLead WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line 3 — Shift Lead" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 150 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD THRESHOLD AT 100 WITH COLOR "warning" ADD COMPONENT "TempTrend" WITH TYPE "realtime-chart" SET LABEL "Temperature Trend" SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD COMPONENT "PumpToggle" WITH TYPE "toggle" SET LABEL "Pump" BIND STATE TO TOPIC "zone1/pump/state" ADD COMPONENT "StartButton" WITH TYPE "button" SET LABEL "Start Line" ON CLICK DO PUBLISH TOPIC "line3/commands/start" WITH 1 ``` Watch it work: subscribe to `line3/commands/start` in the [Data Viewer](/hub/mqtt/data-viewer), click the button, and the `1` arrives instantly. The full set of events (clicks, changes, thresholds) and actions (publish, navigate, invoke, trigger) is in [Actions & Events](/latest/lot-language/panels/actions-events). Save, and the console is complete — verify the labels and ranges; operators will rely on this screen. The finished Line 3 Shift Lead panel showing a temperature gauge, a live temperature trend chart, a pump toggle, and a Start Line button In the Dashboard Manager, hover the panel row and click **Broadcast in new tab**. Copy the URL into any browser — a wall TV, a tablet, a kiosk. Same live data, no HUB access required. ## The complete panel Everything from the steps above in one copy-paste definition: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE PANEL Line3ShiftLead WITH VISIBILITY PUBLIC WITH STATE PUBLISHED WITH TITLE "Line 3 — Shift Lead" WITH LAYOUT "grid" COLUMNS 2 ADD COMPONENT "TempGauge" WITH TYPE "gauge" SET RANGE FROM 0 TO 150 SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD THRESHOLD AT 100 WITH COLOR "warning" ADD COMPONENT "TempTrend" WITH TYPE "realtime-chart" SET LABEL "Temperature Trend" SET UNIT "°C" BIND VALUE TO TOPIC "sensor/temperature" ADD COMPONENT "PumpToggle" WITH TYPE "toggle" SET LABEL "Pump" BIND STATE TO TOPIC "zone1/pump/state" ADD COMPONENT "StartButton" WITH TYPE "button" SET LABEL "Start Line" ON CLICK DO PUBLISH TOPIC "line3/commands/start" WITH 1 ``` ## Where to go next Every widget, property, and bind target. Ready-to-adapt recipes: KPI walls, control panels, and model-backed dashboards. # Creating Agents in Coreflux Source: https://docs.coreflux.org/latest/quick-start/creating-agents Add AI to Coreflux in minutes — talk to live data and put intelligence inside LoT automations, bringing AI to your solutions. ## Turn Your Broker Into a Teammate — and Your Automations Into Thinkers Your devices, integrations, and databases already run on Coreflux — whether you monitor a factory, a solar site, a building portfolio, or a mobile fleet. **AI agents** live in LoT on that same broker: no separate AI platform, no copy-paste into another chat app. **Talk with live data and automate with judgment.** Teams ask in plain language and get answers from what the broker sees *right now*. Your **Actions** already react to MQTT and timers; with agents they can also **reason** on a schedule or when an event fires — a status digest, a plain-language take on an alarm, a handover brief — all through `CALL AGENT` in LoT. | What you gain | Example (any vertical) | | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | | **Ask the system, don't only chart it** | *"Which zones are offline?"* · *"What's output at site B?"* · *"Any inverters under target?"* | | **Smarter automations, not more scripts** | A timer or topic wakes an agent; LoT publishes the result — no babysitting a workflow | | **Reports while you sleep** | Hourly or daily digest on MQTT for supervisors, facility managers, or NOC staff | | **Specialists, not one overloaded chatbot** | A guide for people, a reporter on a schedule, an analyst when alarms fire — each with its own job | You define agents once in LoT (alongside your Actions and Routes). Below are **ready-to-use use cases** you can paste and deploy — pick one to prove value in minutes, or combine several on the same broker. Full options — multi-agent teams, MCP tools, safety rails — are in [AI Routes](/latest/lot-language/routes/ai-routes). **Like a supervisor on the floor *and* a analyst on the line.** People get answers when they ask; your automations get judgment when something changes — all on the broker you already run. *** ## Ready-to-Use Use Cases (Copy & Deploy) Each tab is a full recipe: agent Route, LoT Action where needed, and MQTT topics to try. Adapt names to your vertical — examples in the table. | Use case | Who benefits | How it runs | | ----------------------- | ----------------------- | -------------------------------------------------- | | **Talk with your data** | Operators, support, NOC | Ask on `agents/site/ask`, read `agents/site/reply` | | **Automatic report** | Supervisors, managers | Scheduled summary on `reports/operations/hourly` | | **Alert triage** | On-call, maintenance | Each alert gets a short AI explanation | | **Shift handover** | Shift leads, crews | Plain-language brief before the next rotation | Some examples of use-cases for your industry: | Vertical | Typical data topics | Automatic report might cover | | --------------------- | ----------------------------------- | ----------------------------------------- | | **Manufacturing** | `production/`, `lines/`, `quality/` | Throughput, downtime, batch issues | | **Solar & energy** | `solar/`, `inverters/`, `grid/` | Generation, underperformance, curtailment | | **Smart buildings** | `buildings/`, `hvac/`, `occupancy/` | Comfort, energy, fault patterns | | **Fleet & logistics** | `fleet/`, `vehicles/`, `routes/` | Utilisation, delays, asset health | ### Before you start * A **running broker** — [Installation](/latest/quick-start/installation) and [Getting Started](/latest/quick-start/getting-started) if you are new * A **model provider** — OpenAI, Anthropic, Mistral, or **local Ollama** on your network. Every recipe in the tabs below uses **OpenAI**; swap `PROVIDER`, `MODEL`, and the secret name if you prefer another cloud provider or edge inference (see [Picking a Provider](/latest/lot-language/routes/ai-routes#picking-a-provider)). For these examples, store an [OpenAI API key](https://platform.openai.com/api-keys) once on the broker: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} KEEP SECRET "OPENAI_KEY" WITH "sk-..." ``` The **[HUB AI Assistant](/hub/ai-assistant)** (Coreflux icon in the dock) is a separate built-in helper for drafting LoT and dashboards. This guide is about **your** agents — the ones you own in LoT. **Outcome:** Anyone with MQTT access can ask questions; answers use **live broker data** (topics, Routes, recent payloads). Paste into the LoT Editor (Routes, then Actions) or your notebook: ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE SiteGuide WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.5" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "interactive" WITH INTERACTION_TOPIC "agents/site/interact" WITH SYSTEM_PROMPT "You are a site operations guide. Use broker tools to answer from live MQTT data for this deployment. Be concise." DEFINE ACTION AskSiteGuide ON TOPIC "agents/site/ask" DO CALL AGENT "SiteGuide.execute" WITH (task = PAYLOAD) RETURN AS {reply} PUBLISH TOPIC "agents/site/reply" WITH {reply} ``` In HUB **Routes**, check **SiteGuide** (AGENT). In **Actions**, check **AskSiteGuide**. Both should show healthy. In MQTT Explorer or HUB [Data Viewer](/hub/mqtt/data-viewer), publish to `agents/site/ask` and subscribe to `agents/site/reply`. Try a question that fits your data, for example: ```text theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} Which sensors have been updated in the last few minutes? ``` ```text theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} What is the latest data on telemetry/zone1/status? ``` (Rename `telemetry/` to `production/`, `solar/`, `buildings/`, or your own tree.) The reply topic carries the agent's answer — grounded in what your broker actually sees. You just **talked with your data**. No extra AI server — the agent runs where your MQTT already lives. **Outcome:** **LoT automation with AI** — on a schedule, an Action calls the agent and publishes an **operations summary** to MQTT. No chat window. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE OperationsReporter WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.4-mini" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "autonomous" WITH SYSTEM_PROMPT "You write operations reports from live MQTT. Use broker tools on telemetry/ and related topics. Bullet points: current status, anomalies, items needing follow-up. Plain language for managers. Adapt wording to what the data represents (production, energy, facilities, fleet, etc.)." DEFINE ACTION HourlyOperationsReport ON EVERY 1 HOUR DO CALL AGENT "OperationsReporter.execute" WITH (task = "Hourly operations report: summarise key signals from telemetry/ topics, flag anything off-norm or stale, note follow-ups before the next review period.", use_history = "false") RETURN AS {report} PUBLISH TOPIC "reports/operations/hourly" WITH {report} ``` Add **OperationsReporter** and **HourlyOperationsReport** in the LoT Editor. No public chat line — only the schedule triggers this agent. Subscribe to `reports/operations/hourly` in MQTT Explorer, a dashboard, or the HUB Data Viewer. Publish sample payloads under your data tree (`telemetry/`, `solar/site1/power`, `buildings/tower/hvac`, etc.), then wait for the next run — or temporarily use `ON EVERY 30 SECONDS` and change it back when done. **Scheduled insight on autopilot** — the report speaks your operation's language, not broker diagnostics. **Outcome:** When something hits your alerts topic, the broker adds a **plain-language triage note** — what it might mean and what to check first. Works for any alert source: equipment faults, threshold breaches, connectivity, security events. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE AlertAnalyst WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.4-mini" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "autonomous" WITH SYSTEM_PROMPT "You triage operational alerts from MQTT. In 3 short sentences: what happened, likely cause, recommended first check. Be direct." DEFINE ACTION TriageAlert ON TOPIC "alerts/#" DO CALL AGENT "AlertAnalyst.execute" WITH (task = "Alert on topic " + TOPIC + ": " + PAYLOAD, use_history = "false") RETURN AS {triage} PUBLISH TOPIC "agents/site/alerts/analysis" WITH {triage} ``` Ensure your existing logic still publishes under `alerts/` — for example `alerts/inverter/fault`, `alerts/building/leak`, or `alerts/line2/quality`. Add **AlertAnalyst** and **TriageAlert**. Subscribe to `agents/site/alerts/analysis`. Publish a test alert to `alerts/test/manual` with any message. Read the analysis on the triage topic. On-call gets **context with the alarm**, not only a raw payload. **Outcome:** Before each rotation, a **handover brief** lands on MQTT — what changed, what looks odd, what the incoming team should watch. Common in manufacturing, utilities, and 24/7 operations centers. ```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}} DEFINE ROUTE HandoverBrief WITH TYPE AGENT ADD AGENT_CONFIG WITH PROVIDER "openai" WITH MODEL "gpt-5.5" WITH API_KEY GET SECRET "OPENAI_KEY" WITH BROKER_TOOLS "true" WITH INTERACTION_MODE "autonomous" WITH SYSTEM_PROMPT "You write handover notes for operations teams. Use broker tools. Bullet points: status, anomalies, open items. Max 8 bullets." DEFINE ACTION ShiftHandover ON EVERY 8 HOURS DO CALL AGENT "HandoverBrief.execute" WITH (task = "Prepare handover for the next shift: current operational status, active alarms, and anything unusual in the last 8 hours.", use_history = "false") RETURN AS {brief} PUBLISH TOPIC "agents/site/shift/handover" WITH {brief} ``` Add **HandoverBrief** and **ShiftHandover**. Subscribe to `agents/site/shift/handover`. Temporarily use `ON EVERY 2 MINUTES`, read one brief, then restore `ON EVERY 8 HOURS` for production. **Crew change becomes a story**, not a scramble through dashboards. **Starter pack:** **SiteGuide** + **HourlyOperationsReport** + **AlertAnalyst** — ask your deployment, get scheduled insight, enrich every alarm. Rename topics to `production/`, `solar/`, or `buildings/` when you copy the prompts. *** ## When You're Ready to Grow You already have the building blocks: **chat** (ask/reply topics), **schedules** (`ON EVERY`), and **event-driven** (`ON TOPIC`) automations with `CALL AGENT`. Next level is tuning models per job, adding [MCP Routes](/latest/lot-language/routes/mcp-routes) for Slack or databases, and naming agents by role so LoT stays readable. For multi-agent patterns (operator, specialist, watchdog, reader), see [Multi-Agent Deployments](/latest/lot-language/routes/ai-routes#multi-agent-deployments). *** ## Stay Safe While You Experiment Add `WITH AGENT_MODE "insight"` on a Route so it can explore but not publish or trigger downstream equipment. Switch to full mode when you trust the prompt. Always use `GET SECRET "OPENAI_KEY"` — never paste keys into Route definitions that get saved or shared. Chat for people; timed Actions for machines. Do not put a public chat line on agents that should only run in the background. *** ## What to Explore Next Providers, MCP tools, multi-agent teams, and deployment settings when you outgrow this tutorial. Use MCP in your editor to write LoT faster — separate from agents running on the broker. # Getting Started Source: https://docs.coreflux.org/latest/quick-start/getting-started Go from zero to your first working LoT Action in under 15 minutes. Coreflux HUB overview screen with a 'Start here' arrow pointing to the first navigation icon in the bottom toolbar ## Why This Guide? This is the fastest way to validate your Coreflux setup: in under 15 minutes you'll have a live LoT (Language of Things) Action running inside your broker—proof that your installation works and logic executes at the edge. New to Coreflux? Start with the [Overview](../index). **Like installing an app on your router instead of your phone.** LoT Actions run directly inside the broker -- your logic executes at the edge without an external script or server. ## Prerequisites Before you begin, ensure you have: * **A running Coreflux Broker** — See the [Installation Guide](/latest/quick-start/installation) if you haven't set one up yet * **VS Code** with the LoT Notebooks extension *or* access to the [Coreflux HUB](/hub/overview) in your browser — see the [VS Code Setup Guide](/latest/quick-start/vscode) for the extension * **Basic MQTT knowledge** — Understanding of topics, publish/subscribe patterns *** Ready? Here's what you'll build and deploy. ## Your First LoT Action in 15 Minutes > **Concept Refresher**: You are about to write an action that lives and runs **inside** the broker, not on your laptop. Let's create a **simulated temperature sensor** that publishes a temperature reading every 5 seconds. You'll see data on a topic as soon as you deploy—no external input required. This keeps the whole guide in a single "temperature monitoring" flow. ### Understanding the Action LoT Actions have three parts: 1. **Definition** — `DEFINE ACTION ` 2. **Trigger** — When to run (`ON EVERY