# MQTT — OMNI Protocol Bridge (Mosquitto)

MQTT bridge for BEEB BEEB scooter IoT devices using the **OMNI MQTT.JSON V1.18** protocol (`docs/EngOMNI-Protocal-MQTT.JSON-V1.18_251111-3.pdf`).

## Architecture

```
Scooter (IMEI) ──MQTT──► Mosquitto (:1883) ◄──MQTT── Node bridge (node/mqtt/)
                                                              │
                              ┌───────────────────────────────┼───────────────────────────────┐
                              ▼                               ▼                               ▼
                         MySQL `scooters`              Socket.IO events                  HTTP /mqtt/*
                         (serial_number = IMEI)        scooter-location, etc.            lock / unlock / query
```

### Topic template

```
om/{server|client}/{req|res|data}/{name}/{IMEI}
```

| Direction | Examples |
|-----------|----------|
| Device → Server | `om/client/data/location/{IMEI}`, `om/client/req/register/{IMEI}` |
| Server → Device | `om/server/req/send-rto/{IMEI}`, `om/server/res/register/{IMEI}` |

The Node bridge subscribes to **`om/client/#`** and publishes to **`om/server/#`**.

---

## Setup

### 1) Environment (`.env` in project root)

```env
MQTT_ENABLED=true
MQTT_HOST=65.109.35.85
MQTT_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
MQTT_CLIENT_ID=beeb-mqtt-bridge
MQTT_TOPIC_PREFIX=om
MQTT_QOS=1
MQTT_API_TOKEN=change-me-in-production
```

Map each scooter IMEI to `scooters.serial_number` in Laravel admin.

### 2) Start Mosquitto

```bash
cd node
mkdir -p mosquitto/data
npm run mqtt:broker
```

Config: `node/mosquitto/mosquitto.conf` (port **1883**, persistence under `mosquitto/data/`).

For production, disable `allow_anonymous` and enable `password_file` + `acl_file`.

### 3) Start Node server

```bash
cd node
npm install
npm start
```

Health check:

```bash
curl http://127.0.0.1:5055/mqtt/health
```

---

## Handled inbound topics

| Topic | Action |
|-------|--------|
| `om/client/req/register/{IMEI}` | Reply `om/server/res/register/{IMEI}` with `{ code: 200 }` |
| `om/client/data/sign/{IMEI}` | Update battery; emit `scooter-sign` |
| `om/client/req/heartbeat/{IMEI}` | Reply `{ code: 200 }`; update battery; emit `scooter-heartbeat` |
| `om/client/data/location/{IMEI}` | Update lat/lng; emit `scooter-location` |
| `om/client/data/vehicle-info/{IMEI}` | Update battery/speed metadata; emit `scooter-vehicle-info` |
| `om/client/data/scooter-info/{IMEI}` | Same as vehicle-info |
| `om/client/data/lock-event/{IMEI}` | Emit `scooter-lock-event` |
| `om/client/data/alarm-info/{IMEI}` | Emit `scooter-alarm` |

---

## Scooters table persistence

Device IMEI maps to `scooters.serial_number`. If no row exists, the bridge auto-creates one (`code` = IMEI).

| MQTT topic | OMNI field | DB column | Notes |
|------------|------------|-----------|-------|
| `om/client/data/location/{IMEI}` | `LAT` / `LNG` | `lat`, `lng` | Decimal degrees (NMEA converted when needed). Skipped when GPS invalid (`gpsNum = 0` or empty coords). |
| `om/client/data/location/{IMEI}` | ECU `speedKmh` or GPS-derived | `speed` | km/h. Prefers ECU `speedKmh` from last `scooter-info`, else GPS speed between points. |
| `om/client/req/heartbeat/{IMEI}` | `heartbeat.sysSoc` | `battery_percent` | Clamped 0–100 |
| `om/client/data/sign/{IMEI}` | `sign.sysSoc` | `battery_percent` | Clamped 0–100 |
| `om/client/data/scooter-info/{IMEI}` | `ECU.sysSoc` | `battery_percent` | Clamped 0–100 |
| `om/client/data/scooter-info/{IMEI}` | `ECU.speedKmh` | `speed` | km/h from ECU |

Persistence is handled by `mqtt/scooter-service.js` → `persistScooterState()`. Successful writes log:

```
[MQTT][DB] Updated scooter id=38 imei=862499071894209
```

Verify stored values:

```sql
SELECT id, code, serial_number, battery_percent, lat, lng, speed, updated_at
FROM scooters
WHERE serial_number = '862499071894209';
```

Live traffic log: `node/logs/mqtt-scooter.log`

---

## Rides table persistence

During an **active** or **ending** ride (`rides.status` = `active` | `ending`), MQTT telemetry updates the ride row for that scooter.

| MQTT topic | OMNI field | DB column | Notes |
|------------|------------|-----------|-------|
| `om/client/data/location/{IMEI}` | `LAT` / `LNG` | `end_lat`, `end_lng` | Latest GPS position. Sets `start_lat`/`start_lng` on first point if empty. |
| `om/client/data/location/{IMEI}` | ECU `singleRideMile` or GPS trail | `distance_km` | Prefers ECU trip distance (meters → km). Falls back to per-ride GPS accumulation. |
| `om/client/data/location/{IMEI}` | ECU `runTime` or `started_at` | `duration_seconds` | ECU driving time (seconds) or elapsed since `started_at`. |
| `om/client/data/scooter-info/{IMEI}` | `ECU.singleRideMile` | `distance_km` | Trip distance in meters → km |
| `om/client/data/scooter-info/{IMEI}` | `ECU.runTime` | `duration_seconds` | Driving time in seconds |
| `om/client/data/location/{IMEI}` | `started_at` (billing clock) | `cost` | Live estimate — see § Ride cost below |
| `om/client/data/scooter-info/{IMEI}` | `started_at` (billing clock) | `cost` | Live estimate — see § Ride cost below |

Handled by `mqtt/ride-service.js` → `persistFromLocation()` / `persistFromEcu()`. Requires an existing ride created by the app (`scan` → `unlock`). Successful writes log:

```
[MQTT][DB] Updated ride id=123 scooter=38
```

### Ride cost (live estimate)

Billing logic mirrors `app/Services/Client/RideFlowService.php` (`liveMetrics()` / `end()`).

**Formula (time only — distance and speed do NOT affect cost):**

```text
elapsed_seconds = max(0, now - ride.started_at)
elapsed_minutes = ceil(max(1, elapsed_seconds) / 60)
price_per_minute = site_settings.ride_price_per_minute   // default 0.45 SAR

Without subscription:
  cost = round(elapsed_minutes × price_per_minute, 2)

With active subscription (is_active=1, ends_at > now):
  available = minute_cap is null ? unlimited : max(0, minute_cap - minutes_used)
  uncovered = max(0, elapsed_minutes - available)
  cost = round(uncovered × price_per_minute, 2)
```

Implemented in `mqtt/cost-calculator.js`. **Node does NOT** deduct wallet or increment `subscription.minutes_used` — Laravel `end()` is authoritative for final billing.

| Responsibility | Node MQTT | Laravel `end()` |
|----------------|-----------|-----------------|
| `cost` | Live estimate during `active` | Final value + wallet deduct |
| `minutes_used` | Read only | Incremented on end |
| `duration_seconds` | ECU/GPS telemetry | Overwritten at end from `started_at` |

Verify stored values:

```sql
SELECT id, client_id, scooter_id, status, started_at,
       duration_seconds, distance_km, cost, updated_at
FROM rides
WHERE scooter_id = 38 AND status IN ('active', 'ending')
ORDER BY id DESC
LIMIT 1;
```

Also verify route/coords:

```sql
SELECT id, client_id, scooter_id, status, started_at,
       duration_seconds, distance_km,
       start_lat, start_lng, end_lat, end_lng, updated_at
FROM rides
WHERE scooter_id = 38 AND status IN ('active', 'ending')
ORDER BY id DESC
LIMIT 1;
```

---

## HTTP command API

> **Full lock/unlock API reference:** [MQTT-LOCK-UNLOCK-API.md](./MQTT-LOCK-UNLOCK-API.md)

Base path: `/mqtt` on the Node server (same port as Socket.IO).

Header (when `MQTT_API_TOKEN` is set):

```
X-Mqtt-Token: {MQTT_API_TOKEN}
```

| Method | Path | Description |
|--------|------|-------------|
| GET | `/mqtt/health` | Broker + bridge status |
| POST | `/mqtt/scooters/:imei/lock` | `send-rto` lock — waits for device ack (see [MQTT-LOCK-UNLOCK-API.md](./MQTT-LOCK-UNLOCK-API.md)) |
| POST | `/mqtt/scooters/:imei/unlock` | `send-rto` unlock + throttle on — waits for device ack |
| POST | `/mqtt/scooters/:imei/query-location` | `send-query` gpsInfo |
| POST | `/mqtt/scooters/:imei/control` | Raw `send-rto` payload |

Example:

```bash
curl -X POST "http://127.0.0.1:5055/mqtt/scooters/864475045545158/unlock" \
  -H "Content-Type: application/json" \
  -H "X-Mqtt-Token: change-me-in-production"
```

---

## Socket.IO events (server → client)

> For full `scooter-location` payload, subscription rooms, and DB save rules, see **[SOCKET-SCOOTER-LOCATION.md](./SOCKET-SCOOTER-LOCATION.md)**.  
> For the **active ride mobile screen** (map, speed, distance, battery, wallet balance), see **[SOCKET-MOBILE-ACTIVE-RIDE.md](./SOCKET-MOBILE-ACTIVE-RIDE.md)**.

| Event | When |
|-------|------|
| `scooter-sign` | Device check-in |
| `scooter-heartbeat` | Heartbeat (~4 min) |
| `scooter-location` | GPS report |
| `scooter-vehicle-info` | ECU / vehicle info |
| `scooter-lock-event` | Lock/unlock event |
| `scooter-alarm` | Alarm report |

Payload includes `imei`, `scooter_id`, `scooter_code`, and telemetry fields.

Subscribe to a scooter room (optional, for targeted updates):

```javascript
socket.emit('enter-scooter', { scooter_id: 12 });
// or
socket.emit('enter-scooter', { imei: '864475045545158' });
```

---

## Manual test (mosquitto clients)

Simulate device registration:

```bash
mosquitto_pub -h 127.0.0.1 -t "om/client/req/register/864475045545158" -m '{
  "deviceType":"Scooter",
  "protVersion":"1.1.8",
  "operationId":1860,
  "HardwareType":"M113",
  "register":{
    "productKey":"orrcfhwg",
    "deviceName":"864475045545158",
    "deviceSecret":"797ae339850e2877"
  }
}'

mosquitto_sub -h 127.0.0.1 -t "om/server/res/register/#" -v
```

Simulate GPS:

```bash
mosquitto_pub -h 127.0.0.1 -t "om/client/data/location/864475045545158" -m '{
  "location":[{
    "LNG":"46.675300",
    "LAT":"24.713600",
    "GEO_NS":"N",
    "GEO_EW":"E",
    "gpsNum":"8",
    "hdop":"1.2",
    "timestamp":"1599206433",
    "altitude":"612"
  }],
  "timestamp":"1599206433"
}'
```

---

## File map

```
node/
├── mosquitto/mosquitto.conf
├── mqtt/
│   ├── bridge.js          # MQTT client + subscription loop
│   ├── commands.js        # lock / unlock / query helpers
│   ├── config.js          # env loader
│   ├── handlers.js        # inbound topic handlers
│   ├── routes.js          # HTTP API
│   ├── scooter-service.js # DB updates (scooters table)
│   └── topics.js          # topic builders / parser
└── MQTT.md                # this file
```
