Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/design/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This section contains design documentation for the ros2_medkit project packages.
ros2_medkit_msgs/index
ros2_medkit_opcua/index
ros2_medkit_param_beacon/index
ros2_medkit_roxsie_msgs/index
ros2_medkit_serialization/index
ros2_medkit_sovd_service_interface/index
ros2_medkit_topic_beacon/index
1 change: 1 addition & 0 deletions docs/design/ros2_medkit_roxsie_msgs
7 changes: 7 additions & 0 deletions src/ros2_medkit_roxsie_msgs/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Changelog for package ros2_medkit_roxsie_msgs
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Forthcoming
-----------
* Initial interface definitions for the ROXSIE integration: ``Alarm.msg`` and ``AlarmList.msg`` for the cyclic picture of alarms in the PLC, ``DiagnosticsStatus.msg`` for the condition sent back into the PLC.
36 changes: 36 additions & 0 deletions src/ros2_medkit_roxsie_msgs/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2026 mfaferek93
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

cmake_minimum_required(VERSION 3.8)
project(ros2_medkit_roxsie_msgs)

# Note: ROS2MedkitWarnings is intentionally NOT included here.
# rosidl-generated code triggers strict warnings we cannot fix.
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(builtin_interfaces REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
"msg/Alarm.msg"
"msg/AlarmList.msg"
"msg/DiagnosticsStatus.msg"
DEPENDENCIES builtin_interfaces
)

if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
ament_lint_auto_find_test_dependencies()
endif()

ament_package()
115 changes: 115 additions & 0 deletions src/ros2_medkit_roxsie_msgs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# ros2_medkit_roxsie_msgs

Messages for the ROXSIE integration: PLC alarms into ROS 2, and one status value back
into the PLC.

## Overview

ROXSIE generates the PLC blocks and a ROS 2 package for the data you declare in the YAML
config, exchanged over shared memory on the host. That covers process values. It does not
cover alarms, because alarms live in the CPU message system and not in a data block.

So on a machine with a SIMATIC PLC and ROS 2 you still have two separate lists of what is
wrong. To join them today you either read alarm bits over a protocol and rebuild their
meaning from an engineering export, or someone copies the alarms by hand into a second
list on the panel. Both drift.

This package holds the messages for that exchange. Alarms in the PLC arrive in ROS 2 and
become faults with freeze-frames. Back the other way, the PLC gets one value it can act on.

## A picture, not a stream

`AlarmList` carries every alarm that is active or waiting for acknowledgement, published
cyclically. That is a deliberate choice.

The transport is cyclic anyway: the PLC-side node reads a data block at a fixed rate, so a
picture is what it naturally has.

And a picture survives a restart. Whoever restarts, the next cycle carries the full truth
again. An event stream loses whatever happened while it was away, and the consumer cannot
tell an empty stream from a healthy machine. That gap is not hypothetical: reading alarms
over OPC UA has the same hole, because no table of pending alarms is served on reconnect.

The bridge compares consecutive pictures. An entry that appears becomes a fault, an entry
that disappears heals it.

## Two ways to fill the list

**From bits.** The customer declares alarm bits in a data block and ROXSIE carries them
like any other data. Nothing new is needed on the generator side, so this works today.
`alarm_class`, `priority`, `producer`, `plc_timestamp` and `associated_values` stay empty,
and the freeze-frame comes from the process topics instead. Acknowledgement is a bit, so
either side can set it.

**From the CPU alarm system.** Generated code reads the alarm system with `Get_Alarm` and
mirrors it into the data block. Still no OPC UA and no license. This fills everything: the
event time from the CPU clock, the alarm class, the priority the plant already engineers
with, and the values latched when the alarm fired.

It also picks up alarms nobody declared. The CPU message system carries system diagnostics,
ProDiag, GRAPH, motion and security alarms, and `producer` says which one an entry came
from. A module fault or a drive alarm arrives without any extra engineering.

The trade is acknowledgement. The CPU owns the acknowledged state and has no per-alarm
acknowledge instruction, so setting it for a single alarm needs the panel or OPC UA. We
mirror it rather than write it.

## Messages

### Alarm.msg

One alarm, as one entry of the list.

| Field | Type | Description |
|-------|------|-------------|
| `alarm_id` | uint32 | Numeric alarm identifier from the PLC project |
| `source` | string | Block or instance that declared the alarm |
| `alarm_class` | string | Alarm class from the project, empty in the bit path |
| `priority` | uint8 | 0 to 16 as the PLC knows it, higher is more important |
| `producer` | string | `user_program`, `system_diagnostics`, `prodiag`, `graph`, `motion`, `security` |
| `active` | bool | Whether the condition is currently present |
| `acknowledged` | bool | Whether the alarm has been acknowledged |
| `plc_timestamp` | builtin_interfaces/Time | Last state change, from the PLC clock, UTC |
| `associated_values` | float64[] | Values latched when the alarm fired, 512 bytes total in the CPU |

Two flags rather than one enum, because an alarm that is gone but unacknowledged is a real
state and stays in the list. Those are the same two bits the CPU alarm system tracks.

Alarm text does not travel. The text belongs to the project, changes with the project, and
is resolved on the ROS 2 side from a table that ships with the deployment.

### AlarmList.msg

| Field | Type | Description |
|-------|------|-------------|
| `stamp` | builtin_interfaces/Time | When this picture was taken |
| `alarms` | Alarm[] | Alarms active or not yet acknowledged |

### DiagnosticsStatus.msg

| Field | Type | Description |
|-------|------|-------------|
| `heartbeat` | uint16 | Liveness counter of the diagnostics layer, about 1 Hz |
| `condition` | uint8 | `OK`, `ABNORMAL`, `ACTION_REQUIRED`, `STOP` |
| `class_bitmask` | uint16 | Optional, coarse fault classes for interlocks |
| `scope` | string | Which entity in the SOVD tree this covers |

`condition` says what we are asking of the machine, not what to light up. Machines already
have a state machine, and where PackML is used, a block that turns machine state into
signal column bits. We hand over a condition and let that logic decide.

When `heartbeat` stops, a watchdog in the PLC program raises its own alarm, so the plant
sees that diagnostics are down instead of a green light that means nothing.

`condition` covers every active fault in scope, not only the ones that came from the PLC. A
navigation fault on the robot moves the same value.

## Related packages

- `ros2_medkit_msgs` - the core fault model these alarms are mapped onto
- `ros2_medkit_fault_manager` - fault aggregation, debounce and freeze-frame capture
- `ros2_medkit_diagnostic_bridge` - the same bridging pattern for ROS 2 `/diagnostics`

## License

Apache-2.0
227 changes: 227 additions & 0 deletions src/ros2_medkit_roxsie_msgs/design/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
ros2_medkit_roxsie_msgs
=======================

This section contains design documentation for the ros2_medkit_roxsie_msgs package.

Overview
--------

ROXSIE generates the PLC blocks and a ROS 2 package for the data you declare in the YAML
config, exchanged over shared memory on the host. The generated node sits on the ROS 2
graph, so anything already listening to that graph can read the data.

That covers process values. It does not cover alarms, because alarms live in the CPU
message system and not in a data block.

So on a machine with a SIMATIC PLC and ROS 2 you still have two separate lists of what is
wrong. To join them today you either read alarm bits over a protocol and rebuild their
meaning from an engineering export, or someone copies the alarms by hand into a second
list on the panel. Both drift.

This package holds the messages for that exchange. It has no runtime code, only ``.msg``
definitions that ``rosidl`` compiles into C++ and Python bindings.

A Picture, Not a Stream
-----------------------

``AlarmList`` carries every alarm that is active or waiting for acknowledgement, published
cyclically. Two reasons.

The transport is cyclic anyway. The PLC-side node reads a data block at a fixed rate, so a
picture is what it naturally has. An event stream would be built on top of that and would
need its own delivery guarantees.

A picture also survives a restart. Whoever restarts, the next cycle carries the full truth
again. An event stream loses whatever happened while it was away, and the consumer cannot
tell an empty stream from a healthy machine. The gap is not hypothetical: reading alarms
over OPC UA has the same hole, because no table of pending alarms is served on reconnect.

The bridge compares consecutive pictures. An entry that appears is reported as a fault, an
entry that disappears heals it. Debounce, freeze-frame capture and the audit trail are the
same ones every other fault source in medkit goes through.

Two Ways to Fill the List
-------------------------

**From bits.** The customer declares alarm bits in a data block and ROXSIE carries them
like any other declared data. Nothing new is needed on the generator side, so this path
works today. ``alarm_class``, ``priority``, ``producer``, ``plc_timestamp`` and
``associated_values`` stay empty, and the freeze-frame comes from the process topics the
same deployment already carries. Acknowledgement is a bit, so either side can set it.

**From the CPU alarm system.** Generated code reads the alarm system with ``Get_Alarm`` and
mirrors it into the data block. Still no OPC UA and no license. This fills everything the
message can hold:

* the event time from the CPU clock, rather than the moment ROS 2 noticed
* the alarm class, which says whether the alarm needs acknowledgement
* the priority the plant already engineers with, 0 to 16
* the values the alarm system latched when the alarm fired, capped by the CPU at 512 bytes
across all of them

It also picks up alarms nobody declared. The CPU message system carries system
diagnostics, ProDiag, GRAPH, motion and security alarms, and ``producer`` says which one an
entry came from. A module fault or a drive alarm arrives without any extra engineering,
which is the strongest argument for this path.

The trade is acknowledgement. The CPU owns the acknowledged state and has no per-alarm
acknowledge instruction, so setting it for a single alarm needs the panel or OPC UA. We
mirror it rather than write it.

One cost worth naming: ``Get_Alarm`` reads from a queue, one entry at a time, while the
data block has to hold a picture. The generated code has to keep that picture itself. That
is real work, not a flag.

Architecture
------------

.. plantuml::
:caption: ROXSIE alarm integration - data flow

@startuml roxsie_alarm_integration_architecture

skinparam linetype ortho

package "SIMATIC PLC" {
[CPU alarm system] as AS
[Alarm bits] as BITS
[Alarm list block] as GEN
[Consumed status block] as DB
[Machine logic\nSignal column] as SIG
}

package "ROS 2" {
[ROXSIE generated node] as NODE
[ros2_medkit_roxsie_bridge] as BR
[FaultManager] as FM
[SOVD gateway] as GW
}

[Panel page, dashboards,\nmaintenance clients] as CONS

AS --> GEN : Get_Alarm
BITS --> GEN : bit path
GEN --> NODE : shared memory
NODE --> BR : AlarmList
BR --> FM : ReportFault
FM --> BR : fault events
BR --> NODE : DiagnosticsStatus
NODE --> DB : shared memory
DB --> SIG
FM -- GW
GW --> CONS : HTTPS

@enduml

The generated node talks to the PLC over shared memory on the same host, but publishes on
the ROS 2 graph over the network. So the diagnostics side does not have to run on the
controller. It subscribes like any other ROS 2 participant.

What Goes Back Into the PLC
---------------------------

One value and a heartbeat. ``DiagnosticsStatus`` says what the diagnostics side is asking
of the machine: nothing, something is abnormal, someone has to act, or stop.

It does not say what to light up. Machines already have a state machine, and where PackML
is used, a block that turns machine state into signal column bits according to
ISA TR88.00.02. Driving the lamp ourselves would put two writers on it. We hand over a
condition and let the machine's own logic decide what that means for the lamp, for an
interlock, or for a state transition.

The condition covers every active fault in scope, not only the ones that came from the PLC.
A navigation fault on the robot moves the same value, which is how a problem on the ROS 2
side reaches an operator who never looks at a screen.

``class_bitmask`` is there for machine logic that needs to interlock on a kind of fault
without reading a list. Which fault sets which bit is deployment configuration.

Message Definitions
-------------------

.. list-table::
:header-rows: 1
:widths: 30 70

* - Message
- Purpose
* - ``Alarm.msg``
- One alarm: identity, source, class, priority, producer, active and acknowledged
flags, PLC timestamp, latched values
* - ``AlarmList.msg``
- The cyclic picture: every alarm active or not yet acknowledged
* - ``DiagnosticsStatus.msg``
- What the diagnostics side asks of the machine: heartbeat, condition, class bits,
scope

Alarm text does not travel at runtime. The text belongs to the project, changes with the
project, and is resolved on the ROS 2 side from a table that ships with the deployment.

Failure Behaviour
-----------------

.. list-table::
:header-rows: 1
:widths: 40 60

* - Failure
- Behaviour
* - Diagnostics layer or link down
- The heartbeat stops and a watchdog in the PLC program raises its own alarm, so the
plant sees that diagnostics are down rather than a green light that means nothing
* - PLC or the generated node down
- The bridge reports a communications fault on the ROS 2 side
* - Anything restarts
- The next picture carries the full set of active alarms, so nothing is silently lost

The first row matters most. If our layer dies, the plant has to see it.

What Comes After
----------------

This package covers alarms from the PLC and one condition back. Two extensions follow, both
shaped by what a deployment needs rather than by what is interesting to build.

**OpenTelemetry as another fault source.** A cell is more than a PLC and a robot. The switch
that flaps, the controller that runs hot, the compute in another building that this machine
depends on - none of them speak the PLC alarm system, and most of them already speak
OpenTelemetry. Every one of those vendors ships its own API, and asking each of them for an
integration does not scale; asking all of them for OpenTelemetry does. An OTLP receiver next
to this bridge leaves three things to settle:

* **Which signal becomes a fault.** Log records at error level are the obvious answer.
Metrics are the more interesting one, because ``ros2_medkit_fault_detection`` already
evaluates thresholds and status words for PLC tags and can take a metric the same way.
* **Which entity a stream belongs to.** Resource attributes have to map onto the tree, or
every host lands in one unattached pile.
* **What the operator sees.** Nothing new to wire: an IT fault in the same list already
moves the condition that reaches the signal column.

**OpenTelemetry as an export.** The same faults served over REST can be emitted as
OpenTelemetry, and the per-scope condition as a metric. Two renderings of one state, not two
sources of truth. Teams that already run an observability pipeline add a source rather than
integrate an API.

Neither exists today. There is a hook where an exporter plugs in and nothing behind it.
Building it against a concrete consumer beats guessing at a generic one.

Open Questions
--------------

#. Is ``alarm_id`` unique per CPU, or only per block? If only per block, the key has to be
``(source, alarm_id)``.
#. Where does the alarm number to text table come from - the project export the generator
already reads, or a separate export?
#. How many entries can the generated data block hold? The array is fixed size once
generated, and a plant can declare hundreds of alarms. If the ceiling is low, the fill
has to prioritise and say that it truncated.
#. Is there any per-alarm acknowledge path in the CPU that we have missed? If not,
acknowledgement from the ROS 2 side stays a bit-path feature.
#. Numeric associated values, or typed? Numeric keeps the entry fixed size. Typed carries
more and brings strings back.
#. The bridge needs a mapping: which bit or alarm number means which fault code, and which
fault classes set which bits going back. Kept by hand, that mapping and the PLC program
drift apart, which is the spreadsheet problem all over again. The generator reads the
config and the project export already, so it could emit the mapping file alongside the
generated code and there would be one source of truth. Does the project export carry the
alarm declarations and texts needed for that?
Loading
Loading