diff --git a/rm-mqtt-ws-bridge/demo.sh b/rm-mqtt-ws-bridge/demo.sh
index 65b9f68..fb9ec65 100755
--- a/rm-mqtt-ws-bridge/demo.sh
+++ b/rm-mqtt-ws-bridge/demo.sh
@@ -3,7 +3,31 @@ set -euo pipefail
cd "$(dirname "$0")"
-REQUIRED_PORTS=(8080 3000 9000 4000 1883)
+REFERENCE_CEM_HEALTH_URL="${REFERENCE_CEM_HEALTH_URL:-http://localhost:8003/health}"
+REFERENCE_CEM_WS_URL="${REFERENCE_CEM_WS_URL:-ws://host.docker.internal:8003/ws}"
+
+reference_cem_reachable() {
+ curl -fs -o /dev/null --max-time 2 "$REFERENCE_CEM_HEALTH_URL" 2>/dev/null
+}
+
+COMPOSE_PROFILES_ARGS=()
+if [ -n "${CEM_BASE_URL:-}" ]; then
+ echo "Using external CEM (via CEM_BASE_URL): $CEM_BASE_URL"
+ echo "The bundled CEM stand-in will not start."
+elif reference_cem_reachable; then
+ export CEM_BASE_URL="$REFERENCE_CEM_WS_URL"
+ echo "Reference CEM detected at $REFERENCE_CEM_HEALTH_URL."
+ echo "Wiring the RM to $CEM_BASE_URL. The bundled CEM stand-in will not start."
+else
+ echo "No CEM_BASE_URL supplied and no reference CEM answering at $REFERENCE_CEM_HEALTH_URL."
+ echo "Falling back to the bundled CEM stand-in (simulation mode)."
+ COMPOSE_PROFILES_ARGS=(--profile bundled-cem)
+fi
+
+REQUIRED_PORTS=(8080 3000 4000 1883)
+if [ "${#COMPOSE_PROFILES_ARGS[@]}" -gt 0 ]; then
+ REQUIRED_PORTS+=(9000)
+fi
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
@@ -12,8 +36,8 @@ if command -v lsof >/dev/null 2>&1; then
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
+echo "Building and starting the applications + the demo screen (this can take a few minutes the first time)..."
+docker compose "${COMPOSE_PROFILES_ARGS[@]}" up --build -d
wait_for() {
for _ in $(seq 1 60); do
diff --git a/rm-mqtt-ws-bridge/demo/index.html b/rm-mqtt-ws-bridge/demo/index.html
index fdcd709..2416979 100644
--- a/rm-mqtt-ws-bridge/demo/index.html
+++ b/rm-mqtt-ws-bridge/demo/index.html
@@ -61,9 +61,60 @@
.pane.rm { flex: 1; }
.pane.mqtt { flex: 1; }
.pane iframe { flex: 1; width: 100%; border: 0; background: var(--bg); }
- .pane.cem iframe, .addr-cell.cem .addr-bar { background: #eaedf3; }
+ .pane.cem iframe, .pane.cem .external-cem, .addr-cell.cem .addr-bar { background: #eaedf3; }
.pane.rm iframe, .addr-cell.rm .addr-bar { background: #e9f1ec; }
.pane.mqtt iframe, .addr-cell.mqtt .addr-bar { background: #f2efe9; }
+
+ .external-cem {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 22px 24px;
+ font-family: var(--sans);
+ color: var(--text);
+ overflow: auto;
+ }
+ .external-cem .panel-title {
+ font-size: 15px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .external-cem .panel-tag {
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #2f6b3c;
+ background: #d5ecd8;
+ border-radius: 999px;
+ padding: 2px 8px;
+ }
+ .external-cem .panel-role { color: var(--muted); font-size: 13px; line-height: 1.4; }
+ .external-cem .kv {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ column-gap: 12px;
+ row-gap: 6px;
+ font-size: 12.5px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 12px 14px;
+ }
+ .external-cem .kv .k { color: var(--muted); font-weight: 600; }
+ .external-cem .kv .v { font-family: var(--mono); word-break: break-all; }
+ .external-cem .live-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #52a462;
+ box-shadow: 0 0 0 3px rgba(82, 164, 98, 0.18);
+ }
+ .external-cem .live-dot.offline { background: #b9b9b9; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08); }
@@ -71,7 +122,7 @@
diff --git a/rm-mqtt-ws-bridge/docker-compose.yml b/rm-mqtt-ws-bridge/docker-compose.yml
index 1f6772e..dd8690d 100644
--- a/rm-mqtt-ws-bridge/docker-compose.yml
+++ b/rm-mqtt-ws-bridge/docker-compose.yml
@@ -18,7 +18,7 @@ services:
mosquitto:
image: eclipse-mosquitto:2
ports:
- - "1883:1883"
+ - "${MOSQUITTO_HOST_PORT:-1883}:1883"
volumes:
- ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
@@ -31,13 +31,16 @@ services:
condition: service_started
cem:
condition: service_healthy
+ required: false
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
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"
+ CEM_BASE_URL: ${CEM_BASE_URL:-ws://cem:9000/s2}
S2_PROVIDES_FORECAST: "true"
SOLID_QUEUE_IN_PUMA: "true"
DEMO_ALLOW_FRAMING: "1"
@@ -47,6 +50,7 @@ services:
cem:
build: ./cem
+ profiles: ["bundled-cem"]
environment:
PORT: "9000"
healthcheck:
@@ -64,16 +68,20 @@ services:
environment:
PORT: "4000"
MQTT_URL: "mqtt://mosquitto:1883"
- RESOURCE_ID: "demo-resource-001"
+ RESOURCE_ID: ${RESOURCE_ID:-demo-resource-001}
ports:
- "4000:4000"
demo:
build: ./demo
depends_on:
- - rm
- - cem
- - mqtt-panel
+ rm:
+ condition: service_started
+ mqtt-panel:
+ condition: service_started
+ cem:
+ condition: service_started
+ required: false
ports:
- "8080:80"
diff --git a/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb b/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb
index 6f0af32..6c97121 100644
--- a/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb
+++ b/rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb
@@ -1,5 +1,6 @@
class TrafficController < ApplicationController
skip_forgery_protection
+ before_action :allow_cross_origin
def reset
TrafficLog.clear
@@ -23,6 +24,10 @@ def index
private
+ def allow_cross_origin
+ response.headers["Access-Control-Allow-Origin"] = "*"
+ end
+
def connection_state(resource)
connection = Supervisor.instance.find_connection(resource.s2_identifier)
return connection.status.to_s if connection
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
index 199bfaf..3220299 100644
--- 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
@@ -2,6 +2,11 @@ class FRBCSystemDescriptionFactory < BaseFactory
OPERATION_MODE_OFF_LABEL = "Off".freeze
OPERATION_MODE_CHARGING_LABEL = "Charging".freeze
+ # Namespace so structural UUIDs (actuator, operation modes, transitions) stay stable across
+ # reconnects and restarts. Otherwise every new system description would carry fresh IDs and any
+ # earlier CEM-side instruction would reference actuators/operation_modes that no longer exist.
+ STRUCTURAL_UUID_NAMESPACE = "f5b1a51b-8f7f-4c78-a1c3-3f2d2b3e2f7c".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%
@@ -11,8 +16,9 @@ class FRBCSystemDescriptionFactory < BaseFactory
class << self
def create(resource) # rubocop:disable Metrics/MethodLength
- off_id = SecureRandom.uuid
- charging_id = SecureRandom.uuid
+ actuator_id = structural_id(resource, "actuator")
+ off_id = structural_id(resource, "operation_mode:off")
+ charging_id = structural_id(resource, "operation_mode:charging")
commodity_quantity = commodity_quantity_for(resource)
minimum_power = calc_minimum_power(resource)
maximum_power = calc_maximum_power(resource)
@@ -23,7 +29,7 @@ def create(resource) # rubocop:disable Metrics/MethodLength
"valid_from" => Time.current.iso8601,
"actuators" => [
{
- "id" => SecureRandom.uuid,
+ "id" => actuator_id,
"diagnostic_label" => "Electric Vehicle Charger",
"supported_commodities" => [
S2::Messages::Commodity::Electricity,
@@ -57,8 +63,8 @@ def create(resource) # rubocop:disable Metrics/MethodLength
),
],
"transitions" => [
- create_transition(from: off_id, to: charging_id),
- create_transition(from: charging_id, to: off_id),
+ create_transition(from: off_id, to: charging_id, seed: structural_id(resource, "transition:off_to_charging")),
+ create_transition(from: charging_id, to: off_id, seed: structural_id(resource, "transition:charging_to_off")),
],
"timers" => [],
},
@@ -79,6 +85,10 @@ def create(resource) # rubocop:disable Metrics/MethodLength
private
+ def structural_id(resource, role)
+ Digest::UUID.uuid_v5(STRUCTURAL_UUID_NAMESPACE, "#{resource.s2_identifier}/#{role}")
+ end
+
def create_operation_mode(id:, label:, fill_level_range:, fill_rate:, power_ranges:)
{
"id" => id,
@@ -94,9 +104,9 @@ def create_operation_mode(id:, label:, fill_level_range:, fill_rate:, power_rang
}
end
- def create_transition(from:, to:)
+ def create_transition(from:, to:, seed:)
{
- "id" => SecureRandom.uuid,
+ "id" => seed,
"from" => from,
"to" => to,
"start_timers" => [],
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
index 09e726e..d39813d 100644
--- a/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_logger.rb
+++ b/rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_logger.rb
@@ -6,12 +6,12 @@ def initialize(logger)
end
def log_inbound(topic:, message:)
- TrafficLog.record(peer: "mqtt", direction: "in", message_type: message_type_from(message), detail: detail_from(message))
+ TrafficLog.record(peer: "mqtt", direction: "in", message_type: message_type_from(message), detail: detail_from(message), body: body_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))
+ TrafficLog.record(peer: "mqtt", direction: "out", message_type: message_type_from(message), detail: detail_from(message), body: body_from(message))
log("Outbound MQTT message", topic:, message:)
end
@@ -24,6 +24,12 @@ def message_type_from(message)
nil
end
+ def body_from(message)
+ message.is_a?(String) ? JSON.parse(message) : message
+ rescue StandardError
+ nil
+ end
+
def detail_from(message)
parsed = message.is_a?(String) ? JSON.parse(message) : message
data = parsed["data"]
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
index bd1402a..f8cebdf 100644
--- a/rm-mqtt-ws-bridge/rm/app/lib/s2_message_logger.rb
+++ b/rm-mqtt-ws-bridge/rm/app/lib/s2_message_logger.rb
@@ -4,15 +4,15 @@ def initialize(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:)
+ parsed = parse(message)
+ TrafficLog.record(peer: "cem", direction: "in", body: parsed, **traffic_attrs(parsed))
+ log("Inbound S2 message", resource_id:, message:, message_type: parsed["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:)
+ parsed = parse(message)
+ TrafficLog.record(peer: "cem", direction: "out", body: parsed, **traffic_attrs(parsed))
+ log("Outbound S2 message", resource_id:, message:, message_type: parsed["message_type"])
end
private
@@ -32,10 +32,18 @@ def log(log_message, resource_id:, message:, message_type:)
raise if Rails.env.test?
end
- def extract_message_type(message)
- parsed = JSON.parse(message)
- parsed["message_type"]
+ def parse(message)
+ JSON.parse(message)
rescue JSON::ParserError
- nil
+ {}
+ end
+
+ def traffic_attrs(parsed)
+ {
+ message_type: parsed["message_type"],
+ message_id: parsed["message_id"],
+ subject_message_id: parsed["subject_message_id"],
+ status: parsed["status"],
+ }
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
index 02baefd..3e81e9d 100644
--- a/rm-mqtt-ws-bridge/rm/app/lib/traffic_log.rb
+++ b/rm-mqtt-ws-bridge/rm/app/lib/traffic_log.rb
@@ -3,10 +3,20 @@ class TrafficLog
PATH = Rails.root.join("tmp", "traffic.jsonl")
class << self
- def record(peer:, direction:, message_type:, detail: nil)
+ def record(peer:, direction:, message_type:, detail: nil, message_id: nil, subject_message_id: nil, status: nil, body: 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)
+ entry = JSON.generate({
+ peer: peer,
+ direction: direction,
+ message_type: message_type,
+ detail: detail,
+ message_id: message_id,
+ subject_message_id: subject_message_id,
+ status: status,
+ body: body,
+ 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)
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
index 943418e..19ef42c 100644
--- a/rm-mqtt-ws-bridge/rm/app/views/resources/index.html.erb
+++ b/rm-mqtt-ws-bridge/rm/app/views/resources/index.html.erb
@@ -34,6 +34,46 @@
.trow .pipe .arr.flip svg { transform: scaleX(-1); }
.trow.is-setup .what { font-weight: 500; color: var(--muted); }
+ .ack {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ margin-left: 6px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ border-radius: 50%;
+ vertical-align: middle;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+ font-family: inherit;
+ }
+ .ack.ok { background: #d5ecd8; color: #2f6b3c; }
+ .ack.err { background: #f4d3d3; color: #a12727; }
+ .ack.pending { background: #ececec; color: #888; cursor: default; }
+
+ .trow.expandable { cursor: pointer; }
+ .trow.expandable:hover { background: rgba(0,0,0,0.02); }
+ .raw {
+ grid-column: 1 / -1;
+ display: none;
+ margin: 4px 0 8px 62px;
+ padding: 8px 10px;
+ background: #fafafa;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text);
+ white-space: pre;
+ overflow-x: auto;
+ }
+ .raw.open { display: block; }
+ .raw.rs { border-left: 3px solid #2f6b3c; background: #f2faf3; }
+