diff --git a/rm-mqtt-ws-bridge/LICENSE b/rm-mqtt-ws-bridge/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/rm-mqtt-ws-bridge/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/rm-mqtt-ws-bridge/NOTICE b/rm-mqtt-ws-bridge/NOTICE
new file mode 100644
index 0000000..03240a3
--- /dev/null
+++ b/rm-mqtt-ws-bridge/NOTICE
@@ -0,0 +1,4 @@
+S2 Resource Manager MQTT Bridge (Ruby)
+Copyright 2026 Stekker.app B.V.
+
+Developed by Stekker.app B.V. (https://stekker.com).
diff --git a/rm-mqtt-ws-bridge/README.md b/rm-mqtt-ws-bridge/README.md
new file mode 100644
index 0000000..7f84c3e
--- /dev/null
+++ b/rm-mqtt-ws-bridge/README.md
@@ -0,0 +1,547 @@
+# S2 Resource Manager MQTT Bridge (Ruby)
+
+An **S2 Resource Manager (RM)** reference implementation. It is a Rails app that consumes resource-side MQTT
+messages, stores the resource state, and translates each message into an [S2 protocol](https://s2standard.org/)
+message that it relays over a WebSocket to a **CEM** (Customer Energy Manager) peer. It also turns the CEM's
+`FRBC.Instruction` back into a Stekker `power_setpoint` on MQTT.
+
+> Note: only FRBC messages are supported currently.
+
+> Note: This is by no means an attempt to standardize S2 message over MQTT. The MQTT communication is only supposed to be used internally to interface with your business logic.
+
+The RM is the reference implementation itself. It lives in [`rm/`](rm/) and is the subject of most of this
+document. The last chapter, [The live three-panel demo](#the-live-three-panel-demo), shows the RM talking to
+both of its neighbors at once on a single screen, using two small demo stand-ins (a CEM and an MQTT
+platform) that exist only to make the RM's behavior visible.
+
+Built by [Stekker.app B.V.](https://stekker.com), the team behind the Stekker smart-charging platform.
+
+
+
+## What this demonstrates
+
+Each resource-side MQTT event is parsed, stored when needed, mapped to the correct S2 message type,
+serialized as S2 JSON, and handed to the WebSocket relay; an inbound S2 instruction becomes a concrete
+MQTT power setpoint.
+
+- **Forward bridge path:** MQTT `power_measurements`, `power_forecasts`, `charge_statuses`, `charge_targets`,
+ and `instruction_updates` become S2 protocol messages with the expected `message_type`.
+- **Resource lifecycle:** MQTT `connects` registers `demo-resource-001` and starts the RM WebSocket
+ lifecycle; MQTT `disconnects` marks it inactive and closes the connection.
+- **Reverse bridge path:** an inbound S2 `FRBC.Instruction` becomes an MQTT `power_setpoint` plus an
+ `instruction_update` acknowledgment (see [Reverse path](#reverse-path-s2--mqtt)).
+
+You can see the forward path on its own by running the RM standalone (it falls back to a placeholder CEM), and
+the complete closed loop by running the [three-panel demo](#the-live-three-panel-demo) where the demo CEM
+is wired in.
+
+## Roles: this app is the Resource Manager
+
+In S2 there are two peers. This app is the **Resource Manager (RM)**: it represents an energy resource (here,
+an EV charger) and speaks S2 on its behalf. The **CEM** is the controlling peer. The RM opens the WebSocket,
+completes the handshake with `role: RM`, asserts the peer is `role: CEM`, and sends a `ResourceManagerDetails`
+message. The CEM sends `FRBC.Instruction` messages back.
+
+All S2 knowledge lives in this app: it maps each inbound MQTT message to an `S2::Messages::*` object,
+synthesizes the `FRBC.SystemDescription` and `ResourceManagerDetails` from the stored resource configuration,
+and turns an inbound `FRBC.Instruction` back into a Stekker `power_setpoint`. The S2 wire types, schemas and
+the `S2::Connection` WebSocket class come from the public
+[`stekker/s2-ruby`](https://github.com/stekker/s2-ruby) gem, pinned to an exact revision.
+
+```mermaid
+flowchart LR
+ client["Generic MQTT client (mosquitto_pub)"] -->|publish| broker[("Mosquitto broker")]
+ broker -->|"stekker/resources/+/…"| app["Rails S2 Resource Manager (RM)"]
+ app --- db[("Postgres")]
+ app -->|"S2 over WebSocket"| cem["CEM (demo CEM, or a placeholder when standalone)"]
+ browser["Browser"] -->|"reads the read-only dashboard"| app
+```
+
+A generic MQTT client publishes resource events to the broker; the bridge consumes them through its single
+MQTT listener and relays each one as S2. The browser only reads the dashboard, which is a read-only view of
+the live traffic and the resource state; it never publishes.
+
+## Run the Resource Manager standalone
+
+You need Docker with Compose. From [`rm/`](rm/):
+
+```sh
+cd rm
+docker compose up --build
+```
+
+This starts three services under the compose project `resource-manager-mqtt-ruby`:
+
+- **app**: the Rails RM bridge, on http://localhost:3000
+- **postgres**: PostgreSQL 16 (resource registry and Solid Queue/Cache/Cable)
+- **mosquitto**: Eclipse Mosquitto 2 MQTT broker on port 1883
+
+In another terminal:
+
+```sh
+curl -fsS http://localhost:3000/up # => HTTP/1.1 200 OK
+open http://localhost:3000 # read-only bridge dashboard, no login by default
+```
+
+When the RM runs standalone, there is no external CEM. The configured CEM URL is the local placeholder `ws://localhost:9999/s2`, so
+the app shows MQTT ingest and S2 message construction, then logs that no CEM connection is
+available. That is the expected offline result, not a failure; the [demo chapter](#the-live-three-panel-demo)
+adds the demo CEM and closes the loop.
+
+The app boots cleanly on an empty database: the supervisor only connects resources that already exist, and there
+are none until you publish a `connect`. The dashboard has no login by default; set both `BASIC_AUTH_USERNAME`
+and `BASIC_AUTH_PASSWORD` to require HTTP basic auth.
+
+### Port conflicts
+
+The standalone stack binds host ports `3000` (app) and `1883` (broker). If either is taken, add a local
+`rm/docker-compose.override.yml` next to the compose file (it keeps the same internal ports):
+
+```yaml
+services:
+ app:
+ ports:
+ - "3001:3000"
+ mosquitto:
+ ports:
+ - "1884:1883"
+```
+
+## Drive the bridge
+
+Publish the bundled examples with a generic MQTT client; the dashboard is read-only and never publishes.
+**Publish `connect` first**: it registers the resource and triggers the WebSocket connect. Send `disconnect`
+last.
+
+From `rm/`, `mosquitto_pub` inside the broker container is enough (the `./examples` folder is mounted at
+`/examples`):
+
+```sh
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/connects -f /examples/connect.json
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/power_measurements -f /examples/power_measurement.json
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/power_forecasts -f /examples/power_forecast.json
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/charge_statuses -f /examples/charge_status.json
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/charge_targets -f /examples/charge_target.json
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/instruction_updates -f /examples/instruction_update.json
+docker compose exec mosquitto mosquitto_pub -t stekker/resources/demo-resource-001/disconnects -f /examples/disconnect.json
+```
+
+Watch the translation on stdout:
+
+```sh
+docker compose logs -f app
+```
+
+Each `mosquitto_pub` also appears in the dashboard's "RM messages" feed at http://localhost:3000.
+
+## MQTT topics and S2 mapping
+
+Every bundled example is a real resource-side message the bridge already supports. The S2 column is the
+protocol `message_type` of the message the bridge builds and relays.
+
+| Example file | Topic | S2 message built | Direction |
+| --- | --- | --- | --- |
+| `connect.json` | `stekker/resources/demo-resource-001/connects` | (lifecycle) opens the WebSocket; `ResourceManagerDetails` after the handshake | MQTT → S2 |
+| `power_measurement.json` | `stekker/resources/demo-resource-001/power_measurements` | `PowerMeasurement` | MQTT → S2 |
+| `power_forecast.json` | `stekker/resources/demo-resource-001/power_forecasts` | `PowerForecast` | MQTT → S2 |
+| `charge_status.json` | `stekker/resources/demo-resource-001/charge_statuses` | `FRBC.StorageStatus` | MQTT → S2 |
+| `charge_target.json` | `stekker/resources/demo-resource-001/charge_targets` | `FRBC.FillLevelTargetProfile` | MQTT → S2 |
+| `instruction_update.json` | `stekker/resources/demo-resource-001/instruction_updates` | `InstructionStatusUpdate` | MQTT → S2 |
+| `disconnect.json` | `stekker/resources/demo-resource-001/disconnects` | (lifecycle) marks the resource inactive and closes the WebSocket | MQTT → S2 |
+| (reverse path) | `power_setpoints` + `instruction_updates` | from inbound `FRBC.Instruction` | S2 → MQTT |
+
+The topic shape is `stekker/resources//`. The `resource_id` is read from the message
+body; the bundled examples all use the same demo id `demo-resource-001`.
+
+## Expected output (forward path, static examples)
+
+Standalone, the RM proves the forward half of the bridge: inbound MQTT handling and S2 message construction.
+With no real CEM behind the WebSocket, the bridge connects to the placeholder CEM (`ws://localhost:9999/s2`),
+the connection is refused, and it retries with backoff. After each translation the relay logs that no
+connected connection was found. The app stays healthy throughout. That is the expected caveat, not a failure.
+
+Each translated example logs one pair of lines in `docker compose logs app`:
+
+```text
+[MessageRelayer] Built S2 for resource_id demo-resource-001:
+[MessageRelayer] No connected connection found for resource_id demo-resource-001
+```
+
+The S2 payloads below are **normalized**: the generated `message_id` UUID is shown as ``, and
+container IDs, host ports and machine paths are removed. The static timestamps come straight from the example
+JSON.
+
+Boot and health:
+
+```text
+[Initializer] Starting Supervisor
+[LogMQTTListenerSubscriber] MQTT listener connected
+GET /up returns 200
+```
+
+Connect (lifecycle; standalone the placeholder CEM refuses the connection and the bridge retries):
+
+```text
+Inbound MQTT message ... stekker/resources/demo-resource-001/connects
+Errno::ECONNREFUSED: Connection refused - connect(2) for 127.0.0.1:9999
+[Supervisor] [demo-resource-001] Reconnecting in 5s
+[S2::Connection] [demo-resource-001] Reconnecting in 5s...
+```
+
+Power measurement → `PowerMeasurement`:
+
+```json
+{
+ "message_id": "",
+ "message_type": "PowerMeasurement",
+ "measurement_timestamp": "2026-06-04T10:00:00Z",
+ "values": [
+ { "commodity_quantity": "ELECTRIC.POWER.3_PHASE_SYMMETRIC", "value": 3700 }
+ ]
+}
+```
+
+Power forecast → `PowerForecast`:
+
+```json
+{
+ "message_id": "",
+ "message_type": "PowerForecast",
+ "start_time": "2026-06-04T10:00:00Z",
+ "elements": [
+ { "duration": 900000, "power_values": [{ "commodity_quantity": "ELECTRIC.POWER.3_PHASE_SYMMETRIC", "value_expected": 3700 }] },
+ { "duration": 1800000, "power_values": [{ "commodity_quantity": "ELECTRIC.POWER.3_PHASE_SYMMETRIC", "value_expected": 7400 }] },
+ { "duration": 83700000, "power_values": [{ "commodity_quantity": "ELECTRIC.POWER.3_PHASE_SYMMETRIC", "value_expected": 0 }] }
+ ]
+}
+```
+
+Each `power_values` entry also carries the optional S2 statistical fields (`value_lower_68PPR`,
+`value_upper_limit`, and so on); they are present and `null` here and are omitted above for readability.
+
+Charge status → `FRBC.StorageStatus`:
+
+```json
+{
+ "message_id": "",
+ "message_type": "FRBC.StorageStatus",
+ "present_fill_level": 42.5
+}
+```
+
+Charge target → `FRBC.FillLevelTargetProfile`:
+
+```json
+{
+ "message_id": "",
+ "message_type": "FRBC.FillLevelTargetProfile",
+ "start_time": "2026-06-04T10:00:00Z",
+ "elements": [
+ { "duration": 27900000, "fill_level_range": { "start_of_range": 25.0, "end_of_range": 85.0 } },
+ { "duration": 900000, "fill_level_range": { "start_of_range": 85.0, "end_of_range": 85.0 } }
+ ]
+}
+```
+
+Instruction update → `InstructionStatusUpdate`:
+
+```json
+{
+ "message_id": "",
+ "message_type": "InstructionStatusUpdate",
+ "instruction_id": "demo-instruction-001",
+ "status_type": "SUCCEEDED",
+ "timestamp": "2026-06-04T10:10:00Z"
+}
+```
+
+## Reverse path (S2 → MQTT)
+
+When a CEM sends an `FRBC.Instruction`, `S2MessageHandler` validates it against the cached
+`FRBC.SystemDescription` and enqueues `ProcessFRBCInstructionJob` (`app/jobs/process_frbc_instruction_job.rb`).
+The job:
+
+1. Resolves the instructed actuator and operation mode from the system description.
+2. Computes the power setpoint in watts as `min_power + (max_power - min_power) * operation_mode_factor`, where
+ `min_power`/`max_power` are the instructed operation mode's power range.
+3. Publishes a Stekker `power_setpoint` on `stekker/resources//power_setpoints`.
+4. Publishes an `instruction_update` acknowledgment (`status: ACCEPTED`) on
+ `stekker/resources//instruction_updates`.
+
+The reverse path is **not exercised live** when the RM runs standalone; the
+[demo chapter](#the-live-three-panel-demo) runs it end to end. Below it is shown as a static, normalized
+example, also covered by the in-tree spec.
+
+Inbound S2 `FRBC.Instruction` (representative fields):
+
+```json
+{
+ "message_type": "FRBC.Instruction",
+ "operation_mode": "",
+ "operation_mode_factor": 0.5,
+ "execution_time": "2024-01-01T12:00:00Z"
+}
+```
+
+Outbound Stekker `power_setpoint` (MQTT):
+
+```json
+{
+ "message_type": "power_setpoint",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "reference": "",
+ "starts_at": "2024-01-01T12:00:00Z",
+ "ends_at": "2024-01-02T00:00:00Z",
+ "unit": "W",
+ "value": 5520
+ }
+}
+```
+
+Outbound Stekker `instruction_update` (MQTT):
+
+```json
+{
+ "message_type": "instruction_update",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "reference": "",
+ "updated_at": "",
+ "status": "ACCEPTED"
+ }
+}
+```
+
+The watt value depends only on the operation mode and the `operation_mode_factor`. The cases below are the
+exact values asserted by `spec/jobs/process_frbc_instruction_job_spec.rb` (3 of its 4 examples; the 4th
+asserts the `instruction_update` acknowledgment), which is the in-tree proof of this path and passes
+against the pinned `s2-ruby` revision:
+
+| Operation mode | `operation_mode_factor` | Power floor | Output `value` (W) |
+| --- | --- | --- | --- |
+| Charging | 0.5 | none | 5520 |
+| Charging | 0.5 | `minimum_current = 6` | 7590 |
+| Off | 1.0 | none | 0 |
+
+## Connecting to a real CEM
+
+The placeholder CEM is only the standalone default. You can point this RM at a real CEM, for example in an
+interoperability test with another party's CEM. The demo CEM in [`cem/`](cem/) is exactly such a peer; the
+[demo chapter](#the-live-three-panel-demo) wires it in for you.
+
+> **CEM routing requirement.** For each active resource the RM opens one WebSocket to `CEM_BASE_URL` with the
+> resource id appended as a trailing path segment, for example `wss://your-cem.example/s2/demo-resource-001`.
+> The CEM must accept connections on the base path and read the resource id from that trailing segment. The
+> `resource_id` is the one from the resource's MQTT message body (the same id used in the topics).
+
+Point the RM at the CEM and, if needed, the shared broker:
+
+```sh
+CEM_BASE_URL=wss://your-cem.example/s2
+CEM_USERNAME=... # optional, HTTP Basic
+CEM_PASSWORD=... # optional, HTTP Basic
+MQTT_URL=mqtt://your-broker:1883
+```
+
+Use `wss://` against a real CEM: HTTP Basic credentials travel in the WebSocket upgrade request, so `ws://`
+sends them in the clear.
+
+Once connected, the RM completes the S2 handshake with `role: RM` and expects the peer to announce `role: CEM`.
+It declares support for `FillRateBasedControl` in `ResourceManagerDetails`; the CEM selects it with
+`SelectControlType`, and the RM then sends an `FRBC.SystemDescription`. When the CEM sends an
+`FRBC.Instruction`, the RM translates it to a Stekker `power_setpoint` on MQTT. A successful connection shows
+up in `docker compose logs app`:
+
+```text
+[LogSessionSubscriber] [] S2 Session started
+Outbound S2 message ... "message_type":"ResourceManagerDetails"
+Outbound S2 message ... "message_type":"FRBC.SystemDescription"
+Inbound S2 message ... "message_type":"FRBC.Instruction"
+```
+
+If the CEM runs in another container or on another host, the app container needs a route to it. Add a local
+`rm/docker-compose.override.yml`. To reach another CEM container on a shared Docker network:
+
+```yaml
+services:
+ app:
+ environment:
+ CEM_BASE_URL: "ws://other-cem:9000/s2" # ws:// is fine on a private network; use wss:// for a real CEM
+ networks: [s2demo]
+networks:
+ s2demo:
+ external: true # create it once with: docker network create s2demo
+```
+
+To reach a CEM listening on the host instead:
+
+```yaml
+services:
+ app:
+ environment:
+ CEM_BASE_URL: "ws://host.docker.internal:9000/s2"
+ extra_hosts: ["host.docker.internal:host-gateway"] # required on Linux Docker; harmless on Docker Desktop
+```
+
+**Interop surface (what this RM supports today).** It speaks the `FillRateBasedControl` (FRBC) profile only;
+it authenticates to the CEM with HTTP Basic only; and it points every resource at one CEM endpoint
+(`CEM_BASE_URL`) rather than routing different resources to different CEMs (a runtime simplification, not a
+protocol requirement). It also keeps the [conformance workarounds](#conformance-notes) below. A CEM that
+expects a different control type, a different auth scheme, or fields this RM does not send will not complete
+the handshake.
+
+## Configuration
+
+All configuration is read from environment variables in one place
+([`rm/app/lib/reference_config.rb`](rm/app/lib/reference_config.rb)). The compose file sets sensible demo
+defaults; there are no secret-bearing files in this package.
+
+| ENV | Default in compose | Purpose |
+| --- | --- | --- |
+| `DATABASE_URL` | `postgres://postgres:postgres@postgres:5432/resource_manager_mqtt_ruby` | Rails and Solid Queue/Postgres storage |
+| `MQTT_URL` | `mqtt://mosquitto:1883` | Broker consumed by the RM listener |
+| `CEM_BASE_URL` | `ws://localhost:9999/s2` (standalone) / `ws://cem:9000/s2` (demo) | CEM WebSocket base URL; standalone the default is intentionally unreachable |
+| `CEM_USERNAME`, `CEM_PASSWORD` | unset | Optional CEM WebSocket basic auth |
+| `BASIC_AUTH_USERNAME`, `BASIC_AUTH_PASSWORD` | unset | Optional dashboard basic auth; off unless both are set |
+| `SECRET_KEY_BASE` | set in compose | Rails secret key base (demo-only value in compose) |
+| `S2_PROVIDES_FORECAST` | `true` | Enables the power-forecast translation path |
+| `DEMO_ALLOW_FRAMING` | unset (standalone) / `1` (demo) | When `1`, drops `X-Frame-Options` so the demo screen can frame the dashboard, and enables the demo-only feed-reset endpoint; default-off keeps `SAMEORIGIN` and a strictly read-only dashboard |
+| `IO_EVENT_SELECTOR` | `Select` | Forces the pure-Ruby event selector; the default epoll selector can end up spinning at 100% CPU after an S2 session teardown |
+
+## What is inside
+
+| Area | Files (under `rm/`) |
+| --- | --- |
+| MQTT → S2 translation | `app/lib/mqtt_message_handler.rb`, `app/lib/message_relayer.rb` |
+| S2 message factories | `app/lib/frbc_system_description_factory.rb`, `app/lib/resource_manager_details_factory.rb` |
+| S2 protocol handler | `app/lib/s2_message_handler.rb` |
+| Connection lifecycle | `app/lib/supervisor.rb`, `app/lib/connection_registry.rb` |
+| Reverse path (S2 → MQTT) | `app/jobs/process_frbc_instruction_job.rb` |
+| Read-only bridge dashboard | `app/controllers/resources_controller.rb`, `app/controllers/traffic_controller.rb`, `app/lib/traffic_log.rb` |
+| Bundled examples | `examples/*.json` |
+| Single-source config | `app/lib/reference_config.rb` |
+
+The S2 protocol layer itself is the external `stekker/s2-ruby` gem and is not vendored here. The
+`stekker-mqtt` contract gem is vendored under `rm/vendor/stekker-mqtt` so the build needs no private access.
+
+A spec suite lives under `rm/spec/` as a behavioral reference (notably the reverse-path job spec). It is
+not part of the standalone demo path.
+
+## Conformance notes
+
+The bridge applies a few conformance workarounds required for interoperability with a CEM implementation.
+They are interoperable (no fields the peer does not support):
+
+- The power commodity is hardcoded to `ELECTRIC.POWER.3_PHASE_SYMMETRIC` for every resource, including
+ single-phase ones (`app/lib/base_factory.rb`).
+- The charging operation mode uses a near-zero fill rate (so the CEM controls based on power), and the storage
+ fill-level range is widened for headroom at 0% and 100% state of charge
+ (`app/lib/frbc_system_description_factory.rb`).
+
+These are interoperability choices, not standalone demo behavior.
+
+## Technology
+
+Rails 8, PostgreSQL 16, Eclipse Mosquitto 2, the [Async](https://github.com/socketry/async) reactor for
+WebSocket I/O, Solid Queue/Cache/Cable (database-backed), and a small CSS design-token
+stylesheet for the read-only dashboard.
+
+---
+
+# The live three-panel demo
+
+To show the RM talking to both of its neighbors at once, three applications run side by side and the audience
+watches S2 and MQTT messages travel left to right and back:
+
+```
++----------------------+----------------------+----------------------+
+| CEM (demo) | Resource Manager | MQTT (demo) |
+| | (the real one) | |
++----------------------+----------------------+----------------------+
+ S2 over WebSocket <--> RM <--> MQTT (Stekker contract)
+```
+
+The middle panel is the RM described above. The two side panels are **demo stand-ins** that exist only to make
+the RM's behavior visible. They are not products; the real Stekker smart-charging platform is not
+open source.
+
+## Run the whole demo
+
+From this directory:
+
+```sh
+./demo.sh
+```
+
+Then open `http://localhost:8080`. The script builds and starts the three applications plus the demo screen, waits for
+the RM, and prints the URLs. Stop with `docker compose down`.
+
+### Walk-through
+
+1. In the **MQTT panel** (right), press **Connect charger** and wait until the **CEM panel** (left) shows the
+ Resource Manager as connected (events published before the S2 session is up are not relayed; repeating
+ `connect` on a live session with the same configuration is harmless). Then press **Power Measurement**:
+ the RM (middle) translates the event into S2, and the CEM panel shows it arriving on top of the completed
+ handshake.
+2. In the **CEM panel**, press **Send instruction to lower the load**. The CEM sends an `FRBC.Instruction` built from
+ the system description the RM advertised. The RM translates it and the **MQTT panel** shows the
+ `power_setpoint` that comes back.
+
+That second step is the closed loop: a control instruction from the CEM becomes a concrete power setpoint on
+the MQTT side, through the real bridge.
+
+### The conversation
+
+```
+MQTT panel --connect / power_measurement / charge_target--> RM
+RM --Handshake / ResourceManagerDetails / FRBC.SystemDescription--> CEM
+CEM --Handshake / HandshakeResponse / SelectControlType / FRBC.Instruction--> RM
+RM --power_setpoint / instruction_update (MQTT)--> MQTT panel
+```
+
+Every message a panel shows is real traffic that crossed the wire. The CEM's instruction uses the
+`actuator_id` and `operation_mode` from the `FRBC.SystemDescription` the RM sent. The RM also publishes a heartbeat
+power measurement every quarter hour, as S2 expects, so a 0 W measurement row can appear on its own.
+
+## The three applications
+
+Each application is also a normal `docker compose up` from its own directory, useful for inspecting one in
+isolation.
+
+| Application | What it is | Stack |
+| --- | --- | --- |
+| [`rm/`](rm/) | the real S2 Resource Manager (reference implementation); read-only bridge dashboard | Rails |
+| [`cem/`](cem/) | demo CEM: the S2 WebSocket server the RM connects to; completes the handshake, selects `FILL_RATE_BASED_CONTROL`, caches the `FRBC.SystemDescription`, and sends a real `FRBC.Instruction` on a button press | Ruby (async, s2-ruby) |
+| [`mqtt/`](mqtt/) | demo Stekker-platform stand-in; publish buttons send the bundled events, a reverse-path feed shows `power_setpoints` and `instruction_updates`; bundles the MQTT broker | Node |
+| [`demo/`](demo/) | the single screen that frames the three panels side by side | nginx |
+
+The CEM speaks the real S2 protocol with the same pinned `s2-ruby` revision the RM uses, so the handshake and
+messages are genuine, not faked. The MQTT panel publishes through its own small backend (a browser cannot
+speak MQTT over TCP 1883). The demo screen holds no logic; its iframes point at browser-resolvable host URLs
+(`http://localhost:9000`, `http://localhost:3000`, `http://localhost:4000`), not Docker service names.
+
+## Ports
+
+| Service | Host port | Notes |
+| --- | --- | --- |
+| demo screen | 8080 | the URL the presenter opens |
+| RM | 3000 | the read-only bridge dashboard, framed by the demo screen |
+| CEM | 9000 | HTTP panel and S2 WebSocket server (`/s2/`) |
+| MQTT panel | 4000 | the platform stand-in UI |
+| Mosquitto broker | 1883 | the RM connects here for MQTT |
+
+There is one broker for the whole demo, started by the top-level compose. The RM also ships its own broker in
+`rm/docker-compose.yml` for running `rm/` on its own; the whole-demo compose does not start that one, so only
+one broker binds 1883.
+
+If one of these host ports is taken, remap it in a local `docker-compose.override.yml` next to the top-level
+compose file (the same mechanism shown for the standalone RM under [Port conflicts](#port-conflicts)); the
+demo screen's iframe URLs in `demo/index.html` must then be updated to match.
+
+## The demo/reference boundary
+
+The RM ships secret-free, with secure defaults. It only allows being
+embedded in the demo screen when `DEMO_ALLOW_FRAMING` is set, which the whole-demo compose does. A plain copy of
+`rm/` run on its own, without that switch, keeps `X-Frame-Options: SAMEORIGIN`. The CEM and MQTT panels say
+in their own UIs that they are demo stand-ins.
diff --git a/rm-mqtt-ws-bridge/cem/Dockerfile b/rm-mqtt-ws-bridge/cem/Dockerfile
new file mode 100644
index 0000000..f47985d
--- /dev/null
+++ b/rm-mqtt-ws-bridge/cem/Dockerfile
@@ -0,0 +1,14 @@
+FROM ruby:3.4-slim
+
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y git build-essential && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY Gemfile Gemfile.lock ./
+RUN bundle install
+
+COPY . .
+
+EXPOSE 9000
+CMD ["ruby", "server.rb"]
diff --git a/rm-mqtt-ws-bridge/cem/Gemfile b/rm-mqtt-ws-bridge/cem/Gemfile
new file mode 100644
index 0000000..5178c06
--- /dev/null
+++ b/rm-mqtt-ws-bridge/cem/Gemfile
@@ -0,0 +1,6 @@
+source "https://rubygems.org"
+
+gem "s2-ruby", github: "stekker/s2-ruby", ref: "18583316a64e1d27e4273fe64a73988562848a6d", require: "s2"
+gem "async"
+gem "async-http"
+gem "async-websocket"
diff --git a/rm-mqtt-ws-bridge/cem/Gemfile.lock b/rm-mqtt-ws-bridge/cem/Gemfile.lock
new file mode 100644
index 0000000..8ba2914
--- /dev/null
+++ b/rm-mqtt-ws-bridge/cem/Gemfile.lock
@@ -0,0 +1,134 @@
+GIT
+ remote: https://github.com/stekker/s2-ruby.git
+ revision: 18583316a64e1d27e4273fe64a73988562848a6d
+ ref: 18583316a64e1d27e4273fe64a73988562848a6d
+ specs:
+ s2-ruby (0.1.0)
+ activesupport (>= 7.0)
+ async
+ async-websocket
+ dry-struct (~> 1.6)
+ dry-types (~> 1.7)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (8.1.3)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ json
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ uri (>= 0.13.1)
+ async (2.39.0)
+ console (~> 1.29)
+ fiber-annotation
+ io-event (~> 1.11)
+ metrics (~> 0.12)
+ traces (~> 0.18)
+ async-http (0.95.1)
+ async (>= 2.10.2)
+ async-pool (~> 0.11)
+ io-endpoint (~> 0.14)
+ io-stream (~> 0.6)
+ metrics (~> 0.12)
+ protocol-http (~> 0.62)
+ protocol-http1 (~> 0.39)
+ protocol-http2 (~> 0.26)
+ protocol-url (~> 0.2)
+ traces (~> 0.10)
+ async-pool (0.11.2)
+ async (>= 2.0)
+ async-websocket (0.30.0)
+ async-http (~> 0.76)
+ protocol-http (~> 0.34)
+ protocol-rack (~> 0.7)
+ protocol-websocket (~> 0.17)
+ base64 (0.3.0)
+ bigdecimal (4.1.2)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
+ console (1.36.0)
+ fiber-annotation
+ fiber-local (~> 1.1)
+ json
+ drb (2.2.3)
+ dry-core (1.2.0)
+ concurrent-ruby (~> 1.0)
+ logger
+ zeitwerk (~> 2.6)
+ dry-inflector (1.3.1)
+ dry-logic (1.6.0)
+ bigdecimal
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.1)
+ zeitwerk (~> 2.6)
+ dry-struct (1.8.1)
+ dry-core (~> 1.1)
+ dry-types (~> 1.8, >= 1.8.2)
+ ice_nine (~> 0.11)
+ zeitwerk (~> 2.6)
+ dry-types (1.9.1)
+ bigdecimal (>= 3.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0)
+ dry-inflector (~> 1.0)
+ dry-logic (~> 1.4)
+ zeitwerk (~> 2.6)
+ fiber-annotation (0.2.0)
+ fiber-local (1.1.0)
+ fiber-storage
+ fiber-storage (1.0.1)
+ i18n (1.14.8)
+ concurrent-ruby (~> 1.0)
+ ice_nine (0.11.2)
+ io-endpoint (0.17.2)
+ io-event (1.16.1)
+ io-stream (0.13.0)
+ json (2.19.8)
+ logger (1.7.0)
+ metrics (0.15.0)
+ minitest (6.0.6)
+ drb (~> 2.0)
+ prism (~> 1.5)
+ prism (1.9.0)
+ protocol-hpack (1.5.1)
+ protocol-http (0.62.2)
+ protocol-http1 (0.39.0)
+ protocol-http (~> 0.62)
+ protocol-http2 (0.26.0)
+ protocol-hpack (~> 1.4)
+ protocol-http (~> 0.62)
+ protocol-rack (0.22.1)
+ io-stream (>= 0.10)
+ protocol-http (~> 0.58)
+ rack (>= 1.0)
+ protocol-url (0.4.0)
+ protocol-websocket (0.21.1)
+ protocol-http (~> 0.2)
+ rack (3.2.6)
+ securerandom (0.4.1)
+ traces (0.18.2)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ uri (1.1.1)
+ zeitwerk (2.8.2)
+
+PLATFORMS
+ arm64-darwin-24
+ ruby
+
+DEPENDENCIES
+ async
+ async-http
+ async-websocket
+ s2-ruby!
+
+BUNDLED WITH
+ 2.6.9
diff --git a/rm-mqtt-ws-bridge/cem/docker-compose.yml b/rm-mqtt-ws-bridge/cem/docker-compose.yml
new file mode 100644
index 0000000..bc78447
--- /dev/null
+++ b/rm-mqtt-ws-bridge/cem/docker-compose.yml
@@ -0,0 +1,9 @@
+name: s2-demo-cem
+
+services:
+ cem:
+ build: .
+ environment:
+ PORT: "9000"
+ ports:
+ - "9000:9000"
diff --git a/rm-mqtt-ws-bridge/cem/public/index.html b/rm-mqtt-ws-bridge/cem/public/index.html
new file mode 100644
index 0000000..baeb068
--- /dev/null
+++ b/rm-mqtt-ws-bridge/cem/public/index.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+ CEM (demo)
+
+
+
+
+
+
+
S2 CEMdemo stand-in
+
The congestion controller. Sends the RM an S2 instruction to lower the load.
+
+
+
+
Waiting for the RM to connect…
+
+
+
+
+
+
S2 messages ✓ = acknowledged with ReceptionStatus
Speaks MQTT to the charger and S2 to the CEM, and translates between them both ways.
+
+
+
+
+
+
+
RM messages
+
No messages yet.
+
+
+
+
+
diff --git a/rm-mqtt-ws-bridge/rm/bin/bundle b/rm-mqtt-ws-bridge/rm/bin/bundle
new file mode 100755
index 0000000..c4f9ab9
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/bin/bundle
@@ -0,0 +1,113 @@
+#!/usr/bin/env ruby
+#
+# This file was generated by Bundler.
+#
+# The application 'bundle' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+require "rubygems"
+
+m = Module.new do
+ module_function
+
+ def invoked_as_script?
+ File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__)
+ end
+
+ def env_var_version
+ ENV.fetch("BUNDLER_VERSION", nil)
+ end
+
+ def cli_arg_version
+ return unless invoked_as_script? # don't want to hijack other binstubs
+ return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
+
+ bundler_version = nil
+ update_index = nil
+ ARGV.each_with_index do |a, i|
+ if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN)
+ bundler_version = a
+ end
+ next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
+
+ bundler_version = Regexp.last_match(1)
+ update_index = i
+ end
+ bundler_version
+ end
+
+ def gemfile
+ gemfile = ENV.fetch("BUNDLE_GEMFILE", nil)
+ return gemfile if gemfile
+
+ File.expand_path("../Gemfile", __dir__)
+ end
+
+ def lockfile
+ lockfile =
+ case File.basename(gemfile)
+ when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
+ else "#{gemfile}.lock"
+ end
+ File.expand_path(lockfile)
+ end
+
+ def lockfile_version
+ return unless File.file?(lockfile)
+
+ lockfile_contents = File.read(lockfile)
+ return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
+
+ Regexp.last_match(1)
+ end
+
+ def bundler_requirement
+ @bundler_requirement ||=
+ env_var_version ||
+ cli_arg_version ||
+ bundler_requirement_for(lockfile_version)
+ end
+
+ def bundler_requirement_for(version)
+ return "#{Gem::Requirement.default}.a" unless version
+
+ bundler_gem_version = Gem::Version.new(version)
+
+ bundler_gem_version.approximate_recommendation
+ end
+
+ def load_bundler!
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
+
+ activate_bundler
+ end
+
+ def activate_bundler
+ gem_error = activation_error_handling do
+ gem "bundler", bundler_requirement
+ end
+ return if gem_error.nil?
+
+ require_error = activation_error_handling do
+ require "bundler/version"
+ end
+ if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
+ return
+ end
+
+ warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
+ exit 42
+ end
+
+ def activation_error_handling
+ yield
+ nil
+ rescue StandardError, LoadError => e
+ e
+ end
+end
+
+m.load_bundler!
+
+load Gem.bin_path("bundler", "bundle") if m.invoked_as_script?
diff --git a/rm-mqtt-ws-bridge/rm/bin/docker-entrypoint b/rm-mqtt-ws-bridge/rm/bin/docker-entrypoint
new file mode 100755
index 0000000..237dd12
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/bin/docker-entrypoint
@@ -0,0 +1,14 @@
+#!/bin/bash -e
+
+# Enable jemalloc for reduced memory usage and latency.
+if [ -z "${LD_PRELOAD+x}" ]; then
+ LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
+ export LD_PRELOAD
+fi
+
+# If running the rails server then create or migrate the database.
+if [ "$1" == "./bin/rails" ] && [ "$2" == "server" ]; then
+ ./bin/rails db:prepare
+fi
+
+exec "${@}"
diff --git a/rm-mqtt-ws-bridge/rm/bin/jobs b/rm-mqtt-ws-bridge/rm/bin/jobs
new file mode 100755
index 0000000..dcf59f3
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/bin/jobs
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/environment"
+require "solid_queue/cli"
+
+SolidQueue::Cli.start(ARGV)
diff --git a/rm-mqtt-ws-bridge/rm/bin/rails b/rm-mqtt-ws-bridge/rm/bin/rails
new file mode 100755
index 0000000..efc0377
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/bin/rails
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+APP_PATH = File.expand_path("../config/application", __dir__)
+require_relative "../config/boot"
+require "rails/commands"
diff --git a/rm-mqtt-ws-bridge/rm/bin/rake b/rm-mqtt-ws-bridge/rm/bin/rake
new file mode 100755
index 0000000..4fbf10b
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/bin/rake
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+require_relative "../config/boot"
+require "rake"
+Rake.application.run
diff --git a/rm-mqtt-ws-bridge/rm/config.ru b/rm-mqtt-ws-bridge/rm/config.ru
new file mode 100644
index 0000000..4a3c09a
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config.ru
@@ -0,0 +1,6 @@
+# This file is used by Rack-based servers to start the application.
+
+require_relative "config/environment"
+
+run Rails.application
+Rails.application.load_server
diff --git a/rm-mqtt-ws-bridge/rm/config/application.rb b/rm-mqtt-ws-bridge/rm/config/application.rb
new file mode 100644
index 0000000..8d930c3
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/application.rb
@@ -0,0 +1,50 @@
+require_relative "boot"
+
+require "rails"
+# Pick the frameworks you want:
+require "active_model/railtie"
+require "active_job/railtie"
+require "active_record/railtie"
+# require "active_storage/engine"
+require "action_controller/railtie"
+# require "action_mailer/railtie"
+# require "action_mailbox/engine"
+# require "action_text/engine"
+require "action_view/railtie"
+require "action_cable/engine"
+# require "rails/test_unit/railtie"
+
+require_relative "../app/lib/reference_config"
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module StekkerS2
+ class Application < Rails::Application
+ # Initialize configuration defaults for originally generated Rails version.
+ config.load_defaults 8.0
+
+ config.active_support.isolation_level = :fiber
+
+ # Please, add to the `ignore` list any other `lib` subdirectories that do
+ # not contain `.rb` files, or that should not be reloaded or eager loaded.
+ # Common ones are `templates`, `generators`, or `middleware`, for example.
+ config.autoload_lib(ignore: %w[assets tasks])
+
+ # Configuration for the application, engines, and railties goes here.
+ #
+ # These settings can be overridden in specific environments using the files
+ # in config/environments, which are processed later.
+ #
+ # config.time_zone = "Central Time (US & Canada)"
+ # config.eager_load_paths << Rails.root.join("extras")
+
+ # Don't generate system test files.
+ config.generators.system_tests = nil
+
+ config.s2 = ActiveSupport::OrderedOptions.new
+ config.s2.provides_forecast = ReferenceConfig.provides_forecast?
+
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/boot.rb b/rm-mqtt-ws-bridge/rm/config/boot.rb
new file mode 100644
index 0000000..988a5dd
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/boot.rb
@@ -0,0 +1,4 @@
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+require "bundler/setup" # Set up gems listed in the Gemfile.
+require "bootsnap/setup" # Speed up boot time by caching expensive operations.
diff --git a/rm-mqtt-ws-bridge/rm/config/cable.yml b/rm-mqtt-ws-bridge/rm/config/cable.yml
new file mode 100644
index 0000000..4f8054a
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/cable.yml
@@ -0,0 +1,14 @@
+# Async adapter only works within the same process, so for manually triggering cable updates from a console,
+# and seeing results in the browser, you must do so from the web console (running inside the dev process),
+# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
+# to make the web console appear.
+development:
+ adapter: async
+
+test:
+ adapter: test
+
+production:
+ adapter: solid_cable
+ polling_interval: 0.1.seconds
+ message_retention: 1.day
diff --git a/rm-mqtt-ws-bridge/rm/config/cache.yml b/rm-mqtt-ws-bridge/rm/config/cache.yml
new file mode 100644
index 0000000..ae55ceb
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/cache.yml
@@ -0,0 +1,15 @@
+default: &default
+ store_options:
+ # Cap age of oldest cache entry to fulfill retention policies
+ # max_age: <%= 60.days.to_i %>
+ max_size: <%= 256.megabytes %>
+ namespace: <%= Rails.env %>
+
+development:
+ <<: *default
+
+test:
+ <<: *default
+
+production:
+ <<: *default
diff --git a/rm-mqtt-ws-bridge/rm/config/database.yml b/rm-mqtt-ws-bridge/rm/config/database.yml
new file mode 100644
index 0000000..2aeee5a
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/database.yml
@@ -0,0 +1,87 @@
+# PostgreSQL. Versions 9.3 and up are supported.
+#
+# Install the pg driver:
+# gem install pg
+# On macOS with Homebrew:
+# gem install pg -- --with-pg-config=/usr/local/bin/pg_config
+# On Windows:
+# gem install pg
+# Choose the win32 build.
+# Install PostgreSQL and put its /bin directory on your path.
+#
+# Configure Using Gemfile
+# gem "pg"
+#
+default: &default
+ adapter: postgresql
+ encoding: unicode
+ # For details on connection pooling, see Rails configuration guide
+ # https://guides.rubyonrails.org/configuring.html#database-pooling
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+
+
+development:
+ <<: *default
+ database: stekker_s2_development
+
+ # The specified database role being used to connect to PostgreSQL.
+ # To create additional roles in PostgreSQL see `$ createuser --help`.
+ # When left blank, PostgreSQL will use the default role. This is
+ # the same name as the operating system user running Rails.
+ #username: stekker_s2
+
+ # The password associated with the PostgreSQL role (username).
+ #password:
+
+ # Connect on a TCP socket. Omitted by default since the client uses a
+ # domain socket that doesn't need configuration. Windows does not have
+ # domain sockets, so uncomment these lines.
+ #host: localhost
+
+ # The TCP port the server listens on. Defaults to 5432.
+ # If your server runs on a different port number, change accordingly.
+ #port: 5432
+
+ # Schema search path. The server defaults to $user,public
+ #schema_search_path: myapp,sharedapp,public
+
+ # Minimum log levels, in increasing order:
+ # debug5, debug4, debug3, debug2, debug1,
+ # log, notice, warning, error, fatal, and panic
+ # Defaults to warning.
+ #min_messages: notice
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ username: <%= ENV.fetch('STEKKER_DATABASE_USERNAME', 'stekker_s2') %>
+ database: stekker_s2_test<%= ENV['TEST_ENV_NUMBER'] %>
+ password: <%= ENV.fetch('STEKKER_DATABASE_PASSWORD', 'stekker_s2') %>
+ host: 127.0.0.1
+ pool: 5
+ timeout: 2000
+
+# Provide the production connection URL through the environment when you boot
+# the app. The standalone compose file sets this for the demo:
+#
+# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
+#
+# If the connection URL is provided in the special DATABASE_URL environment
+# variable, Rails will automatically merge its configuration values on top of
+# the values provided in this file. Alternatively, you can specify a connection
+# URL environment variable explicitly:
+#
+# production:
+# url: <%= ENV["MY_APP_DATABASE_URL"] %>
+#
+# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
+# for a full overview on how database connection configuration can be specified.
+#
+
+production:
+ <<: *default
+<% unless ENV["DATABASE_URL"].to_s.empty? %>
+ url: <%= ENV.fetch("DATABASE_URL") %>
+<% end %>
diff --git a/rm-mqtt-ws-bridge/rm/config/environment.rb b/rm-mqtt-ws-bridge/rm/config/environment.rb
new file mode 100644
index 0000000..cac5315
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/environment.rb
@@ -0,0 +1,5 @@
+# Load the Rails application.
+require_relative "application"
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/rm-mqtt-ws-bridge/rm/config/environments/development.rb b/rm-mqtt-ws-bridge/rm/config/environments/development.rb
new file mode 100644
index 0000000..1da9f37
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/environments/development.rb
@@ -0,0 +1,71 @@
+require "active_support/core_ext/integer/time"
+require "ecs_logging/logger"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Make code changes take effect immediately without server restart.
+ config.enable_reloading = true
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+
+ # Enable server timing.
+ config.server_timing = true
+
+ # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
+ # Run rails dev:cache to toggle Action Controller caching.
+ if Rails.root.join("tmp/caching-dev.txt").exist?
+ config.action_controller.perform_caching = true
+ config.action_controller.enable_fragment_cache_logging = true
+ config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
+ else
+ config.action_controller.perform_caching = false
+ end
+
+ # Change to :null_store to avoid any caching.
+ config.cache_store = :memory_store
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Highlight code that triggered database queries in logs.
+ config.active_record.verbose_query_logs = true
+
+ # Append comments with runtime information tags to SQL queries in logs.
+ config.active_record.query_log_tags_enabled = true
+
+ # Highlight code that enqueued background job in logs.
+ config.active_job.verbose_enqueue_logs = true
+
+ # Highlight code that triggered redirect in logs.
+ config.action_dispatch.verbose_redirect_logs = true
+
+ # Suppress logger output for asset requests.
+ config.assets.quiet = true
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Uncomment if you wish to allow Action Cable access from any origin.
+ # config.action_cable.disable_request_forgery_protection = true
+
+ # Raise error when a before_action's only/except options reference missing actions.
+ config.action_controller.raise_on_missing_callback_actions = true
+
+ # Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
+ # config.generators.apply_rubocop_autocorrect_after_generate!
+
+ # ECS-formatted logging (null logger for development)
+ config.ecs_logger = EcsLogging::Logger.new(IO::NULL)
+
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/environments/production.rb b/rm-mqtt-ws-bridge/rm/config/environments/production.rb
new file mode 100644
index 0000000..f93363c
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/environments/production.rb
@@ -0,0 +1,84 @@
+require "active_support/core_ext/integer/time"
+require "ecs_logging/logger"
+require_relative "../../app/lib/stekker_formatter"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.enable_reloading = false
+
+ # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
+ config.eager_load = true
+
+ # Full error reports are disabled.
+ config.consider_all_requests_local = false
+
+ # Turn on fragment caching in view templates.
+ config.action_controller.perform_caching = true
+
+ config.public_file_server.headers = { "cache-control" => "no-store" }
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.asset_host = "http://assets.example.com"
+
+ # Assume all access to the app is happening through a SSL-terminating reverse proxy.
+ # config.assume_ssl = true
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ # config.force_ssl = true
+
+ # Skip http-to-https redirect for the default health check endpoint.
+ # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
+
+ config.log_tags = [:request_id]
+ config.logger = ActiveSupport::TaggedLogging.logger($stdout)
+
+ # Change to "debug" to log everything (including potentially personally-identifiable information!).
+ config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
+
+ # Prevent health checks from clogging up the logs.
+ config.silence_healthcheck_path = "/up"
+
+ # Don't log any deprecations.
+ config.active_support.report_deprecations = false
+
+ # Replace the default in-process memory cache store with a durable alternative.
+ config.cache_store = :solid_cache_store
+
+ # Replace the default in-process and non-durable queuing backend for Active Job.
+ config.active_job.queue_adapter = :solid_queue
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+
+ # Only use :id for inspections in production.
+ config.active_record.attributes_for_inspect = [:id]
+
+ # ECS-formatted logs are written to stdout for `docker compose logs app`.
+ config.ecs_logger = EcsLogging::Logger.new($stdout).tap do |lgr|
+ lgr.formatter = StekkerFormatter.new
+ end
+
+ # Enable DNS rebinding protection and other `Host` header attacks.
+ # config.hosts = [
+ # "example.com", # Allow requests from example.com
+ # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
+ # ]
+ #
+ # Skip DNS rebinding protection for the default health check endpoint.
+ # config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
+
+ if ENV["DEMO_ALLOW_FRAMING"] == "1"
+ config.action_dispatch.default_headers = {
+ "X-Content-Type-Options" => "nosniff",
+ "X-Permitted-Cross-Domain-Policies" => "none",
+ "Referrer-Policy" => "strict-origin-when-cross-origin",
+ "Content-Security-Policy" => "frame-ancestors 'self' http://localhost:8080",
+ }
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/environments/test.rb b/rm-mqtt-ws-bridge/rm/config/environments/test.rb
new file mode 100644
index 0000000..5e395bc
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/environments/test.rb
@@ -0,0 +1,54 @@
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+
+require "ecs_logging/logger"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # While tests run files are not watched, reloading is not necessary.
+ config.enable_reloading = false
+
+ # Eager loading loads your entire application. When running a single test locally,
+ # this is usually not necessary, and can slow down your test suite. However, it's
+ # recommended that you enable it in continuous integration systems to ensure eager
+ # loading is working properly before running the app.
+ config.eager_load = ENV["CI"].present?
+
+ # Configure public file server for tests with cache-control for performance.
+ config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+ config.cache_store = :null_store
+
+ # Render exception templates for rescuable exceptions and raise for other exceptions.
+ config.action_dispatch.show_exceptions = :rescuable
+
+ # Disable request forgery protection in test environment.
+ config.action_controller.allow_forgery_protection = false
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ # config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Raise error when a before_action's only/except options reference missing actions.
+ config.action_controller.raise_on_missing_callback_actions = true
+
+ if config.respond_to?(:factory_bot)
+ config.factory_bot.definition_file_paths << Stekker::MQTT::Factories::LOAD_PATH
+ end
+
+ Stekker::MQTT::MessageHandler.raise_handling_errors = true
+
+ # ECS-formatted logging (null logger for tests)
+ config.ecs_logger = EcsLogging::Logger.new(IO::NULL)
+
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/assets.rb b/rm-mqtt-ws-bridge/rm/config/initializers/assets.rb
new file mode 100644
index 0000000..4873244
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/assets.rb
@@ -0,0 +1,7 @@
+# Be sure to restart your server when you modify this file.
+
+# Version of your assets, change this if you want to expire all your assets.
+Rails.application.config.assets.version = "1.0"
+
+# Add additional assets to the asset load path.
+# Rails.application.config.assets.paths << Emoji.images_path
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/content_security_policy.rb b/rm-mqtt-ws-bridge/rm/config/initializers/content_security_policy.rb
new file mode 100644
index 0000000..d51d713
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/content_security_policy.rb
@@ -0,0 +1,29 @@
+# Be sure to restart your server when you modify this file.
+
+# Define an application-wide content security policy.
+# See the Securing Rails Applications Guide for more information:
+# https://guides.rubyonrails.org/security.html#content-security-policy-header
+
+# Rails.application.configure do
+# config.content_security_policy do |policy|
+# policy.default_src :self, :https
+# policy.font_src :self, :https, :data
+# policy.img_src :self, :https, :data
+# policy.object_src :none
+# policy.script_src :self, :https
+# policy.style_src :self, :https
+# # Specify URI for violation reports
+# # policy.report_uri "/csp-violation-report-endpoint"
+# end
+#
+# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
+# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
+# config.content_security_policy_nonce_directives = %w(script-src style-src)
+#
+# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
+# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
+# # config.content_security_policy_nonce_auto = true
+#
+# # Report violations without enforcing the policy.
+# # config.content_security_policy_report_only = true
+# end
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/filter_parameter_logging.rb b/rm-mqtt-ws-bridge/rm/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..c0b717f
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this file.
+
+# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
+# Use this to limit dissemination of sensitive information.
+# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
+Rails.application.config.filter_parameters += [
+ :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
+]
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/inflections.rb b/rm-mqtt-ws-bridge/rm/config/initializers/inflections.rb
new file mode 100644
index 0000000..c0e8139
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/inflections.rb
@@ -0,0 +1,16 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, "\\1en"
+# inflect.singular /^(ox)en/i, "\\1"
+# inflect.irregular "person", "people"
+# inflect.uncountable %w( fish sheep )
+# end
+
+ActiveSupport::Inflector.inflections(:en) do |inflect|
+ inflect.acronym "FRBC"
+ inflect.acronym "MQTT"
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/instrumentation.rb b/rm-mqtt-ws-bridge/rm/config/initializers/instrumentation.rb
new file mode 100644
index 0000000..4451d8c
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/instrumentation.rb
@@ -0,0 +1,10 @@
+Rails.application.config.to_prepare do
+ {
+ "session.s2": LogSessionSubscriber,
+ "listener.mqtt.stekker": LogMQTTListenerSubscriber,
+ "message_handler.mqtt.stekker": LogMQTTMessageHandlerSubscriber,
+ }.each do |event, subscriber|
+ subscriber.detach_from event
+ subscriber.attach_to event
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/new_framework_defaults_8_1.rb b/rm-mqtt-ws-bridge/rm/config/initializers/new_framework_defaults_8_1.rb
new file mode 100644
index 0000000..4145417
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/new_framework_defaults_8_1.rb
@@ -0,0 +1,75 @@
+# Be sure to restart your server when you modify this file.
+#
+# This file eases your Rails 8.1 framework defaults upgrade.
+#
+# Uncomment each configuration one by one to switch to the new default.
+# Once your application is ready to run with all new defaults, you can remove
+# this file and set the `config.load_defaults` to `8.1`.
+#
+# Read the Guide for Upgrading Ruby on Rails for more info on each option.
+# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
+
+###
+# Skips escaping HTML entities and line separators. When set to `false`, the
+# JSON renderer no longer escapes these to improve performance.
+#
+# Example:
+# class PostsController < ApplicationController
+# def index
+# render json: { key: "\u2028\u2029<>&" }
+# end
+# end
+#
+# Renders `{"key":"\u2028\u2029\u003c\u003e\u0026"}` with the previous default, but `{"key":" <>&"}`
+# with the config set to `false`.
+#
+# Applications that want to keep the escaping behavior can set the config to `true`.
+#++
+# Rails.configuration.action_controller.escape_json_responses = false
+
+###
+# Skips escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON.
+#
+# Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript
+# 2019. As such it's no longer a concern in modern browsers:
+# https://caniuse.com/mdn-javascript_builtins_json_json_superset.
+#++
+# Rails.configuration.active_support.escape_js_separators_in_json = false
+
+###
+# Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values
+# on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or
+# `primary_key`) to fall back on.
+#
+# The current behavior of not raising an error has been deprecated, and this configuration option will be removed
+# in Rails 8.2.
+#++
+# Rails.configuration.active_record.raise_on_missing_required_finder_order_columns = true
+
+###
+# Controls how Rails handles path relative URL redirects.
+# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError`
+# for relative URLs without a leading slash, which can help prevent open redirect vulnerabilities.
+#
+# Example:
+# redirect_to "example.com" # Raises UnsafeRedirectError
+# redirect_to "@attacker.com" # Raises UnsafeRedirectError
+# redirect_to "/safe/path" # Works correctly
+#
+# Applications that want to allow these redirects can set the config to `:log` (previous default)
+# to only log warnings, or `:notify` to send ActiveSupport notifications.
+#++
+# Rails.configuration.action_controller.action_on_path_relative_redirect = :raise
+
+###
+# Use a Ruby parser to track dependencies between Action View templates
+#++
+# Rails.configuration.action_view.render_tracker = :ruby
+
+###
+# When enabled, hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden parameter fields
+# included in `button_to` forms will omit the `autocomplete="off"` attribute.
+#
+# Applications that want to keep generating the `autocomplete` attribute for those tags can set it to `false`.
+#++
+# Rails.configuration.action_view.remove_hidden_field_autocomplete = true
diff --git a/rm-mqtt-ws-bridge/rm/config/initializers/s2.rb b/rm-mqtt-ws-bridge/rm/config/initializers/s2.rb
new file mode 100644
index 0000000..831014d
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/initializers/s2.rb
@@ -0,0 +1,20 @@
+Rails.application.config.to_prepare do
+ S2.logger = Rails.logger
+ S2.message_handler_class = S2MessageHandler
+end
+
+if defined?(Rails::Server)
+ Rails.logger.info "[Initializer] Rails server starting, initializing Supervisor"
+
+ Rails.application.config.after_initialize do
+ Rails.logger.info "[Initializer] Starting Supervisor"
+ Supervisor.instance.startup
+ end
+
+ at_exit do
+ Rails.logger.info "[Initializer] Stopping Supervisor"
+ Supervisor.instance.shutdown
+ end
+else
+ Rails.logger.info "[Initializer] Not a Rails server, skipping Supervisor initialization"
+end
diff --git a/rm-mqtt-ws-bridge/rm/config/puma.rb b/rm-mqtt-ws-bridge/rm/config/puma.rb
new file mode 100644
index 0000000..0bd1efc
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/puma.rb
@@ -0,0 +1,42 @@
+# This configuration file will be evaluated by Puma. The top-level methods that
+# are invoked here are part of Puma's configuration DSL. For more information
+# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
+#
+# Puma starts a configurable number of processes (workers) and each process
+# serves each request in a thread from an internal thread pool.
+#
+# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
+# should only set this value when you want to run 2 or more workers. The
+# default is already 1. You can set it to `auto` to automatically start a worker
+# for each available processor.
+#
+# The ideal number of threads per worker depends both on how much time the
+# application spends waiting for IO operations and on how much you wish to
+# prioritize throughput over latency.
+#
+# As a rule of thumb, increasing the number of threads will increase how much
+# traffic a given process can handle (throughput), but due to CRuby's
+# Global VM Lock (GVL) it has diminishing returns and will degrade the
+# response time (latency) of the application.
+#
+# The default is set to 3 threads as it's deemed a decent compromise between
+# throughput and latency for the average Rails application.
+#
+# Any libraries that use a connection pool or another resource pool should
+# be configured to provide at least as many connections as the number of
+# threads. This includes Active Record's `pool` parameter in `database.yml`.
+threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
+threads threads_count, threads_count
+
+# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
+port ENV.fetch("PORT", 3000)
+
+# Allow puma to be restarted by `bin/rails restart` command.
+plugin :tmp_restart
+
+# Run the Solid Queue supervisor inside Puma for this single-server demo.
+plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
+
+# Specify the PID file. Defaults to tmp/pids/server.pid in development.
+# In other environments, only set the PID file if requested.
+pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
diff --git a/rm-mqtt-ws-bridge/rm/config/queue.yml b/rm-mqtt-ws-bridge/rm/config/queue.yml
new file mode 100644
index 0000000..9eace59
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/queue.yml
@@ -0,0 +1,18 @@
+default: &default
+ dispatchers:
+ - polling_interval: 1
+ batch_size: 500
+ workers:
+ - queues: "*"
+ threads: 3
+ processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
+ polling_interval: 0.1
+
+development:
+ <<: *default
+
+test:
+ <<: *default
+
+production:
+ <<: *default
diff --git a/rm-mqtt-ws-bridge/rm/config/recurring.yml b/rm-mqtt-ws-bridge/rm/config/recurring.yml
new file mode 100644
index 0000000..d7f1133
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/recurring.yml
@@ -0,0 +1,18 @@
+# examples:
+# periodic_cleanup:
+# class: CleanSoftDeletedRecordsJob
+# queue: background
+# args: [ 1000, { batch_size: 500 } ]
+# schedule: every hour
+# periodic_cleanup_with_command:
+# command: "SoftDeletedRecord.due.delete_all"
+# priority: 2
+# schedule: at 5am every day
+
+production:
+ clear_solid_queue_finished_jobs:
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
+ schedule: every hour at minute 12
+ send_heartbeat_power_measurements:
+ class: SendHeartbeatPowerMeasurementsJob
+ schedule: "0,15,30,45 * * * *"
diff --git a/rm-mqtt-ws-bridge/rm/config/routes.rb b/rm-mqtt-ws-bridge/rm/config/routes.rb
new file mode 100644
index 0000000..325d373
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/config/routes.rb
@@ -0,0 +1,10 @@
+Rails.application.routes.draw do
+ get "up" => "rails/health#show", as: :rails_health_check
+
+ root "resources#index"
+
+ get "traffic", to: "traffic#index"
+ post "traffic/reset", to: "traffic#reset" if ReferenceConfig.demo_allow_framing?
+
+ resources :resources, only: [:index, :show]
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20250908135623_create_resources.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20250908135623_create_resources.rb
new file mode 100644
index 0000000..218ab49
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20250908135623_create_resources.rb
@@ -0,0 +1,10 @@
+class CreateResources < ActiveRecord::Migration[8.0]
+ def change
+ create_table :resources do |t|
+ t.string :s2_identifier, null: false
+ t.string :stekker_identifier, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20250923094344_setup_solid_cable.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20250923094344_setup_solid_cable.rb
new file mode 100644
index 0000000..b942372
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20250923094344_setup_solid_cable.rb
@@ -0,0 +1,13 @@
+class SetupSolidCable < ActiveRecord::Migration[8.0]
+ def change
+ create_table "solid_cable_messages", force: :cascade do |t|
+ t.binary "channel", limit: 1024, null: false
+ t.binary "payload", limit: 536_870_912, null: false
+ t.datetime "created_at", null: false
+ t.integer "channel_hash", limit: 8, null: false
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
+ end
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20250923094447_setup_solid_queue.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20250923094447_setup_solid_queue.rb
new file mode 100644
index 0000000..b0f9813
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20250923094447_setup_solid_queue.rb
@@ -0,0 +1,135 @@
+class SetupSolidQueue < ActiveRecord::Migration[8.0]
+ def change
+ create_table "solid_queue_blocked_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "queue_name", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "concurrency_key", null: false
+ t.datetime "expires_at", null: false
+ t.datetime "created_at", null: false
+ t.index %w[concurrency_key priority job_id], name: "index_solid_queue_blocked_executions_for_release"
+ t.index %w[expires_at concurrency_key], name: "index_solid_queue_blocked_executions_for_maintenance"
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_claimed_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.bigint "process_id"
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
+ t.index %w[process_id job_id], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
+ end
+
+ create_table "solid_queue_failed_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.text "error"
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_jobs", force: :cascade do |t|
+ t.string "queue_name", null: false
+ t.string "class_name", null: false
+ t.text "arguments"
+ t.integer "priority", default: 0, null: false
+ t.string "active_job_id"
+ t.datetime "scheduled_at"
+ t.datetime "finished_at"
+ t.string "concurrency_key"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
+ t.index %w[queue_name finished_at], name: "index_solid_queue_jobs_for_filtering"
+ t.index %w[scheduled_at finished_at], name: "index_solid_queue_jobs_for_alerting"
+ end
+
+ create_table "solid_queue_pauses", force: :cascade do |t|
+ t.string "queue_name", null: false
+ t.datetime "created_at", null: false
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
+ end
+
+ create_table "solid_queue_processes", force: :cascade do |t|
+ t.string "kind", null: false
+ t.datetime "last_heartbeat_at", null: false
+ t.bigint "supervisor_id"
+ t.integer "pid", null: false
+ t.string "hostname"
+ t.text "metadata"
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
+ t.index %w[name supervisor_id],
+ name: "index_solid_queue_processes_on_name_and_supervisor_id",
+ unique: true
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
+ end
+
+ create_table "solid_queue_ready_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "queue_name", null: false
+ t.integer "priority", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
+ t.index %w[priority job_id], name: "index_solid_queue_poll_all"
+ t.index %w[queue_name priority job_id], name: "index_solid_queue_poll_by_queue"
+ end
+
+ create_table "solid_queue_recurring_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "task_key", null: false
+ t.datetime "run_at", null: false
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
+ t.index %w[task_key run_at],
+ name: "index_solid_queue_recurring_executions_on_task_key_and_run_at",
+ unique: true
+ end
+
+ create_table "solid_queue_recurring_tasks", force: :cascade do |t|
+ t.string "key", null: false
+ t.string "schedule", null: false
+ t.string "command", limit: 2048
+ t.string "class_name"
+ t.text "arguments"
+ t.string "queue_name"
+ t.integer "priority", default: 0
+ t.boolean "static", default: true, null: false
+ t.text "description"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
+ end
+
+ create_table "solid_queue_scheduled_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "queue_name", null: false
+ t.integer "priority", default: 0, null: false
+ t.datetime "scheduled_at", null: false
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
+ t.index %w[scheduled_at priority job_id], name: "index_solid_queue_dispatch_all"
+ end
+
+ create_table "solid_queue_semaphores", force: :cascade do |t|
+ t.string "key", null: false
+ t.integer "value", default: 1, null: false
+ t.datetime "expires_at", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
+ t.index %w[key value], name: "index_solid_queue_semaphores_on_key_and_value"
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
+ end
+
+ add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20250923094521_setup_solid_cache.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20250923094521_setup_solid_cache.rb
new file mode 100644
index 0000000..9bbce96
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20250923094521_setup_solid_cache.rb
@@ -0,0 +1,14 @@
+class SetupSolidCache < ActiveRecord::Migration[8.0]
+ def change
+ create_table "solid_cache_entries", force: :cascade do |t|
+ t.binary "key", limit: 1024, null: false
+ t.binary "value", limit: 536_870_912, null: false
+ t.datetime "created_at", null: false
+ t.integer "key_hash", limit: 8, null: false
+ t.integer "byte_size", limit: 4, null: false
+ t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
+ t.index %w[key_hash byte_size], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
+ t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
+ end
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20250923120130_remove_stekker_identifier_from_resources.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20250923120130_remove_stekker_identifier_from_resources.rb
new file mode 100644
index 0000000..2746c57
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20250923120130_remove_stekker_identifier_from_resources.rb
@@ -0,0 +1,5 @@
+class RemoveStekkerIdentifierFromResources < ActiveRecord::Migration[8.0]
+ def change
+ remove_column :resources, :stekker_identifier, :string, null: false
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20251002095142_add_configuration_to_resource.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20251002095142_add_configuration_to_resource.rb
new file mode 100644
index 0000000..d5cf7dc
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20251002095142_add_configuration_to_resource.rb
@@ -0,0 +1,5 @@
+class AddConfigurationToResource < ActiveRecord::Migration[8.0]
+ def change
+ add_column :resources, :configuration, :jsonb, null: false, default: {}
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20251002100437_add_uniqueness_to_resource_s2_identifier.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20251002100437_add_uniqueness_to_resource_s2_identifier.rb
new file mode 100644
index 0000000..b7fceee
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20251002100437_add_uniqueness_to_resource_s2_identifier.rb
@@ -0,0 +1,5 @@
+class AddUniquenessToResourceS2Identifier < ActiveRecord::Migration[8.0]
+ def change
+ add_index :resources, :s2_identifier, unique: true
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20251002110554_add_disconnected_at_to_resource.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20251002110554_add_disconnected_at_to_resource.rb
new file mode 100644
index 0000000..18525dc
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20251002110554_add_disconnected_at_to_resource.rb
@@ -0,0 +1,5 @@
+class AddDisconnectedAtToResource < ActiveRecord::Migration[8.0]
+ def change
+ add_column :resources, :disconnected_at, :datetime
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/migrate/20260303155137_add_last_power_measurement_at_to_resources.rb b/rm-mqtt-ws-bridge/rm/db/migrate/20260303155137_add_last_power_measurement_at_to_resources.rb
new file mode 100644
index 0000000..4a0785b
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/migrate/20260303155137_add_last_power_measurement_at_to_resources.rb
@@ -0,0 +1,5 @@
+class AddLastPowerMeasurementAtToResources < ActiveRecord::Migration[8.1]
+ def change
+ add_column :resources, :last_power_measurement_at, :datetime
+ end
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/schema.rb b/rm-mqtt-ws-bridge/rm/db/schema.rb
new file mode 100644
index 0000000..dbb0106
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/db/schema.rb
@@ -0,0 +1,175 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[8.1].define(version: 2026_03_03_155137) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "pg_catalog.plpgsql"
+
+ create_table "resources", force: :cascade do |t|
+ t.jsonb "configuration", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "disconnected_at"
+ t.datetime "last_power_measurement_at"
+ t.string "s2_identifier", null: false
+ t.datetime "updated_at", null: false
+ t.index ["s2_identifier"], name: "index_resources_on_s2_identifier", unique: true
+ end
+
+ create_table "solid_cable_messages", force: :cascade do |t|
+ t.binary "channel", null: false
+ t.bigint "channel_hash", null: false
+ t.datetime "created_at", null: false
+ t.binary "payload", null: false
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
+ end
+
+ create_table "solid_cache_entries", force: :cascade do |t|
+ t.integer "byte_size", null: false
+ t.datetime "created_at", null: false
+ t.binary "key", null: false
+ t.bigint "key_hash", null: false
+ t.binary "value", null: false
+ t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
+ t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
+ t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
+ end
+
+ create_table "solid_queue_blocked_executions", force: :cascade do |t|
+ t.string "concurrency_key", null: false
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
+ t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_claimed_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.bigint "process_id"
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
+ t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
+ end
+
+ create_table "solid_queue_failed_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "error"
+ t.bigint "job_id", null: false
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_jobs", force: :cascade do |t|
+ t.string "active_job_id"
+ t.text "arguments"
+ t.string "class_name", null: false
+ t.string "concurrency_key"
+ t.datetime "created_at", null: false
+ t.datetime "finished_at"
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at"
+ t.datetime "updated_at", null: false
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
+ t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
+ t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
+ end
+
+ create_table "solid_queue_pauses", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "queue_name", null: false
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
+ end
+
+ create_table "solid_queue_processes", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "hostname"
+ t.string "kind", null: false
+ t.datetime "last_heartbeat_at", null: false
+ t.text "metadata"
+ t.string "name", null: false
+ t.integer "pid", null: false
+ t.bigint "supervisor_id"
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
+ t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
+ end
+
+ create_table "solid_queue_ready_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
+ t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
+ t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
+ end
+
+ create_table "solid_queue_recurring_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.datetime "run_at", null: false
+ t.string "task_key", null: false
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
+ t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
+ end
+
+ create_table "solid_queue_recurring_tasks", force: :cascade do |t|
+ t.text "arguments"
+ t.string "class_name"
+ t.string "command", limit: 2048
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.string "key", null: false
+ t.integer "priority", default: 0
+ t.string "queue_name"
+ t.string "schedule", null: false
+ t.boolean "static", default: true, null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
+ end
+
+ create_table "solid_queue_scheduled_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
+ t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
+ end
+
+ create_table "solid_queue_semaphores", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.string "key", null: false
+ t.datetime "updated_at", null: false
+ t.integer "value", default: 1, null: false
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
+ t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
+ end
+
+ add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+end
diff --git a/rm-mqtt-ws-bridge/rm/db/seeds.rb b/rm-mqtt-ws-bridge/rm/db/seeds.rb
new file mode 100644
index 0000000..e69de29
diff --git a/rm-mqtt-ws-bridge/rm/docker-compose.yml b/rm-mqtt-ws-bridge/rm/docker-compose.yml
new file mode 100644
index 0000000..5b2a023
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/docker-compose.yml
@@ -0,0 +1,47 @@
+name: resource-manager-mqtt-ruby
+
+services:
+ app:
+ build: .
+ depends_on:
+ postgres:
+ condition: service_healthy
+ mosquitto:
+ condition: service_started
+ environment:
+ RAILS_ENV: production
+ RAILS_LOG_LEVEL: info
+ SECRET_KEY_BASE: "demo-secret-key-base-for-standalone-reference-only"
+ DATABASE_URL: "postgres://postgres:postgres@postgres:5432/resource_manager_mqtt_ruby"
+ MQTT_URL: "mqtt://mosquitto:1883"
+ CEM_BASE_URL: "ws://localhost:9999/s2"
+ S2_PROVIDES_FORECAST: "true"
+ SOLID_QUEUE_IN_PUMA: "true"
+ IO_EVENT_SELECTOR: "Select" # the default epoll selector can spin at 100% CPU after an S2 session teardown; the pure-Ruby selector is immune
+ ports:
+ - "3000:3000"
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: resource_manager_mqtt_ruby
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_USER: postgres
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d resource_manager_mqtt_ruby"]
+ interval: 2s
+ timeout: 5s
+ retries: 20
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+
+ mosquitto:
+ image: eclipse-mosquitto:2
+ ports:
+ - "1883:1883"
+ volumes:
+ - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
+ - ./examples:/examples:ro
+
+volumes:
+ postgres-data:
diff --git a/rm-mqtt-ws-bridge/rm/examples/charge_status.json b/rm-mqtt-ws-bridge/rm/examples/charge_status.json
new file mode 100644
index 0000000..668a0d2
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/charge_status.json
@@ -0,0 +1,8 @@
+{
+ "message_type": "charge_status",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "sampled_at": "2026-06-04T10:05:00Z",
+ "value": 42.5
+ }
+}
diff --git a/rm-mqtt-ws-bridge/rm/examples/charge_target.json b/rm-mqtt-ws-bridge/rm/examples/charge_target.json
new file mode 100644
index 0000000..4a1ff72
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/charge_target.json
@@ -0,0 +1,10 @@
+{
+ "message_type": "charge_target",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "starts_at": "2026-06-04T10:00:00Z",
+ "ends_at": "2026-06-04T18:00:00Z",
+ "direct_percentage": 25.0,
+ "smart_percentage": 85.0
+ }
+}
diff --git a/rm-mqtt-ws-bridge/rm/examples/connect.json b/rm-mqtt-ws-bridge/rm/examples/connect.json
new file mode 100644
index 0000000..1d2fde9
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/connect.json
@@ -0,0 +1,11 @@
+{
+ "message_type": "connect",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "name": "Demo EV charger",
+ "voltage": 230,
+ "number_of_phases": 3,
+ "minimum_current": 0,
+ "maximum_current": 16
+ }
+}
diff --git a/rm-mqtt-ws-bridge/rm/examples/disconnect.json b/rm-mqtt-ws-bridge/rm/examples/disconnect.json
new file mode 100644
index 0000000..993af8d
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/disconnect.json
@@ -0,0 +1,4 @@
+{
+ "message_type": "disconnect",
+ "resource_id": "demo-resource-001"
+}
diff --git a/rm-mqtt-ws-bridge/rm/examples/instruction_update.json b/rm-mqtt-ws-bridge/rm/examples/instruction_update.json
new file mode 100644
index 0000000..12f78e0
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/instruction_update.json
@@ -0,0 +1,9 @@
+{
+ "message_type": "instruction_update",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "reference": "demo-instruction-001",
+ "updated_at": "2026-06-04T10:10:00Z",
+ "status": "SUCCEEDED"
+ }
+}
diff --git a/rm-mqtt-ws-bridge/rm/examples/power_forecast.json b/rm-mqtt-ws-bridge/rm/examples/power_forecast.json
new file mode 100644
index 0000000..c7f35d0
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/power_forecast.json
@@ -0,0 +1,22 @@
+{
+ "message_type": "power_forecast",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "start_at": "2026-06-04T10:00:00Z",
+ "unit": "W",
+ "values": [
+ {
+ "timestamp": "2026-06-04T10:00:00Z",
+ "value": 3700
+ },
+ {
+ "timestamp": "2026-06-04T10:15:00Z",
+ "value": 7400
+ },
+ {
+ "timestamp": "2026-06-04T10:45:00Z",
+ "value": 0
+ }
+ ]
+ }
+}
diff --git a/rm-mqtt-ws-bridge/rm/examples/power_measurement.json b/rm-mqtt-ws-bridge/rm/examples/power_measurement.json
new file mode 100644
index 0000000..ea28589
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/examples/power_measurement.json
@@ -0,0 +1,9 @@
+{
+ "message_type": "power_measurement",
+ "resource_id": "demo-resource-001",
+ "data": {
+ "sampled_at": "2026-06-04T10:00:00Z",
+ "unit": "W",
+ "value": 3700
+ }
+}
diff --git a/rm-mqtt-ws-bridge/rm/lib/tasks/.keep b/rm-mqtt-ws-bridge/rm/lib/tasks/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/rm-mqtt-ws-bridge/rm/log/.keep b/rm-mqtt-ws-bridge/rm/log/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/rm-mqtt-ws-bridge/rm/mosquitto.conf b/rm-mqtt-ws-bridge/rm/mosquitto.conf
new file mode 100644
index 0000000..c8348ac
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/mosquitto.conf
@@ -0,0 +1,2 @@
+listener 1883
+allow_anonymous true
diff --git a/rm-mqtt-ws-bridge/rm/public/400.html b/rm-mqtt-ws-bridge/rm/public/400.html
new file mode 100644
index 0000000..ced6482
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/public/400.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+ The server cannot process the request due to a client error (400 Bad Request)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.
+
+
+
+
+
+
diff --git a/rm-mqtt-ws-bridge/rm/public/404.html b/rm-mqtt-ws-bridge/rm/public/404.html
new file mode 100644
index 0000000..16ec3ce
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/public/404.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+ The page you were looking for doesn't exist (404 Not found)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.
+
+
+
+
+
+
diff --git a/rm-mqtt-ws-bridge/rm/public/406-unsupported-browser.html b/rm-mqtt-ws-bridge/rm/public/406-unsupported-browser.html
new file mode 100644
index 0000000..765064c
--- /dev/null
+++ b/rm-mqtt-ws-bridge/rm/public/406-unsupported-browser.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+ Your browser is not supported (406 Not Acceptable)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your browser is not supported. Please upgrade your browser to continue.
The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.