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. + +![The live three-panel demo: the demo CEM, the RM bridge and the MQTT platform stand-in side by side, each feed showing the full S2 conversation from handshake to power setpoint](docs/demo.png) + +## 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…
+
+ +
+
+ + +
No messages yet.
+
+ + + + diff --git a/rm-mqtt-ws-bridge/cem/public/tokens.css b/rm-mqtt-ws-bridge/cem/public/tokens.css new file mode 100644 index 0000000..4fc9565 --- /dev/null +++ b/rm-mqtt-ws-bridge/cem/public/tokens.css @@ -0,0 +1,174 @@ +:root { + --bg: #eef1f5; + --panel: #ffffff; + --panel-2: #f3f6f9; + --border: #dce2e9; + --border-strong: #c5cdd7; + --text: #1b2733; + --muted: #51606f; + --faint: #6b7685; + + --brand: #0d8f61; + --brand-ink: #0a7a52; + --brand-deep: #08643f; + --brand-soft: #e6f6ef; + + --report: #2563b0; + --report-ink: #1f4f8f; + --report-soft: #e8effb; + + --command: #a85c0a; + --command-ink: #8a4c08; + --command-soft: #fbf0df; + + --setup: #6b7685; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --radius: 10px; + --radius-sm: 7px; + --gap: 14px; + --header-min: 84px; + --primary-min: 76px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +.panel { + display: flex; + flex-direction: column; + height: 100vh; + padding: var(--gap); + gap: var(--gap); + background: var(--bg); +} + +.panel-header { + display: flex; + flex-direction: column; + gap: 4px; + min-height: var(--header-min); + padding-bottom: var(--gap); + border-bottom: 1px solid var(--border); +} + +.primary { + display: flex; + flex-direction: column; + gap: 10px; + min-height: var(--primary-min); +} + +.panel-title { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.1px; + color: var(--text); +} + +.panel-role { + font-size: 12.5px; + line-height: 1.45; + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.panel-tag { + margin-left: 9px; + font-size: 12px; + font-weight: 550; + letter-spacing: 0; + color: var(--faint); +} + +.panel-tag.is-reference { + color: var(--text); + font-weight: 650; +} + +.section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.7px; + font-weight: 650; + color: var(--faint); + margin: 2px 0; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.btn { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 8px 12px; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; +} + +.btn:hover { border-color: var(--brand); background: var(--brand-soft); } +.btn:active { background: var(--panel-2); } +.btn:disabled { opacity: 0.55; cursor: default; } +.btn-primary { + border-color: var(--brand-ink); + background: var(--brand-ink); + color: #ffffff; +} +.btn-primary:hover { background: var(--brand-deep); border-color: var(--brand-deep); } + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} + +.feed { + flex: 1; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 13px; + line-height: 1.5; +} + +.empty { color: var(--faint); font-style: italic; } + +.tstamp { + font-family: var(--mono); + font-size: 10.5px; + color: var(--faint); + white-space: nowrap; +} + +.statusline { + display: flex; + flex-wrap: wrap; + gap: 3px 20px; + font-size: 12.5px; + color: var(--muted); +} +.statusline .live { color: var(--brand-ink); font-weight: 600; } diff --git a/rm-mqtt-ws-bridge/cem/server.rb b/rm-mqtt-ws-bridge/cem/server.rb new file mode 100644 index 0000000..b64ff2f --- /dev/null +++ b/rm-mqtt-ws-bridge/cem/server.rb @@ -0,0 +1,240 @@ +require "bundler/setup" +require "json" +require "securerandom" +require "logger" +require "async" +require "async/http/server" +require "async/http/endpoint" +require "async/websocket/adapters/http" +require "s2" + +$stdout.sync = true +PORT = Integer(ENV.fetch("PORT", "9000")) +PUBLIC_DIR = File.join(__dir__, "public") +LOGGER = Logger.new($stdout) + +SESSIONS = {} + +def session_for(resource_id) + SESSIONS[resource_id] ||= { connected: false, outbound: nil, system_description: nil, control_type: nil, log: [] } +end + +def log_message(resource_id, dir, message) + log = session_for(resource_id)[:log] + log << { + dir: dir, + message_type: message["message_type"], + message_id: message["message_id"], + subject_message_id: message["subject_message_id"], + at: Time.now.utc.iso8601, + }.compact + log.shift while log.length > 50 +end + +def cem_handshake + S2::Messages::Handshake.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::HandshakeMessageType::Handshake, + "role" => S2::Messages::EnergyManagementRole::Cem, + "supported_protocol_versions" => S2.supported_protocol_versions, + ) +end + +def handshake_response + S2::Messages::HandshakeResponse.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::HandshakeResponseMessageType::HandshakeResponse, + "selected_protocol_version" => S2.supported_protocol_versions.first, + ) +end + +def select_control_type_frbc + S2::Messages::SelectControlType.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::SelectControlTypeMessageType::SelectControlType, + "control_type" => S2::Messages::ControlType::FillRateBasedControl, + ) +end + +def reception_status(subject_message_id) + S2::Messages::ReceptionStatus.from_dynamic!( + "message_type" => S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + "subject_message_id" => subject_message_id, + "status" => S2::Messages::ReceptionStatusValues::Ok, + ) +end + +def frbc_instruction_from(system_description) + actuator = system_description.fetch("actuators").first + raise "FRBC.SystemDescription has no actuators" unless actuator + + modes = actuator.fetch("operation_modes") + mode = modes.find { |m| m["diagnostic_label"].to_s.match?(/charg/i) } || + modes.find { |m| !m["abnormal_condition_only"] } || + modes.first + raise "actuator has no usable operation mode" unless mode + + S2::Messages::FRBCInstruction.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::FRBCInstructionMessageType::FRBCInstruction, + "id" => SecureRandom.uuid, + "actuator_id" => actuator.fetch("id"), + "operation_mode" => mode.fetch("id"), + "operation_mode_factor" => 0.5, + "execution_time" => (Time.now + 30).utc.iso8601, + "abnormal_condition" => false, + ) +end + +def send_s2(connection, resource_id, message) + json = message.to_json + connection.send_text(json) + connection.flush + log_message(resource_id, "out", JSON.parse(json)) +end + +def handle_connection(connection, resource_id) + token = SecureRandom.uuid + outbound = Async::Queue.new + previous = SESSIONS[resource_id] + SESSIONS[resource_id] = { + token: token, + connected: true, + outbound: outbound, + system_description: nil, + control_type: nil, + log: previous ? previous[:log] : [], + } + LOGGER.info "[cem] RM connected for resource #{resource_id}" + + writer = Async do + while (message = outbound.dequeue) + case message + when :ping + connection.send_ping + connection.flush + when :instruct + current = SESSIONS[resource_id] + sd = current && current[:token] == token ? current[:system_description] : nil + send_s2(connection, resource_id, frbc_instruction_from(sd)) if sd + else + send_s2(connection, resource_id, message) + end + end + end + + heartbeat = Async do + loop do + sleep 15 + outbound.enqueue(:ping) + end + end + + while (frame = connection.read) + message = JSON.parse(frame.to_str) + type = message["message_type"] + message_id = message["message_id"] + log_message(resource_id, "in", message) + current = SESSIONS[resource_id] + mine = current && current[:token] == token + + case type + when "ReceptionStatus" + next + when "Handshake" + outbound.enqueue(reception_status(message_id)) + outbound.enqueue(cem_handshake) + outbound.enqueue(handshake_response) + when "ResourceManagerDetails" + outbound.enqueue(reception_status(message_id)) + outbound.enqueue(select_control_type_frbc) + current[:control_type] = "FILL_RATE_BASED_CONTROL" if mine + when "FRBC.SystemDescription" + outbound.enqueue(reception_status(message_id)) + current[:system_description] = message if mine + LOGGER.info "[cem] system description cached for #{resource_id}; instruction button enabled" + else + outbound.enqueue(reception_status(message_id)) if message_id + end + end +rescue StandardError => e + LOGGER.warn "[cem] connection error for #{resource_id}: #{e.class}: #{e.message}" +ensure + current = SESSIONS[resource_id] + if current && current[:token] == token + current[:connected] = false + current[:outbound] = nil + current[:system_description] = nil + end + writer&.stop + heartbeat&.stop + LOGGER.info "[cem] RM disconnected for resource #{resource_id}" +end + +def active_session + connected = SESSIONS.select { |_id, s| s[:connected] } + connected.find { |_id, s| s[:system_description] } || connected.first || SESSIONS.first +end + +def response(status, content_type, body) + Async::HTTP::Protocol::Response[status, { "content-type" => content_type, "cache-control" => "no-store" }, [body]] +end + +def serve_static(path) + file = path == "/" ? "index.html" : path.sub(%r{\A/}, "") + full = File.expand_path(File.join(PUBLIC_DIR, file)) + return response(404, "text/plain", "not found") unless full.start_with?(PUBLIC_DIR) && File.file?(full) + + response(200, full.end_with?(".css") ? "text/css" : "text/html", File.read(full)) +end + +def state_payload + resource_id, session = active_session + { + connected: !!(session && session[:connected]), + resource_id: resource_id, + control_type: session && session[:control_type], + has_system_description: !!(session && session[:system_description]), + can_instruct: !!(session && session[:connected] && session[:system_description] && session[:outbound]), + log: session ? session[:log].last(40).reverse : [], + } +end + +def handle_instruct + resource_id, session = active_session + unless session && session[:system_description] && session[:outbound] + return response(409, "application/json", JSON.generate({ error: "no connected resource with a system description yet" })) + end + + session[:outbound].enqueue(:instruct) + response(200, "application/json", JSON.generate({ queued: "FRBC.Instruction", resource_id: resource_id })) +rescue StandardError => e + response(500, "application/json", JSON.generate({ error: e.message })) +end + +def handle_reset + SESSIONS.each_value { |session| session[:log]&.clear } + response(200, "application/json", JSON.generate({ reset: true })) +end + +def handle(request) + path = request.path.split("?").first + + if Async::WebSocket::Adapters::HTTP.websocket?(request) && path.start_with?("/s2/") + resource_id = path.split("/").reject(&:empty?).last + return Async::WebSocket::Adapters::HTTP.open(request) { |connection| handle_connection(connection, resource_id) } + end + + return handle_reset if request.method == "POST" && path == "/api/reset" + return handle_instruct if request.method == "POST" && path == "/api/instruct" + return response(200, "application/json", JSON.generate(state_payload)) if request.method == "GET" && path == "/api/state" + return serve_static(path) if request.method == "GET" + + response(405, "text/plain", "method not allowed") +end + +Async do + endpoint = Async::HTTP::Endpoint.parse("http://0.0.0.0:#{PORT}", protocol: Async::HTTP::Protocol::HTTP1) + LOGGER.info "[cem] listening on :#{PORT} (HTTP panel + S2 WebSocket server on /s2/)" + Async::HTTP::Server.new(->(request) { handle(request) }, endpoint).run +end diff --git a/rm-mqtt-ws-bridge/demo.sh b/rm-mqtt-ws-bridge/demo.sh new file mode 100755 index 0000000..65b9f68 --- /dev/null +++ b/rm-mqtt-ws-bridge/demo.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +REQUIRED_PORTS=(8080 3000 9000 4000 1883) +if command -v lsof >/dev/null 2>&1; then + for port in "${REQUIRED_PORTS[@]}"; do + if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Note: port $port is already in use. If that is not this demo, 'docker compose up' will report a bind error below." >&2 + fi + done +fi + +echo "Building and starting the three applications + the demo screen (this can take a few minutes the first time)..." +docker compose up --build -d + +wait_for() { + for _ in $(seq 1 60); do + if curl -fs -o /dev/null "$1" 2>/dev/null; then + return 0 + fi + sleep 2 + done + return 1 +} + +echo "Waiting for the RM..." +if ! wait_for "http://localhost:3000/up"; then + echo "The RM did not answer on http://localhost:3000/up. Check 'docker compose logs rm'." >&2 + exit 1 +fi + +cat <<'BANNER' + +Demo is up. Open the single screen: + + http://localhost:8080 + +Panels (also reachable on their own): + CEM http://localhost:9000 + RM http://localhost:3000 + MQTT http://localhost:4000 + +Walk-through: in the MQTT panel press "Connect charger", wait until the CEM panel +shows the RM connected, then press "Power Measurement". Then in +the CEM panel press "Send instruction to lower the load" and watch a power_setpoint +arrive back in the MQTT panel. + +Stop with: docker compose down +BANNER diff --git a/rm-mqtt-ws-bridge/demo/Dockerfile b/rm-mqtt-ws-bridge/demo/Dockerfile new file mode 100644 index 0000000..08d4974 --- /dev/null +++ b/rm-mqtt-ws-bridge/demo/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx:alpine +COPY default.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/index.html +COPY tokens.css /usr/share/nginx/html/tokens.css +EXPOSE 80 diff --git a/rm-mqtt-ws-bridge/demo/default.conf b/rm-mqtt-ws-bridge/demo/default.conf new file mode 100644 index 0000000..68ef74c --- /dev/null +++ b/rm-mqtt-ws-bridge/demo/default.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + etag off; + add_header Cache-Control "no-store" always; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/rm-mqtt-ws-bridge/demo/docker-compose.yml b/rm-mqtt-ws-bridge/demo/docker-compose.yml new file mode 100644 index 0000000..be1d35f --- /dev/null +++ b/rm-mqtt-ws-bridge/demo/docker-compose.yml @@ -0,0 +1,7 @@ +name: s2-demo-demo + +services: + demo: + build: . + ports: + - "8080:80" diff --git a/rm-mqtt-ws-bridge/demo/index.html b/rm-mqtt-ws-bridge/demo/index.html new file mode 100644 index 0000000..fdcd709 --- /dev/null +++ b/rm-mqtt-ws-bridge/demo/index.html @@ -0,0 +1,112 @@ + + + + + + S2 RM to MQTT Bridge Demo + + + + +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ + + diff --git a/rm-mqtt-ws-bridge/demo/tokens.css b/rm-mqtt-ws-bridge/demo/tokens.css new file mode 100644 index 0000000..4fc9565 --- /dev/null +++ b/rm-mqtt-ws-bridge/demo/tokens.css @@ -0,0 +1,174 @@ +:root { + --bg: #eef1f5; + --panel: #ffffff; + --panel-2: #f3f6f9; + --border: #dce2e9; + --border-strong: #c5cdd7; + --text: #1b2733; + --muted: #51606f; + --faint: #6b7685; + + --brand: #0d8f61; + --brand-ink: #0a7a52; + --brand-deep: #08643f; + --brand-soft: #e6f6ef; + + --report: #2563b0; + --report-ink: #1f4f8f; + --report-soft: #e8effb; + + --command: #a85c0a; + --command-ink: #8a4c08; + --command-soft: #fbf0df; + + --setup: #6b7685; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --radius: 10px; + --radius-sm: 7px; + --gap: 14px; + --header-min: 84px; + --primary-min: 76px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +.panel { + display: flex; + flex-direction: column; + height: 100vh; + padding: var(--gap); + gap: var(--gap); + background: var(--bg); +} + +.panel-header { + display: flex; + flex-direction: column; + gap: 4px; + min-height: var(--header-min); + padding-bottom: var(--gap); + border-bottom: 1px solid var(--border); +} + +.primary { + display: flex; + flex-direction: column; + gap: 10px; + min-height: var(--primary-min); +} + +.panel-title { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.1px; + color: var(--text); +} + +.panel-role { + font-size: 12.5px; + line-height: 1.45; + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.panel-tag { + margin-left: 9px; + font-size: 12px; + font-weight: 550; + letter-spacing: 0; + color: var(--faint); +} + +.panel-tag.is-reference { + color: var(--text); + font-weight: 650; +} + +.section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.7px; + font-weight: 650; + color: var(--faint); + margin: 2px 0; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.btn { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 8px 12px; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; +} + +.btn:hover { border-color: var(--brand); background: var(--brand-soft); } +.btn:active { background: var(--panel-2); } +.btn:disabled { opacity: 0.55; cursor: default; } +.btn-primary { + border-color: var(--brand-ink); + background: var(--brand-ink); + color: #ffffff; +} +.btn-primary:hover { background: var(--brand-deep); border-color: var(--brand-deep); } + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} + +.feed { + flex: 1; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 13px; + line-height: 1.5; +} + +.empty { color: var(--faint); font-style: italic; } + +.tstamp { + font-family: var(--mono); + font-size: 10.5px; + color: var(--faint); + white-space: nowrap; +} + +.statusline { + display: flex; + flex-wrap: wrap; + gap: 3px 20px; + font-size: 12.5px; + color: var(--muted); +} +.statusline .live { color: var(--brand-ink); font-weight: 600; } diff --git a/rm-mqtt-ws-bridge/docker-compose.yml b/rm-mqtt-ws-bridge/docker-compose.yml new file mode 100644 index 0000000..1f6772e --- /dev/null +++ b/rm-mqtt-ws-bridge/docker-compose.yml @@ -0,0 +1,81 @@ +name: s2-demo + +services: + 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: + - ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + rm: + build: ./rm + depends_on: + postgres: + condition: service_healthy + mosquitto: + condition: service_started + cem: + condition: service_healthy + 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://cem:9000/s2" + S2_PROVIDES_FORECAST: "true" + SOLID_QUEUE_IN_PUMA: "true" + DEMO_ALLOW_FRAMING: "1" + 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" + + cem: + build: ./cem + environment: + PORT: "9000" + healthcheck: + test: ["CMD", "ruby", "-e", "require 'net/http'; exit(Net::HTTP.get_response(URI('http://localhost:9000/api/state')).is_a?(Net::HTTPSuccess) ? 0 : 1)"] + interval: 3s + timeout: 5s + retries: 20 + ports: + - "9000:9000" + + mqtt-panel: + build: ./mqtt + depends_on: + - mosquitto + environment: + PORT: "4000" + MQTT_URL: "mqtt://mosquitto:1883" + RESOURCE_ID: "demo-resource-001" + ports: + - "4000:4000" + + demo: + build: ./demo + depends_on: + - rm + - cem + - mqtt-panel + ports: + - "8080:80" + +volumes: + postgres-data: diff --git a/rm-mqtt-ws-bridge/docs/demo.png b/rm-mqtt-ws-bridge/docs/demo.png new file mode 100644 index 0000000..5cb0cb9 Binary files /dev/null and b/rm-mqtt-ws-bridge/docs/demo.png differ diff --git a/rm-mqtt-ws-bridge/mqtt/.gitignore b/rm-mqtt-ws-bridge/mqtt/.gitignore new file mode 100644 index 0000000..e5d70ce --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/.gitignore @@ -0,0 +1,44 @@ +# Node modules +node_modules/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log +logs/ + +# Runtime files +.pids/ +*.pid +tmp/ +temp/ + +# Environment +.env +.env.* + +# Coverage, testing and build outputs +.nyc_output/ +coverage/ +dist/ +build/ + +# Packages +*.tgz + +# Docker overrides and local compose files +docker-compose.override.yml +docker-compose.*.local.yml +/.docker/ + +# Editor directories and OS files +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp + +# Caches +.cache/ +.sass-cache/ diff --git a/rm-mqtt-ws-bridge/mqtt/Dockerfile b/rm-mqtt-ws-bridge/mqtt/Dockerfile new file mode 100644 index 0000000..69d6d44 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY . . +EXPOSE 4000 +CMD ["node", "server.js"] diff --git a/rm-mqtt-ws-bridge/mqtt/docker-compose.yml b/rm-mqtt-ws-bridge/mqtt/docker-compose.yml new file mode 100644 index 0000000..e5a82b7 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/docker-compose.yml @@ -0,0 +1,20 @@ +name: s2-demo-mqtt + +services: + mosquitto: + image: eclipse-mosquitto:2 + ports: + - "1883:1883" + volumes: + - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + mqtt-panel: + build: . + depends_on: + - mosquitto + environment: + PORT: "4000" + MQTT_URL: "mqtt://mosquitto:1883" + RESOURCE_ID: "demo-resource-001" + ports: + - "4000:4000" diff --git a/rm-mqtt-ws-bridge/mqtt/examples/charge_status.json b/rm-mqtt-ws-bridge/mqtt/examples/charge_status.json new file mode 100644 index 0000000..668a0d2 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/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/mqtt/examples/charge_target.json b/rm-mqtt-ws-bridge/mqtt/examples/charge_target.json new file mode 100644 index 0000000..4a1ff72 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/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/mqtt/examples/connect.json b/rm-mqtt-ws-bridge/mqtt/examples/connect.json new file mode 100644 index 0000000..1d2fde9 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/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/mqtt/examples/disconnect.json b/rm-mqtt-ws-bridge/mqtt/examples/disconnect.json new file mode 100644 index 0000000..993af8d --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/examples/disconnect.json @@ -0,0 +1,4 @@ +{ + "message_type": "disconnect", + "resource_id": "demo-resource-001" +} diff --git a/rm-mqtt-ws-bridge/mqtt/examples/power_forecast.json b/rm-mqtt-ws-bridge/mqtt/examples/power_forecast.json new file mode 100644 index 0000000..c7f35d0 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/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/mqtt/examples/power_measurement.json b/rm-mqtt-ws-bridge/mqtt/examples/power_measurement.json new file mode 100644 index 0000000..ea28589 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/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/mqtt/mosquitto.conf b/rm-mqtt-ws-bridge/mqtt/mosquitto.conf new file mode 100644 index 0000000..c8348ac --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/mosquitto.conf @@ -0,0 +1,2 @@ +listener 1883 +allow_anonymous true diff --git a/rm-mqtt-ws-bridge/mqtt/package.json b/rm-mqtt-ws-bridge/mqtt/package.json new file mode 100644 index 0000000..f00c139 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/package.json @@ -0,0 +1,14 @@ +{ + "name": "mqtt-platform-demo", + "version": "0.0.0", + "private": true, + "description": "Demo stand-in for the Stekker smart-charging platform: publishes resource-side MQTT events and shows the RM's reverse-path replies.", + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.21.2", + "mqtt": "^5.10.1" + } +} diff --git a/rm-mqtt-ws-bridge/mqtt/public/index.html b/rm-mqtt-ws-bridge/mqtt/public/index.html new file mode 100644 index 0000000..1f9f127 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/public/index.html @@ -0,0 +1,158 @@ + + + + + + MQTT platform (demo) + + + + +
+
+
MQTT Platformdemo stand-in
+
Simulates the charger over MQTT: publishes its events and shows the traffic with the RM in both directions.
+
+ +
+
+
+ + +
No messages yet.
+
+ + + + diff --git a/rm-mqtt-ws-bridge/mqtt/public/tokens.css b/rm-mqtt-ws-bridge/mqtt/public/tokens.css new file mode 100644 index 0000000..4fc9565 --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/public/tokens.css @@ -0,0 +1,174 @@ +:root { + --bg: #eef1f5; + --panel: #ffffff; + --panel-2: #f3f6f9; + --border: #dce2e9; + --border-strong: #c5cdd7; + --text: #1b2733; + --muted: #51606f; + --faint: #6b7685; + + --brand: #0d8f61; + --brand-ink: #0a7a52; + --brand-deep: #08643f; + --brand-soft: #e6f6ef; + + --report: #2563b0; + --report-ink: #1f4f8f; + --report-soft: #e8effb; + + --command: #a85c0a; + --command-ink: #8a4c08; + --command-soft: #fbf0df; + + --setup: #6b7685; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --radius: 10px; + --radius-sm: 7px; + --gap: 14px; + --header-min: 84px; + --primary-min: 76px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +.panel { + display: flex; + flex-direction: column; + height: 100vh; + padding: var(--gap); + gap: var(--gap); + background: var(--bg); +} + +.panel-header { + display: flex; + flex-direction: column; + gap: 4px; + min-height: var(--header-min); + padding-bottom: var(--gap); + border-bottom: 1px solid var(--border); +} + +.primary { + display: flex; + flex-direction: column; + gap: 10px; + min-height: var(--primary-min); +} + +.panel-title { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.1px; + color: var(--text); +} + +.panel-role { + font-size: 12.5px; + line-height: 1.45; + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.panel-tag { + margin-left: 9px; + font-size: 12px; + font-weight: 550; + letter-spacing: 0; + color: var(--faint); +} + +.panel-tag.is-reference { + color: var(--text); + font-weight: 650; +} + +.section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.7px; + font-weight: 650; + color: var(--faint); + margin: 2px 0; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.btn { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 8px 12px; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; +} + +.btn:hover { border-color: var(--brand); background: var(--brand-soft); } +.btn:active { background: var(--panel-2); } +.btn:disabled { opacity: 0.55; cursor: default; } +.btn-primary { + border-color: var(--brand-ink); + background: var(--brand-ink); + color: #ffffff; +} +.btn-primary:hover { background: var(--brand-deep); border-color: var(--brand-deep); } + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} + +.feed { + flex: 1; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 13px; + line-height: 1.5; +} + +.empty { color: var(--faint); font-style: italic; } + +.tstamp { + font-family: var(--mono); + font-size: 10.5px; + color: var(--faint); + white-space: nowrap; +} + +.statusline { + display: flex; + flex-wrap: wrap; + gap: 3px 20px; + font-size: 12.5px; + color: var(--muted); +} +.statusline .live { color: var(--brand-ink); font-weight: 600; } diff --git a/rm-mqtt-ws-bridge/mqtt/server.js b/rm-mqtt-ws-bridge/mqtt/server.js new file mode 100644 index 0000000..594900c --- /dev/null +++ b/rm-mqtt-ws-bridge/mqtt/server.js @@ -0,0 +1,120 @@ +import express from "express"; +import mqtt from "mqtt"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PORT = process.env.PORT || 4000; +const BROKER_URL = process.env.MQTT_URL || "mqtt://localhost:1883"; +const RESOURCE_ID = process.env.RESOURCE_ID || "demo-resource-001"; + +const TOPIC_SUFFIX_BY_MESSAGE_TYPE = { + connect: "connects", + disconnect: "disconnects", + power_measurement: "power_measurements", + power_forecast: "power_forecasts", + charge_target: "charge_targets", + charge_status: "charge_statuses", +}; + +const REVERSE_PATH_SUFFIXES = ["power_setpoints", "instruction_updates", "s2_ready_notifications"]; +const RECEIVED_BUFFER_LIMIT = 50; + +const examplesDir = join(__dirname, "examples"); +const examplePayloadByType = {}; +for (const file of readdirSync(examplesDir).filter((f) => f.endsWith(".json"))) { + const type = file.replace(/\.json$/, ""); + examplePayloadByType[type] = JSON.parse(readFileSync(join(examplesDir, file), "utf8")); + // ensure any resource_id in the example payloads matches configured RESOURCE_ID + const replaceResourceId = (val) => { + if (Array.isArray(val)) { + for (const item of val) replaceResourceId(item); + } else if (val && typeof val === "object") { + if (Object.prototype.hasOwnProperty.call(val, "resource_id")) val.resource_id = RESOURCE_ID; + for (const k of Object.keys(val)) replaceResourceId(val[k]); + } + }; + replaceResourceId(examplePayloadByType[type]); +} + +function topicFor(suffix) { + return `stekker/resources/${RESOURCE_ID}/${suffix}`; +} + +const feed = []; +let chargerConnected = false; +function remember(entry) { + feed.unshift({ ...entry, at: new Date().toISOString() }); + if (feed.length > RECEIVED_BUFFER_LIMIT) feed.pop(); +} + +const client = mqtt.connect(BROKER_URL, { reconnectPeriod: 2000 }); +client.on("connect", () => { + for (const suffix of REVERSE_PATH_SUFFIXES) client.subscribe(topicFor(suffix)); + console.log(`[mqtt-panel] connected to ${BROKER_URL}, subscribed to reverse-path topics`); +}); +client.on("message", (incomingTopic, payload) => { + let body; + try { + body = JSON.parse(payload.toString()); + } catch { + body = payload.toString(); + } + remember({ dir: "in", topic: incomingTopic, message_type: body?.message_type, body }); +}); +client.on("error", (err) => console.error("[mqtt-panel] broker error:", err.message)); + +const app = express(); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); +app.use(express.static(join(__dirname, "public"), { etag: false, lastModified: false, cacheControl: false })); + +app.get("/api/examples", (_req, res) => { + res.json( + Object.keys(examplePayloadByType) + .filter((type) => TOPIC_SUFFIX_BY_MESSAGE_TYPE[type]) + .map((type) => ({ + type, + topic: topicFor(TOPIC_SUFFIX_BY_MESSAGE_TYPE[type]), + resource_id: RESOURCE_ID, + })), + ); +}); + +app.post("/api/publish/:type", (req, res) => { + const { type } = req.params; + const payload = examplePayloadByType[type]; + const suffix = TOPIC_SUFFIX_BY_MESSAGE_TYPE[type]; + if (!payload || !suffix) { + res.status(404).json({ error: `unknown example '${type}'` }); + return; + } + const targetTopic = topicFor(suffix); + client.publish(targetTopic, JSON.stringify(payload), { qos: 1 }, (err) => { + if (err) { + res.status(502).json({ error: err.message }); + return; + } + console.log(`[mqtt-panel] published ${type} -> ${targetTopic}`); + if (type === "connect") chargerConnected = true; + if (type === "disconnect") chargerConnected = false; + remember({ dir: "out", topic: targetTopic, message_type: payload.message_type, body: payload }); + res.json({ published: type, topic: targetTopic }); + }); +}); + +app.get("/api/received", (_req, res) => res.json(feed)); + +app.get("/api/state", (_req, res) => res.json({ charger_connected: chargerConnected })); + +app.post("/api/reset", (_req, res) => { + feed.length = 0; + res.json({ reset: true }); +}); + +app.get("/healthz", (_req, res) => res.json({ ok: true, broker: BROKER_URL })); + +app.listen(PORT, () => console.log(`[mqtt-panel] listening on :${PORT}, broker ${BROKER_URL}`)); diff --git a/rm-mqtt-ws-bridge/rm/.dockerignore b/rm-mqtt-ws-bridge/rm/.dockerignore new file mode 100644 index 0000000..85f1033 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/.dockerignore @@ -0,0 +1,14 @@ +/.git +/.bundle +/.env* +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +/tmp/pids/* +!/tmp/pids/.keep +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep +/public/assets diff --git a/rm-mqtt-ws-bridge/rm/.gitattributes b/rm-mqtt-ws-bridge/rm/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/rm-mqtt-ws-bridge/rm/.gitignore b/rm-mqtt-ws-bridge/rm/.gitignore new file mode 100644 index 0000000..8826cf5 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/.gitignore @@ -0,0 +1,17 @@ +/.bundle +/.env* +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep +/public/assets +/.rspec-failures +/docker-compose.override.yml diff --git a/rm-mqtt-ws-bridge/rm/.rspec b/rm-mqtt-ws-bridge/rm/.rspec new file mode 100644 index 0000000..a35c44f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/.rspec @@ -0,0 +1 @@ +--require rails_helper diff --git a/rm-mqtt-ws-bridge/rm/.ruby-version b/rm-mqtt-ws-bridge/rm/.ruby-version new file mode 100644 index 0000000..47b322c --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/.ruby-version @@ -0,0 +1 @@ +3.4.1 diff --git a/rm-mqtt-ws-bridge/rm/Dockerfile b/rm-mqtt-ws-bridge/rm/Dockerfile new file mode 100644 index 0000000..728ab2f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This image is used by docker-compose.yml for the standalone reference demo. + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.1 +FROM ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 postgresql-client && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" + +# Build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY --link Gemfile Gemfile.lock .ruby-version ./ +COPY --link vendor/stekker-mqtt ./vendor/stekker-mqtt +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompile assets without requiring a real secret key at build time. +RUN SECRET_KEY_BASE_DUMMY=1 \ + DATABASE_URL=postgres://postgres:postgres@postgres:5432/resource_manager_mqtt_ruby \ + ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Install packages needed at runtime +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + git \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start the Rails server on the same internal port exposed by compose. +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0"] diff --git a/rm-mqtt-ws-bridge/rm/Gemfile b/rm-mqtt-ws-bridge/rm/Gemfile new file mode 100644 index 0000000..5243c3a --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/Gemfile @@ -0,0 +1,37 @@ +source "https://rubygems.org" + +gem "async" +gem "async-http" +gem "async-websocket" +gem "bootsnap", require: false +gem "dry-struct" +gem "dry-types" +gem "ecs-logging" +gem "jbuilder" +gem "mqtt" +gem "pg", "~> 1.1" +gem "propshaft" +gem "puma", ">= 5.0" +gem "rails", "~> 8.1.2" +gem "s2-ruby", github: "stekker/s2-ruby", ref: "7c0e956a86ab4c81618253ca8efb57665aa453fa", require: "s2" +gem "solid_cable" +gem "solid_cache" +gem "solid_queue" +gem "stekker-mqtt", path: "vendor/stekker-mqtt" +gem "tzinfo-data", platforms: [:windows, :jruby] + +group :development, :test do + gem "brakeman", require: false + gem "debug", platforms: [:mri, :windows] +end + +group :development do + gem "web-console" +end + +group :test do + gem "async-rspec" + gem "factory_bot_rails" + gem "rspec-rails" + gem "shoulda-matchers" +end diff --git a/rm-mqtt-ws-bridge/rm/Gemfile.lock b/rm-mqtt-ws-bridge/rm/Gemfile.lock new file mode 100644 index 0000000..23a5cc2 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/Gemfile.lock @@ -0,0 +1,433 @@ +GIT + remote: https://github.com/stekker/s2-ruby.git + revision: 7c0e956a86ab4c81618253ca8efb57665aa453fa + ref: 7c0e956a86ab4c81618253ca8efb57665aa453fa + specs: + s2-ruby (0.1.0) + activesupport (>= 7.0) + async + async-websocket + dry-struct (~> 1.6) + dry-types (~> 1.7) + +PATH + remote: vendor/stekker-mqtt + specs: + stekker-mqtt (0.1.0) + activesupport (>= 6.0) + dry-struct + dry-types + mqtt + +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.18) + railties + actioncable (8.1.2.1) + actionpack (= 8.1.2.1) + activesupport (= 8.1.2.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.2.1) + actionpack (= 8.1.2.1) + activejob (= 8.1.2.1) + activerecord (= 8.1.2.1) + activestorage (= 8.1.2.1) + activesupport (= 8.1.2.1) + mail (>= 2.8.0) + actionmailer (8.1.2.1) + actionpack (= 8.1.2.1) + actionview (= 8.1.2.1) + activejob (= 8.1.2.1) + activesupport (= 8.1.2.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.2.1) + actionview (= 8.1.2.1) + activesupport (= 8.1.2.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.2.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2.1) + activerecord (= 8.1.2.1) + activestorage (= 8.1.2.1) + activesupport (= 8.1.2.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.2.1) + activesupport (= 8.1.2.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.2.1) + activesupport (= 8.1.2.1) + globalid (>= 0.3.6) + activemodel (8.1.2.1) + activesupport (= 8.1.2.1) + activerecord (8.1.2.1) + activemodel (= 8.1.2.1) + activesupport (= 8.1.2.1) + timeout (>= 0.4.0) + activestorage (8.1.2.1) + actionpack (= 8.1.2.1) + activejob (= 8.1.2.1) + activerecord (= 8.1.2.1) + activesupport (= 8.1.2.1) + marcel (~> 1.0) + activesupport (8.1.2.1) + 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.36.0) + console (~> 1.29) + fiber-annotation + io-event (~> 1.11) + metrics (~> 0.12) + traces (~> 0.18) + async-http (0.94.2) + async (>= 2.10.2) + async-pool (~> 0.11) + io-endpoint (~> 0.14) + io-stream (~> 0.6) + metrics (~> 0.12) + protocol-http (~> 0.58) + protocol-http1 (~> 0.36) + protocol-http2 (~> 0.22) + protocol-url (~> 0.2) + traces (~> 0.10) + async-pool (0.11.1) + async (>= 2.0) + async-rspec (1.17.1) + rspec (~> 3.0) + rspec-files (~> 1.0) + rspec-memory (~> 1.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.0) + bindex (0.8.1) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.1.0) + racc + builder (3.3.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + console (1.34.2) + fiber-annotation + fiber-local (~> 1.1) + json + crass (1.0.6) + date (3.5.1) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + 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.0) + dry-core (~> 1.1) + dry-types (~> 1.8, >= 1.8.2) + ice_nine (~> 0.11) + zeitwerk (~> 2.6) + dry-types (1.9.0) + bigdecimal (>= 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + ecs-logging (1.0.0) + erb (6.0.4) + erubi (1.13.1) + et-orbi (1.3.0) + tzinfo + factory_bot (6.5.5) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) + fugit (1.11.2) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + io-console (0.8.2) + io-endpoint (0.17.2) + io-event (1.14.2) + io-stream (0.11.1) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.19.5) + logger (1.7.0) + loofah (2.25.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + metrics (0.15.0) + mini_mime (1.1.5) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + mqtt (0.7.0) + logger + msgpack (1.8.0) + net-imap (0.6.4) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-musl) + racc (~> 1.4) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.2.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + protocol-hpack (1.5.1) + protocol-http (0.58.1) + protocol-http1 (0.37.0) + protocol-http (~> 0.58) + protocol-http2 (0.24.0) + protocol-hpack (~> 1.4) + protocol-http (~> 0.47) + protocol-rack (0.21.0) + io-stream (>= 0.10) + protocol-http (~> 0.58) + rack (>= 1.0) + protocol-url (0.4.0) + protocol-websocket (0.20.2) + protocol-http (~> 0.2) + psych (5.3.1) + date + stringio + puma (7.0.1) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.6) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.2.1) + actioncable (= 8.1.2.1) + actionmailbox (= 8.1.2.1) + actionmailer (= 8.1.2.1) + actionpack (= 8.1.2.1) + actiontext (= 8.1.2.1) + actionview (= 8.1.2.1) + activejob (= 8.1.2.1) + activemodel (= 8.1.2.1) + activerecord (= 8.1.2.1) + activestorage (= 8.1.2.1) + activesupport (= 8.1.2.1) + bundler (>= 1.15.0) + railties (= 8.1.2.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.2.1) + actionpack (= 8.1.2.1) + activesupport (= 8.1.2.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-files (1.1.3) + rspec (~> 3.0) + rspec-memory (1.0.4) + rspec (~> 3.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.5) + securerandom (0.4.1) + shoulda-matchers (6.5.0) + activesupport (>= 5.2.0) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.7) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.1) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (>= 1.3.1) + stringio (3.2.0) + thor (1.5.0) + timeout (0.6.1) + traces (0.18.2) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.5) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin-23 + arm64-darwin-24 + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + async + async-http + async-rspec + async-websocket + bootsnap + brakeman + debug + dry-struct + dry-types + ecs-logging + factory_bot_rails + jbuilder + mqtt + pg (~> 1.1) + propshaft + puma (>= 5.0) + rails (~> 8.1.2) + rspec-rails + s2-ruby! + shoulda-matchers + solid_cable + solid_cache + solid_queue + stekker-mqtt! + tzinfo-data + web-console + +BUNDLED WITH + 2.6.9 diff --git a/rm-mqtt-ws-bridge/rm/Rakefile b/rm-mqtt-ws-bridge/rm/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/rm-mqtt-ws-bridge/rm/app/controllers/application_controller.rb b/rm-mqtt-ws-bridge/rm/app/controllers/application_controller.rb new file mode 100644 index 0000000..1d2c1af --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/controllers/application_controller.rb @@ -0,0 +1,27 @@ +class ApplicationController < ActionController::Base + before_action :authenticate_dashboard + after_action :disable_response_caching + + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + private + + def disable_response_caching + response.headers["Cache-Control"] = "no-store" + end + + def authenticate_dashboard + return unless ReferenceConfig.dashboard_auth_enabled? + + authenticate_or_request_with_http_basic("S2 Resource Manager MQTT Bridge") do |username, password| + secure_compare(username, ReferenceConfig.basic_auth_username) && + secure_compare(password, ReferenceConfig.basic_auth_password) + end + end + + def secure_compare(left, right) + left.bytesize == right.bytesize && + ActiveSupport::SecurityUtils.secure_compare(left, right) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/controllers/concerns/.keep b/rm-mqtt-ws-bridge/rm/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/rm-mqtt-ws-bridge/rm/app/controllers/resources_controller.rb b/rm-mqtt-ws-bridge/rm/app/controllers/resources_controller.rb new file mode 100644 index 0000000..da787e8 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/controllers/resources_controller.rb @@ -0,0 +1,15 @@ +class ResourcesController < ApplicationController + def index + render :index, layout: "bridge" + end + + def show + resource = Resource.find(params[:id]) + connection = Supervisor.instance.find_connection(resource.s2_identifier) + + respond_to do |format| + format.html { render html: helpers.resource_uptime(connection) } + format.json { render json: connection&.state || {} } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb b/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb new file mode 100644 index 0000000..6f0af32 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb @@ -0,0 +1,32 @@ +class TrafficController < ApplicationController + skip_forgery_protection + + def reset + TrafficLog.clear + render json: { reset: true } + end + + def index + resource = Resource.order(:created_at).last + + render json: { + broker: ReferenceConfig.mqtt_url, + cem_endpoint: ReferenceConfig.cem.base_url, + supervisor: Supervisor.instance.status.to_s, + resource: resource && { + id: resource.s2_identifier, + state: connection_state(resource), + }, + traffic: TrafficLog.recent, + } + end + + private + + def connection_state(resource) + connection = Supervisor.instance.find_connection(resource.s2_identifier) + return connection.status.to_s if connection + + resource.disconnected_at ? "disconnected" : "idle" + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/helpers/application_helper.rb b/rm-mqtt-ws-bridge/rm/app/helpers/application_helper.rb new file mode 100644 index 0000000..059aab8 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/helpers/application_helper.rb @@ -0,0 +1,7 @@ +module ApplicationHelper + def resource_uptime(connection) + return "N/A" unless connection&.connected_at + + distance_of_time_in_words(Time.current, connection.connected_at) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/jobs/application_job.rb b/rm-mqtt-ws-bridge/rm/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/rm-mqtt-ws-bridge/rm/app/jobs/process_frbc_instruction_job.rb b/rm-mqtt-ws-bridge/rm/app/jobs/process_frbc_instruction_job.rb new file mode 100644 index 0000000..36753d3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/jobs/process_frbc_instruction_job.rb @@ -0,0 +1,68 @@ +class ProcessFRBCInstructionJob < ApplicationJob + INSTRUCTION_DURATION = 12.hours.freeze + + def perform(resource_id:, frbc_system_description_json:, frbc_instruction_json:) + frbc_system_description = S2::Messages::FRBCSystemDescription.from_json!(frbc_system_description_json) + frbc_instruction = S2::Messages::FRBCInstruction.from_json!(frbc_instruction_json) + + mqtt_publisher.with_connection do |connection| + connection.publish( + topic: "stekker/resources/#{resource_id}/power_setpoints", + qos: 1, + message: build_power_setpoint_message(resource_id, frbc_system_description, frbc_instruction), + ) + + connection.publish( + topic: "stekker/resources/#{resource_id}/instruction_updates", + qos: 1, + message: build_instruction_update_message(resource_id, frbc_instruction), + ) + end + end + + private + + def mqtt_publisher + Stekker::MQTT::Publisher.new( + url: ReferenceConfig.mqtt_url, + logger: MQTTMessageLogger.new(Rails.configuration.ecs_logger), + ) + end + + def build_power_setpoint_message(resource_id, frbc_system_description, frbc_instruction) + starts_at = Time.zone.parse(frbc_instruction.execution_time) + ends_at = starts_at + INSTRUCTION_DURATION + + Stekker::MQTT::Messages::PowerSetpoint.from_dynamic!( + "message_type" => Stekker::MQTT::Messages::PowerSetpointMessageType::PowerSetpoint, + "resource_id" => resource_id, + "data" => { + "reference" => frbc_instruction.id, + "starts_at" => starts_at.iso8601, + "ends_at" => ends_at.iso8601, + "unit" => "W", + "value" => calc_power_setpoint(frbc_system_description, frbc_instruction), + }, + ) + end + + def build_instruction_update_message(resource_id, frbc_instruction) + Stekker::MQTT::Messages::InstructionUpdate.from_dynamic!( + "message_type" => Stekker::MQTT::Messages::InstructionUpdateMessageType::InstructionUpdate, + "resource_id" => resource_id, + "data" => { + "reference" => frbc_instruction.id, + "updated_at" => Time.current.iso8601, + "status" => Stekker::MQTT::Messages::Status::Accepted, + }, + ) + end + + def calc_power_setpoint(frbc_system_description, frbc_instruction) # rubocop:disable Metrics/AbcSize + actuator = frbc_system_description.actuators.find { it.id == frbc_instruction.actuator_id } + operation_mode = actuator.operation_modes.find { it.id == frbc_instruction.operation_mode } + min_power = operation_mode.elements.first.power_ranges.first.start_of_range + max_power = operation_mode.elements.first.power_ranges.first.end_of_range + (((max_power - min_power) * frbc_instruction.operation_mode_factor) + min_power).round + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/jobs/publish_s2_ready_notification_job.rb b/rm-mqtt-ws-bridge/rm/app/jobs/publish_s2_ready_notification_job.rb new file mode 100644 index 0000000..56b4cef --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/jobs/publish_s2_ready_notification_job.rb @@ -0,0 +1,22 @@ +class PublishS2ReadyNotificationJob < ApplicationJob + def perform(resource_id:) + message = Stekker::MQTT::Messages::S2ReadyNotification.from_dynamic!( + "message_type" => Stekker::MQTT::Messages::S2ReadyNotificationMessageType::S2ReadyNotification, + "resource_id" => resource_id, + ) + + mqtt_publisher.publish( + topic: "stekker/resources/#{resource_id}/#{message.message_type.pluralize}", + message:, + ) + end + + private + + def mqtt_publisher + Stekker::MQTT::Publisher.new( + url: ReferenceConfig.mqtt_url, + logger: MQTTMessageLogger.new(Rails.configuration.ecs_logger), + ) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/jobs/send_heartbeat_power_measurement_job.rb b/rm-mqtt-ws-bridge/rm/app/jobs/send_heartbeat_power_measurement_job.rb new file mode 100644 index 0000000..878493b --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/jobs/send_heartbeat_power_measurement_job.rb @@ -0,0 +1,32 @@ +class SendHeartbeatPowerMeasurementJob < ApplicationJob + def perform(resource_id:) + mqtt_publisher.with_connection do |connection| + connection.publish( + topic: "stekker/resources/#{resource_id}/power_measurements", + qos: 0, + message: build_power_measurement(resource_id), + ) + end + end + + private + + def mqtt_publisher + Stekker::MQTT::Publisher.new( + url: ReferenceConfig.mqtt_url, + logger: MQTTMessageLogger.new(Rails.configuration.ecs_logger), + ) + end + + def build_power_measurement(resource_id) + Stekker::MQTT::Messages::PowerMeasurement.from_dynamic!( + "message_type" => Stekker::MQTT::Messages::PowerMeasurementMessageType::PowerMeasurement, + "resource_id" => resource_id, + "data" => { + "sampled_at" => Time.current.iso8601, + "unit" => Stekker::MQTT::Messages::Unit::W, + "value" => 0, + }, + ) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/jobs/send_heartbeat_power_measurements_job.rb b/rm-mqtt-ws-bridge/rm/app/jobs/send_heartbeat_power_measurements_job.rb new file mode 100644 index 0000000..676b9b3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/jobs/send_heartbeat_power_measurements_job.rb @@ -0,0 +1,9 @@ +class SendHeartbeatPowerMeasurementsJob < ApplicationJob + def perform + jobs = Resource.active.needing_heartbeat.map do |resource| + SendHeartbeatPowerMeasurementJob.new(resource_id: resource.s2_identifier) + end + + ActiveJob.perform_all_later(jobs) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/base_factory.rb b/rm-mqtt-ws-bridge/rm/app/lib/base_factory.rb new file mode 100644 index 0000000..6c49f58 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/base_factory.rb @@ -0,0 +1,10 @@ +class BaseFactory + class << self + # rubocop:disable Layout/LineLength + # Conformance workaround: hardcoded to 3_PHASE_SYMMETRIC for every resource, including single-phase resources, for CEM interoperability. + # rubocop:enable Layout/LineLength + def commodity_quantity_for(_resource) + S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/connection_registry.rb b/rm-mqtt-ws-bridge/rm/app/lib/connection_registry.rb new file mode 100644 index 0000000..d6ccba2 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/connection_registry.rb @@ -0,0 +1,34 @@ +class ConnectionRegistry + delegate :clear, + :each_value, + :find, + :size, + to: :@connections + + def initialize + @connections = {} + end + + def register_connection(resource_id, connection) + raise "Connection already registered for #{resource_id}" if registered_connection?(resource_id) + + @connections[resource_id] = connection + end + + def unregister_connection(resource_id) + @connections.delete(resource_id) + end + + def registered_connection?(resource_id) + @connections.has_key?(resource_id) + end + + def find_connection(resource_id, expected_status: nil) + connection = @connections[resource_id] + return nil unless connection + + return connection if expected_status.nil? || connection.status == expected_status + + nil + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/error_notifier.rb b/rm-mqtt-ws-bridge/rm/app/lib/error_notifier.rb new file mode 100644 index 0000000..e83e42b --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/error_notifier.rb @@ -0,0 +1,10 @@ +class ErrorNotifier + class << self + def notify_error(error, extra: {}, tags: {}) + return if error.nil? + + Rails.logger.error("#{error.class}: #{error.message}") + Rails.logger.error(Array(error.backtrace).join("\n")) if Rails.env.local? + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/frbc_system_description_factory.rb b/rm-mqtt-ws-bridge/rm/app/lib/frbc_system_description_factory.rb new file mode 100644 index 0000000..199bfaf --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/frbc_system_description_factory.rb @@ -0,0 +1,125 @@ +class FRBCSystemDescriptionFactory < BaseFactory + OPERATION_MODE_OFF_LABEL = "Off".freeze + OPERATION_MODE_CHARGING_LABEL = "Charging".freeze + + # Workarounds to simplify EV control for CEM interoperability: + # - Extremely low fill_rate effectively disables the buffer, so the CEM bases control only on power + # - Extended fill_level_range (-10 to 110) provides headroom at SoC 0% and 100% + CEM_WORKAROUND_FILL_LEVEL_MIN = -10 + CEM_WORKAROUND_FILL_LEVEL_MAX = 110 + CEM_WORKAROUND_NONZERO_FILL_RATE = 0.00000001 + + class << self + def create(resource) # rubocop:disable Metrics/MethodLength + off_id = SecureRandom.uuid + charging_id = SecureRandom.uuid + commodity_quantity = commodity_quantity_for(resource) + minimum_power = calc_minimum_power(resource) + maximum_power = calc_maximum_power(resource) + + S2::Messages::FRBCSystemDescription.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::FRBCSystemDescriptionMessageType::FRBCSystemDescription, + "valid_from" => Time.current.iso8601, + "actuators" => [ + { + "id" => SecureRandom.uuid, + "diagnostic_label" => "Electric Vehicle Charger", + "supported_commodities" => [ + S2::Messages::Commodity::Electricity, + ], + "operation_modes" => [ + create_operation_mode( + id: off_id, + label: OPERATION_MODE_OFF_LABEL, + fill_level_range: { "start_of_range" => 0, "end_of_range" => 100 }, + fill_rate: { "start_of_range" => 0, "end_of_range" => 0 }, + power_ranges: [ + { + "start_of_range" => 0, + "end_of_range" => 0, + "commodity_quantity" => commodity_quantity, + }, + ], + ), + create_operation_mode( + id: charging_id, + label: OPERATION_MODE_CHARGING_LABEL, + fill_level_range: { "start_of_range" => 0, "end_of_range" => 100 }, + fill_rate: { "start_of_range" => 0.0, "end_of_range" => CEM_WORKAROUND_NONZERO_FILL_RATE }, + power_ranges: [ + { + "start_of_range" => minimum_power, + "end_of_range" => maximum_power, + "commodity_quantity" => commodity_quantity, + }, + ], + ), + ], + "transitions" => [ + create_transition(from: off_id, to: charging_id), + create_transition(from: charging_id, to: off_id), + ], + "timers" => [], + }, + ], + "storage" => { + "diagnostic_label" => "Electric Vehicle Battery", + "fill_level_label" => "State of Charge", + "provides_leakage_behaviour" => false, + "provides_fill_level_target_profile" => true, + "provides_usage_forecast" => false, + "fill_level_range" => { + "start_of_range" => CEM_WORKAROUND_FILL_LEVEL_MIN, + "end_of_range" => CEM_WORKAROUND_FILL_LEVEL_MAX, + }, + }, + ) + end + + private + + def create_operation_mode(id:, label:, fill_level_range:, fill_rate:, power_ranges:) + { + "id" => id, + "diagnostic_label" => label, + "elements" => [ + { + "fill_level_range" => fill_level_range, + "fill_rate" => fill_rate, + "power_ranges" => power_ranges, + }, + ], + "abnormal_condition_only" => false, + } + end + + def create_transition(from:, to:) + { + "id" => SecureRandom.uuid, + "from" => from, + "to" => to, + "start_timers" => [], + "blocking_timers" => [], + "transition_duration" => 3_000, + "abnormal_condition_only" => false, + } + end + + def calc_minimum_power(resource) + amperage = resource.configuration.fetch("minimum_current") + voltage = resource.configuration.fetch("voltage") + number_of_phases = resource.configuration.fetch("number_of_phases") + + amperage * voltage * number_of_phases + end + + def calc_maximum_power(resource) + amperage = resource.configuration.fetch("maximum_current") + voltage = resource.configuration.fetch("voltage") + number_of_phases = resource.configuration.fetch("number_of_phases") + + amperage * voltage * number_of_phases + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/instruction_status_tracker.rb b/rm-mqtt-ws-bridge/rm/app/lib/instruction_status_tracker.rb new file mode 100644 index 0000000..82e70f3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/instruction_status_tracker.rb @@ -0,0 +1,15 @@ +class InstructionStatusTracker + def initialize + @relayed = Set.new + end + + def already_relayed?(resource_id:, instruction_id:, status:) + key = [resource_id, instruction_id, status] + + !@relayed.add?(key) + end + + def clear_resource(resource_id) + @relayed.delete_if { |key| key.first == resource_id } + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/message_relayer.rb b/rm-mqtt-ws-bridge/rm/app/lib/message_relayer.rb new file mode 100644 index 0000000..8aab9d5 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/message_relayer.rb @@ -0,0 +1,18 @@ +class MessageRelayer + def initialize(connection_registry) + @connection_registry = connection_registry + end + + def relay_message(resource_id:, message:) + expected_status = :connected + Rails.logger.info( + "[#{self.class}] Built S2 #{message.message_type} for resource_id #{resource_id}: #{message.to_json}", + ) + + if (connection = @connection_registry.find_connection(resource_id, expected_status:)) + connection.send_message(message) + else + Rails.logger.warn("[#{self.class}] No #{expected_status} connection found for resource_id #{resource_id}") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_handler.rb b/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_handler.rb new file mode 100644 index 0000000..d242194 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_handler.rb @@ -0,0 +1,173 @@ +class MQTTMessageHandler < Stekker::MQTT::MessageHandler + MINIMUM_ELEMENT_DURATION = 15.minutes.freeze + + on Stekker::MQTT::Messages::Connect do |message| + configuration = message.data.to_dynamic + existing = Resource.find_by(s2_identifier: message.resource_id) + connection = Supervisor.instance.find_connection(message.resource_id) + already_live = existing && existing.disconnected_at.nil? && + existing.configuration == configuration && + connection&.status == :connected + + Resource.upsert( + { + s2_identifier: message.resource_id, + configuration: configuration, + disconnected_at: nil, + }, + unique_by: :s2_identifier, + ) + + Supervisor.instance.reconnect(message.resource_id) unless already_live + end + + on Stekker::MQTT::Messages::Disconnect do |message| + resource = Resource.find_by(s2_identifier: message.resource_id) + resource&.update!(disconnected_at: Time.current) + @instruction_status_tracker.clear_resource(message.resource_id) + Supervisor.instance.disconnect(message.resource_id) + end + + on Stekker::MQTT::Messages::PowerMeasurement do |message| + resource = Resource.find_by!(s2_identifier: message.resource_id) + + relay_message( + resource_id: message.resource_id, + message: S2::Messages::PowerMeasurement.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::PowerMeasurementMessageType::PowerMeasurement, + "measurement_timestamp" => message.data.sampled_at, + "values" => [ + { + "commodity_quantity" => BaseFactory.commodity_quantity_for(resource), + "value" => message.data.value, + }, + ], + ), + ) + + resource.record_power_measurement! + end + + on Stekker::MQTT::Messages::PowerForecast do |message| + next unless Rails.configuration.s2.provides_forecast + + resource = Resource.find_by!(s2_identifier: message.resource_id) + profile = message.data.values + start_at = Time.zone.parse(message.data.start_at) + values_timestamps = message.data.values.pluck(:timestamp).map { |ts| Time.zone.parse(ts) } + end_time = [start_at + 24.hours, *values_timestamps].max + + elements = profile.map.with_index do |power_forecast_value, index| + power_in_watts = power_forecast_value.value + next_element_start_time = Time.zone.parse(profile[index + 1].timestamp) if profile[index + 1].present? + element_end_time = next_element_start_time || end_time + element_start_time = Time.zone.parse(power_forecast_value.timestamp) + duration = (element_end_time - element_start_time).in_milliseconds.to_i + + { + "duration" => duration, + "power_values" => [ + { + "commodity_quantity" => BaseFactory.commodity_quantity_for(resource), + "value_expected" => power_in_watts, + }, + ], + } + end + + relay_message( + resource_id: message.resource_id, + message: S2::Messages::PowerForecast.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::PowerForecastMessageType::PowerForecast, + "start_time" => start_at.iso8601, + "elements" => elements, + ), + ) + end + + on Stekker::MQTT::Messages::ChargeStatus do |message| + relay_message( + resource_id: message.resource_id, + message: S2::Messages::FRBCStorageStatus.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::FRBCStorageStatusMessageType::FRBCStorageStatus, + "present_fill_level" => message.data.value, + ), + ) + end + + on Stekker::MQTT::Messages::ChargeTarget do |message| + starts_at = Time.zone.parse(message.data.starts_at) + ends_at = Time.zone.parse(message.data.ends_at) + main_element_duration = [ends_at - starts_at - MINIMUM_ELEMENT_DURATION, MINIMUM_ELEMENT_DURATION].max + + relay_message( + resource_id: message.resource_id, + message: S2::Messages::FRBCFillLevelTargetProfile.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::FRBCFillLevelTargetProfileMessageType::FRBCFillLevelTargetProfile, + "start_time" => message.data.starts_at, + "elements" => [ + { + "duration" => main_element_duration.in_milliseconds.to_i, + "fill_level_range" => { + "start_of_range" => message.data.direct_percentage, + "end_of_range" => message.data.smart_percentage, + }, + }, + { + "duration" => MINIMUM_ELEMENT_DURATION.in_milliseconds.to_i, + "fill_level_range" => { + "start_of_range" => message.data.smart_percentage, + "end_of_range" => message.data.smart_percentage, + }, + }, + ], + ), + ) + end + + on Stekker::MQTT::Messages::InstructionUpdate do |message| + if @instruction_status_tracker.already_relayed?( + resource_id: message.resource_id, + instruction_id: message.data.reference, + status: message.data.status, + ) + Rails.logger.info( + "[#{self.class}] Skipped duplicate instruction update " \ + "resource=#{message.resource_id} reference=#{message.data.reference} status=#{message.data.status}", + ) + next + end + + relay_message( + resource_id: message.resource_id, + message: S2::Messages::InstructionStatusUpdate.from_dynamic!( + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::InstructionStatusUpdateMessageType::InstructionStatusUpdate, + "instruction_id" => message.data.reference, + "timestamp" => message.data.updated_at, + "status_type" => message.data.status, + ), + ) + end + + def initialize(message_relayer:, instruction_status_tracker: InstructionStatusTracker.new) + super() + + @message_relayer = message_relayer + @instruction_status_tracker = instruction_status_tracker + end + + def handle_message(payload) + Rails.application.executor.wrap { super } + end + + protected + + def relay_message(resource_id:, message:) + @message_relayer.relay_message(resource_id:, message:) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_logger.rb b/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_logger.rb new file mode 100644 index 0000000..09e726e --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_logger.rb @@ -0,0 +1,55 @@ +class MQTTMessageLogger + TOPIC_PATTERN = %r{\Astekker/resources/([^/]+)/} + + def initialize(logger) + @logger = logger + end + + def log_inbound(topic:, message:) + TrafficLog.record(peer: "mqtt", direction: "in", message_type: message_type_from(message), detail: detail_from(message)) + log("Inbound MQTT message", topic:, message:) + end + + def log_outbound(topic:, message:) + TrafficLog.record(peer: "mqtt", direction: "out", message_type: message_type_from(message), detail: detail_from(message)) + log("Outbound MQTT message", topic:, message:) + end + + private + + def message_type_from(message) + parsed = message.is_a?(String) ? JSON.parse(message) : message + parsed["message_type"] + rescue StandardError + nil + end + + def detail_from(message) + parsed = message.is_a?(String) ? JSON.parse(message) : message + data = parsed["data"] + return nil unless data.is_a?(Hash) && !data["value"].nil? + + data["unit"] ? "#{data['value']} #{data['unit']}" : data["value"].to_s + rescue StandardError + nil + end + + def log(log_message, topic:, message:) + @logger.info( + log_message, + mqtt: { + topic:, + resource_id: extract_resource_id(topic), + message:, + }.compact, + ) + rescue StandardError => e + ErrorNotifier.notify_error(e) + + raise if Rails.env.test? + end + + def extract_resource_id(topic) + topic.match(TOPIC_PATTERN)&.[](1) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/reference_config.rb b/rm-mqtt-ws-bridge/rm/app/lib/reference_config.rb new file mode 100644 index 0000000..8946609 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/reference_config.rb @@ -0,0 +1,44 @@ +class ReferenceConfig + Cem = Struct.new(:base_url, :username, :password, keyword_init: true) + + class << self + def mqtt_url + env_value("MQTT_URL") || "mqtt://mosquitto:1883" + end + + def cem + Cem.new( + base_url: env_value("CEM_BASE_URL") || "ws://localhost:9999/s2", + username: env_value("CEM_USERNAME"), + password: env_value("CEM_PASSWORD"), + ) + end + + def demo_allow_framing? + env_value("DEMO_ALLOW_FRAMING") == "1" + end + + def basic_auth_username + env_value("BASIC_AUTH_USERNAME") + end + + def basic_auth_password + env_value("BASIC_AUTH_PASSWORD") + end + + def dashboard_auth_enabled? + basic_auth_username && basic_auth_password + end + + def provides_forecast? + !%w[false 0 no off].include?((env_value("S2_PROVIDES_FORECAST") || "true").downcase) + end + + private + + def env_value(name) + value = ENV[name].to_s.strip + value.empty? ? nil : value + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/resource_manager_details_factory.rb b/rm-mqtt-ws-bridge/rm/app/lib/resource_manager_details_factory.rb new file mode 100644 index 0000000..d53410a --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/resource_manager_details_factory.rb @@ -0,0 +1,32 @@ +class ResourceManagerDetailsFactory < BaseFactory + def self.create(resource) + S2::Messages::ResourceManagerDetails.from_dynamic!( + "manufacturer" => manufacturer_identifier, + "message_id" => SecureRandom.uuid, + "message_type" => S2::Messages::ResourceManagerDetailsMessageType::ResourceManagerDetails, + "resource_id" => resource.s2_identifier, + "name" => resource.configuration.fetch("name"), + "roles" => [ + { + "commodity" => S2::Messages::Commodity::Electricity, + "role" => S2::Messages::RoleType::EnergyConsumer, + }, + ], + "instruction_processing_delay" => 5.minutes.in_milliseconds, + "available_control_types" => [ + S2::Messages::ControlType::FillRateBasedControl, + ], + "provides_forecast" => Rails.configuration.s2.provides_forecast, + "provides_power_measurement_types" => [ + commodity_quantity_for(resource), + ], + ) + end + + def self.manufacturer_identifier + git_rev = ENV.fetch("GIT_REV", "unknown")[0, 9] + s2_version = S2.supported_protocol_versions.first + + "StekkerS2/#{git_rev} (#{Rails.env}; Ruby/#{RUBY_VERSION}) S2/#{s2_version}" + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/s2_message_handler.rb b/rm-mqtt-ws-bridge/rm/app/lib/s2_message_handler.rb new file mode 100644 index 0000000..efe7fa9 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/s2_message_handler.rb @@ -0,0 +1,99 @@ +class S2MessageHandler < S2::MessageHandler + class ProtocolVersionMismatch < S2::MessageHandler::BaseError; end + + rescue_from ProtocolVersionMismatch do |error| + reply to: error.message_id, status: :permanent_error, diagnostic_label: error.message + end + + around_handle do |_payload, continue| + Rails.application.executor.wrap { continue.call } + end + + on S2::Messages::ReceptionStatus do |message| + @message_sender.receive_reception_status(message) + end + + on S2::Messages::Handshake do |message| + assert_state! message, status: :websocket_connected + assert_attribute! message, role: S2::Messages::EnergyManagementRole::Cem + + unless message.supported_protocol_versions.intersect?(S2.supported_protocol_versions) + raise( + ProtocolVersionMismatch.new( + "No protocol version matches, supported versions: #{S2.supported_protocol_versions.join(", ")}", + message.message_id, + ), + ) + end + + reply_ok to: message + end + + on S2::Messages::HandshakeResponse do |message| + assert_state! message, status: :websocket_connected + + reply_ok to: message + + update_state status: :initialized, + protocol_version: message.selected_protocol_version + + resource = Resource.find_by!(s2_identifier: @state[:resource_id]) + send_message ResourceManagerDetailsFactory.create(resource) + end + + on S2::Messages::SelectControlType do |message| + assert_state! message, status: :initialized + assert_attribute! message, control_type: S2::Messages::ControlType::FillRateBasedControl + + reply_ok to: message + + update_state control_type: message.control_type + + resource = Resource.find_by!(s2_identifier: @state[:resource_id]) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + send_message(frbc_system_description) do + update_state frbc_system_description: + end + + PublishS2ReadyNotificationJob.perform_later(resource_id: @state[:resource_id]) + end + + on S2::Messages::FRBCInstruction do |message| + assert_state! message, status: :initialized + assert_state! message, control_type: S2::Messages::ControlType::FillRateBasedControl + assert_actuator_id! message + assert_operation_mode_id! message + + reply_ok to: message + + ProcessFRBCInstructionJob.perform_later( + resource_id: @state[:resource_id], + frbc_system_description_json: @state[:frbc_system_description].to_json, + frbc_instruction_json: message.to_json, + ) + end + + private + + def assert_actuator_id!(frbc_instruction) + frbc_system_description = @state[:frbc_system_description] + return if frbc_system_description&.actuators&.any? { it.id == frbc_instruction.actuator_id } + + raise S2::MessageHandler::Asserting::InvalidContent.new( + "Invalid attribute: unknown actuator_id '#{frbc_instruction.actuator_id}'", + frbc_instruction.message_id, + ) + end + + def assert_operation_mode_id!(frbc_instruction) + frbc_system_description = @state[:frbc_system_description] + return if frbc_system_description&.actuators&.any? do |actuator| + actuator.operation_modes.any? { |mode| mode.id == frbc_instruction.operation_mode } + end + + raise S2::MessageHandler::Asserting::InvalidContent.new( + "Invalid attribute: unknown operation_mode '#{frbc_instruction.operation_mode}'", + frbc_instruction.message_id, + ) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/s2_message_logger.rb b/rm-mqtt-ws-bridge/rm/app/lib/s2_message_logger.rb new file mode 100644 index 0000000..bd1402a --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/s2_message_logger.rb @@ -0,0 +1,41 @@ +class S2MessageLogger + def initialize(logger) + @logger = logger + end + + def log_inbound(resource_id:, message:) + message_type = extract_message_type(message) + TrafficLog.record(peer: "cem", direction: "in", message_type:) + log("Inbound S2 message", resource_id:, message:, message_type:) + end + + def log_outbound(resource_id:, message:) + message_type = extract_message_type(message) + TrafficLog.record(peer: "cem", direction: "out", message_type:) + log("Outbound S2 message", resource_id:, message:, message_type:) + end + + private + + def log(log_message, resource_id:, message:, message_type:) + @logger.info( + log_message, + s2: { + resource_id:, + message_type:, + message:, + }.compact, + ) + rescue StandardError => e + ErrorNotifier.notify_error(e) + + raise if Rails.env.test? + end + + def extract_message_type(message) + parsed = JSON.parse(message) + parsed["message_type"] + rescue JSON::ParserError + nil + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/stekker_formatter.rb b/rm-mqtt-ws-bridge/rm/app/lib/stekker_formatter.rb new file mode 100644 index 0000000..61586f4 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/stekker_formatter.rb @@ -0,0 +1,33 @@ +class StekkerFormatter + def initialize(delegate = EcsLogging::Formatter.new) + @delegate = delegate + end + + def call(severity, time, progname, msg, **extras) + @delegate.call( + severity, + time, + progname, + msg, + **extras, + **metadata, + ) + end + + private + + def metadata + trace_id = SecureRandom.uuid + span_id = SecureRandom.uuid + + { + labels: { + environment: Rails.env, + git_sha: ENV.fetch("GIT_REV", "unknown"), + }, + "trace.id": trace_id, + "transaction.id": trace_id, + "span.id": span_id, + } + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/supervisor.rb b/rm-mqtt-ws-bridge/rm/app/lib/supervisor.rb new file mode 100644 index 0000000..c822796 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/supervisor.rb @@ -0,0 +1,308 @@ +require "singleton" +require "async" + +class Supervisor + include Singleton + + INITIAL_RECONNECT_BACKOFF = 5.seconds + MAX_RECONNECT_BACKOFF = 5.minutes + + delegate :find_connection, to: :@connection_registry + + attr_reader :status + + def initialize + @connection_registry = ConnectionRegistry.new + @reactor_thread = nil + @reactor = nil + @reactor_mailbox = Queue.new # Ruby's thread-safe Queue + @wakeup_r = @wakeup_w = nil + @status = :initialized + + @reconnect_backoffs = {} + @scheduled_reconnects = Set.new + @connection_event_subscriptions = [] + + @mqtt_listener = nil + end + + def startup + return if reactor_running? + + Rails.logger.info "[#{self.class.name}] Starting reactor thread" + @status = :running + + subscribe_to_connection_events + + resource_ids = fetch_resource_ids + @reactor_thread = spawn_reactor_thread(resource_ids) + + start_mqtt_listener + end + + def shutdown # rubocop:disable Metrics/AbcSize + Rails.logger.info "[#{self.class.name}] Stopping #{@connection_registry.size} connections" + @status = :stopping + + stop_mqtt_listener + unsubscribe_from_connection_events + + if reactor_running? + post_to_reactor do + disconnect_all + Async::Task.current.reactor.stop + end + end + + stop_reactor_thread + @status = :stopped + Rails.logger.info "[#{self.class.name}] All disconnected" + end + + def connect(resource_id, parent: nil) + root_task = @reactor || parent + + return unless root_task + return if @connection_registry.registered_connection?(resource_id) + + credentials = ReferenceConfig.cem + ws_url = "#{credentials.base_url}/#{resource_id}" + headers = build_auth_headers(credentials) + + root_task.async do |task| + S2::Connection.new(resource_id:, task:, ws_url:, headers:).tap do |connection| + @connection_registry.register_connection(resource_id, connection) + connection.connect + end + end + end + + def disconnect(resource_id) + if (connection = @connection_registry.unregister_connection(resource_id)) + post_to_reactor { connection.disconnect } + else + Rails.logger.warn "[#{self.class.name}] Connection '#{resource_id}' not found" + end + end + + def reconnect(resource_id) + post_to_reactor do + if (connection = @connection_registry.unregister_connection(resource_id)) + connection.disconnect + end + + connect(resource_id) + end + end + + def send_message(resource_id, message) + with_connection(resource_id) do |connection| + connection.send_message(message) + end + end + + def running? + @status == :running + end + + private + + def subscribe_to_connection_events + @connection_event_subscriptions << ActiveSupport::Notifications.subscribe( + "connection_errored.session.s2", + ) do |event| + handle_connection_errored(event.payload[:resource_id]) + end + + @connection_event_subscriptions << ActiveSupport::Notifications.subscribe( + "connected.session.s2", + ) do |event| + @reconnect_backoffs.delete(event.payload[:resource_id]) + end + end + + def unsubscribe_from_connection_events + @connection_event_subscriptions.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) } + @connection_event_subscriptions.clear + @scheduled_reconnects.clear + @reconnect_backoffs.clear + end + + def handle_connection_errored(resource_id) + return if resource_id.nil? + return unless @status == :running + return unless reactor_running? + return unless @connection_registry.registered_connection?(resource_id) + return unless @scheduled_reconnects.add?(resource_id) + + backoff = @reconnect_backoffs.fetch(resource_id, INITIAL_RECONNECT_BACKOFF) + @reconnect_backoffs[resource_id] = [backoff * 2, MAX_RECONNECT_BACKOFF].min + + Rails.logger.info "[#{self.class.name}] [#{resource_id}] Reconnecting in #{backoff.to_i}s" + spawn_reconnect(resource_id, backoff) + end + + def spawn_reconnect(resource_id, backoff) + post_to_reactor do + @reactor.async do + sleep backoff.to_f + + next unless @status == :running + next unless @connection_registry.registered_connection?(resource_id) + next if @connection_registry.find_connection(resource_id, expected_status: :connected) + + reconnect(resource_id) + rescue StandardError => e + ErrorNotifier.notify_error(e) + ensure + @scheduled_reconnects.delete(resource_id) + end + end + end + + def build_auth_headers(credentials) + return {} unless credentials.username && credentials.password + + token = Base64.strict_encode64("#{credentials.username}:#{credentials.password}") + { "authorization" => "Basic #{token}" } + end + + def with_connection(resource_id) + if (connection = find_connection(resource_id)) + yield connection + else + Rails.logger.warn "[#{self.class.name}] Connection '#{resource_id}' not found" + end + end + + def reactor_running? + @reactor_thread&.alive? + end + + def fetch_resource_ids + Resource.active.pluck(:s2_identifier) + end + + def spawn_reactor_thread(resource_ids) # rubocop:disable Metrics/AbcSize + Thread.new do # rubocop:disable Metrics/BlockLength + @wakeup_r, @wakeup_w = IO.pipe + + Async do |task| # rubocop:disable Metrics/BlockLength + @reactor = task + + task.async do + loop do + @wakeup_r.readpartial(1) + + loop do + job = begin + @reactor_mailbox.pop(true) # true = non-blocking + rescue ThreadError + break + end + + begin + job.call + rescue StandardError => e + ErrorNotifier.notify_error(e) + end + end + end + end + + resource_ids.each { |rid| connect(rid, parent: task) } + + begin + @wakeup_w.write(".") + rescue IOError + # ignore + end + + Async::Task.current.sleep + ensure + Rails.logger.info "[#{self.class.name}] Reactor thread exiting" + + @reactor = nil + @status = :stopped + + begin + @wakeup_r&.close + rescue IOError + # ignore + end + + begin + @wakeup_w&.close + rescue IOError + # ignore + end + + @wakeup_r = @wakeup_w = nil + end + end + end + + def run_reactor(task, resource_ids) + @reactor = task + + Rails.logger.info "[#{self.class.name}] Connecting to resources #{resource_ids}" + resource_ids.each { |resource_id| connect(resource_id, parent: task) } + + Async::Task.current.sleep + end + + def disconnect_all + @connection_registry.each_value(&:disconnect) + @connection_registry.clear + end + + def stop_reactor_thread + return unless @reactor_thread + + @reactor_thread.join + @reactor_thread = nil + end + + def post_to_reactor(&block) + return unless reactor_running? + + @reactor_mailbox << block + + begin + @wakeup_w&.write(".") + rescue IOError + # ignore + end + end + + def start_mqtt_listener + sleep 1 unless Rails.env.test? # wait a bit to let S2 connections establish first + + @mqtt_listener = Stekker::MQTT::Listener.new( + url: ReferenceConfig.mqtt_url, + connect_opts: { + client_id: "stekker_s2", + clean_session: false, + }, + message_handler: MQTTMessageHandler.new( + message_relayer: MessageRelayer.new(@connection_registry), + ), + executor: Rails.application.reloader, + topics: { + "stekker/resources/+/charge_statuses" => 0, + "stekker/resources/+/charge_targets" => 0, + "stekker/resources/+/connects" => 0, + "stekker/resources/+/disconnects" => 0, + "stekker/resources/+/instruction_updates" => 1, + "stekker/resources/+/power_forecasts" => 0, + "stekker/resources/+/power_measurements" => 0, + }, + ) + @mqtt_listener.start + end + + def stop_mqtt_listener + @mqtt_listener&.stop + @mqtt_listener = nil + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/lib/traffic_log.rb b/rm-mqtt-ws-bridge/rm/app/lib/traffic_log.rb new file mode 100644 index 0000000..02baefd --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/lib/traffic_log.rb @@ -0,0 +1,48 @@ +class TrafficLog + MAX = 80 + PATH = Rails.root.join("tmp", "traffic.jsonl") + + class << self + def record(peer:, direction:, message_type:, detail: nil) + return if message_type.blank? + + entry = JSON.generate({ peer: peer, direction: direction, message_type: message_type, detail: detail, at: Time.current.utc.iso8601 }.compact) + File.open(PATH, File::RDWR | File::CREAT, 0o644) do |file| + file.flock(File::LOCK_EX) + kept = file.read.split("\n").last(MAX - 1) + kept << entry + file.rewind + file.truncate(0) + file.write(kept.join("\n") << "\n") + end + rescue StandardError => e + Rails.logger.warn("TrafficLog.record failed: #{e.message}") + nil + end + + def clear + File.open(PATH, File::RDWR | File::CREAT, 0o644) do |file| + file.flock(File::LOCK_EX) + file.truncate(0) + end + rescue StandardError => e + Rails.logger.warn("TrafficLog.clear failed: #{e.message}") + nil + end + + def recent + return [] unless File.exist?(PATH) + + lines = File.open(PATH, File::RDONLY) do |file| + file.flock(File::LOCK_SH) + file.read.split("\n") + end + + lines.reverse.filter_map do |line| + JSON.parse(line) + rescue JSON::ParserError + nil + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/models/application_record.rb b/rm-mqtt-ws-bridge/rm/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/rm-mqtt-ws-bridge/rm/app/models/concerns/.keep b/rm-mqtt-ws-bridge/rm/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/rm-mqtt-ws-bridge/rm/app/models/resource.rb b/rm-mqtt-ws-bridge/rm/app/models/resource.rb new file mode 100644 index 0000000..e488f19 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/models/resource.rb @@ -0,0 +1,16 @@ +class Resource < ApplicationRecord + ALMOST_15_MINUTES = 888.seconds + + validates :s2_identifier, presence: true, uniqueness: true + + scope :active, -> { where(disconnected_at: nil) } + scope :needing_heartbeat, -> { where(last_power_measurement_at: [nil, ...ALMOST_15_MINUTES.ago]) } + + def record_power_measurement! + update!(last_power_measurement_at: Time.current) + end + + def active? + disconnected_at.blank? + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/subscribers/log_mqtt_listener_subscriber.rb b/rm-mqtt-ws-bridge/rm/app/subscribers/log_mqtt_listener_subscriber.rb new file mode 100644 index 0000000..3754a12 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/subscribers/log_mqtt_listener_subscriber.rb @@ -0,0 +1,56 @@ +class LogMQTTListenerSubscriber < ActiveSupport::Subscriber + attach_to :"listener.mqtt.stekker" + + def starting(_event) + Rails.logger.info("[#{self.class.name}] Starting MQTT listener") + end + + def connected(_event) + Rails.logger.info("[#{self.class.name}] MQTT listener connected") + end + + def subscribed(event) + topics = event.payload[:topics] + Rails.logger.info("[#{self.class.name}] Subscribed to MQTT topics: #{topics}") + end + + def message_received(event) + topic = event.payload[:topic] + message = event.payload[:message] + + mqtt_logger.log_inbound(topic:, message:) + end + + def listen_errored(event) + Rails.logger.error("[#{self.class.name}] MQTT listener error") + ErrorNotifier.notify_error(event.payload[:exception]) + end + + def connection_errored(event) + Rails.logger.error("[#{self.class.name}] MQTT connection error") + ErrorNotifier.notify_error(event.payload[:exception]) + end + + def get_errored(event) + Rails.logger.error("[#{self.class.name}] MQTT get error") + ErrorNotifier.notify_error(event.payload[:exception]) + end + + def graceful_shutdown(_event) + Rails.logger.info("[#{self.class.name}] MQTT listener shutting down gracefully") + end + + def thread_exited(_event) + Rails.logger.info("[#{self.class.name}] MQTT listener thread exited") + end + + def stopped(_event) + Rails.logger.info("[#{self.class.name}] MQTT listener stopped") + end + + private + + def mqtt_logger + MQTTMessageLogger.new(Rails.configuration.ecs_logger) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/subscribers/log_mqtt_message_handler_subscriber.rb b/rm-mqtt-ws-bridge/rm/app/subscribers/log_mqtt_message_handler_subscriber.rb new file mode 100644 index 0000000..25c31cd --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/subscribers/log_mqtt_message_handler_subscriber.rb @@ -0,0 +1,8 @@ +class LogMQTTMessageHandlerSubscriber < ActiveSupport::Subscriber + attach_to :"message_handler.mqtt.stekker" + + def message_errored(event) + Rails.logger.error("[#{self.class.name}] MQTT message handling error") + ErrorNotifier.notify_error(event.payload[:exception]) + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/subscribers/log_session_subscriber.rb b/rm-mqtt-ws-bridge/rm/app/subscribers/log_session_subscriber.rb new file mode 100644 index 0000000..251c5ff --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/subscribers/log_session_subscriber.rb @@ -0,0 +1,67 @@ +class LogSessionSubscriber < ActiveSupport::Subscriber + attach_to :"session.s2" + + def connected(event) + Rails.logger.info( + "[#{self.class.name}] [#{event.payload[:resource_id]}] S2 Session connected to #{event.payload[:url]}", + ) + end + + def started(event) + Rails.logger.info("[#{self.class.name}] [#{event.payload[:resource_id]}] S2 Session started") + end + + def disconnected(event) + Rails.logger.info("[#{self.class.name}] [#{event.payload[:resource_id]}] S2 Session disconnected") + end + + def stopped(event) + Rails.logger.info("[#{self.class.name}] [#{event.payload[:resource_id]}] S2 Session stopped") + end + + def message_received(event) + payload = event.payload[:payload] + resource_id = event.payload[:resource_id] + + s2_logger.log_inbound(resource_id:, message: payload) + end + + def message_sent(event) + payload = event.payload[:payload] + resource_id = event.payload[:resource_id] + + s2_logger.log_outbound(resource_id:, message: payload) + end + + def connection_errored(event) + exception = event.payload[:exception] + resource_id = event.payload[:resource_id] + + ErrorNotifier.notify_error( + exception, + extra: response_details(exception), + tags: { resource_id: resource_id }.compact, + ) + end + + def message_errored(event) + ErrorNotifier.notify_error(event.payload[:exception]) + end + + private + + def s2_logger + S2MessageLogger.new(Rails.configuration.ecs_logger) + end + + def response_details(exception) + return {} unless exception.is_a?(Async::WebSocket::ConnectionError) && exception.response + + response = exception.response + { + http_status: response.status, + http_headers: response.headers.to_h.transform_values { |value| Array(value).join(", ") }, + http_body: response.body&.read&.byteslice(0, 1024), + }.compact + end +end diff --git a/rm-mqtt-ws-bridge/rm/app/views/layouts/bridge.html.erb b/rm-mqtt-ws-bridge/rm/app/views/layouts/bridge.html.erb new file mode 100644 index 0000000..dec3ee0 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/views/layouts/bridge.html.erb @@ -0,0 +1,12 @@ + + + + + Resource Manager (bridge) + + + + + <%= yield %> + + diff --git a/rm-mqtt-ws-bridge/rm/app/views/resources/index.html.erb b/rm-mqtt-ws-bridge/rm/app/views/resources/index.html.erb new file mode 100644 index 0000000..943418e --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/app/views/resources/index.html.erb @@ -0,0 +1,133 @@ +
+
+
S2 RM to MQTT Bridgethe reference implementation
+
Speaks MQTT to the charger and S2 to the CEM, and translates between them both ways.
+
+ +
+
+
+ + +
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.

+
+
+ + + + diff --git a/rm-mqtt-ws-bridge/rm/public/422.html b/rm-mqtt-ws-bridge/rm/public/422.html new file mode 100644 index 0000000..56b9409 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/public/422.html @@ -0,0 +1,120 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

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.

+
+
+ + + + diff --git a/rm-mqtt-ws-bridge/rm/public/500.html b/rm-mqtt-ws-bridge/rm/public/500.html new file mode 100644 index 0000000..ef5ea47 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/public/500.html @@ -0,0 +1,120 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/rm-mqtt-ws-bridge/rm/public/icon.png b/rm-mqtt-ws-bridge/rm/public/icon.png new file mode 100644 index 0000000..9b1592e Binary files /dev/null and b/rm-mqtt-ws-bridge/rm/public/icon.png differ diff --git a/rm-mqtt-ws-bridge/rm/public/icon.svg b/rm-mqtt-ws-bridge/rm/public/icon.svg new file mode 100644 index 0000000..eb5111d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/public/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/rm-mqtt-ws-bridge/rm/public/robots.txt b/rm-mqtt-ws-bridge/rm/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/rm-mqtt-ws-bridge/rm/public/tokens.css b/rm-mqtt-ws-bridge/rm/public/tokens.css new file mode 100644 index 0000000..4fc9565 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/public/tokens.css @@ -0,0 +1,174 @@ +:root { + --bg: #eef1f5; + --panel: #ffffff; + --panel-2: #f3f6f9; + --border: #dce2e9; + --border-strong: #c5cdd7; + --text: #1b2733; + --muted: #51606f; + --faint: #6b7685; + + --brand: #0d8f61; + --brand-ink: #0a7a52; + --brand-deep: #08643f; + --brand-soft: #e6f6ef; + + --report: #2563b0; + --report-ink: #1f4f8f; + --report-soft: #e8effb; + + --command: #a85c0a; + --command-ink: #8a4c08; + --command-soft: #fbf0df; + + --setup: #6b7685; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --radius: 10px; + --radius-sm: 7px; + --gap: 14px; + --header-min: 84px; + --primary-min: 76px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +.panel { + display: flex; + flex-direction: column; + height: 100vh; + padding: var(--gap); + gap: var(--gap); + background: var(--bg); +} + +.panel-header { + display: flex; + flex-direction: column; + gap: 4px; + min-height: var(--header-min); + padding-bottom: var(--gap); + border-bottom: 1px solid var(--border); +} + +.primary { + display: flex; + flex-direction: column; + gap: 10px; + min-height: var(--primary-min); +} + +.panel-title { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.1px; + color: var(--text); +} + +.panel-role { + font-size: 12.5px; + line-height: 1.45; + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.panel-tag { + margin-left: 9px; + font-size: 12px; + font-weight: 550; + letter-spacing: 0; + color: var(--faint); +} + +.panel-tag.is-reference { + color: var(--text); + font-weight: 650; +} + +.section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.7px; + font-weight: 650; + color: var(--faint); + margin: 2px 0; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.btn { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 8px 12px; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; +} + +.btn:hover { border-color: var(--brand); background: var(--brand-soft); } +.btn:active { background: var(--panel-2); } +.btn:disabled { opacity: 0.55; cursor: default; } +.btn-primary { + border-color: var(--brand-ink); + background: var(--brand-ink); + color: #ffffff; +} +.btn-primary:hover { background: var(--brand-deep); border-color: var(--brand-deep); } + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} + +.feed { + flex: 1; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 13px; + line-height: 1.5; +} + +.empty { color: var(--faint); font-style: italic; } + +.tstamp { + font-family: var(--mono); + font-size: 10.5px; + color: var(--faint); + white-space: nowrap; +} + +.statusline { + display: flex; + flex-wrap: wrap; + gap: 3px 20px; + font-size: 12.5px; + color: var(--muted); +} +.statusline .live { color: var(--brand-ink); font-weight: 600; } diff --git a/rm-mqtt-ws-bridge/rm/script/.keep b/rm-mqtt-ws-bridge/rm/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/resources.rb b/rm-mqtt-ws-bridge/rm/spec/factories/resources.rb new file mode 100644 index 0000000..d8c855c --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/resources.rb @@ -0,0 +1,32 @@ +FactoryBot.define do + factory :resource do + s2_identifier { SecureRandom.uuid } + configuration do + build(:stekker_mqtt_connect).data.to_dynamic + end + active + + trait :one_phase do + configuration do + build( + :stekker_mqtt_connect, + data: { + name: "My charger - #1", + voltage: 230, + number_of_phases: 1, + minimum_current: 0, + maximum_current: 16, + }, + ).data.to_dynamic + end + end + + trait :active do + disconnected_at { nil } + end + + trait :inactive do + disconnected_at { Time.current } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/frbc/instruction.rb b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/frbc/instruction.rb new file mode 100644 index 0000000..27f887d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/frbc/instruction.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :s2_frbc_instruction, class: "S2::Messages::FRBCInstruction" do + initialize_with { S2::Messages::FRBCInstruction.from_dynamic!(attributes.with_indifferent_access) } + + message_id { generate(:uuid) } + message_type { S2::Messages::FRBCInstructionMessageType::FRBCInstruction } + + abnormal_condition { false } + actuator_id { generate(:uuid) } + execution_time { Time.current.iso8601 } + id { generate(:uuid) } + operation_mode { generate(:uuid) } + operation_mode_factor { 1 } + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/handshake.rb b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/handshake.rb new file mode 100644 index 0000000..205bbe4 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/handshake.rb @@ -0,0 +1,17 @@ +FactoryBot.define do + factory :s2_handshake, class: "S2::Messages::Handshake" do + initialize_with { S2::Messages::Handshake.from_dynamic!(attributes.with_indifferent_access) } + message_id { generate(:uuid) } + message_type { S2::Messages::HandshakeMessageType::Handshake } + cem + supported_protocol_versions { S2.supported_protocol_versions } + + trait :cem do + role { S2::Messages::EnergyManagementRole::Cem } + end + + trait :rm do + role { S2::Messages::EnergyManagementRole::Rm } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/handshake_response.rb b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/handshake_response.rb new file mode 100644 index 0000000..a5dccf1 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/handshake_response.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :s2_handshake_response, class: "S2::Messages::HandshakeResponse" do + initialize_with { S2::Messages::HandshakeResponse.from_dynamic!(attributes.with_indifferent_access) } + + message_id { generate(:uuid) } + message_type { S2::Messages::HandshakeResponseMessageType::HandshakeResponse } + selected_protocol_version { "0.0.2-beta" } + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/reception_status.rb b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/reception_status.rb new file mode 100644 index 0000000..81acd96 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/reception_status.rb @@ -0,0 +1,33 @@ +FactoryBot.define do + factory :s2_reception_status, class: "S2::Messages::ReceptionStatus" do + initialize_with do + S2::Messages::ReceptionStatus.from_dynamic!( + attributes.stringify_keys, + ) + end + + message_type { S2::Messages::ReceptionStatusMessageType::ReceptionStatus } + subject_message_id { "00000000-0000-0000-0000-000000000000" } + ok + + trait :invalid_content do + status { S2::Messages::ReceptionStatusValues::InvalidContent } + end + + trait :invalid_data do + status { S2::Messages::ReceptionStatusValues::InvalidData } + end + + trait :invalid_message do + status { S2::Messages::ReceptionStatusValues::InvalidMessage } + end + + trait :ok do + status { S2::Messages::ReceptionStatusValues::Ok } + end + + trait :permanent_error do + status { S2::Messages::ReceptionStatusValues::PermanentError } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/select_control_type.rb b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/select_control_type.rb new file mode 100644 index 0000000..6f7cd7d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/s2/messages/select_control_type.rb @@ -0,0 +1,39 @@ +FactoryBot.define do + factory :s2_select_control_type, class: "S2::Messages::SelectControlType" do + initialize_with do + S2::Messages::SelectControlType.from_dynamic!(attributes.with_indifferent_access) + end + + message_id { generate(:uuid) } + message_type { S2::Messages::SelectControlTypeMessageType::SelectControlType } + frbc + + trait :frbc do + control_type { S2::Messages::ControlType::FillRateBasedControl } + end + + trait :ddbc do + control_type { S2::Messages::ControlType::DemandDrivenBasedControl } + end + + trait :no_selection do + control_type { S2::Messages::ControlType::NoSelection } + end + + trait :not_controlable do + control_type { S2::Messages::ControlType::NotControlable } + end + + trait :ombc do + control_type { S2::Messages::ControlType::OperationModeBasedControl } + end + + trait :pebc do + control_type { S2::Messages::ControlType::PowerEnvelopeBasedControl } + end + + trait :ppbc do + control_type { S2::Messages::ControlType::PowerProfileBasedControl } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/factories/uuid.rb b/rm-mqtt-ws-bridge/rm/spec/factories/uuid.rb new file mode 100644 index 0000000..57026f7 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/factories/uuid.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + sequence :uuid do |_n| + SecureRandom.uuid + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/jobs/process_frbc_instruction_job_spec.rb b/rm-mqtt-ws-bridge/rm/spec/jobs/process_frbc_instruction_job_spec.rb new file mode 100644 index 0000000..9dcb7c4 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/jobs/process_frbc_instruction_job_spec.rb @@ -0,0 +1,154 @@ +describe ProcessFRBCInstructionJob do + describe "#perform" do + it "publishes an MQTT power_setpoint message when charging" do + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + frbc_instruction = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: frbc_system_description.actuators.first.operation_modes.second.id, # Charging mode + operation_mode_factor: 0.5, + execution_time: "2024-01-01T12:00:00Z", + ) + + with_fake_mqtt_client do |mqtt_client| + described_class.perform_now( + resource_id: resource.s2_identifier, + frbc_system_description_json: frbc_system_description.to_json, + frbc_instruction_json: frbc_instruction.to_json, + ) + + expect(mqtt_client).to have_published_mqtt_message( + topic: "stekker/resources/#{resource.s2_identifier}/power_setpoints", + message: { + data: { + ends_at: "2024-01-02T00:00:00Z", + reference: frbc_instruction.id, + starts_at: "2024-01-01T12:00:00Z", + unit: "W", + value: 5_520, + }, + message_type: "power_setpoint", + resource_id: resource.s2_identifier, + }, + retain: false, + qos: 1, + ) + end + end + + it "publishes an MQTT power_setpoint message when charging with power floor set" do + resource = create( + :resource, + configuration: build(:stekker_mqtt_connect).data.to_dynamic.tap do |config| + config[:minimum_current] = 6 + end, + ) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + frbc_instruction = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: frbc_system_description.actuators.first.operation_modes.second.id, # Charging mode + operation_mode_factor: 0.5, + execution_time: "2024-01-01T12:00:00Z", + ) + + with_fake_mqtt_client do |mqtt_client| + described_class.perform_now( + resource_id: resource.s2_identifier, + frbc_system_description_json: frbc_system_description.to_json, + frbc_instruction_json: frbc_instruction.to_json, + ) + + expect(mqtt_client).to have_published_mqtt_message( + topic: "stekker/resources/#{resource.s2_identifier}/power_setpoints", + message: { + data: { + reference: frbc_instruction.id, + starts_at: "2024-01-01T12:00:00Z", + ends_at: "2024-01-02T00:00:00Z", + unit: "W", + value: 7_590, + }, + message_type: "power_setpoint", + resource_id: resource.s2_identifier, + }, + retain: false, + qos: 1, + ) + end + end + + it "publishes an MQTT power_setpoint message when off" do + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + frbc_instruction = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: frbc_system_description.actuators.first.operation_modes.first.id, # Off mode + operation_mode_factor: 1.0, + ) + + with_fake_mqtt_client do |mqtt_client| + described_class.perform_now( + resource_id: resource.s2_identifier, + frbc_system_description_json: frbc_system_description.to_json, + frbc_instruction_json: frbc_instruction.to_json, + ) + + expect(mqtt_client).to have_published_mqtt_message( + topic: "stekker/resources/#{resource.s2_identifier}/power_setpoints", + message: { + data: { + ends_at: (Time.zone.parse(frbc_instruction.execution_time) + 12.hours).iso8601, + reference: frbc_instruction.id, + starts_at: frbc_instruction.execution_time, + unit: "W", + value: 0, + }, + message_type: "power_setpoint", + resource_id: resource.s2_identifier, + }, + retain: false, + qos: 1, + ) + end + end + + it "publishes an MQTT instruction_update message with status ACCEPTED" do + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + frbc_instruction = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: frbc_system_description.actuators.first.operation_modes.second.id, # Charging mode + operation_mode_factor: 0.5, + ) + + freeze_time do + with_fake_mqtt_client do |mqtt_client| + described_class.perform_now( + resource_id: resource.s2_identifier, + frbc_system_description_json: frbc_system_description.to_json, + frbc_instruction_json: frbc_instruction.to_json, + ) + + expect(mqtt_client).to have_published_mqtt_message( + topic: "stekker/resources/#{resource.s2_identifier}/instruction_updates", + message: { + data: { + reference: frbc_instruction.id, + updated_at: Time.current.iso8601, + status: Stekker::MQTT::Messages::Status::Accepted, + }, + message_type: "instruction_update", + resource_id: resource.s2_identifier, + }, + retain: false, + qos: 1, + ) + end + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/jobs/publish_s2_ready_notification_job_spec.rb b/rm-mqtt-ws-bridge/rm/spec/jobs/publish_s2_ready_notification_job_spec.rb new file mode 100644 index 0000000..acf3d3d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/jobs/publish_s2_ready_notification_job_spec.rb @@ -0,0 +1,21 @@ +describe PublishS2ReadyNotificationJob do + describe "#perform" do + it "publishes an MQTT s2_ready_notification message" do + resource = create(:resource) + + with_fake_mqtt_client do |mqtt_client| + described_class.perform_now(resource_id: resource.s2_identifier) + + expect(mqtt_client).to have_published_mqtt_message( + topic: "stekker/resources/#{resource.s2_identifier}/s2_ready_notifications", + message: { + message_type: "s2_ready_notification", + resource_id: resource.s2_identifier, + }, + retain: false, + qos: 0, + ) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/jobs/send_heartbeat_power_measurement_job_spec.rb b/rm-mqtt-ws-bridge/rm/spec/jobs/send_heartbeat_power_measurement_job_spec.rb new file mode 100644 index 0000000..121c70d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/jobs/send_heartbeat_power_measurement_job_spec.rb @@ -0,0 +1,26 @@ +describe SendHeartbeatPowerMeasurementJob do + it "publishes a zero-value MQTT power measurement" do + resource = create(:resource, :active) + + freeze_time do + with_fake_mqtt_client do |mqtt_client| + described_class.perform_now(resource_id: resource.s2_identifier) + + expect(mqtt_client).to have_published_mqtt_message( + topic: "stekker/resources/#{resource.s2_identifier}/power_measurements", + message: { + message_type: "power_measurement", + resource_id: resource.s2_identifier, + data: { + sampled_at: Time.current.iso8601, + unit: "W", + value: 0, + }, + }, + retain: false, + qos: 0, + ) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/jobs/send_heartbeat_power_measurements_job_spec.rb b/rm-mqtt-ws-bridge/rm/spec/jobs/send_heartbeat_power_measurements_job_spec.rb new file mode 100644 index 0000000..8615d4f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/jobs/send_heartbeat_power_measurements_job_spec.rb @@ -0,0 +1,39 @@ +describe SendHeartbeatPowerMeasurementsJob do + it "enqueues heartbeat jobs for active resources needing a heartbeat" do + create(:resource, :active, last_power_measurement_at: nil) + _recent = create(:resource, :active, last_power_measurement_at: 800.seconds.ago) + _inactive = create(:resource, :inactive) + + allow(ActiveJob).to receive(:perform_all_later) + + described_class.perform_now + + expect(ActiveJob).to have_received(:perform_all_later).with( + [an_instance_of(SendHeartbeatPowerMeasurementJob)], + ) + end + + it "does not enqueue heartbeats when all resources have recent measurements" do + create(:resource, :active, last_power_measurement_at: 800.seconds.ago) + create(:resource, :active, last_power_measurement_at: 5.minutes.ago) + + allow(ActiveJob).to receive(:perform_all_later) + + described_class.perform_now + + expect(ActiveJob).to have_received(:perform_all_later).with([]) + end + + it "enqueues a heartbeat for a resource with a stale power measurement" do + create(:resource, :active, last_power_measurement_at: 20.minutes.ago) + _recent = create(:resource, :active, last_power_measurement_at: 800.seconds.ago) + + allow(ActiveJob).to receive(:perform_all_later) + + described_class.perform_now + + expect(ActiveJob).to have_received(:perform_all_later).with( + [an_instance_of(SendHeartbeatPowerMeasurementJob)], + ) + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/base_factory_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/base_factory_spec.rb new file mode 100644 index 0000000..0cb62d3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/base_factory_spec.rb @@ -0,0 +1,12 @@ +describe BaseFactory do + describe ".commodity_quantity_for" do + it "always returns 3_PHASE_SYMMETRIC regardless of number_of_phases" do + [1, 2, 3].each do |phases| + resource = build(:resource, configuration: { "number_of_phases" => phases }) + + expect(described_class.commodity_quantity_for(resource)) + .to eq(S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/error_notifier_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/error_notifier_spec.rb new file mode 100644 index 0000000..f7326c6 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/error_notifier_spec.rb @@ -0,0 +1,35 @@ +describe ErrorNotifier do + describe "::notify_error" do + let(:error) do + raise "Test error" + rescue StandardError => e + e + end + + it "logs the error message and backtrace locally" do + allow(Rails.logger).to receive(:error) + + described_class.notify_error(error) + + expect(Rails.logger).to have_received(:error).with("RuntimeError: Test error") + expect(Rails.logger).to have_received(:error).with(error.backtrace.join("\n")) + end + + it "omits the backtrace outside of local environments" do + allow(Rails.logger).to receive(:error) + allow(Rails.env).to receive(:local?).and_return(false) + + described_class.notify_error(error) + + expect(Rails.logger).to have_received(:error).with("RuntimeError: Test error") + expect(Rails.logger).not_to have_received(:error).with(error.backtrace.join("\n")) + end + + it "accepts extra context and tags without raising" do + allow(Rails.logger).to receive(:error) + + expect { described_class.notify_error(error, extra: { http_status: 404 }, tags: { resource_id: "RM-1" }) } + .not_to raise_error + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/instruction_status_tracker_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/instruction_status_tracker_spec.rb new file mode 100644 index 0000000..17e4fd8 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/instruction_status_tracker_spec.rb @@ -0,0 +1,53 @@ +describe InstructionStatusTracker do + describe "#already_relayed?" do + it "returns false for an unseen combination" do + tracker = described_class.new + + result = tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED") + + expect(result).to be false + end + + it "returns true for a previously seen combination" do + tracker = described_class.new + tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED") + + result = tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED") + + expect(result).to be true + end + + it "distinguishes different statuses for the same instruction" do + tracker = described_class.new + tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED") + + result = tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "SUCCEEDED") + + expect(result).to be false + end + + it "distinguishes different resources" do + tracker = described_class.new + tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED") + + result = tracker.already_relayed?(resource_id: "res-2", instruction_id: "instr-1", status: "STARTED") + + expect(result).to be false + end + end + + describe "#clear_resource" do + it "removes all tracked entries for a resource" do + tracker = described_class.new + tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED") + tracker.already_relayed?(resource_id: "res-2", instruction_id: "instr-2", status: "STARTED") + + tracker.clear_resource("res-1") + + expect(tracker.already_relayed?(resource_id: "res-1", instruction_id: "instr-1", status: "STARTED")) + .to be false + expect(tracker.already_relayed?(resource_id: "res-2", instruction_id: "instr-2", status: "STARTED")) + .to be true + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/message_relayer_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/message_relayer_spec.rb new file mode 100644 index 0000000..fa134af --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/message_relayer_spec.rb @@ -0,0 +1,50 @@ +describe MessageRelayer do + describe "#relay_message" do + it "sends the message to the found connected connection" do + resource_id = "resource-123" + connection = instance_double(S2::Connection) + allow(connection).to receive(:send_message) + allow(connection).to receive(:status).and_return(:connected) + connection_registry = ConnectionRegistry.new + connection_registry.register_connection("resource-123", connection) + relayer = described_class.new(connection_registry) + message = double("S2 message", message_type: "PowerMeasurement", to_json: "{}") + + relayer.relay_message(resource_id:, message:) + + expect(connection).to have_received(:send_message).with(message) + end + + it "logs a warning if no connection is registered" do + resource_id = "nonexistent-resource" + connection_registry = ConnectionRegistry.new + relayer = described_class.new(connection_registry) + message = double("S2 message", message_type: "PowerMeasurement", to_json: "{}") + allow(Rails.logger).to receive(:warn) + + relayer.relay_message(resource_id:, message:) + + expect(Rails.logger) + .to have_received(:warn) + .with("[MessageRelayer] No connected connection found for resource_id #{resource_id}") + end + + it "logs a warning if registered connection is not connected" do + resource_id = "resource-123" + connection = instance_double(S2::Connection) + allow(connection).to receive(:send_message) + allow(connection).to receive(:status).and_return(:disconnected) + + connection_registry = ConnectionRegistry.new + relayer = described_class.new(connection_registry) + message = double("S2 message", message_type: "PowerMeasurement", to_json: "{}") + allow(Rails.logger).to receive(:warn) + + relayer.relay_message(resource_id:, message:) + + expect(Rails.logger) + .to have_received(:warn) + .with("[MessageRelayer] No connected connection found for resource_id #{resource_id}") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/mqtt_message_handler_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/mqtt_message_handler_spec.rb new file mode 100644 index 0000000..83c2629 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/mqtt_message_handler_spec.rb @@ -0,0 +1,489 @@ +describe MQTTMessageHandler do + describe "connect" do + it "registers a new Resource" do + valid_message = build(:stekker_mqtt_connect) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + expect { handler.handle_message(valid_message.to_json) } + .to change(Resource, :count).by(1) + + resource = Resource.last + expect(resource.s2_identifier).to eq(valid_message.resource_id) + expect(resource.configuration).to eq(valid_message.data.to_dynamic) + end + + it "updates an existing Resource" do + existing_resource = create(:resource, configuration: { old: "configuration" }, disconnected_at: Time.current) + valid_message = build(:stekker_mqtt_connect, resource_id: existing_resource.s2_identifier) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + expect { handler.handle_message(valid_message.to_json) } + .not_to change(Resource, :count) + + existing_resource.reload + expect(existing_resource.configuration).to eq(valid_message.data.to_dynamic) + expect(existing_resource.disconnected_at).to be_nil + end + + it "reconnects the S2 connection" do + valid_message = build(:stekker_mqtt_connect) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + supervisor = instance_double(Supervisor) + allow(Supervisor).to receive(:instance).and_return(supervisor) + allow(supervisor).to receive(:find_connection).and_return(nil) + allow(supervisor).to receive(:reconnect) + + handler.handle_message(valid_message.to_json) + + expect(supervisor).to have_received(:reconnect).with(valid_message.resource_id) + end + + it "leaves a live session alone when the same connect repeats" do + valid_message = build(:stekker_mqtt_connect) + existing_resource = create( + :resource, + s2_identifier: valid_message.resource_id, + configuration: valid_message.data.to_dynamic, + disconnected_at: nil, + ) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + supervisor = instance_double(Supervisor) + connection = instance_double(S2::Connection, status: :connected) + allow(Supervisor).to receive(:instance).and_return(supervisor) + allow(supervisor).to receive(:find_connection).with(existing_resource.s2_identifier).and_return(connection) + allow(supervisor).to receive(:reconnect) + + handler.handle_message(valid_message.to_json) + + expect(supervisor).not_to have_received(:reconnect) + end + + it "reconnects a live session when the configuration changes" do + valid_message = build(:stekker_mqtt_connect) + existing_resource = create( + :resource, + s2_identifier: valid_message.resource_id, + configuration: { "name" => "Old charger", "voltage" => 230 }, + disconnected_at: nil, + ) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + supervisor = instance_double(Supervisor) + connection = instance_double(S2::Connection, status: :connected) + allow(Supervisor).to receive(:instance).and_return(supervisor) + allow(supervisor).to receive(:find_connection).with(existing_resource.s2_identifier).and_return(connection) + allow(supervisor).to receive(:reconnect) + + handler.handle_message(valid_message.to_json) + + expect(supervisor).to have_received(:reconnect).with(valid_message.resource_id) + end + end + + describe "disconnect" do + it "marks the Resource as disconnected" do + existing_resource = create(:resource, disconnected_at: nil) + valid_message = build(:stekker_mqtt_disconnect, resource_id: existing_resource.s2_identifier) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + freeze_time do + expect { handler.handle_message(valid_message.to_json) } + .to change { existing_resource.reload.disconnected_at }.from(nil).to(Time.current) + end + end + + it "disconnects the S2 connection" do + existing_resource = create(:resource) + valid_message = build(:stekker_mqtt_disconnect, resource_id: existing_resource.s2_identifier) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + supervisor = instance_double(Supervisor) + allow(Supervisor).to receive(:instance).and_return(supervisor) + allow(supervisor).to receive(:disconnect) + + handler.handle_message(valid_message.to_json) + + expect(supervisor).to have_received(:disconnect).with(existing_resource.s2_identifier) + end + + it "does nothing if the Resource does not exist" do + valid_message = build(:stekker_mqtt_disconnect) + message_relayer = instance_double(MessageRelayer) + handler = described_class.new(message_relayer:) + + expect { handler.handle_message(valid_message.to_json) } + .not_to change(Resource, :count) + end + + it "clears tracked instruction statuses for the disconnected resource" do + existing_resource = create(:resource, disconnected_at: nil) + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + instruction_status_tracker = InstructionStatusTracker.new + handler = described_class.new(message_relayer:, instruction_status_tracker:) + + instruction_update = build(:stekker_mqtt_instruction_update, resource_id: existing_resource.s2_identifier) + handler.handle_message(instruction_update.to_json) + + supervisor = instance_double(Supervisor) + allow(Supervisor).to receive(:instance).and_return(supervisor) + allow(supervisor).to receive(:disconnect) + + disconnect_message = build(:stekker_mqtt_disconnect, resource_id: existing_resource.s2_identifier) + handler.handle_message(disconnect_message.to_json) + + handler.handle_message(instruction_update.to_json) + + expect(message_relayer).to have_received(:relay_message).twice + end + end + + describe "power_measurement" do + it "relays a 3-phase power measurement with ElectricPower3_PhaseSymmetric" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_power_measurement) + create(:resource, s2_identifier: valid_message.resource_id) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(resource_id).to eq(valid_message.resource_id) + expect(message).to be_a(S2::Messages::PowerMeasurement) + expect(message.attributes).to include( + message_type: S2::Messages::PowerMeasurementMessageType::PowerMeasurement, + measurement_timestamp: valid_message.data.sampled_at, + values: [ + a_hash_including( + commodity_quantity: S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + value: 1500, + ), + ], + ) + end + end + + it "still emits ElectricPower3_PhaseSymmetric for a 1-phase resource (hardcoded in BaseFactory)" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_power_measurement) + create(:resource, :one_phase, s2_identifier: valid_message.resource_id) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(message.attributes).to include( + values: [ + a_hash_including( + commodity_quantity: S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + value: 1500, + ), + ], + ) + end + end + + it "records the power measurement timestamp on the resource" do + resource = create(:resource) + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_power_measurement, resource_id: resource.s2_identifier) + + freeze_time do + handler.handle_message(valid_message.to_json) + + expect(resource.reload.last_power_measurement_at).to eq(Time.current) + end + end + + it "instruments an error for invalid unit" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + allow(ActiveSupport::Notifications).to receive(:instrument) + handler = described_class.new(message_relayer:) + handler.raise_handling_errors = false + + invalid_message = <<~JSON + { + "message_type": "power_measurement", + "resource_id": "#{generate(:uuid)}", + "data": { + "sampled_at": "#{Time.current.iso8601}", + "value": 1500, + "unit": "kW" + } + } + JSON + + handler.handle_message(invalid_message) + + expect(message_relayer).not_to have_received(:relay_message) + expect(ActiveSupport::Notifications).to have_received(:instrument) do |event_name, payload| + expect(event_name).to eq("message_errored.message_handler.mqtt.stekker") + expect(payload[:payload]).to eq(invalid_message) + expect(payload[:exception]).to be_a(Dry::Struct::Error) + expect(payload[:exception].message).to match(/invalid type for.+unit/i) + end + end + end + + describe "power_forecast" do + it "does not relay power forecast messages when provides_forecast is disabled" do + freeze_time + + original = Rails.configuration.s2.provides_forecast + Rails.configuration.s2.provides_forecast = false + + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_power_forecast) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).not_to have_received(:relay_message) + ensure + Rails.configuration.s2.provides_forecast = original + end + + it "relays a 3-phase power forecast with ElectricPower3_PhaseSymmetric" do + freeze_time + + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_power_forecast) + create(:resource, s2_identifier: valid_message.resource_id) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(resource_id).to eq(valid_message.resource_id) + expect(message).to be_a(S2::Messages::PowerForecast) + + expect(message).to have_attributes( + message_type: S2::Messages::PowerForecastMessageType::PowerForecast, + start_time: valid_message.data.start_at, + ) + + expect(message.elements[0]).to have_attributes( + duration: 10.minutes.in_milliseconds, + power_values: [ + an_object_having_attributes( + commodity_quantity: S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + value_expected: 11_040, + ), + ], + ) + + expect(message.elements[1]).to have_attributes( + duration: 20.minutes.in_milliseconds, + power_values: [ + an_object_having_attributes( + commodity_quantity: S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + value_expected: 6_720, + ), + ], + ) + + expect(message.elements[2]).to have_attributes( + duration: (23.hours + 25.minutes).in_milliseconds, + power_values: [ + an_object_having_attributes( + commodity_quantity: S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + value_expected: 0, + ), + ], + ) + end + end + + it "still emits ElectricPower3_PhaseSymmetric for a 1-phase resource (hardcoded in BaseFactory)" do + freeze_time + + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_power_forecast) + create(:resource, :one_phase, s2_identifier: valid_message.resource_id) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(message.elements[0]).to have_attributes( + power_values: [ + an_object_having_attributes( + commodity_quantity: S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + ), + ], + ) + end + end + + it "instruments an error for invalid unit" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + allow(ActiveSupport::Notifications).to receive(:instrument) + handler = described_class.new(message_relayer:) + handler.raise_handling_errors = false + + invalid_message = <<~JSON + { + "message_type": "power_forecast", + "resource_id": "#{generate(:uuid)}", + "data": { + "start_at": "#{Time.current.iso8601}", + "unit": "kW", + "values": [ + { "timestamp": "#{5.minutes.from_now.iso8601}", "value": 11040 }, + { "timestamp": "#{15.minutes.from_now.iso8601}", "value": 6720 }, + { "timestamp": "#{35.minutes.from_now.iso8601}", "value": 0 } + ] + } + } + JSON + + handler.handle_message(invalid_message) + + expect(message_relayer).not_to have_received(:relay_message) + expect(ActiveSupport::Notifications).to have_received(:instrument) do |event_name, payload| + expect(event_name).to eq("message_errored.message_handler.mqtt.stekker") + expect(payload[:payload]).to eq(invalid_message) + expect(payload[:exception]).to be_a(Dry::Struct::Error) + expect(payload[:exception].message).to match(/invalid type for.+unit/i) + end + end + end + + describe "charge_status" do + it "relays charge status messages as StorageStatus messages" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_charge_status) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(resource_id).to eq(valid_message.resource_id) + expect(message).to be_a(S2::Messages::FRBCStorageStatus) + expect(message.attributes).to include( + message_type: S2::Messages::FRBCStorageStatusMessageType::FRBCStorageStatus, + present_fill_level: valid_message.data.value, + ) + end + end + end + + describe "charge_target" do + it "relays charge target messages as FRBC.FillLevelTargetProfile messages" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_charge_target) + starts_at = Time.zone.parse(valid_message.data.starts_at) + ends_at = Time.zone.parse(valid_message.data.ends_at) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(resource_id).to eq(valid_message.resource_id) + expect(message).to be_a(S2::Messages::FRBCFillLevelTargetProfile) + expect(message.attributes).to include( + message_type: S2::Messages::FRBCFillLevelTargetProfileMessageType::FRBCFillLevelTargetProfile, + start_time: valid_message.data.starts_at, + ) + + expect(message.elements).to include( + an_object_having_attributes( + duration: (ends_at - starts_at - 15.minutes).in_milliseconds, + fill_level_range: an_object_having_attributes( + start_of_range: valid_message.data.direct_percentage, + end_of_range: valid_message.data.smart_percentage, + ), + ), + ) + + expect(message.elements).to include( + an_object_having_attributes( + duration: 15.minutes.in_milliseconds, + fill_level_range: an_object_having_attributes( + start_of_range: valid_message.data.smart_percentage, + end_of_range: valid_message.data.smart_percentage, + ), + ), + ) + end + end + end + + describe "instruction_update" do + it "relays instruction update messages as InstructionStatusUpdate messages" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + handler = described_class.new(message_relayer:) + + valid_message = build(:stekker_mqtt_instruction_update) + + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message) do |resource_id:, message:| + expect(resource_id).to eq(valid_message.resource_id) + expect(message).to be_a(S2::Messages::InstructionStatusUpdate) + expect(message.attributes).to include( + message_type: S2::Messages::InstructionStatusUpdateMessageType::InstructionStatusUpdate, + instruction_id: valid_message.data.reference, + timestamp: valid_message.data.updated_at, + status_type: valid_message.data.status, + ) + end + end + + it "skips duplicate instruction updates for the same resource, reference and status" do + message_relayer = instance_double(MessageRelayer) + allow(message_relayer).to receive(:relay_message) + instruction_status_tracker = InstructionStatusTracker.new + handler = described_class.new(message_relayer:, instruction_status_tracker:) + + valid_message = build(:stekker_mqtt_instruction_update) + + handler.handle_message(valid_message.to_json) + handler.handle_message(valid_message.to_json) + + expect(message_relayer).to have_received(:relay_message).once + end + end + + describe "database connection isolation" do + it "handles each message inside the Rails executor so it doesn't reuse a stale connection" do + valid_message = build(:stekker_mqtt_connect) + handler = described_class.new(message_relayer: instance_double(MessageRelayer)) + allow(Supervisor).to receive(:instance).and_return(instance_double(Supervisor, reconnect: nil, find_connection: nil)) + allow(Rails.application.executor).to receive(:wrap).and_call_original + + handler.handle_message(valid_message.to_json) + + expect(Rails.application.executor).to have_received(:wrap) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/mqtt_message_logger_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/mqtt_message_logger_spec.rb new file mode 100644 index 0000000..4d1bc7d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/mqtt_message_logger_spec.rb @@ -0,0 +1,58 @@ +describe MQTTMessageLogger do + describe "#log_inbound" do + it "logs inbound MQTT message with topic, resource_id, and message" do + logger = instance_double(EcsLogging::Logger, info: nil) + + described_class.new(logger).log_inbound( + topic: "stekker/resources/ABC123/measurements", + message: '{"state": "charging"}', + ) + + expect(logger).to have_received(:info).with( + "Inbound MQTT message", + mqtt: { + topic: "stekker/resources/ABC123/measurements", + resource_id: "ABC123", + message: '{"state": "charging"}', + }, + ) + end + + it "omits resource_id when topic does not match expected pattern" do + logger = instance_double(EcsLogging::Logger, info: nil) + + described_class.new(logger).log_inbound( + topic: "other/topic/format", + message: '{"data": "value"}', + ) + + expect(logger).to have_received(:info).with( + "Inbound MQTT message", + mqtt: { + topic: "other/topic/format", + message: '{"data": "value"}', + }, + ) + end + end + + describe "#log_outbound" do + it "logs outbound MQTT message with topic, resource_id, and message" do + logger = instance_double(EcsLogging::Logger, info: nil) + + described_class.new(logger).log_outbound( + topic: "stekker/resources/ABC123/connects", + message: '{"message_type": "connect"}', + ) + + expect(logger).to have_received(:info).with( + "Outbound MQTT message", + mqtt: { + topic: "stekker/resources/ABC123/connects", + resource_id: "ABC123", + message: '{"message_type": "connect"}', + }, + ) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/connection_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/connection_spec.rb new file mode 100644 index 0000000..7418db7 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/connection_spec.rb @@ -0,0 +1,65 @@ +describe S2::Connection, :async do + describe "#connect" do + it "uses exponential backoff for reconnection attempts up to 1 hour" do + ws = FakeWebSocket.new + connection_attempts = 0 + sleep_durations = [] + + allow(Async::WebSocket::Client).to receive(:connect) do |_endpoint, &block| + connection_attempts += 1 + raise Errno::ECONNREFUSED if connection_attempts < 15 + + block.call(ws) + end + + resource_id = SecureRandom.uuid + ws_url = "ws://example.com/#{resource_id}" + + connection = described_class.new(resource_id:, ws_url:) + + allow(connection).to receive(:sleep) { |duration| sleep_durations << duration } + + task = Async do + connection.connect + end + + Async::Task.current.sleep 0.1 + + connection.disconnect + task.stop + + expect(sleep_durations).to eq([5, 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600, 3600, 3600, 3600]) + end + + it "reuses the same endpoint across reconnection attempts" do + ws = FakeWebSocket.new + connection_attempts = [] + + allow(Async::WebSocket::Client).to receive(:connect) do |endpoint, &block| + connection_attempts << endpoint.object_id + raise Errno::ECONNREFUSED if connection_attempts.size < 3 + + block.call(ws) + end + + resource_id = SecureRandom.uuid + ws_url = "ws://example.com/#{resource_id}" + + connection = described_class.new(resource_id:, ws_url:) + + allow(connection).to receive(:sleep) + + task = Async do + connection.connect + end + + Async::Task.current.sleep 0.1 + + connection.disconnect + task.stop + + expect(connection_attempts.size).to be >= 3 + expect(connection_attempts.uniq.size).to eq(1) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_factory_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_factory_spec.rb new file mode 100644 index 0000000..1bbaddd --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_factory_spec.rb @@ -0,0 +1,72 @@ +describe S2::MessageFactory do + describe "::create_message" do + it "creates a message object from valid JSON data" do + json_data = <<~JSON + { + "message_type": "Handshake", + "message_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "role": "RM", + "supported_protocol_versions": ["1.0", "1.1"] + } + JSON + + message = described_class.create_message(json_data) + + expect(message).to be_a(S2::Messages::Handshake) + + expect(message).to have_attributes( + message_type: "Handshake", + message_id: "ce7b430c-25be-41ec-b97c-ac0258aff498", + role: "RM", + supported_protocol_versions: %w[1.0 1.1], + ) + end + + it "raises InvalidMessageFormatError for invalid JSON data" do + invalid_json_data = "{ invalid json }" + + expect { described_class.create_message(invalid_json_data) } + .to raise_error(S2::MessageFactory::InvalidMessageFormat, "Invalid JSON") + end + + it "raises MissingMessageTypeError when message_type is missing" do + json_data = <<~JSON + { + "message_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "role": "RM", + "supported_protocol_versions": ["1.0", "1.1"] + } + JSON + + expect { described_class.create_message(json_data) } + .to raise_error(S2::MessageFactory::MissingMessageType, "Message type not provided") + end + + it "raises UnsupportedMessageTypeError for unsupported message_type" do + json_data = <<~JSON + { + "message_type": "UnknownType", + "message_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "role": "RM", + "supported_protocol_versions": ["1.0", "1.1"] + } + JSON + + expect { described_class.create_message(json_data) } + .to raise_error(S2::MessageFactory::UnsupportedMessageType, /Message type not supported: UnknownType/) + end + + it "raises InvalidMessagePayloadError for missing required fields" do + json_data = <<~JSON + { + "message_type": "Handshake", + "message_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "supported_protocol_versions": ["1.0", "1.1"] + } + JSON + + expect { described_class.create_message(json_data) } + .to raise_error(S2::MessageFactory::InvalidMessagePayload, /Field missing in payload: role/) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/dispatching_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/dispatching_spec.rb new file mode 100644 index 0000000..f8ebfbc --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/dispatching_spec.rb @@ -0,0 +1,118 @@ +describe S2::MessageHandler::Dispatching do + describe "::on" do + it "registers a handler for the specified message class" do + handler_block = proc {} + + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + + on S2::Messages::Handshake, &handler_block + end + + expect(message_handler_class.handlers[S2::Messages::Handshake.name]).to eq(handler_block) + end + + it "raises an error when defining a second handler for the same message" do + handler_block = proc {} + + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + + on S2::Messages::Handshake, &handler_block + end + + expect { message_handler_class.on(S2::Messages::Handshake, &handler_block) } + .to raise_error(/Handler already registered for S2::Messages::Handshake/) + end + end + + describe "#dispatch_message" do + it "invokes the registered handler for the message" do + handler_invoked = false + handler_block = proc { |message| handler_invoked = message.instance_of?(S2::Messages::Handshake) } + + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + + on S2::Messages::Handshake, &handler_block + end + + handler = message_handler_class.new + message = build(:s2_handshake) + + handler.dispatch_message(message) + expect(handler_invoked).to be true + end + + it "raises an error if no handler is registered for the message" do + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + end + + handler = message_handler_class.new + message = build(:s2_handshake) + + expect { handler.dispatch_message(message) } + .to raise_error( + S2::MessageHandler::Dispatching::HandlerNotFound, + /No handler registered for S2::Messages::Handshake/, + ) + end + + it "raises an error if no handler is registered for a message without message_id" do + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + end + + handler = message_handler_class.new + message = build(:s2_reception_status) + + expect { handler.dispatch_message(message) } + .to raise_error( + S2::MessageHandler::Dispatching::HandlerNotFound, + /No handler registered for S2::Messages::ReceptionStatus/, + ) + end + + it "allows passing a custom type" do + handler_invoked = false + handler_block = proc { |message| handler_invoked = message.instance_of?(S2::Messages::Handshake) } + + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + + on "my_custom_message_type", &handler_block + end + + handler = message_handler_class.new + message = build(:s2_handshake) + + handler.dispatch_message(message, type: "my_custom_message_type") + expect(handler_invoked).to be true + end + + it "raises an ArgumentError when the specified type is nil" do + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + end + + handler = message_handler_class.new + message = build(:s2_handshake) + + expect { handler.dispatch_message(message, type: nil) } + .to raise_error(ArgumentError, "Missing message type") + end + + it "raises an ArgumentError when the specified type is blank" do + message_handler_class = Class.new do + include S2::MessageHandler::Dispatching + end + + handler = message_handler_class.new + message = build(:s2_handshake) + + expect { handler.dispatch_message(message, type: "") } + .to raise_error(ArgumentError, "Missing message type") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/error_handling_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/error_handling_spec.rb new file mode 100644 index 0000000..ae0e685 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/error_handling_spec.rb @@ -0,0 +1,234 @@ +describe S2::MessageHandler::ErrorHandling do + it "replies to InvalidMessageFormat with invalid_data status" do + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise S2::MessageFactory::InvalidMessageFormat.new("test_id", "Invalid format") + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + allow(message_sender).to receive(:send_message) + + handler.rescue_with_handler(S2::MessageFactory::InvalidMessageFormat.new("Invalid format", "test_id")) + + expect(message_sender).to have_received(:send_message) do |message| + expect(message).to be_a(S2::Messages::ReceptionStatus) + expect(message).to have_attributes( + subject_message_id: "test_id", + status: S2::Messages::ReceptionStatusValues::InvalidData, + diagnostic_label: "Invalid format", + ) + end + end + + it "replies to MissingMessageType with invalid_message status" do + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise S2::MessageFactory::MissingMessageType.new("test_id", "Missing type") + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + allow(message_sender).to receive(:send_message) + + handler.rescue_with_handler(S2::MessageFactory::MissingMessageType.new("Missing type", "test_id")) + + expect(message_sender).to have_received(:send_message) do |message| + expect(message).to be_a(S2::Messages::ReceptionStatus) + expect(message).to have_attributes( + subject_message_id: "test_id", + status: S2::Messages::ReceptionStatusValues::InvalidMessage, + diagnostic_label: "Missing type", + ) + end + end + + it "replies to InvalidMessagePayload with invalid_message status" do + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise S2::MessageFactory::InvalidMessagePayload.new("test_id", "Invalid payload") + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + allow(message_sender).to receive(:send_message) + + handler.rescue_with_handler(S2::MessageFactory::InvalidMessagePayload.new("Invalid payload", "test_id")) + + expect(message_sender).to have_received(:send_message) do |message| + expect(message).to be_a(S2::Messages::ReceptionStatus) + expect(message).to have_attributes( + subject_message_id: "test_id", + status: S2::Messages::ReceptionStatusValues::InvalidMessage, + diagnostic_label: "Invalid payload", + ) + end + end + + it "replies to UnsupportedMessageType with invalid_message status" do + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise S2::MessageFactory::UnsupportedMessageType.new("test_id", "Unsupported type") + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + allow(message_sender).to receive(:send_message) + + handler.rescue_with_handler(S2::MessageFactory::UnsupportedMessageType.new("Unsupported type", "test_id")) + + expect(message_sender).to have_received(:send_message) do |message| + expect(message).to be_a(S2::Messages::ReceptionStatus) + expect(message).to have_attributes( + subject_message_id: "test_id", + status: S2::Messages::ReceptionStatusValues::InvalidMessage, + diagnostic_label: "Unsupported type", + ) + end + end + + it "replies to HandlerNotFound with invalid_message status" do + instance_double(S2::Messages::Handshake, message_type: "UnknownType", message_id: "test_id") + + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise S2::MessageHandler::Dispatching::HandlerNotFound.new(message, "test_id") + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + + allow(message_sender).to receive(:send_message) + + handler.rescue_with_handler( + S2::MessageHandler::Dispatching::HandlerNotFound.new("Handler not found!"), + ) + + expect(message_sender).to have_received(:send_message) do |message| + expect(message).to be_a(S2::Messages::ReceptionStatus) + expect(message).to have_attributes( + subject_message_id: S2::MessageHandler::Replying::NULL_MESSAGE_ID, + status: S2::Messages::ReceptionStatusValues::InvalidMessage, + diagnostic_label: "Unsupported message type", + ) + end + end + + it "replies to StandardError with temporary_error status" do + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise StandardError, "General error" + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + allow(message_sender).to receive(:send_message) + + begin + raise "General error" + rescue StandardError => e + exception = e + end + + handler.rescue_with_handler(exception) + + expect(message_sender).to have_received(:send_message) do |message| + expect(message).to be_a(S2::Messages::ReceptionStatus) + expect(message).to have_attributes( + subject_message_id: S2::MessageHandler::Replying::NULL_MESSAGE_ID, + status: S2::Messages::ReceptionStatusValues::TemporaryError, + diagnostic_label: nil, + ) + end + end + + it "emits notification when rescuing StandardError" do + test_class = Class.new do + include S2::MessageHandler::ErrorHandling + + reply_with :@message_sender + + def initialize(message_sender:) + @message_sender = message_sender + end + + def test_method + raise StandardError, "General error" + end + end + + message_sender = instance_double(S2::MessageSender) + handler = test_class.new(message_sender: message_sender) + allow(message_sender).to receive(:send_message) + + begin + raise "General error" + rescue StandardError => e + exception = e + end + + callback = ->(*args) do + event = ActiveSupport::Notifications::Event.new(*args) + expect(event.name).to eq("message_errored.session.s2") + expect(event.payload[:exception]).to eq(exception) + end + + ActiveSupport::Notifications.subscribed(callback, "message_errored.session.s2") do + handler.rescue_with_handler(exception) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/replying_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/replying_spec.rb new file mode 100644 index 0000000..45710d8 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler/replying_spec.rb @@ -0,0 +1,159 @@ +describe S2::MessageHandler::Replying do + describe "::reply_with" do + it "sets the replier to the specified method or instance variable" do + message_handler_class = Class.new do + include S2::MessageHandler::Replying + + reply_with :@custom_replier + end + + expect(message_handler_class.replier).to eq(:@custom_replier) + end + end + + describe "#reply" do + it "sends the reply message using the configured replier" do + replier = instance_double(S2::MessageSender) + allow(replier).to receive(:send_message) + + message_handler_class = Class.new do + include S2::MessageHandler::Replying + + reply_with :@custom_replier + + def initialize(custom_replier:) + @custom_replier = custom_replier + end + end + + handler = message_handler_class.new(custom_replier: replier) + handshake = build(:s2_handshake) + expected_reply_message = build( + :s2_reception_status, + status: S2::Messages::ReceptionStatusValues::InvalidMessage, + subject_message_id: handshake.message_id, + diagnostic_label: "Your message is completely bogus", + ) + + handler.reply( + to: handshake, + status: :invalid_message, + diagnostic_label: "Your message is completely bogus", + ) + + expect(replier).to have_received(:send_message).with(expected_reply_message) + end + + it "raises an error if an unknown status is provided" do + message_handler_class = Class.new do + include S2::MessageHandler::Replying + + reply_with :@custom_replier + + def initialize(custom_replier:) + @custom_replier = custom_replier + end + end + + handler = message_handler_class.new(custom_replier: instance_double(S2::MessageSender)) + handshake = build(:s2_handshake) + + expect { handler.reply(to: handshake, status: :unknown_status) } + .to raise_error(ArgumentError, /Unknown status: unknown_status/) + end + + it "raises an error if no replier is configured" do + message_handler_class = Class.new do + include S2::MessageHandler::Replying + end + + handler = message_handler_class.new + handshake = build(:s2_handshake) + + expect { handler.reply(to: handshake, status: :ok) } + .to raise_error(RuntimeError, /No replier configured/) + end + + it "uses a null message ID if the 'to' message is nil" do + replier = instance_double(S2::MessageSender) + allow(replier).to receive(:send_message) + + message_handler_class = Class.new do + include S2::MessageHandler::Replying + + reply_with :@custom_replier + + def initialize(custom_replier:) + @custom_replier = custom_replier + end + end + + handler = message_handler_class.new(custom_replier: replier) + expected_reply_message = build( + :s2_reception_status, + :ok, + subject_message_id: S2::MessageHandler::Replying::NULL_MESSAGE_ID, + ) + + handler.reply(to: nil, status: :ok) + + expect(replier).to have_received(:send_message).with(expected_reply_message) + end + + it "uses a null message ID if the 'to' message has no message_id" do + replier = instance_double(S2::MessageSender) + allow(replier).to receive(:send_message) + + message_handler_class = Class.new do + include S2::MessageHandler::Replying + + reply_with :@custom_replier + + def initialize(custom_replier:) + @custom_replier = custom_replier + end + end + + handler = message_handler_class.new(custom_replier: replier) + message_without_id = build(:s2_handshake, message_id: "") + expected_reply_message = build( + :s2_reception_status, + :ok, + subject_message_id: S2::MessageHandler::Replying::NULL_MESSAGE_ID, + ) + + handler.reply(to: message_without_id, status: :ok) + + expect(replier).to have_received(:send_message).with(expected_reply_message) + end + end + + describe "#reply_ok" do + it "sends an OK reply message using the configured replier" do + replier = instance_double(S2::MessageSender) + allow(replier).to receive(:send_message) + + message_handler_class = Class.new do + include S2::MessageHandler::Replying + + reply_with :@custom_replier + + def initialize(custom_replier:) + @custom_replier = custom_replier + end + end + + handler = message_handler_class.new(custom_replier: replier) + handshake = build(:s2_handshake) + expected_reply_message = build( + :s2_reception_status, + :ok, + subject_message_id: handshake.message_id, + ) + + handler.reply_ok(to: handshake) + + expect(replier).to have_received(:send_message).with(expected_reply_message) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler_spec.rb new file mode 100644 index 0000000..ffe8b68 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_handler_spec.rb @@ -0,0 +1,84 @@ +describe S2::MessageHandler do + describe "#handle_message" do + it "invokes a registered handler for the message" do + message_sender = instance_double(S2::MessageSender) + + message_handler_class = Class.new(S2::MessageHandler) do + attr_reader :received_message + + on S2::Messages::Handshake do |message| + @received_message = message + end + end + + handler = message_handler_class.new(message_sender:) + message = build(:s2_handshake) + + handler.handle_message(message.to_json) + expect(handler.received_message).to eq(message) + end + + it "replies to errors with a fitting error reception status" do + message_sender = instance_double(S2::MessageSender) + allow(message_sender).to receive(:send_message) + + message_handler_class = Class.new(described_class) + + handler = message_handler_class.new(message_sender:) + + expect { handler.handle_message("") }.not_to raise_error + + expect(message_sender).to have_received(:send_message) do |reception_status| + expect(reception_status).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::InvalidData, + diagnostic_label: "Invalid JSON", + subject_message_id: S2::MessageHandler::Replying::NULL_MESSAGE_ID, + ) + end + end + + it "calls before, after, and around callbacks" do + message_sender = instance_double(S2::MessageSender) + allow(message_sender).to receive(:send_message) + + callback_order = [] + + message_handler_class = Class.new(S2::MessageHandler) do + before_handle do |payload| + callback_order << [:before, payload] + end + + after_handle do |payload| + callback_order << [:after, payload] + end + + around_handle do |payload, inner| + callback_order << [:around_start, payload] + inner.call + callback_order << [:around_end, payload] + end + + on S2::Messages::Handshake do |message| + callback_order << [:handler, message] + end + end + + handler = message_handler_class.new(message_sender:) + message = build(:s2_handshake) + + payload = message.to_json + handler.handle_message(payload) + + expect(callback_order).to eq( + [ + [:before, payload], + [:around_start, payload], + [:handler, message], + [:around_end, payload], + [:after, payload], + ], + ) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_sender_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_sender_spec.rb new file mode 100644 index 0000000..e263812 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/message_sender_spec.rb @@ -0,0 +1,107 @@ +describe S2::MessageSender do + describe "#send_message" do + it "sends payload when no success block is given" do + message = build(:s2_handshake) + sent_messages = [] + sender = described_class.new(resource_id: generate(:uuid)) { |json| sent_messages << json } + + sender.send_message(message) + + expect(sent_messages).to eq([message.to_json]) + end + + it "registers a success block and does not log a timeout when OK arrives in time" do + allow(S2.logger).to receive(:warn) + message = build(:s2_handshake) + reception_status = build(:s2_reception_status, subject_message_id: message.message_id) + sent_messages = [] + sender = described_class.new(resource_id: generate(:uuid)) { |json| sent_messages << json } + + called = nil + Async do |task| + sender.send_message(message, timeout: 0.01) { |status| called = status } + sender.receive_reception_status(reception_status) + task.sleep 0.02 + end + + expect(called).to eq(reception_status) + expect(S2.logger).not_to have_received(:warn) + end + + it "logs a warning on timeout when no status arrives" do + allow(S2.logger).to receive(:warn) + message = build(:s2_handshake) + sender = described_class.new(resource_id: generate(:uuid)) do |_json| + # omit sending + end + + Async do |task| + sender.send_message(message, timeout: 0.01) { |_| raise "should not be called" } + task.sleep 0.02 + end + + expect(S2.logger).to have_received(:warn) { |&blk| + expect(blk.call).to match(/Timeout waiting for ReceptionStatus for #{message.message_id}/) + } + end + end + + describe "#receive_reception_status" do + it "only logs for non-OK and does not call the success block" do + allow(S2.logger).to receive(:warn) + message = build(:s2_handshake) + not_ok = build(:s2_reception_status, :permanent_error, subject_message_id: message.message_id) + sender = described_class.new(resource_id: generate(:uuid)) do |_json| + # omit sending + end + + called = nil + Async do |task| + sender.send_message(message, timeout: 0.01) { |status| called = status } + sender.receive_reception_status(not_ok) + task.sleep 0.02 + end + + expect(called).to be_nil + expect(S2.logger).to have_received(:warn) { |&blk| + expect(blk.call).to include("Non-OK ReceptionStatus for #{message.message_id}: PERMANENT_ERROR") + } + end + + it "handles OK when no success block was registered" do + allow(S2.logger).to receive(:debug) + message = build(:s2_handshake) + reception_status = build(:s2_reception_status, subject_message_id: message.message_id) + sent_messages = [] + sender = described_class.new(resource_id: generate(:uuid)) { |json| sent_messages << json } + + Async do + sender.send_message(message) + sender.receive_reception_status(reception_status) + end + + expect(sent_messages).to eq([message.to_json]) + expect(S2.logger).to have_received(:debug) + end + end + + describe "#cleanup" do + it "stops all timeout tasks" do + message = build(:s2_handshake) + sender = described_class.new(resource_id: generate(:uuid)) do |_json| + # omit sending + end + + Async do |task| + sender.send_message(message, timeout: 1) do |_| + # empty callback + end + task.sleep 0.01 + expect(sender.timeout_tasks.size).to eq(1) + sender.cleanup + task.sleep 0.01 + expect(sender.timeout_tasks.size).to eq(0) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2/session_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2/session_spec.rb new file mode 100644 index 0000000..5613272 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2/session_spec.rb @@ -0,0 +1,126 @@ +describe S2::Session do + describe "#start", :async do + it "starts and stops the session" do + ws = FakeWebSocket.new + resource_id = SecureRandom.uuid + session = described_class.new(resource_id:, ws:) + + expect(session.status).to eq(:initialized) + + task = Async { session.start } + Async::Task.current.sleep 0.01 + + expect(session.status).to eq(:running) + + session.stop + Async::Task.current.sleep 0.01 + + expect(session.status).to eq(:stopped) + + task.stop + end + + it "handles incoming messages" do + ws = FakeWebSocket.new + message_handler = instance_double(S2::MessageHandler) + allow(message_handler).to receive(:handle_message) + allow(S2.message_handler_class).to receive(:new).and_return(message_handler) + + resource_id = SecureRandom.uuid + session = described_class.new(resource_id:, ws:) + task = Async { session.start } + Async::Task.current.sleep 0.01 + + ws.simulate_incoming('{"type":"test_message"}') + Async::Task.current.sleep 0.01 + + expect(message_handler).to have_received(:handle_message).with('{"type":"test_message"}') + + session.stop + task.stop + end + + it "sends outgoing messages" do + ws = FakeWebSocket.new + queue = Async::Queue.new + resource_id = SecureRandom.uuid + session = described_class.new(resource_id:, ws:, queue:) + + task = Async { session.start } + + queue.enqueue({ type: "outgoing_message" }) + + Async::Task.current.sleep 0.01 + + expect(ws.sent_messages).to include('{"type":"outgoing_message"}') + + session.stop + task.stop + end + end + + describe "#stop", :async do + it "is idempotent" do + ws = FakeWebSocket.new + resource_id = SecureRandom.uuid + session = described_class.new(resource_id:, ws:) + + expect(session.status).to eq(:initialized) + + task = Async { session.start } + Async::Task.current.sleep 0.01 + + expect(session.status).to eq(:running) + + session.stop + Async::Task.current.sleep 0.01 + + expect(session.status).to eq(:stopped) + + # Call stop again to ensure idempotency + session.stop + Async::Task.current.sleep 0.01 + + expect(session.status).to eq(:stopped) + + task.stop + end + + it "cleans up the message sender" do + ws = FakeWebSocket.new + resource_id = SecureRandom.uuid + message_sender = instance_double(S2::MessageSender) + allow(S2::MessageSender).to receive(:new).and_return(message_sender) + session = described_class.new(resource_id:, ws:) + allow(message_sender).to receive(:cleanup) + + task = Async { session.start } + Async::Task.current.sleep 0.01 + + session.stop + Async::Task.current.sleep 0.01 + + expect(message_sender).to have_received(:cleanup) + + task.stop + end + + it "closes the WebSocket connection" do + ws = FakeWebSocket.new + resource_id = SecureRandom.uuid + session = described_class.new(resource_id:, ws:) + + task = Async { session.start } + Async::Task.current.sleep 0.01 + + expect(ws).not_to be_closed + + session.stop + Async::Task.current.sleep 0.01 + + expect(ws).to be_closed + + task.stop + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2_message_handler_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2_message_handler_spec.rb new file mode 100644 index 0000000..ce652f0 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2_message_handler_spec.rb @@ -0,0 +1,491 @@ +describe S2MessageHandler do + describe "S2::Messages::ReceptionStatus" do + it "forwards the message to the message sender" do + message_sender = instance_double(S2::MessageSender) + handler = described_class.new(message_sender:) + message = build(:s2_reception_status) + allow(message_sender).to receive(:receive_reception_status) + + handler.handle_message(message.to_json) + + expect(message_sender).to have_received(:receive_reception_status).with(message) + end + end + + describe "S2::Messages::Handshake" do + it "responds with ReceptionStatus OK when it's valid" do + message_sender = MessageSenderStub.new + state = { status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::Ok, + diagnostic_label: nil, + subject_message_id: message.message_id, + ) + end + + it "responds with a permanent error when no protocol version matches" do + message_sender = MessageSenderStub.new + state = { status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake, supported_protocol_versions: ["0.0.1"]) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: + "No protocol version matches, supported versions: #{S2.supported_protocol_versions.join(", ")}", + subject_message_id: message.message_id, + ) + end + + it "responds with a permanent error when the status is not websocket_connected" do + message_sender = MessageSenderStub.new + state = { status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: "Invalid state: expected status to be 'websocket_connected', got 'initialized'", + subject_message_id: message.message_id, + ) + end + + it "responds with a permanent error when the role is not CEM" do + message_sender = MessageSenderStub.new + state = { status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake, :rm) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: "Invalid attribute: expected role to be 'CEM', got 'RM'", + subject_message_id: message.message_id, + ) + end + end + + describe "S2::Messages::HandshakeResponse" do + it "responds with ReceptionStatus OK when it's valid" do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("GIT_REV", "unknown").and_return("unknown") + message_sender = MessageSenderStub.new + resource = create(:resource) + state = { resource_id: resource.s2_identifier, status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake_response) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(2) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::Ok, + diagnostic_label: nil, + subject_message_id: message.message_id, + ) + expect(message_sender.sent_messages.second).to have_attributes( + message_type: S2::Messages::ResourceManagerDetailsMessageType::ResourceManagerDetails, + manufacturer: "StekkerS2/unknown (test; Ruby/#{RUBY_VERSION}) S2/0.0.2-beta", + resource_id: state[:resource_id], + resource_manager_details_name: resource.configuration.fetch("name"), + roles: [ + have_attributes( + commodity: S2::Messages::Commodity::Electricity, + role: S2::Messages::RoleType::EnergyConsumer, + ), + ], + instruction_processing_delay: 5.minutes.in_milliseconds, + available_control_types: [S2::Messages::ControlType::FillRateBasedControl], + provides_forecast: true, + provides_power_measurement_types: [S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric], + ) + end + + it "updates the state to initialized and sets the protocol version when it's valid" do + message_sender = MessageSenderStub.new + resource = create(:resource) + state = { resource_id: resource.s2_identifier, status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake_response, selected_protocol_version: "0.0.2-beta") + + expect { handler.handle_message(message.to_json) } + .to change { state } + .from(resource_id: resource.s2_identifier, status: :websocket_connected) + .to(resource_id: resource.s2_identifier, status: :initialized, protocol_version: "0.0.2-beta") + end + + it "responds with a permanent error when the status is not websocket_connected" do + message_sender = MessageSenderStub.new + state = { resource_id: generate(:uuid), status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_handshake_response) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: "Invalid state: expected status to be 'websocket_connected', got 'initialized'", + subject_message_id: message.message_id, + ) + end + end + + describe "S2::Messages::SelectControlType" do + it "responds with ReceptionStatus OK when it's valid" do + message_sender = MessageSenderStub.new + state = { status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_select_control_type, :frbc) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(2) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::Ok, + diagnostic_label: nil, + subject_message_id: message.message_id, + ) + end + + it "updates the state with the selected control type and FRBC system description when it's valid" do + message_sender = MessageSenderStub.new + resource = create(:resource) + state = { resource_id: resource.s2_identifier, status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_select_control_type, :frbc) + + expect { handler.handle_message(message.to_json) } + .to change { state } + .from(resource_id: resource.s2_identifier, status: :initialized) + .to( + resource_id: resource.s2_identifier, + status: :initialized, + control_type: S2::Messages::ControlType::FillRateBasedControl, + frbc_system_description: be_a(S2::Messages::FRBCSystemDescription), + ) + end + + it "sends an FRBCSystemDescription request after selecting FillRateBasedControl" do + message_sender = MessageSenderStub.new + resource = create(:resource) + state = { resource_id: resource.s2_identifier, status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_select_control_type, :frbc) + + freeze_time do + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(2) + expect(message_sender.sent_messages.second.to_dynamic).to match( + a_hash_including( + "message_type" => S2::Messages::FRBCSystemDescriptionMessageType::FRBCSystemDescription, + "valid_from" => Time.current.iso8601, + "actuators" => [ + a_hash_including( + "diagnostic_label" => "Electric Vehicle Charger", + "supported_commodities" => [S2::Messages::Commodity::Electricity], + "operation_modes" => [ + a_hash_including( + "id" => be_a(String), + "diagnostic_label" => "Off", + "elements" => [ + a_hash_including( + "fill_level_range" => a_hash_including( + "start_of_range" => 0, + "end_of_range" => 100, + ), + "fill_rate" => a_hash_including( + "start_of_range" => 0, + "end_of_range" => 0, + ), + "power_ranges" => [ + a_hash_including( + "start_of_range" => 0, + "end_of_range" => 0, + "commodity_quantity" => S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + ), + ], + ), + ], + "abnormal_condition_only" => false, + ), + a_hash_including( + "id" => be_a(String), + "diagnostic_label" => "Charging", + "elements" => [ + a_hash_including( + "fill_level_range" => a_hash_including( + "start_of_range" => 0, + "end_of_range" => 100, + ), + "fill_rate" => a_hash_including( + "start_of_range" => 0.0, + "end_of_range" => 0.00000001, + ), + "power_ranges" => [ + a_hash_including( + "start_of_range" => 0, + "end_of_range" => 11_040, + "commodity_quantity" => S2::Messages::CommodityQuantity::ElectricPower3_PhaseSymmetric, + ), + ], + ), + ], + ), + ], + "transitions" => all( + a_hash_including( + "from" => be_a(String), + "to" => be_a(String), + ), + ), + "timers" => [], + ), + ], + "storage" => a_hash_including( + "diagnostic_label" => "Electric Vehicle Battery", + "fill_level_label" => "State of Charge", + "provides_leakage_behaviour" => false, + "provides_fill_level_target_profile" => true, + "provides_usage_forecast" => false, + "fill_level_range" => a_hash_including( + "start_of_range" => -10, + "end_of_range" => 110, + ), + ), + ), + ) + end + end + + it "enqueues PublishS2ReadyNotificationJob when SelectControlType is handled" do + message_sender = MessageSenderStub.new + resource = create(:resource) + state = { resource_id: resource.s2_identifier, status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_select_control_type, :frbc) + + expect { handler.handle_message(message.to_json) } + .to have_enqueued_job(PublishS2ReadyNotificationJob) + .with(resource_id: resource.s2_identifier) + end + + it "responds with a permanent error when the status is not initialized" do + message_sender = MessageSenderStub.new + state = { status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_select_control_type) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: "Invalid state: expected status to be 'initialized', got 'websocket_connected'", + subject_message_id: message.message_id, + ) + end + + it "responds with a permanent error when the control type is not FillRateBasedControl" do + message_sender = MessageSenderStub.new + state = { status: :initialized } + handler = described_class.new(message_sender:, state:) + message = build(:s2_select_control_type, :not_controlable) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: + "Invalid attribute: expected control_type to be 'FILL_RATE_BASED_CONTROL', got 'NOT_CONTROLABLE'", + subject_message_id: message.message_id, + ) + end + end + + describe "S2::Messages::FRBCInstruction" do + it "responds with ReceptionStatus OK when it's valid" do + message_sender = MessageSenderStub.new + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + state = { + resource_id: resource.s2_identifier, + status: :initialized, + control_type: S2::Messages::ControlType::FillRateBasedControl, + frbc_system_description:, + } + handler = described_class.new(message_sender:, state:) + message = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: frbc_system_description.actuators.first.operation_modes.first.id, + ) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::Ok, + diagnostic_label: nil, + subject_message_id: message.message_id, + ) + end + + it "schedules a ProcessFRBCInstructionJob" do + message_sender = MessageSenderStub.new + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + state = { + resource_id: resource.s2_identifier, + status: :initialized, + control_type: S2::Messages::ControlType::FillRateBasedControl, + frbc_system_description:, + } + handler = described_class.new(message_sender:, state:) + message = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: frbc_system_description.actuators.first.operation_modes.first.id, + ) + + expect { handler.handle_message(message.to_json) } + .to have_enqueued_job(ProcessFRBCInstructionJob) + .with( + resource_id: resource.s2_identifier, + frbc_system_description_json: frbc_system_description.to_json, + frbc_instruction_json: message.to_json, + ) + end + + it "responds with a permanent error when the status is not initialized" do + message_sender = MessageSenderStub.new + state = { status: :websocket_connected } + handler = described_class.new(message_sender:, state:) + message = build(:s2_frbc_instruction) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: "Invalid state: expected status to be 'initialized', got 'websocket_connected'", + subject_message_id: message.message_id, + ) + end + + it "responds with a permanent error when the control type is not FillRateBasedControl" do + message_sender = MessageSenderStub.new + state = { status: :initialized, control_type: S2::Messages::ControlType::NotControlable } + handler = described_class.new(message_sender:, state:) + message = build(:s2_frbc_instruction) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::PermanentError, + diagnostic_label: + "Invalid state: expected control_type to be 'FILL_RATE_BASED_CONTROL', got 'NOT_CONTROLABLE'", + subject_message_id: message.message_id, + ) + end + + it "responds with invalid content error when the actuator_id is unknown" do + message_sender = MessageSenderStub.new + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + state = { + resource_id: resource.s2_identifier, + status: :initialized, + control_type: S2::Messages::ControlType::FillRateBasedControl, + frbc_system_description:, + } + handler = described_class.new(message_sender:, state:) + message = build( + :s2_frbc_instruction, + actuator_id: generate(:uuid), + operation_mode: frbc_system_description.actuators.first.operation_modes.first.id, + ) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::InvalidContent, + diagnostic_label: "Invalid attribute: unknown actuator_id '#{message.actuator_id}'", + subject_message_id: message.message_id, + ) + end + + it "responds with invalid content error when the operation_mode is unknown" do + message_sender = MessageSenderStub.new + resource = create(:resource) + frbc_system_description = FRBCSystemDescriptionFactory.create(resource) + state = { + resource_id: resource.s2_identifier, + status: :initialized, + control_type: S2::Messages::ControlType::FillRateBasedControl, + frbc_system_description:, + } + handler = described_class.new(message_sender:, state:) + message = build( + :s2_frbc_instruction, + actuator_id: frbc_system_description.actuators.first.id, + operation_mode: generate(:uuid), + ) + + handler.handle_message(message.to_json) + + expect(message_sender.sent_messages.size).to eq(1) + expect(message_sender.sent_messages.first).to have_attributes( + message_type: S2::Messages::ReceptionStatusMessageType::ReceptionStatus, + status: S2::Messages::ReceptionStatusValues::InvalidContent, + diagnostic_label: "Invalid attribute: unknown operation_mode '#{message.operation_mode}'", + subject_message_id: message.message_id, + ) + end + end + + describe "database connection isolation" do + it "handles each message inside the Rails executor so fibers don't share a connection" do + message_sender = instance_double(S2::MessageSender) + allow(message_sender).to receive(:receive_reception_status) + allow(Rails.application.executor).to receive(:wrap).and_call_original + + handler = described_class.new(message_sender:) + + handler.handle_message(build(:s2_reception_status).to_json) + + expect(Rails.application.executor).to have_received(:wrap) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/s2_message_logger_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/s2_message_logger_spec.rb new file mode 100644 index 0000000..3d836a3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/s2_message_logger_spec.rb @@ -0,0 +1,52 @@ +describe S2MessageLogger do + describe "#log_inbound" do + it "logs inbound S2 message with resource_id, message_type, and message" do + logger = instance_double(EcsLogging::Logger, info: nil) + message = '{"message_type": "FRBCInstruction", "id": "123"}' + + described_class.new(logger).log_inbound(resource_id: "ABC123", message:) + + expect(logger).to have_received(:info).with( + "Inbound S2 message", + s2: { + resource_id: "ABC123", + message_type: "FRBCInstruction", + message:, + }, + ) + end + + it "handles invalid JSON gracefully" do + logger = instance_double(EcsLogging::Logger, info: nil) + message = "not valid json" + + described_class.new(logger).log_inbound(resource_id: "ABC123", message:) + + expect(logger).to have_received(:info).with( + "Inbound S2 message", + s2: { + resource_id: "ABC123", + message:, + }, + ) + end + end + + describe "#log_outbound" do + it "logs outbound S2 message with resource_id, message_type, and message" do + logger = instance_double(EcsLogging::Logger, info: nil) + message = '{"message_type": "PowerMeasurement", "values": []}' + + described_class.new(logger).log_outbound(resource_id: "ABC123", message:) + + expect(logger).to have_received(:info).with( + "Outbound S2 message", + s2: { + resource_id: "ABC123", + message_type: "PowerMeasurement", + message:, + }, + ) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/supervisor_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/supervisor_spec.rb new file mode 100644 index 0000000..5981334 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/supervisor_spec.rb @@ -0,0 +1,472 @@ +describe Supervisor, :async do + before do + described_class.instance.shutdown + + mqtt_listener = instance_double(Stekker::MQTT::Listener) + allow(mqtt_listener).to receive(:start) + allow(mqtt_listener).to receive(:stop) + allow(Stekker::MQTT::Listener).to receive(:new).and_return(mqtt_listener) + end + + after do + described_class.instance.shutdown + end + + describe "#start_all" do + it "sets the status to :running" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(supervisor.status).to eq(:running) + end + + it "does not start if already running" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + expect(supervisor.status).to eq(:running) + + supervisor.startup + Async::Task.current.sleep 0.01 + expect(supervisor.status).to eq(:running) + end + + it "starts sessions for each active resource" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + active_resource = create(:resource, :active) + inactive_resource = create(:resource, :inactive) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(supervisor.find_connection(active_resource.s2_identifier).status).to eq(:connected) + expect(supervisor.find_connection(inactive_resource.s2_identifier)).to be_nil + end + + it "sends a handshake message upon starting a session" do + ws = FakeWebSocket.new + allow(Async::WebSocket::Client).to receive(:connect).and_yield(ws) + _resource = create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(ws.sent_messages).to include(a_string_matching(/"message_type":"Handshake"/)) + end + + it "starts the MQTT service" do + mqtt_listener = instance_double(Stekker::MQTT::Listener) + allow(mqtt_listener).to receive(:start) + allow(mqtt_listener).to receive(:stop) + allow(Stekker::MQTT::Listener).to receive(:new).and_return(mqtt_listener) + supervisor = described_class.instance + + supervisor.startup + sleep 0.01 + supervisor.shutdown + + expect(mqtt_listener).to have_received(:start).once + end + end + + describe "#stop_all" do + it "sets the status to :stopped" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + expect(supervisor.status).to eq(:running) + + supervisor.shutdown + Async::Task.current.sleep 0.01 + expect(supervisor.status).to eq(:stopped) + end + + it "stops all sessions" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + expect(supervisor.find_connection(resource.s2_identifier).status).to eq(:connected) + + supervisor.shutdown + Async::Task.current.sleep 0.01 + expect(supervisor.find_connection(resource.s2_identifier)).to be_nil + end + end + + describe "#send_message" do + it "sends a message to a specific session" do + ws = FakeWebSocket.new + allow(Async::WebSocket::Client).to receive(:connect).and_yield(ws) + resource = create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + expect(supervisor.find_connection(resource.s2_identifier).status).to eq(:connected) + + message = { type: "test_message" } + supervisor.send_message(resource.s2_identifier, message) + Async::Task.current.sleep 0.01 + + expect(ws.sent_messages).to include(message.to_json) + end + end + + describe "#running?" do + it "returns true when running" do + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(supervisor.running?).to be true + end + + it "returns false when stopped" do + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + supervisor.shutdown + Async::Task.current.sleep 0.01 + + expect(supervisor.running?).to be false + end + end + + describe "#connect" do + it "passes basic auth headers when CEM auth is configured" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + + allow(ReferenceConfig).to receive(:cem).and_return( + ReferenceConfig::Cem.new(base_url: "ws://example.test/s2", username: "user", password: "pass"), + ) + + create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(Async::WebSocket::Client).to have_received(:connect).with( + anything, + headers: { "authorization" => "Basic #{Base64.strict_encode64("user:pass")}" }, + ) + end + + it "passes no auth headers when CEM auth is not configured" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + + allow(ReferenceConfig).to receive(:cem).and_return( + ReferenceConfig::Cem.new(base_url: "ws://example.test/s2", username: nil, password: nil), + ) + + create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(Async::WebSocket::Client).to have_received(:connect).with( + anything, + headers: {}, + ) + end + + it "passes no auth headers when only username is configured" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + + allow(ReferenceConfig).to receive(:cem).and_return( + ReferenceConfig::Cem.new(base_url: "ws://example.test/s2", username: "user", password: nil), + ) + + create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(Async::WebSocket::Client).to have_received(:connect).with( + anything, + headers: {}, + ) + end + + it "passes no auth headers when only password is configured" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + + allow(ReferenceConfig).to receive(:cem).and_return( + ReferenceConfig::Cem.new(base_url: "ws://example.test/s2", username: nil, password: "pass"), + ) + + create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(Async::WebSocket::Client).to have_received(:connect).with( + anything, + headers: {}, + ) + end + end + + describe "#connection" do + it "returns the connection for a resource" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + expect(supervisor.find_connection(resource.s2_identifier)).to be_nil + + supervisor.startup + Async::Task.current.sleep 0.01 + + expect(supervisor.find_connection(resource.s2_identifier).status).to eq(:connected) + expect(supervisor.find_connection(resource.s2_identifier).status).to eq(:connected) + + supervisor.shutdown + Async::Task.current.sleep 0.01 + + expect(supervisor.find_connection(resource.s2_identifier)).to be_nil + end + end + + describe "reconnect on unexpected disconnection" do + def fire_connection_errored(resource_id) + exception = StandardError.new("ws went away").tap { |e| e.set_backtrace(caller) } + ActiveSupport::Notifications.instrument( + "connection_errored.session.s2", + resource_id:, + exception:, + ) + end + + def fire_connected(resource_id) + ActiveSupport::Notifications.instrument( + "connected.session.s2", + resource_id:, + url: "ws://example.com/#{resource_id}", + ) + end + + it "reschedules the connection after the current backoff when it errors" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + sleep_durations = [] + allow(supervisor).to receive(:sleep) { |duration| sleep_durations << duration } + allow(supervisor).to receive(:reconnect).and_call_original + + supervisor.startup + Async::Task.current.sleep 0.01 + + allow(supervisor.find_connection(resource.s2_identifier)).to receive(:status).and_return(:reconnecting) + + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + + expect(sleep_durations).to eq([5.0]) + expect(supervisor).to have_received(:reconnect).with(resource.s2_identifier) + end + + it "does not reconnect when the connection has already recovered before the backoff elapsed" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + allow(supervisor).to receive(:sleep) + allow(supervisor).to receive(:reconnect) + + supervisor.startup + Async::Task.current.sleep 0.01 + + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + + expect(supervisor).not_to have_received(:reconnect) + end + + it "doubles the backoff on consecutive errors up to five minutes" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + sleep_durations = [] + allow(supervisor).to receive(:sleep) { |duration| sleep_durations << duration } + allow(supervisor).to receive(:reconnect) + + supervisor.startup + Async::Task.current.sleep 0.01 + + 15.times do + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + end + + expect(sleep_durations).to eq( + [5.0, 10.0, 20.0, 40.0, 80.0, 160.0, 300.0, 300.0, 300.0, 300.0, 300.0, 300.0, 300.0, 300.0, 300.0], + ) + end + + it "resets the backoff when the connection reports connected again" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + sleep_durations = [] + allow(supervisor).to receive(:sleep) { |duration| sleep_durations << duration } + allow(supervisor).to receive(:reconnect) + + supervisor.startup + Async::Task.current.sleep 0.01 + + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + + fire_connected(resource.s2_identifier) + + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + + expect(sleep_durations).to eq([5.0, 10.0, 5.0]) + end + + it "does not reconnect resources that are no longer active" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + inactive = create(:resource, :inactive) + supervisor = described_class.instance + + allow(supervisor).to receive(:sleep) + allow(supervisor).to receive(:reconnect) + + supervisor.startup + Async::Task.current.sleep 0.01 + + fire_connection_errored(inactive.s2_identifier) + Async::Task.current.sleep 0.01 + + expect(supervisor).not_to have_received(:reconnect) + end + + it "coalesces rapid errors so only one reconnect is scheduled at a time" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + sleep_durations = [] + sleep_started = Async::Notification.new + release_sleep = Async::Notification.new + allow(supervisor).to receive(:sleep) do |duration| + sleep_durations << duration + sleep_started.signal + release_sleep.wait + end + allow(supervisor).to receive(:reconnect) + + supervisor.startup + Async::Task.current.sleep 0.01 + + allow(supervisor.find_connection(resource.s2_identifier)).to receive(:status).and_return(:reconnecting) + + fire_connection_errored(resource.s2_identifier) + sleep_started.wait + + 2.times { fire_connection_errored(resource.s2_identifier) } + release_sleep.signal + Async::Task.current.sleep 0.01 + + expect(sleep_durations).to eq([5.0]) + expect(supervisor).to have_received(:reconnect).once + end + + it "stops listening after shutdown" do + allow(Async::WebSocket::Client).to receive(:connect).and_yield(FakeWebSocket.new) + resource = create(:resource) + supervisor = described_class.instance + + allow(supervisor).to receive(:reconnect) + + supervisor.startup + Async::Task.current.sleep 0.01 + supervisor.shutdown + Async::Task.current.sleep 0.01 + + fire_connection_errored(resource.s2_identifier) + Async::Task.current.sleep 0.01 + + expect(supervisor).not_to have_received(:reconnect) + end + end + + describe "integration tests", :ws_server do + it "connects a websocket client to a websocket server" do + endpoint = Async::HTTP::Endpoint.parse("#{ws_server.url}/ws") + + Async do + Async::WebSocket::Client.connect(endpoint) do |ws| + ws.write("abc") + expect(ws.read.to_str).to eq("abc") + end + end.wait + end + + it "supervisor connects to the websocket server" do + sent_messages = [] + received_messages = [] + + message_sent_subscriber = ActiveSupport::Notifications.subscribe("message_sent.session.s2") do |event| + sent_messages << event.payload[:payload] + end + + message_rcvd_subscriber = ActiveSupport::Notifications.subscribe("message_received.session.s2") do |event| + received_messages << event.payload[:payload] + end + + create(:resource) + supervisor = described_class.instance + + supervisor.startup + Async::Task.current.sleep 0.1 + + expect(supervisor.status).to eq(:running) + expect(supervisor.find_connection(Resource.first.s2_identifier).status).to eq(:connected) + + expect(sent_messages.size).to eq(2) + expect(sent_messages.first).to include( + %("message_type":"Handshake","role":"RM","supported_protocol_versions":["0.0.2-beta"]), + ) + expect(sent_messages.second).to include( + %("diagnostic_label":"Invalid attribute: expected role to be 'CEM', got 'RM'","message_type":"ReceptionStatus","status":"PERMANENT_ERROR"), # rubocop:disable Layout/LineLength + ) + + # Because the WebsocketServer merely echoes received messages + expect(received_messages.size).to eq(2) + expect(received_messages.first).to include( + %("message_type":"Handshake","role":"RM","supported_protocol_versions":["0.0.2-beta"]), + ) + expect(received_messages.second).to include( + %("diagnostic_label":"Invalid attribute: expected role to be 'CEM', got 'RM'","message_type":"ReceptionStatus","status":"PERMANENT_ERROR"), # rubocop:disable Layout/LineLength + ) + ensure + ActiveSupport::Notifications.unsubscribe(message_sent_subscriber) + ActiveSupport::Notifications.unsubscribe(message_rcvd_subscriber) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/lib/traffic_log_spec.rb b/rm-mqtt-ws-bridge/rm/spec/lib/traffic_log_spec.rb new file mode 100644 index 0000000..128dc12 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/lib/traffic_log_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe TrafficLog do + let(:dir) { Dir.mktmpdir } + + before { stub_const("TrafficLog::PATH", Pathname.new(dir).join("traffic.jsonl")) } + after { FileUtils.remove_entry(dir) } + + describe ".clear" do + it "drops the whole log so a demo restart starts from a clean slate" do + described_class.record(peer: "cem", direction: "out", message_type: "Handshake") + described_class.record(peer: "cem", direction: "in", message_type: "SelectControlType") + described_class.record(peer: "mqtt", direction: "in", message_type: "power_measurement") + described_class.record(peer: "cem", direction: "in", message_type: "FRBC.Instruction") + + described_class.clear + + expect(described_class.recent).to eq([]) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/models/resource_spec.rb b/rm-mqtt-ws-bridge/rm/spec/models/resource_spec.rb new file mode 100644 index 0000000..85ca06e --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/models/resource_spec.rb @@ -0,0 +1,22 @@ +describe Resource do + describe "validations" do + subject { build(:resource) } + + it { is_expected.to validate_presence_of(:s2_identifier) } + end + + describe "scopes" do + describe "::active" do + it "returns active resources" do + active_resource = create(:resource, :active) + _inactive_resource = create(:resource, :inactive) + + expect(described_class.active).to eq([active_resource]) + end + + it "returns an empty list when no resources are active" do + expect(described_class.active).to eq([]) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/rails_helper.rb b/rm-mqtt-ws-bridge/rm/spec/rails_helper.rb new file mode 100644 index 0000000..7595049 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/rails_helper.rb @@ -0,0 +1,34 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +abort("The Rails environment is running in production mode!") if Rails.env.production? +require "rspec/rails" + +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end + +Rails.root.glob("spec/support/**/*.rb").each { |f| require f } + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.fixture_paths = [ + Rails.root.join("spec/fixtures"), + ] + + config.include ActiveSupport::Testing::TimeHelpers + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.example_status_persistence_file_path = ".rspec-failures" + config.filter_rails_from_backtrace! + config.infer_spec_type_from_file_location! + config.shared_context_metadata_behavior = :apply_to_host_groups + config.use_transactional_fixtures = true +end diff --git a/rm-mqtt-ws-bridge/rm/spec/requests/resources_spec.rb b/rm-mqtt-ws-bridge/rm/spec/requests/resources_spec.rb new file mode 100644 index 0000000..a239b27 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/requests/resources_spec.rb @@ -0,0 +1,48 @@ +describe "Resources" do + describe "GET /resources/:id" do + it "returns connection state as JSON" do + resource = create(:resource) + connection_state = { + resource_id: resource.s2_identifier, + status: :initialized, + frbc_system_description: { + actuators: [{ id: "actuator-1", operation_modes: [{ id: "mode-1" }] }], + }, + } + connection = instance_double(S2::Connection, state: connection_state) + supervisor = instance_double(Supervisor, find_connection: connection) + allow(Supervisor).to receive(:instance).and_return(supervisor) + + get resource_path(resource, format: :json) + + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + + expect(response.parsed_body["resource_id"]).to eq(resource.s2_identifier) + expect(response.parsed_body.dig("frbc_system_description", "actuators", 0, "id")).to eq("actuator-1") + end + + it "returns empty JSON when no connection exists" do + resource = create(:resource) + supervisor = instance_double(Supervisor, find_connection: nil) + allow(Supervisor).to receive(:instance).and_return(supervisor) + + get resource_path(resource, format: :json) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq({}) + end + + it "renders HTML with N/A uptime when the connection has not reached :connected yet" do + resource = create(:resource) + connection = instance_double(S2::Connection, connected_at: nil, state: nil, status: :connecting) + supervisor = instance_double(Supervisor, find_connection: connection) + allow(Supervisor).to receive(:instance).and_return(supervisor) + + get resource_path(resource) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("N/A") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/requests/traffic_spec.rb b/rm-mqtt-ws-bridge/rm/spec/requests/traffic_spec.rb new file mode 100644 index 0000000..b441362 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/requests/traffic_spec.rb @@ -0,0 +1,22 @@ +describe "Traffic" do + describe "POST /traffic/reset" do + it "is not routable without the demo screen switch" do + post "/traffic/reset" + + expect(response).to have_http_status(:not_found) + end + + it "clears the feed when the demo screen switch is on" do + ENV["DEMO_ALLOW_FRAMING"] = "1" + Rails.application.reload_routes! + + post "/traffic/reset" + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq("reset" => true) + ensure + ENV.delete("DEMO_ALLOW_FRAMING") + Rails.application.reload_routes! + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/stekker_s2/application_spec.rb b/rm-mqtt-ws-bridge/rm/spec/stekker_s2/application_spec.rb new file mode 100644 index 0000000..7efbad3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/stekker_s2/application_spec.rb @@ -0,0 +1,5 @@ +describe StekkerS2::Application do + it "isolates ActiveRecord per fiber so the Async reactor's fibers don't share a connection" do + expect(ActiveSupport::IsolatedExecutionState.isolation_level).to eq(:fiber) + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/subscribers/log_mqtt_listener_subscriber_spec.rb b/rm-mqtt-ws-bridge/rm/spec/subscribers/log_mqtt_listener_subscriber_spec.rb new file mode 100644 index 0000000..9c5bfa0 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/subscribers/log_mqtt_listener_subscriber_spec.rb @@ -0,0 +1,188 @@ +describe LogMQTTListenerSubscriber do + describe "starting" do + it "logs the starting event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "starting.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + {}, + ) + + described_class.new.starting(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] Starting MQTT listener") + end + end + + describe "connected" do + it "logs the connected event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "connected.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + {}, + ) + + described_class.new.connected(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] MQTT listener connected") + end + end + + describe "subscribed" do + it "logs the subscribed event with topics" do + allow(Rails.logger).to receive(:info) + topics = %w[topic1 topic2] + event = ActiveSupport::Notifications::Event.new( + "subscribed.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + { topics: }, + ) + + described_class.new.subscribed(event) + + expect(Rails.logger) + .to have_received(:info).with("[#{described_class}] Subscribed to MQTT topics: [\"topic1\", \"topic2\"]") + end + end + + describe "message_received" do + it "logs the message received event via MQTTMessageLogger" do + ecs_logger = instance_double(EcsLogging::Logger, info: nil) + allow(Rails.configuration).to receive(:ecs_logger).and_return(ecs_logger) + + topic = "stekker/resources/ABC123/measurements" + message = '{"state": "charging"}' + event = ActiveSupport::Notifications::Event.new( + "message_received.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + { topic:, message: }, + ) + + described_class.new.message_received(event) + + expect(ecs_logger).to have_received(:info).with( + "Inbound MQTT message", + mqtt: { + topic:, + resource_id: "ABC123", + message:, + }, + ) + end + end + + describe "listen_errored" do + it "notifies error when listen_errored event occurs" do + allow(ErrorNotifier).to receive(:notify_error) + exception = StandardError.new("Test error") + event = ActiveSupport::Notifications::Event.new( + "listen_errored.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + { exception: }, + ) + + described_class.new.listen_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with(exception) + end + end + + describe "connection_errored" do + it "notifies error when connection_errored event occurs" do + allow(ErrorNotifier).to receive(:notify_error) + exception = StandardError.new("Test error") + event = ActiveSupport::Notifications::Event.new( + "connection_errored.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + { exception: }, + ) + + described_class.new.connection_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with(exception) + end + end + + describe "get_errored" do + it "notifies error when get_errored event occurs" do + allow(ErrorNotifier).to receive(:notify_error) + exception = StandardError.new("Test error") + event = ActiveSupport::Notifications::Event.new( + "get_errored.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + { exception: }, + ) + + described_class.new.get_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with(exception) + end + end + + describe "graceful_shutdown" do + it "logs the graceful shutdown event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "graceful_shutdown.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + {}, + ) + + described_class.new.graceful_shutdown(event) + + expect(Rails.logger) + .to have_received(:info).with("[#{described_class}] MQTT listener shutting down gracefully") + end + end + + describe "thread_exited" do + it "logs the thread exited event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "thread_exited.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + {}, + ) + + described_class.new.thread_exited(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] MQTT listener thread exited") + end + end + + describe "stopped" do + it "logs the stopped event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "stopped.listener.mqtt.stekker", + Time.current, + Time.current, + 1, + {}, + ) + + described_class.new.stopped(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] MQTT listener stopped") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/subscribers/log_mqtt_message_handler_subscriber_spec.rb b/rm-mqtt-ws-bridge/rm/spec/subscribers/log_mqtt_message_handler_subscriber_spec.rb new file mode 100644 index 0000000..cdb9579 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/subscribers/log_mqtt_message_handler_subscriber_spec.rb @@ -0,0 +1,20 @@ +describe LogMQTTMessageHandlerSubscriber do + describe "#message_errored" do + it "notifies the error notifier with the exception from the event payload" do + allow(ErrorNotifier).to receive(:notify_error) + exception = StandardError.new("Test error") + event = ActiveSupport::Notifications::Event.new( + "message_errored.message_handler.mqtt.stekker", + Time.current, + Time.current, + SecureRandom.uuid, + { exception: }, + ) + + subscriber = described_class.new + subscriber.message_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with(exception) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/subscribers/log_session_subscriber_spec.rb b/rm-mqtt-ws-bridge/rm/spec/subscribers/log_session_subscriber_spec.rb new file mode 100644 index 0000000..aa7eec3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/subscribers/log_session_subscriber_spec.rb @@ -0,0 +1,210 @@ +describe LogSessionSubscriber do + describe "connected" do + it "logs the connection event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "connected.session.s2", + Time.current, + Time.current, + 1, + { resource_id: 123, url: "ws://example.com" }, + ) + + described_class.new.connected(event) + + expect(Rails.logger) + .to have_received(:info).with("[#{described_class}] [123] S2 Session connected to ws://example.com") + end + end + + describe "started" do + it "logs the started event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "started.session.s2", + Time.current, + Time.current, + 1, + { resource_id: 123 }, + ) + + described_class.new.started(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] [123] S2 Session started") + end + end + + describe "disconnected" do + it "logs the disconnected event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "disconnected.session.s2", + Time.current, + Time.current, + 1, + { resource_id: 123 }, + ) + + described_class.new.disconnected(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] [123] S2 Session disconnected") + end + end + + describe "stopped" do + it "logs the stopped event" do + allow(Rails.logger).to receive(:info) + event = ActiveSupport::Notifications::Event.new( + "stopped.session.s2", + Time.current, + Time.current, + 1, + { resource_id: 123 }, + ) + + described_class.new.stopped(event) + + expect(Rails.logger).to have_received(:info).with("[#{described_class}] [123] S2 Session stopped") + end + end + + describe "message_received" do + it "logs the message received event via S2MessageLogger" do + ecs_logger = instance_double(EcsLogging::Logger, info: nil) + allow(Rails.configuration).to receive(:ecs_logger).and_return(ecs_logger) + + message = '{"message_type": "FRBCInstruction", "id": "123"}' + event = ActiveSupport::Notifications::Event.new( + "message_received.session.s2", + Time.current, + Time.current, + 1, + { resource_id: "ABC123", payload: message }, + ) + + described_class.new.message_received(event) + + expect(ecs_logger).to have_received(:info).with( + "Inbound S2 message", + s2: { + resource_id: "ABC123", + message_type: "FRBCInstruction", + message:, + }, + ) + end + end + + describe "message_sent" do + it "logs the message sent event via S2MessageLogger" do + ecs_logger = instance_double(EcsLogging::Logger, info: nil) + allow(Rails.configuration).to receive(:ecs_logger).and_return(ecs_logger) + + message = '{"message_type": "PowerMeasurement", "values": []}' + event = ActiveSupport::Notifications::Event.new( + "message_sent.session.s2", + Time.current, + Time.current, + 1, + { resource_id: "ABC123", payload: message }, + ) + + described_class.new.message_sent(event) + + expect(ecs_logger).to have_received(:info).with( + "Outbound S2 message", + s2: { + resource_id: "ABC123", + message_type: "PowerMeasurement", + message:, + }, + ) + end + end + + describe "connection_errored" do + before { allow(ErrorNotifier).to receive(:notify_error) } + + it "notifies the error and tags it with the resource_id" do + exception = StandardError.new("Test error") + event = ActiveSupport::Notifications::Event.new( + "connection_errored.session.s2", + Time.current, + Time.current, + 1, + { exception:, resource_id: "RM-123" }, + ) + + described_class.new.connection_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with( + exception, + extra: {}, + tags: { resource_id: "RM-123" }, + ) + end + + it "attaches HTTP response details from Async::WebSocket::ConnectionError" do + body = instance_double(Protocol::HTTP::Body::Readable, read: "Resource not found") + headers = Protocol::HTTP::Headers["content-type" => "application/problem+json"] + response = Protocol::HTTP::Response.new("HTTP/2", 404, headers, body) + exception = Async::WebSocket::ConnectionError.new("Failed to negotiate connection!", response) + event = ActiveSupport::Notifications::Event.new( + "connection_errored.session.s2", + Time.current, + Time.current, + 1, + { exception:, resource_id: "RM-123" }, + ) + + described_class.new.connection_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with( + exception, + extra: { + http_status: 404, + http_headers: { "content-type" => "application/problem+json" }, + http_body: "Resource not found", + }, + tags: { resource_id: "RM-123" }, + ) + end + + it "omits HTTP details when the ConnectionError carries no response" do + exception = Async::WebSocket::ConnectionError.new("Failed to negotiate connection!", nil) + event = ActiveSupport::Notifications::Event.new( + "connection_errored.session.s2", + Time.current, + Time.current, + 1, + { exception:, resource_id: "RM-123" }, + ) + + described_class.new.connection_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with( + exception, + extra: {}, + tags: { resource_id: "RM-123" }, + ) + end + end + + describe "message_errored" do + it "notifies the error" do + exception = StandardError.new("Test error") + allow(ErrorNotifier).to receive(:notify_error) + event = ActiveSupport::Notifications::Event.new( + "message_errored.session.s2", + Time.current, + Time.current, + 1, + { exception: }, + ) + + described_class.new.message_errored(event) + + expect(ErrorNotifier).to have_received(:notify_error).with(exception) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/async.rb b/rm-mqtt-ws-bridge/rm/spec/support/async.rb new file mode 100644 index 0000000..1f8a646 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/async.rb @@ -0,0 +1,10 @@ +require "async" + +RSpec.configure do |config| + config.around(:each, :async) do |example| + Async do |task| + example.run + task.stop + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/factory_bot.rb b/rm-mqtt-ws-bridge/rm/spec/support/factory_bot.rb new file mode 100644 index 0000000..c7890e4 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/factory_bot.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/fake_mqtt_client.rb b/rm-mqtt-ws-bridge/rm/spec/support/fake_mqtt_client.rb new file mode 100644 index 0000000..3b70d43 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/fake_mqtt_client.rb @@ -0,0 +1,12 @@ +require "stekker/mqtt/fake_client" +require "stekker/mqtt/rspec/matchers" + +def with_fake_mqtt_client(&) # rubocop:disable Metrics/AbcSize + fake_mqtt_client = Stekker::MQTT::FakeClient.new + allow(MQTT::Client).to receive(:connect).and_yield(fake_mqtt_client) + + yield fake_mqtt_client + + expect(MQTT::Client).to have_received(:connect).at_least(:once).with(ReferenceConfig.mqtt_url) + allow(MQTT::Client).to receive(:connect).and_call_original +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/fake_websocket.rb b/rm-mqtt-ws-bridge/rm/spec/support/fake_websocket.rb new file mode 100644 index 0000000..3f6bbf2 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/fake_websocket.rb @@ -0,0 +1,40 @@ +class FakeWebSocket + attr_reader :sent_messages + + def initialize + @incoming = Async::Queue.new + @buffered_messages = [] + @sent_messages = [] + @closed = false + end + + def read + return nil if @closed + + @incoming.dequeue + rescue Async::Stop + nil + end + + def write(payload) + @buffered_messages << payload + end + + def flush + @sent_messages.concat(@buffered_messages) + @buffered_messages.clear + end + + def close + @closed = true + @incoming.enqueue(nil) # Unblock any waiting reads + end + + def simulate_incoming(payload) + @incoming.enqueue(payload) + end + + def closed? + @closed + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/message_sender_stub.rb b/rm-mqtt-ws-bridge/rm/spec/support/message_sender_stub.rb new file mode 100644 index 0000000..9cbde6d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/message_sender_stub.rb @@ -0,0 +1,17 @@ +class MessageSenderStub < S2::MessageSender + attr_reader :sent_messages + + def initialize + super(resource_id: FactoryBot.generate(:uuid)) do |_message| + # ignored + end + + @sent_messages = [] + end + + def send_message(message, &) + @sent_messages << message + + yield if block_given? + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/shoulda_matchers.rb b/rm-mqtt-ws-bridge/rm/spec/support/shoulda_matchers.rb new file mode 100644 index 0000000..7d045f3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/shoulda_matchers.rb @@ -0,0 +1,6 @@ +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/rm-mqtt-ws-bridge/rm/spec/support/websocket_server.rb b/rm-mqtt-ws-bridge/rm/spec/support/websocket_server.rb new file mode 100644 index 0000000..e5edef0 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/spec/support/websocket_server.rb @@ -0,0 +1,87 @@ +require "async/websocket/adapters/http" + +class WebsocketServer + DEFAULT_PATH = "/ws".freeze + + attr_reader :url, :port + + def initialize(host: "127.0.0.1", path: DEFAULT_PATH) + @host = host + @path = path + @port = find_free_port(host) + @endpoint = Async::HTTP::Endpoint.parse( + "http://#{@host}:#{@port}", + protocol: Async::HTTP::Protocol::HTTP1, + ) + @server = Async::HTTP::Server.new(method(:app), @endpoint) + @url = "http://#{@host}:#{@port}" + @reactor = @task = nil + end + + def start + @reactor = Async do |task| + @task = task.async { @server.run } + @task.sleep 0.01 + end + + self + end + + def stop + @task&.stop + @reactor&.stop + ensure + @task = @reactor = nil + end + + private + + def app(request) # rubocop:disable Metrics/AbcSize + Rails.logger.info "WebSocket Server received request: #{request.method} #{request.path}" + + if request.path.start_with?(@path) && Async::WebSocket::Adapters::HTTP.websocket?(request) + Rails.logger.info "WebSocket Server upgrading to WebSocket protocol" + + response = Async::WebSocket::Adapters::HTTP.open(request) do |ws| + Rails.logger.info "WebSocket Server connected" + + while (msg = ws.read) + Rails.logger.info "WebSocket Server received: #{msg.to_str}" + ws.write(msg) # echo message back + end + end + + return response + end + + Async::HTTP::Protocol::Response[200, { "content-type" => "text/plain" }, ["OK"]] + end + + def find_free_port(host) + TCPServer.open(host, 0) { |s| s.addr[1] } + end +end + +module WebsocketServerHelper + def ws_server + @ws_server + end +end + +RSpec.configure do |config| + config.include WebsocketServerHelper + + config.around(:each, :ws_server) do |example| + old_cem_base_url = ENV["CEM_BASE_URL"] + @ws_server = WebsocketServer.new.start + ENV["CEM_BASE_URL"] = "#{@ws_server.url}/ws" + + begin + example.run + ensure + @ws_server.stop + @ws_server = nil + ENV["CEM_BASE_URL"] = old_cem_base_url + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/.keep b/rm-mqtt-ws-bridge/rm/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.gitignore b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.gitignore new file mode 100644 index 0000000..71aca8e --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.gitignore @@ -0,0 +1,9 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +spec/examples.txt diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.rspec b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.ruby-version b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.ruby-version new file mode 100644 index 0000000..47b322c --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/.ruby-version @@ -0,0 +1 @@ +3.4.1 diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Gemfile b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Gemfile new file mode 100644 index 0000000..fddcede --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Gemfile @@ -0,0 +1,10 @@ +source "https://rubygems.org" + +# Specify your gem's dependencies in stekker-mqtt.gemspec +gemspec + +gem "factory_bot" + +gem "rake", "~> 13.0" + +gem "rspec" diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Gemfile.lock b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Gemfile.lock new file mode 100644 index 0000000..42d4ad7 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Gemfile.lock @@ -0,0 +1,95 @@ +PATH + remote: . + specs: + stekker-mqtt (0.1.0) + activesupport (>= 6.0) + dry-struct + dry-types + mqtt + +GEM + remote: https://rubygems.org/ + specs: + activesupport (8.0.4.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.0.1) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + diff-lcs (1.6.2) + drb (2.2.3) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-struct (1.8.0) + 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) + factory_bot (6.5.5) + activesupport (>= 6.1.0) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + logger (1.7.0) + minitest (5.27.0) + mqtt (0.7.0) + logger + rake (13.3.0) + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + zeitwerk (2.7.3) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + factory_bot + rake (~> 13.0) + rspec + stekker-mqtt! + +BUNDLED WITH + 2.6.9 diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/LICENSE.txt b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/LICENSE.txt new file mode 100644 index 0000000..b0cb2e3 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Stekker.app B.V. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/README.md b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/README.md new file mode 100644 index 0000000..fb89ae4 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/README.md @@ -0,0 +1,3 @@ +# Stekker::MQTT + +Contains MQTT message definitions for Stekker. \ No newline at end of file diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Rakefile b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Rakefile new file mode 100644 index 0000000..2995527 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/Rakefile @@ -0,0 +1 @@ +require "bundler/gem_tasks" diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/console b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/console new file mode 100755 index 0000000..17c33ca --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "stekker/mqtt" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/generate_messages b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/generate_messages new file mode 100755 index 0000000..34bb476 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/generate_messages @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby + +require "json" + +Dir.glob("lib/json-schema/*.schema.json").each do |file| + json = JSON.parse(File.read(file)) + json["type"] ||= "object" unless json["enum"] + File.write(file, JSON.pretty_generate(json, indent: " ") + "\n") +end + +`npx -y quicktype -s schema lib/json-schema/*.schema.json -l ruby --namespace Stekker::MQTT::Messages -o lib/stekker/mqtt/messages.rb --strictness coercible` diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/setup b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/charge_status.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/charge_status.schema.json new file mode 100644 index 0000000..fae6e8f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/charge_status.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/charge-status.schema.json", + "title": "ChargeStatus", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "charge_status", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "sampled_at", + "value" + ], + "properties": { + "sampled_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Timestamp when the measurement was taken." + }, + "value": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Percentage", + "description": "Charge status as a percentage." + } + }, + "description": "Container for charge status." + } + }, + "examples": [ + { + "message_type": "charge_status", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "sampled_at": "2025-09-29T12:34:56Z", + "value": 57.8 + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/charge_target.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/charge_target.schema.json new file mode 100644 index 0000000..f6b5dea --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/charge_target.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/charge-target.schema.json", + "title": "ChargeTarget", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "charge_target", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "starts_at", + "ends_at", + "direct_percentage", + "smart_percentage" + ], + "properties": { + "starts_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Timestamp when the charge target starts." + }, + "ends_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Timestamp when the charge target ends." + }, + "direct_percentage": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Percentage", + "description": "The charge level to be reached as soon as possible." + }, + "smart_percentage": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Percentage", + "description": "The charge level to be reached before ends_at." + } + }, + "description": "Container for charge target." + } + }, + "examples": [ + { + "message_type": "charge_target", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "sampled_at": "2025-09-29T12:34:56Z", + "value": 57.8 + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/common.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/common.schema.json new file mode 100644 index 0000000..6a9dfdb --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/common.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/common.json", + "title": "CommonDefs", + "$defs": { + "Percentage": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage value between 0 and 100." + }, + "Timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp (UTC recommended)." + }, + "UUIDv4": { + "type": "string", + "format": "uuid", + "description": "UUID v4 or v5 identifier.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[45][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + "UnitW": { + "type": "string", + "const": "W", + "description": "Unit of measurement. Always 'W'." + } + }, + "type": "object" +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/connect.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/connect.schema.json new file mode 100644 index 0000000..f07e5dd --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/connect.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/connect.schema.json", + "title": "Connect", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "connect", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "voltage", + "number_of_phases", + "minimum_current", + "maximum_current" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name of the connector or resource." + }, + "voltage": { + "type": "integer", + "minimum": 1, + "description": "Nominal voltage of the connection in volts." + }, + "number_of_phases": { + "type": "integer", + "enum": [ + 1, + 3 + ], + "description": "Number of electrical phases available. Must be 1 or 3." + }, + "minimum_current": { + "type": "integer", + "minimum": 0, + "description": "Minimum available current per phase in amperes." + }, + "maximum_current": { + "type": "integer", + "minimum": 1, + "description": "Maximum available current per phase in amperes." + } + }, + "description": "Container object with connection details." + } + }, + "examples": [ + { + "message_type": "connect", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "name": "Some name", + "voltage": 230, + "number_of_phases": 3, + "minimum_current": 6, + "maximum_current": 16 + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/disconnect.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/disconnect.schema.json new file mode 100644 index 0000000..af4a71f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/disconnect.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/disconnect.schema.json", + "title": "Disconnect", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id" + ], + "properties": { + "message_type": { + "const": "disconnect", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + } + }, + "examples": [ + { + "message_type": "disconnect", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/instruction_update.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/instruction_update.schema.json new file mode 100644 index 0000000..cc56b3c --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/instruction_update.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/instruction-update.schema.json", + "title": "InstructionUpdate", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "instruction_update", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "status", + "updated_at" + ], + "properties": { + "reference": { + "type": "string", + "minLength": 1, + "description": "Reference string identifying the instruction to update." + }, + "status": { + "type": "string", + "enum": [ + "NEW", + "ACCEPTED", + "REJECTED", + "REVOKED", + "STARTED", + "SUCCEEDED", + "ABORTED" + ], + "description": "Instruction status. Must be one of: REJECTED, STARTED, SUCCEEDED, ABORTED." + }, + "updated_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Timestamp when the instruction status was last updated." + } + }, + "description": "Instruction update details." + } + }, + "examples": [ + { + "message_type": "instruction_update", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "reference": "abc-123", + "status": "SUCCEEDED", + "updated_at": "2025-10-06T14:25:00Z" + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_forecast.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_forecast.schema.json new file mode 100644 index 0000000..41a65a2 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_forecast.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/power-forecast.schema.json", + "title": "PowerForecast", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "power_forecast", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "start_at", + "values", + "unit" + ], + "properties": { + "start_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Start time of the forecast (ISO 8601)." + }, + "values": { + "type": "array", + "minItems": 1, + "description": "List of forecasted power values over time.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "timestamp", + "value" + ], + "properties": { + "timestamp": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp" + }, + "value": { + "type": "integer", + "minimum": 0, + "description": "Forecasted power value in watts." + } + } + } + }, + "unit": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UnitW" + } + }, + "description": "Container for forecast data." + } + }, + "examples": [ + { + "message_type": "power_forecast", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "start_at": "2025-09-29T12:30:00Z", + "values": [ + { + "timestamp": "2025-09-29T12:30:00Z", + "value": 3500 + }, + { + "timestamp": "2025-09-29T12:45:00Z", + "value": 4000 + } + ], + "unit": "W" + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_measurement.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_measurement.schema.json new file mode 100644 index 0000000..3738650 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_measurement.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/power-measurement.schema.json", + "title": "PowerMeasurement", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "power_measurement", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "sampled_at", + "value", + "unit" + ], + "properties": { + "sampled_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Timestamp when the measurement was taken." + }, + "value": { + "type": "integer", + "minimum": 0, + "description": "Power value in watts." + }, + "unit": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UnitW" + } + }, + "description": "Container for measurement data." + } + }, + "examples": [ + { + "message_type": "power_measurement", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "sampled_at": "2025-09-29T12:34:56Z", + "value": 7200, + "unit": "W" + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_setpoint.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_setpoint.schema.json new file mode 100644 index 0000000..cc1457f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/power_setpoint.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/power-setpoint.schema.json", + "title": "PowerSetpoint", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id", + "data" + ], + "properties": { + "message_type": { + "const": "power_setpoint", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "starts_at", + "value", + "unit" + ], + "properties": { + "reference": { + "type": "string", + "minLength": 1, + "description": "Unique reference string for this power setpoint instruction." + }, + "starts_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "Start time of the power setpoint in ISO 8601 format." + }, + "ends_at": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/Timestamp", + "description": "End time of the power setpoint in ISO 8601 format." + }, + "value": { + "type": "integer", + "minimum": 0, + "description": "Maximum allowed power in watts." + }, + "unit": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UnitW" + } + }, + "description": "Container object with power setpoint details." + } + }, + "examples": [ + { + "message_type": "power_setpoint", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "data": { + "reference": "abc-123", + "starts_at": "2025-10-06T12:00:00Z", + "ends_at": "2025-10-06T13:00:00Z", + "value": 11040, + "unit": "W" + } + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/s2_ready_notification.schema.json b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/s2_ready_notification.schema.json new file mode 100644 index 0000000..2187034 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/json-schema/s2_ready_notification.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.stekker.app/s2_ready_notification.schema.json", + "title": "S2ReadyNotification", + "type": "object", + "additionalProperties": false, + "required": [ + "message_type", + "resource_id" + ], + "properties": { + "message_type": { + "const": "s2_ready_notification", + "description": "Fixed message type identifier." + }, + "resource_id": { + "$ref": "https://schemas.stekker.app/common.json#/$defs/UUIDv4" + } + }, + "examples": [ + { + "message_type": "s2_ready_notification", + "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ] +} diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt.rb new file mode 100644 index 0000000..649c9b4 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt.rb @@ -0,0 +1,14 @@ +require_relative "mqtt/factories" +require_relative "mqtt/listener" +require_relative "mqtt/message_factory" +require_relative "mqtt/message_handler" +require_relative "mqtt/messages" +require_relative "mqtt/publisher" +require_relative "mqtt/version" + +module Stekker + module MQTT + class Error < StandardError; end + # Your code goes here... + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/factories.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/factories.rb new file mode 100644 index 0000000..8c46feb --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/factories.rb @@ -0,0 +1,7 @@ +module Stekker + module MQTT + module Factories + LOAD_PATH = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "..", "spec", "factories")).freeze + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/fake_client.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/fake_client.rb new file mode 100644 index 0000000..5f0eeb6 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/fake_client.rb @@ -0,0 +1,24 @@ +module Stekker + module MQTT + class FakeClient + attr_reader :published_messages + + def initialize + @published_messages = [] + end + + def publish(topic, message = "", retain = false, qos = 0) # rubocop:disable Style/OptionalBooleanParameter + @published_messages << [topic, message, retain, qos] + end + + def has_published?(expected_topic, expected_message, expected_retain = false, expected_qos = 0) # rubocop:disable Style/OptionalBooleanParameter + @published_messages.any? do |(published_topic, published_message, retain, qos)| + published_topic == expected_topic && + JSON.parse(published_message) == JSON.parse(expected_message) && + retain == expected_retain && + qos == expected_qos + end + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/listener.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/listener.rb new file mode 100644 index 0000000..d6882be --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/listener.rb @@ -0,0 +1,108 @@ +require "mqtt" + +module Stekker + module MQTT + class Listener + def initialize(url:, topics:, message_handler:, reconnect_delay_seconds: 5, connect_opts: {}, executor: nil) + @url = url + @connect_opts = connect_opts + @topics = topics.is_a?(String) ? [topics] : topics + @message_handler = message_handler + @reconnect_delay_seconds = reconnect_delay_seconds + @executor = executor + + @thread = nil + @status = :initialized + end + + def run + @status = :running + + listen_loop + end + + def start + return if running? + + @status = :running + + @thread = Thread.new do + listen_loop + + ActiveSupport::Notifications.instrument("thread_exited.listener.mqtt.stekker") + end + end + + def stop(timeout: 2) + thread = @thread + return unless thread + + @status = :stopping + + unless thread.join(timeout) + thread.kill + thread.join + end + + @thread = nil + @status = :stopped + + ActiveSupport::Notifications.instrument("stopped.listener.mqtt.stekker") + end + + def running? + !!@thread&.alive? + end + + private + + def listen_loop + loop do + ActiveSupport::Notifications.instrument("starting.listener.mqtt.stekker") + + begin + listen + + ActiveSupport::Notifications.instrument("graceful_shutdown.listener.mqtt.stekker") + rescue StandardError => e + ActiveSupport::Notifications.instrument("listen_errored.listener.mqtt.stekker", exception: e) + ensure + break if @status != :running + + sleep @reconnect_delay_seconds + end + end + end + + def listen + ::MQTT::Client.connect(@url, **@connect_opts) do |client| + ActiveSupport::Notifications.instrument("connected.listener.mqtt.stekker") + + client.subscribe(*@topics) + + ActiveSupport::Notifications.instrument("subscribed.listener.mqtt.stekker", topics: @topics) + + client.get do |topic, message| + ActiveSupport::Notifications.instrument("message_received.listener.mqtt.stekker", topic:, message:) + + wrap_execution do + @message_handler.handle_message(message) + rescue StandardError => e + ActiveSupport::Notifications.instrument("message_handling_error.listener.mqtt.stekker", exception: e) + end + rescue StandardError => e + ActiveSupport::Notifications.instrument("get_errored.listener.mqtt.stekker", exception: e) + end + end + end + + def wrap_execution(&) + if @executor + @executor.wrap(&) + else + yield + end + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/message_factory.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/message_factory.rb new file mode 100644 index 0000000..0c5bb09 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/message_factory.rb @@ -0,0 +1,50 @@ +require_relative "messages" + +class Stekker::MQTT::MessageFactory + MESSAGE_TYPE_TO_MESSAGE_CLASS = { + Stekker::MQTT::Messages::ConnectMessageType::Connect => Stekker::MQTT::Messages::Connect, + Stekker::MQTT::Messages::DisconnectMessageType::Disconnect => Stekker::MQTT::Messages::Disconnect, + Stekker::MQTT::Messages::ChargeStatusMessageType::ChargeStatus => Stekker::MQTT::Messages::ChargeStatus, + Stekker::MQTT::Messages::ChargeTargetMessageType::ChargeTarget => Stekker::MQTT::Messages::ChargeTarget, + Stekker::MQTT::Messages::InstructionUpdateMessageType::InstructionUpdate => Stekker::MQTT::Messages::InstructionUpdate, + Stekker::MQTT::Messages::PowerForecastMessageType::PowerForecast => Stekker::MQTT::Messages::PowerForecast, + Stekker::MQTT::Messages::PowerMeasurementMessageType::PowerMeasurement => Stekker::MQTT::Messages::PowerMeasurement, + Stekker::MQTT::Messages::PowerSetpointMessageType::PowerSetpoint => Stekker::MQTT::Messages::PowerSetpoint, + Stekker::MQTT::Messages::S2ReadyNotificationMessageType::S2ReadyNotification => Stekker::MQTT::Messages::S2ReadyNotification, + }.freeze + + class BaseError < StandardError; end + class InvalidMessageFormat < BaseError; end + class MissingMessageType < BaseError; end + class UnsupportedMessageType < BaseError; end + class InvalidMessagePayload < BaseError; end + + class << self + def create_message(data) + message = data.is_a?(String) ? parse_json(data) : data + message_type = message["message_type"] + + raise(MissingMessageType, "Message type not provided") if message_type.to_s.empty? + + message_class = MESSAGE_TYPE_TO_MESSAGE_CLASS[message_type] + + raise(UnsupportedMessageType, "Message type not supported: #{message_type}") if message_class.nil? + + build_message(message_class, message) + end + + private + + def parse_json(data) + JSON.parse(data) + rescue JSON::ParserError + raise(InvalidMessageFormat, "Invalid JSON") + end + + def build_message(message_class, message) + message_class.from_dynamic!(message) + rescue KeyError => e + raise(InvalidMessagePayload, "Field missing in payload: #{e.key}") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/message_handler.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/message_handler.rb new file mode 100644 index 0000000..fc76287 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/message_handler.rb @@ -0,0 +1,62 @@ +require "active_support/core_ext/class/attribute" +require "active_support/core_ext/object/blank" + +module Stekker + module MQTT + class MessageHandler + class HandlerNotFound < StandardError; end + + class_attribute :handlers, instance_writer: false, default: {}.freeze + class_attribute :raise_handling_errors, instance_writer: true, instance_reader: true, default: false + + class << self + def on(type, &) + raise ArgumentError, "Handler already registered for #{type}" if handler_registered?(type) + + register_handler(type, &) + end + + def handler_for(type) + handlers[type.to_s] + end + + private + + def handler_registered?(type) + handlers.has_key?(type.to_s) + end + + def register_handler(type, &block) + new_map = handlers.dup + new_map[type.to_s] = block + self.handlers = new_map.freeze + end + end + + def handle_message(payload) + message = Stekker::MQTT::MessageFactory.create_message(payload) + dispatch_message(message) + rescue StandardError => e + ActiveSupport::Notifications.instrument( + "message_errored.message_handler.mqtt.stekker", + exception: e, + payload: payload, + ) + + raise if raise_handling_errors + end + + private + + def dispatch_message(message, type: message.class) + raise ArgumentError, "Missing message type" if type.blank? + + handler = self.class.handler_for(type) + + raise(HandlerNotFound, "No handler registered for #{type}") unless handler + + instance_exec(message, &handler) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/messages.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/messages.rb new file mode 100644 index 0000000..73caa43 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/messages.rb @@ -0,0 +1,757 @@ +# This code may look unusually verbose for Ruby (and it is), but +# it performs some subtle and complex validation of JSON data. +# +# To parse this JSON, add 'dry-struct' and 'dry-types' gems, then do: +# +# charge_status = ChargeStatus.from_json! "{…}" +# puts charge_status.data.sampled_at +# +# charge_target = ChargeTarget.from_json! "{…}" +# puts charge_target.data.direct_percentage +# +# common_defs = CommonDefs.from_json! "{…}" +# puts common_defs["…"] +# +# connect = Connect.from_json! "{…}" +# puts connect.data.maximum_current.even? +# +# disconnect = Disconnect.from_json! "{…}" +# puts disconnect.message_type == DisconnectMessageType::Disconnect +# +# instruction_update = InstructionUpdate.from_json! "{…}" +# puts instruction_update.data.reference +# +# power_forecast = PowerForecast.from_json! "{…}" +# puts power_forecast.data.values.first.timestamp +# +# power_measurement = PowerMeasurement.from_json! "{…}" +# puts power_measurement.data.sampled_at +# +# power_setpoint = PowerSetpoint.from_json! "{…}" +# puts power_setpoint.data.ends_at +# +# s2_ready_notification = S2ReadyNotification.from_json! "{…}" +# puts s2_ready_notification.message_type == S2ReadyNotificationMessageType::S2ReadyNotification +# +# If from_json! succeeds, the value returned matches the schema. + +require 'json' +require 'dry-types' +require 'dry-struct' + +module Stekker + module MQTT + module Messages + module Types + include Dry.Types(default: :nominal) + + Integer = Coercible::Integer + Hash = Coercible::Hash + String = Coercible::String + Double = Coercible::Float | Coercible::Integer + ChargeStatusMessageType = Coercible::String.enum("charge_status") + ChargeTargetMessageType = Coercible::String.enum("charge_target") + ConnectMessageType = Coercible::String.enum("connect") + DisconnectMessageType = Coercible::String.enum("disconnect") + Status = Coercible::String.enum("ABORTED", "ACCEPTED", "NEW", "REJECTED", "REVOKED", "STARTED", "SUCCEEDED") + InstructionUpdateMessageType = Coercible::String.enum("instruction_update") + Unit = Coercible::String.enum("W") + PowerForecastMessageType = Coercible::String.enum("power_forecast") + PowerMeasurementMessageType = Coercible::String.enum("power_measurement") + PowerSetpointMessageType = Coercible::String.enum("power_setpoint") + S2ReadyNotificationMessageType = Coercible::String.enum("s2_ready_notification") + end + + # Container for charge status. + class ChargeStatusData < Dry::Struct + + # Timestamp when the measurement was taken. + attribute :sampled_at, Types::String + + # Charge status as a percentage. + attribute :value, Types::Double + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + sampled_at: d.fetch("sampled_at"), + value: d.fetch("value"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "sampled_at" => sampled_at, + "value" => value, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module ChargeStatusMessageType + ChargeStatus = "charge_status" + end + + class ChargeStatus < Dry::Struct + + # Container for charge status. + attribute :data, ChargeStatusData + + # Fixed message type identifier. + attribute :message_type, Types::ChargeStatusMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: ChargeStatusData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Container for charge target. + class ChargeTargetData < Dry::Struct + + # The charge level to be reached as soon as possible. + attribute :direct_percentage, Types::Double + + # Timestamp when the charge target ends. + attribute :ends_at, Types::String + + # The charge level to be reached before ends_at. + attribute :smart_percentage, Types::Double + + # Timestamp when the charge target starts. + attribute :starts_at, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + direct_percentage: d.fetch("direct_percentage"), + ends_at: d.fetch("ends_at"), + smart_percentage: d.fetch("smart_percentage"), + starts_at: d.fetch("starts_at"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "direct_percentage" => direct_percentage, + "ends_at" => ends_at, + "smart_percentage" => smart_percentage, + "starts_at" => starts_at, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module ChargeTargetMessageType + ChargeTarget = "charge_target" + end + + class ChargeTarget < Dry::Struct + + # Container for charge target. + attribute :data, ChargeTargetData + + # Fixed message type identifier. + attribute :message_type, Types::ChargeTargetMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: ChargeTargetData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Container object with connection details. + class ConnectData < Dry::Struct + + # Maximum available current per phase in amperes. + attribute :maximum_current, Types::Integer + + # Minimum available current per phase in amperes. + attribute :minimum_current, Types::Integer + + # Human-readable name of the connector or resource. + attribute :data_name, Types::String + + # Number of electrical phases available. Must be 1 or 3. + attribute :number_of_phases, Types::Integer + + # Nominal voltage of the connection in volts. + attribute :voltage, Types::Integer + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + maximum_current: d.fetch("maximum_current"), + minimum_current: d.fetch("minimum_current"), + data_name: d.fetch("name"), + number_of_phases: d.fetch("number_of_phases"), + voltage: d.fetch("voltage"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "maximum_current" => maximum_current, + "minimum_current" => minimum_current, + "name" => data_name, + "number_of_phases" => number_of_phases, + "voltage" => voltage, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module ConnectMessageType + Connect = "connect" + end + + class Connect < Dry::Struct + + # Container object with connection details. + attribute :data, ConnectData + + # Fixed message type identifier. + attribute :message_type, Types::ConnectMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: ConnectData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module DisconnectMessageType + Disconnect = "disconnect" + end + + class Disconnect < Dry::Struct + + # Fixed message type identifier. + attribute :message_type, Types::DisconnectMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Instruction status. Must be one of: REJECTED, STARTED, SUCCEEDED, ABORTED. + module Status + Aborted = "ABORTED" + Accepted = "ACCEPTED" + New = "NEW" + Rejected = "REJECTED" + Revoked = "REVOKED" + Started = "STARTED" + Succeeded = "SUCCEEDED" + end + + # Instruction update details. + class InstructionUpdateData < Dry::Struct + + # Reference string identifying the instruction to update. + attribute :reference, Types::String + + # Instruction status. Must be one of: REJECTED, STARTED, SUCCEEDED, ABORTED. + attribute :status, Types::Status + + # Timestamp when the instruction status was last updated. + attribute :updated_at, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + reference: d.fetch("reference"), + status: d.fetch("status"), + updated_at: d.fetch("updated_at"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "reference" => reference, + "status" => status, + "updated_at" => updated_at, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module InstructionUpdateMessageType + InstructionUpdate = "instruction_update" + end + + class InstructionUpdate < Dry::Struct + + # Instruction update details. + attribute :data, InstructionUpdateData + + # Fixed message type identifier. + attribute :message_type, Types::InstructionUpdateMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: InstructionUpdateData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Unit of measurement. Always 'W'. + module Unit + W = "W" + end + + class Value < Dry::Struct + attribute :timestamp, Types::String + + # Forecasted power value in watts. + attribute :value, Types::Integer + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + timestamp: d.fetch("timestamp"), + value: d.fetch("value"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "timestamp" => timestamp, + "value" => value, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Container for forecast data. + class PowerForecastData < Dry::Struct + + # Start time of the forecast (ISO 8601). + attribute :start_at, Types::String + + attribute :unit, Types::Unit + + # List of forecasted power values over time. + attribute :values, Types.Array(Value) + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + start_at: d.fetch("start_at"), + unit: d.fetch("unit"), + values: d.fetch("values").map { |x| Value.from_dynamic!(x) }, + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "start_at" => start_at, + "unit" => unit, + "values" => values.map { |x| x.to_dynamic }, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module PowerForecastMessageType + PowerForecast = "power_forecast" + end + + class PowerForecast < Dry::Struct + + # Container for forecast data. + attribute :data, PowerForecastData + + # Fixed message type identifier. + attribute :message_type, Types::PowerForecastMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: PowerForecastData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Container for measurement data. + class PowerMeasurementData < Dry::Struct + + # Timestamp when the measurement was taken. + attribute :sampled_at, Types::String + + attribute :unit, Types::Unit + + # Power value in watts. + attribute :value, Types::Integer + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + sampled_at: d.fetch("sampled_at"), + unit: d.fetch("unit"), + value: d.fetch("value"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "sampled_at" => sampled_at, + "unit" => unit, + "value" => value, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module PowerMeasurementMessageType + PowerMeasurement = "power_measurement" + end + + class PowerMeasurement < Dry::Struct + + # Container for measurement data. + attribute :data, PowerMeasurementData + + # Fixed message type identifier. + attribute :message_type, Types::PowerMeasurementMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: PowerMeasurementData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + # Container object with power setpoint details. + class PowerSetpointData < Dry::Struct + + # End time of the power setpoint in ISO 8601 format. + attribute :ends_at, Types::String.optional + + # Unique reference string for this power setpoint instruction. + attribute :reference, Types::String + + # Start time of the power setpoint in ISO 8601 format. + attribute :starts_at, Types::String + + attribute :unit, Types::Unit + + # Maximum allowed power in watts. + attribute :value, Types::Integer + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + ends_at: d["ends_at"], + reference: d.fetch("reference"), + starts_at: d.fetch("starts_at"), + unit: d.fetch("unit"), + value: d.fetch("value"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "ends_at" => ends_at, + "reference" => reference, + "starts_at" => starts_at, + "unit" => unit, + "value" => value, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module PowerSetpointMessageType + PowerSetpoint = "power_setpoint" + end + + class PowerSetpoint < Dry::Struct + + # Container object with power setpoint details. + attribute :data, PowerSetpointData + + # Fixed message type identifier. + attribute :message_type, Types::PowerSetpointMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + data: PowerSetpointData.from_dynamic!(d.fetch("data")), + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "data" => data.to_dynamic, + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module S2ReadyNotificationMessageType + S2ReadyNotification = "s2_ready_notification" + end + + class S2ReadyNotification < Dry::Struct + + # Fixed message type identifier. + attribute :message_type, Types::S2ReadyNotificationMessageType + + attribute :resource_id, Types::String + + def self.from_dynamic!(d) + d = Types::Hash[d] + new( + message_type: d.fetch("message_type"), + resource_id: d.fetch("resource_id"), + ) + end + + def self.from_json!(json) + from_dynamic!(JSON.parse(json)) + end + + def to_dynamic + { + "message_type" => message_type, + "resource_id" => resource_id, + } + end + + def to_json(options = nil) + JSON.generate(to_dynamic, options) + end + end + + module Stekker + module MQTT + module Messages + class CommonDefs + def self.from_json!(json) + Types::Hash[JSON.parse(json, quirks_mode: true)].map { |k, v| [k, Types::Any[v]] }.to_h + end + end + end + end + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/publisher.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/publisher.rb new file mode 100644 index 0000000..e8c113a --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/publisher.rb @@ -0,0 +1,35 @@ +require "mqtt" + +module Stekker + module MQTT + class Publisher + def initialize(url:, logger:) + @url = url + @logger = logger + end + + def publish(topic:, message:, retain: false, qos: 0) + with_connection { |connection| connection.publish(topic:, message:, retain:, qos:) } + end + + def with_connection + ::MQTT::Client.connect(@url) do |client| + yield Connection.new(client, @logger) + end + end + + class Connection + def initialize(client, logger) + @client = client + @logger = logger + end + + def publish(topic:, message:, retain: false, qos: 0) + json_message = message.is_a?(String) ? message : message.to_json + @client.publish(topic, json_message, retain, qos) + @logger&.log_outbound(topic:, message: json_message) + end + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/rspec/matchers.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/rspec/matchers.rb new file mode 100644 index 0000000..447c18f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/rspec/matchers.rb @@ -0,0 +1,94 @@ +require "json" + +RSpec::Matchers.define :have_published_mqtt_message do |topic:, message:, retain: false, qos: 0| # rubocop:disable Metrics/BlockLength + match do |client| + build_expected(topic, message, retain, qos) + collect_actual(client) + @actual_messages.any? { |m| m == @expected } + end + + match_when_negated do |client| + build_expected(topic, message, retain, qos) + collect_actual(client) + @actual_messages.none? { |m| m == @expected } + end + + failure_message do |_client| + parts = [] + parts << "Expected MQTT publish:\n#{pretty(@expected)}" + parts << if @actual_messages.empty? + "But no messages were published." + else + "But the client published:\n #{@actual_messages.map { |m| pretty(m) }.join("\n")}" + end + parts.join("\n") + end + + failure_message_when_negated do |_client| + "Did not expect MQTT publish, but found:\n#{pretty(@expected)}" + end + + description do + "publish to #{topic.inspect} with retain=#{retain} and qos=#{qos}" + end + + diffable + + attr_reader :expected + + def actual + (@actual_messages&.find { |m| m[:topic] == @expected[:topic] } || + @actual_messages&.first) || {} + end + + # Helpers + def build_expected(topic, message, retain, qos) + @expected = { + topic: topic, + payload: normalize_payload(message), + retain: retain, + qos: qos, + } + end + + def collect_actual(client) + raw = client.published_messages + @actual_messages = raw.map do |(t, m, r, q)| + { topic: t, payload: normalize_payload(m), retain: r, qos: q } + end + end + + def normalize_payload(obj) + case obj + when String + begin + deep_stringify_keys(JSON.parse(obj)) + rescue JSON::ParserError + obj + end + when Hash, Array + deep_stringify_keys(obj) + else + obj + end + end + + def deep_stringify_keys(value) + case value + when Hash + value.each_with_object({}) do |(k, v), acc| + acc[k.to_s] = deep_stringify_keys(v) + end + when Array + value.map { |v| deep_stringify_keys(v) } + else + value + end + end + + def pretty(obj) + JSON.pretty_generate(obj) + rescue StandardError + obj.inspect + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/version.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/version.rb new file mode 100644 index 0000000..2da4119 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/lib/stekker/mqtt/version.rb @@ -0,0 +1,5 @@ +module Stekker + module MQTT + VERSION = "0.1.0".freeze + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/charge_status.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/charge_status.rb new file mode 100644 index 0000000..71ff873 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/charge_status.rb @@ -0,0 +1,17 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_charge_status, class: "Stekker::MQTT::Messages::ChargeStatus" do + initialize_with { Stekker::MQTT::Messages::ChargeStatus.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::ChargeStatusMessageType::ChargeStatus } + + data do + { + sampled_at: Time.current.iso8601, + value: 50, + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/charge_target.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/charge_target.rb new file mode 100644 index 0000000..61575c7 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/charge_target.rb @@ -0,0 +1,19 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_charge_target, class: "Stekker::MQTT::Messages::ChargeTarget" do + initialize_with { Stekker::MQTT::Messages::ChargeTarget.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::ChargeTargetMessageType::ChargeTarget } + + data do + { + starts_at: Time.current.iso8601, + ends_at: 8.hours.from_now.iso8601, + direct_percentage: 25.0, + smart_percentage: 100.0, + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/connect.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/connect.rb new file mode 100644 index 0000000..bdc9c28 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/connect.rb @@ -0,0 +1,20 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_connect, class: "Stekker::MQTT::Messages::Connect" do + initialize_with { Stekker::MQTT::Messages::Connect.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::ConnectMessageType::Connect } + + data do + { + name: "My charger - #1", + voltage: 230, + number_of_phases: 3, + minimum_current: 0, + maximum_current: 16, + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/disconnect.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/disconnect.rb new file mode 100644 index 0000000..522b45d --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/disconnect.rb @@ -0,0 +1,10 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_disconnect, class: "Stekker::MQTT::Messages::Disconnect" do + initialize_with { Stekker::MQTT::Messages::Disconnect.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::DisconnectMessageType::Disconnect } + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/instruction_update.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/instruction_update.rb new file mode 100644 index 0000000..3daf893 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/instruction_update.rb @@ -0,0 +1,18 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_instruction_update, class: "Stekker::MQTT::Messages::InstructionUpdate" do + initialize_with { Stekker::MQTT::Messages::InstructionUpdate.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::InstructionUpdateMessageType::InstructionUpdate } + + data do + { + reference: SecureRandom.uuid, + updated_at: Time.current.iso8601, + status: Stekker::MQTT::Messages::Status::Succeeded, + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_forecast.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_forecast.rb new file mode 100644 index 0000000..6911430 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_forecast.rb @@ -0,0 +1,22 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_power_forecast, class: "Stekker::MQTT::Messages::PowerForecast" do + initialize_with { Stekker::MQTT::Messages::PowerForecast.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::PowerForecastMessageType::PowerForecast } + + data do + { + start_at: Time.current.iso8601, + unit: "W", + values: [ + { timestamp: 5.minutes.from_now.iso8601, value: 11_040 }, + { timestamp: 15.minutes.from_now.iso8601, value: 6_720 }, + { timestamp: 35.minutes.from_now.iso8601, value: 0 }, + ], + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_measurement.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_measurement.rb new file mode 100644 index 0000000..15cb83e --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_measurement.rb @@ -0,0 +1,18 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_power_measurement, class: "Stekker::MQTT::Messages::PowerMeasurement" do + initialize_with { Stekker::MQTT::Messages::PowerMeasurement.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::PowerMeasurementMessageType::PowerMeasurement } + + data do + { + sampled_at: Time.current.iso8601, + value: 1500, + unit: "W", + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_setpoint.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_setpoint.rb new file mode 100644 index 0000000..df9cc84 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/power_setpoint.rb @@ -0,0 +1,19 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_power_setpoint, class: "Stekker::MQTT::Messages::PowerSetpoint" do + initialize_with { Stekker::MQTT::Messages::PowerSetpoint.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::PowerSetpointMessageType::PowerSetpoint } + + data do + { + reference: SecureRandom.uuid, + starts_at: Time.current.iso8601, + value: 1500, + unit: "W", + } + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/s2_ready_notification.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/s2_ready_notification.rb new file mode 100644 index 0000000..656a321 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/factories/s2_ready_notification.rb @@ -0,0 +1,10 @@ +require "factory_bot" + +FactoryBot.define do + factory :stekker_mqtt_s2_ready_notification, class: "Stekker::MQTT::Messages::S2ReadyNotification" do + initialize_with { Stekker::MQTT::Messages::S2ReadyNotification.from_dynamic!(attributes.with_indifferent_access) } + + resource_id { SecureRandom.uuid } + message_type { Stekker::MQTT::Messages::S2ReadyNotificationMessageType::S2ReadyNotification } + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/spec_helper.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/spec_helper.rb new file mode 100644 index 0000000..9433bf2 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/spec_helper.rb @@ -0,0 +1,26 @@ +require_relative "../lib/stekker/mqtt" + +require "support/factory_bot" +require "active_support/core_ext/hash/indifferent_access" + +RSpec.configure do |config| + Kernel.srand config.seed + config.disable_monkey_patching! + config.example_status_persistence_file_path = "spec/examples.txt" + config.expose_dsl_globally = true + config.filter_run_when_matching :focus + config.order = :random + config.profile_examples = 10 + config.shared_context_metadata_behavior = :apply_to_host_groups + config.warnings = true + + config.default_formatter = "doc" if config.files_to_run.one? + + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/listener_spec.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/listener_spec.rb new file mode 100644 index 0000000..5cf0777 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/listener_spec.rb @@ -0,0 +1,126 @@ +describe Stekker::MQTT::Listener do + describe "#start" do + it "subscribes to the specified topics and processes messages" do + url = "mqtt://localhost" + topics = %w[test/topic1 test/topic2] + message_handler = instance_double(Stekker::MQTT::MessageHandler) + allow(message_handler).to receive(:handle_message) + listener = described_class.new(url:, topics:, message_handler:) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:subscribe).with(*topics) + allow(client).to receive(:get) + .and_yield("test/topic1", "Test message 1") + .and_yield("test/topic2", "Test message 2") + + listener.start + + sleep 0.01 + + expect(message_handler).to have_received(:handle_message).with("Test message 1") + expect(message_handler).to have_received(:handle_message).with("Test message 2") + end + + it "instruments errors when message handling fails" do + url = "mqtt://localhost" + topics = %w[test/topic] + message_handler = instance_double(Stekker::MQTT::MessageHandler) + allow(message_handler).to receive(:handle_message) + listener = described_class.new(url:, topics:, message_handler:) + + client_double = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client_double) + allow(client_double).to receive(:subscribe).with(*topics) + allow(client_double).to receive(:get).and_yield("test/topic", "Faulty message") + allow(ActiveSupport::Notifications).to receive(:instrument) + + error = StandardError.new("Handler error") + allow(message_handler).to receive(:handle_message).with("Faulty message").and_raise(error) + + listener.start + + sleep 0.01 + + expect(ActiveSupport::Notifications).to have_received(:instrument) + .with("message_handling_error.listener.mqtt.stekker", exception: error) + end + end + + describe "#run" do + it "listens for messages without spawning a thread" do + url = "mqtt://localhost" + topics = %w[test/topic] + message_handler = instance_double(Stekker::MQTT::MessageHandler) + listener = described_class.new(url:, topics:, message_handler:, reconnect_delay_seconds: 0) + + allow(message_handler).to receive(:handle_message) do + listener.instance_variable_set(:@status, :stopping) + end + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:subscribe).with(*topics) + allow(client).to receive(:get).and_yield("test/topic", "Test message") + + listener.run + + expect(message_handler).to have_received(:handle_message).with("Test message") + expect(listener).not_to be_running + end + end + + describe "executor" do + it "wraps message handling in the executor when provided" do + url = "mqtt://localhost" + topics = %w[test/topic] + message_handler = instance_double(Stekker::MQTT::MessageHandler) + + executor = class_double(ActiveSupport::Executor) + allow(executor).to receive(:wrap).and_yield + + listener = described_class.new(url:, topics:, message_handler:, executor:, reconnect_delay_seconds: 0) + + allow(message_handler).to receive(:handle_message) do + listener.instance_variable_set(:@status, :stopping) + end + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:subscribe).with(*topics) + allow(client).to receive(:get).and_yield("test/topic", "Test message") + + listener.run + + expect(executor).to have_received(:wrap).once + expect(message_handler).to have_received(:handle_message).with("Test message") + end + end + + describe "#stop" do + it "stops the listener thread" do + url = "mqtt://localhost" + topics = %w[test/topic] + message_handler = instance_double(Stekker::MQTT::MessageHandler) + allow(message_handler).to receive(:handle_message) + listener = described_class.new(url:, topics:, message_handler:) + + client_double = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client_double) + allow(client_double).to receive(:subscribe).with(*topics) + allow(client_double).to receive(:get).and_yield("test/topic", "Test message") + + listener.start + + sleep 0.01 + + expect(listener).to be_running + + listener.stop(timeout: 0.01) + + sleep 0.01 + + expect(listener).not_to be_running + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/message_factory_spec.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/message_factory_spec.rb new file mode 100644 index 0000000..850cb11 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/message_factory_spec.rb @@ -0,0 +1,90 @@ +describe Stekker::MQTT::MessageFactory do + describe "::create_message" do + it "creates a message object from valid JSON data" do + json_data = <<~JSON + { + "message_type": "power_measurement", + "resource_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "data": { + "sampled_at": "2024-10-01T12:00:00Z", + "unit": "W", + "value": 1500 + } + } + JSON + + message = described_class.create_message(json_data) + + expect(message).to be_a(Stekker::MQTT::Messages::PowerMeasurement) + + expect(message).to have_attributes( + message_type: "power_measurement", + resource_id: "ce7b430c-25be-41ec-b97c-ac0258aff498", + data: have_attributes( + sampled_at: "2024-10-01T12:00:00Z", + unit: "W", + value: 1500, + ), + ) + end + + it "raises InvalidMessageFormatError for invalid JSON data" do + invalid_json_data = "{ invalid json }" + + expect { described_class.create_message(invalid_json_data) } + .to raise_error(Stekker::MQTT::MessageFactory::InvalidMessageFormat, "Invalid JSON") + end + + it "raises MissingMessageTypeError when message_type is missing" do + json_data = <<~JSON + { + "resource_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "data": { + "sampled_at": "2024-10-01T12:00:00Z", + "unit": "W", + "value": 1500.5 + } + } + JSON + + expect { described_class.create_message(json_data) } + .to raise_error(Stekker::MQTT::MessageFactory::MissingMessageType, "Message type not provided") + end + + it "raises UnsupportedMessageTypeError for unsupported message_type" do + json_data = <<~JSON + { + "message_type": "UnknownType", + "resource_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "data": { + "sampled_at": "2024-10-01T12:00:00Z", + "unit": "W", + "value": 1500.5 + } + } + JSON + + expect { described_class.create_message(json_data) } + .to raise_error( + Stekker::MQTT::MessageFactory::UnsupportedMessageType, + /Message type not supported: UnknownType/, + ) + end + + it "raises InvalidMessagePayloadError for missing required fields" do + json_data = <<~JSON + { + "message_type": "power_measurement", + "resource_id": "ce7b430c-25be-41ec-b97c-ac0258aff498", + "data": { + "sampled_at": "2024-10-01T12:00:00Z", + "value": 1500.5 + } + } + JSON + + expect { described_class.create_message(json_data) } + .to raise_error(Stekker::MQTT::MessageFactory::InvalidMessagePayload, /Field missing in payload: unit/) + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/message_handler_spec.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/message_handler_spec.rb new file mode 100644 index 0000000..21d1c24 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/message_handler_spec.rb @@ -0,0 +1,66 @@ +describe Stekker::MQTT::MessageHandler do + describe "::on" do + it "registers a handler for the specified message class" do + handler_block = proc {} + + message_handler_class = Class.new(Stekker::MQTT::MessageHandler) do + on Stekker::MQTT::Messages::Connect, &handler_block + end + + expect(message_handler_class.handlers[Stekker::MQTT::Messages::Connect.name]).to eq(handler_block) + end + + it "raises an error when defining a second handler for the same message" do + handler_block = proc {} + + message_handler_class = Class.new(Stekker::MQTT::MessageHandler) do + on Stekker::MQTT::Messages::Connect, &handler_block + end + + expect { message_handler_class.on(Stekker::MQTT::Messages::Connect, &handler_block) } + .to raise_error(/Handler already registered for Stekker::MQTT::Messages::Connect/) + end + end + + describe "#handle_message" do + it "invokes the registered handler for the message" do + handler_invoked = false + handler_block = proc { |message| handler_invoked = message.instance_of?(Stekker::MQTT::Messages::Connect) } + + message_handler_class = Class.new(Stekker::MQTT::MessageHandler) do + on Stekker::MQTT::Messages::Connect, &handler_block + end + + handler = message_handler_class.new + message = build(:stekker_mqtt_connect) + + handler.handle_message(message.to_json) + expect(handler_invoked).to be true + end + + it "instruments an error event when message handling fails" do + handler_block = proc { |_message| raise StandardError, "Handler error" } + + message_handler_class = Class.new(Stekker::MQTT::MessageHandler) do + on Stekker::MQTT::Messages::Connect, &handler_block + end + + message = build(:stekker_mqtt_connect) + payload = message.to_json + + callback = ->(*args) do + event = ActiveSupport::Notifications::Event.new(*args) + expect(event.name).to eq("message_errored.message_handler.mqtt.stekker") + expect(event.payload[:payload]).to eq(payload) + expect(event.payload[:exception]).to be_a(StandardError) + expect(event.payload[:exception].message).to eq("Handler error") + end + + handler = message_handler_class.new + + ActiveSupport::Notifications.subscribed(callback, "message_errored.message_handler.mqtt.stekker") do + handler.handle_message(payload) + end + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/publisher_spec.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/publisher_spec.rb new file mode 100644 index 0000000..b388bd6 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/stekker/mqtt/publisher_spec.rb @@ -0,0 +1,129 @@ +class FakeLogger + def log_outbound(topic:, message:) + end +end + +describe Stekker::MQTT::Publisher do + describe "#publish" do + it "publishes the message to the MQTT broker with default retain and qos" do + url = "mqtt://localhost" + logger = instance_double(FakeLogger) + allow(logger).to receive(:log_outbound) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + publisher = described_class.new(url:, logger:) + publisher.publish(topic: "test/topic", message: "Test message") + + expect(client).to have_received(:publish).with("test/topic", "Test message", false, 0) + end + + it "publishes with custom qos" do + url = "mqtt://localhost" + logger = instance_double(FakeLogger) + allow(logger).to receive(:log_outbound) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + publisher = described_class.new(url:, logger:) + publisher.publish(topic: "test/topic", message: "Test message", qos: 1) + + expect(client).to have_received(:publish).with("test/topic", "Test message", false, 1) + end + + it "publishes with retain flag" do + url = "mqtt://localhost" + logger = instance_double(FakeLogger) + allow(logger).to receive(:log_outbound) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + publisher = described_class.new(url:, logger:) + publisher.publish(topic: "test/topic", message: "Test message", retain: true) + + expect(client).to have_received(:publish).with("test/topic", "Test message", true, 0) + end + + it "logs the outbound message" do + url = "mqtt://localhost" + logger = instance_double(FakeLogger) + allow(logger).to receive(:log_outbound) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + publisher = described_class.new(url:, logger:) + publisher.publish(topic: "test/topic", message: "Test message") + + expect(logger).to have_received(:log_outbound).with( + topic: "test/topic", + message: "Test message", + ) + end + + it "converts message to JSON when it responds to to_json" do + url = "mqtt://localhost" + logger = instance_double(FakeLogger) + allow(logger).to receive(:log_outbound) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + message = instance_double(Stekker::MQTT::Messages::PowerMeasurement, to_json: '{"key":"value"}') + + publisher = described_class.new(url:, logger:) + publisher.publish(topic: "test/topic", message:) + + expect(client).to have_received(:publish).with("test/topic", '{"key":"value"}', false, 0) + expect(logger).to have_received(:log_outbound).with( + topic: "test/topic", + message: '{"key":"value"}', + ) + end + + it "works without a logger" do + url = "mqtt://localhost" + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + publisher = described_class.new(url:, logger: nil) + publisher.publish(topic: "test/topic", message: "Test message") + + expect(client).to have_received(:publish).with("test/topic", "Test message", false, 0) + end + end + + describe "#with_connection" do + it "yields a connection that can publish multiple messages" do + url = "mqtt://localhost" + logger = instance_double(FakeLogger) + allow(logger).to receive(:log_outbound) + + client = instance_double(MQTT::Client) + allow(MQTT::Client).to receive(:connect).with(url).and_yield(client) + allow(client).to receive(:publish) + + publisher = described_class.new(url:, logger:) + publisher.with_connection do |connection| + connection.publish(topic: "topic/1", message: "Message 1") + connection.publish(topic: "topic/2", message: "Message 2", qos: 1) + end + + expect(MQTT::Client).to have_received(:connect).once + expect(client).to have_received(:publish).with("topic/1", "Message 1", false, 0) + expect(client).to have_received(:publish).with("topic/2", "Message 2", false, 1) + expect(logger).to have_received(:log_outbound).with(topic: "topic/1", message: "Message 1") + expect(logger).to have_received(:log_outbound).with(topic: "topic/2", message: "Message 2") + end + end +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/support/factory_bot.rb b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/support/factory_bot.rb new file mode 100644 index 0000000..bd54bb1 --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/spec/support/factory_bot.rb @@ -0,0 +1,7 @@ +require "factory_bot" + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods + + FactoryBot.find_definitions +end diff --git a/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/stekker-mqtt.gemspec b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/stekker-mqtt.gemspec new file mode 100644 index 0000000..6a6cd9f --- /dev/null +++ b/rm-mqtt-ws-bridge/rm/vendor/stekker-mqtt/stekker-mqtt.gemspec @@ -0,0 +1,30 @@ +require_relative "lib/stekker/mqtt/version" + +Gem::Specification.new do |spec| + spec.name = "stekker-mqtt" + spec.version = Stekker::MQTT::VERSION + spec.authors = ["Stekker.app B.V."] + + spec.summary = "Stekker MQTT integration" + spec.description = "Stekker MQTT integration" + spec.homepage = "https://stekker.com" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.4.1" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["rubygems_mfa_required"] = "true" + + spec.files = Dir.chdir(__dir__) do + Dir.glob("lib/**/*", File::FNM_DOTMATCH).select { |f| File.file?(f) } + + Dir.glob("{README*,LICENSE*,CHANGELOG*}").select { |f| File.file?(f) } + end + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "activesupport", ">= 6.0" + spec.add_dependency "dry-struct" + spec.add_dependency "dry-types" + spec.add_dependency "mqtt" +end