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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions rm-mqtt-ws-bridge/demo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
127 changes: 124 additions & 3 deletions rm-mqtt-ws-bridge/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,68 @@
.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); }
</style>
</head>
<body>
<div class="demo">
<div class="addr-row">
<div class="addr-cell cem">
<div class="addr-bar">
<input class="addr-input" value="ws://localhost:9000/s2" readonly aria-label="CEM S2 endpoint the RM connects to" />
<input class="addr-input" id="cem-url" value="" readonly aria-label="CEM S2 endpoint the RM connects to" />
</div>
</div>
<div class="addr-cell rm">
Expand All @@ -87,12 +138,81 @@
</div>
</div>
<div class="panes">
<div class="pane cem"><iframe src="http://localhost:9000" title="CEM demo"></iframe></div>
<div class="pane cem" id="cem-pane"></div>
<div class="pane rm"><iframe src="http://localhost:3000" title="RM"></iframe></div>
<div class="pane mqtt"><iframe src="http://localhost:4000" title="MQTT platform demo"></iframe></div>
</div>
</div>
<script>
const cemUrlInput = document.getElementById("cem-url");
const cemPane = document.getElementById("cem-pane");

const BUNDLED_CEM_HOSTS = new Set(["cem", "localhost", "127.0.0.1", "host.docker.internal"]);

function isBundledCem(url) {
try {
const u = new URL(url);
return BUNDLED_CEM_HOSTS.has(u.hostname) && (u.port === "9000" || u.port === "");
} catch {
return false;
}
}

function renderBundledCem() {
if (cemPane.querySelector("iframe")) return;
cemPane.innerHTML = '<iframe src="http://localhost:9000" title="CEM demo"></iframe>';
}

function renderExternalCem(url, connected) {
cemPane.innerHTML = `
<div class="external-cem">
<div>
<div class="panel-title">S2 CEM<span class="panel-tag">external</span></div>
<div class="panel-role">
The RM is connected to an external CEM over S2. This peer has no
embedded dashboard, so this panel only reports the link status. Live
traffic still shows up in the RM feed in the middle.
</div>
</div>
<div class="kv">
<div class="k">Endpoint</div>
<div class="v">${escapeHtml(url)}</div>
<div class="k">Link</div>
<div class="v">
<span class="live-dot ${connected ? "" : "offline"}"></span>
${connected ? "connected" : "not connected"}
</div>
</div>
</div>
`;
}

function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}

async function refreshCemPane() {
try {
const res = await fetch("http://localhost:3000/traffic", { headers: { Accept: "application/json" } });
const d = await res.json();
const url = d.cem_endpoint || "";
cemUrlInput.value = url;
const connected = d.resource && d.resource.state === "connected";
if (isBundledCem(url)) {
renderBundledCem();
} else {
renderExternalCem(url, connected);
}
} catch (e) {
cemUrlInput.value = "(RM not reachable)";
}
}

refreshCemPane();
setInterval(refreshCemPane, 2000);

document.getElementById("reset").onclick = () => {
const post = (url) => fetch(url, { method: "POST", mode: "no-cors" }).catch(() => {});
post("http://localhost:4000/api/publish/disconnect");
Expand All @@ -105,6 +225,7 @@
document.querySelectorAll(".pane iframe").forEach((frame) => {
frame.src = frame.src;
});
refreshCemPane();
}, 1200);
};
</script>
Expand Down
20 changes: 14 additions & 6 deletions rm-mqtt-ws-bridge/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -47,6 +50,7 @@ services:

cem:
build: ./cem
profiles: ["bundled-cem"]
environment:
PORT: "9000"
healthcheck:
Expand All @@ -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"

Expand Down
5 changes: 5 additions & 0 deletions rm-mqtt-ws-bridge/rm/app/controllers/traffic_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class TrafficController < ApplicationController
skip_forgery_protection
before_action :allow_cross_origin

def reset
TrafficLog.clear
Expand All @@ -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
Expand Down
24 changes: 17 additions & 7 deletions rm-mqtt-ws-bridge/rm/app/lib/frbc_system_description_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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" => [],
},
Expand All @@ -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,
Expand All @@ -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" => [],
Expand Down
10 changes: 8 additions & 2 deletions rm-mqtt-ws-bridge/rm/app/lib/mqtt_message_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"]
Expand Down
Loading