# Soundbox Backend MQTT Specification

This document describes the backend contract used by the QF100 soundbox firmware in this project.

Source reference:

- MQTT payload parser: `app/source/MainApp/demo.c`
- Config endpoint constants: `app/source/MainApp/globalDefine.h`

## 1. Overview

The device communicates with the backend in two stages:

1. The device calls the config API to receive MQTT connection settings.
2. The device connects to MQTT and subscribes to the topic returned by the config API.

The backend then publishes JSON payloads to that topic.

Current firmware mode:

- `SAMPLE_MQTT_DEMO` is enabled by default.
- `MQTT_STRICT_TEST_DEMO` is disabled unless explicitly enabled in firmware.
- QF100 reports `dev-model` as `EDOTF1`.
- `DEFAULT_LANGUAGE` is `2`, used here as Indonesian/IDR mode.
- Payment and QR amounts are treated as whole rupiah, not cents.
- The QR amount label shown on screen is `Nominal: Rp <amount>`.
- The status screen shows battery percentage and network signal with `dBm`.

## 2. Device Config API

The device sends a JSON request body to `CONFIG_ADDR`.

Current configured endpoint:

```text
http://sms.bizone.id/speaker/dev-config
```

Transport:

- HTTP is currently configured for the device config API.
- The firmware calls `sdk_http_get()` with the `http://` config URL.
- This avoids TLS/certificate validation issues during config fetch.

### Request Body

```json
{
  "dev-model": "EDOTF1",
  "item-number": "00",
  "dev-sn": "DEVICE_SN",
  "hardware-config": "0x0F",
  "fw-version": "1.0.0",
  "fw-build": 1,
  "app-config-version": 0,
  "imei": "IMEI",
  "imsi": "IMSI",
  "iccid": "ICCID"
}
```

### Successful Response

```json
{
  "error-code": 0,
  "mqtt": {
    "broker-ip": "broker.example.com",
    "broker-port": 1883,
    "client-id": "soundbox-DEVICE_SN",
    "user-name": "mqtt_user",
    "password": "mqtt_password",
    "subscribe-topic": "soundbox/DEVICE_SN/down",
    "keep-alive": 60
  }
}
```

### Required Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `error-code` | number | yes | Must be `0` for success. |
| `mqtt` | object | yes | Required when `error-code` is `0`. |
| `mqtt.broker-ip` | string | yes | Hostname or IP. |
| `mqtt.broker-port` | number | yes | MQTT port. |
| `mqtt.client-id` | string | yes | MQTT client ID. |
| `mqtt.user-name` | string | yes | MQTT username. |
| `mqtt.password` | string | yes | MQTT password. |
| `mqtt.subscribe-topic` | string | yes | Topic the device will subscribe to. |
| `mqtt.keep-alive` | number | yes | Keep-alive interval in seconds. |

Important:

- Numeric fields must be JSON numbers, not strings.
- If any required MQTT field is missing, the device treats the config as invalid.
- `app-config-version` parsing is present but commented out in firmware, so it is not currently applied.

### Error Response

```json
{
  "error-code": 1001
}
```

Any non-zero `error-code` is treated as config failure by the firmware.

## 3. MQTT Payment Payload

The backend publishes this payload to `mqtt.subscribe-topic`.

```json
{
  "header": {
    "category": 1
  },
  "data": {
    "pay-amount": 15000
  }
}
```

### Required Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header` | object | yes | Container for message metadata. |
| `header.category` | number | yes | Use `1` for payment amount playback. |
| `data` | object | yes | Container for message body. |
| `data.pay-amount` | number | yes | Must be greater than `0`. |

Behavior:

- The device formats `pay-amount` into a 12-digit string.
- Example: `15000` becomes `000000015000`.
- In the current Indonesian/IDR firmware, the value is treated as whole rupiah.
- Example: `pay-amount: 30000` is displayed as `30000` and spoken as `tiga puluh ribu rupiah`.
- The device displays the amount and plays the payment audio.

Do not send `pay-amount` as a string:

```json
{
  "data": {
    "pay-amount": "15000"
  }
}
```

Use a number instead:

```json
{
  "data": {
    "pay-amount": 15000
  }
}
```

## 3.1 MQTT Transport Security

Current firmware setting:

```c
#define MQTT_TLS_ENABLE (0)
```

This means MQTT currently connects without TLS.

The SDK and firmware code do support MQTT TLS:

- `sdk_MQTT_connect(..., tls, ...)` accepts a TLS flag.
- The firmware already passes `MQTT_TLS_ENABLE` into `sdk_MQTT_connect()`.
- Certificate setup hooks exist in firmware:
  - `MQTT_SERVER_CA_CRT`
  - `MQTT_CLIENT_CA_CRT`
  - `MQTT_CLIENT_PRIKEY`
  - `sdk_setx509cer(...)`
  - `sdk_setx509_own_cer(...)`
  - `sdk_setx509_prikey(...)`

To use MQTTS, firmware must be rebuilt with:

```c
#define MQTT_TLS_ENABLE (1)
```

Backend recommendation:

- For current firmware, expose plain MQTT, usually port `1883`.
- For TLS firmware, expose MQTTS, usually port `8883`, and provide the correct server CA chain expected by the firmware.
- Do not assume MQTTS is active unless the firmware was rebuilt with `MQTT_TLS_ENABLE (1)`.

## 4. MQTT OTA Trigger Payload

The backend can trigger an OTA check by publishing:

```json
{
  "header": {
    "category": 2
  },
  "data": {
    "fw-version": "1.0.1",
    "fw-build": 2
  }
}
```

### Required Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header.category` | number | yes | Use `2` for OTA trigger. |
| `data.fw-version` | string | yes | Target firmware version. |
| `data.fw-build` | number | yes | Target firmware build, must be greater than `0`. |

Behavior:

- If `fw-version` is greater than the current firmware version, the device starts OTA.
- If `fw-version` is equal but `fw-build` is greater, the device starts OTA.
- Otherwise, the device logs that no update is needed.

## 5. MQTT Dynamic QR Display Payload

The backend can ask the device to display a dynamic QR code by publishing to `mqtt.subscribe-topic`:

```json
{
  "header": {
    "category": 4
  },
  "data": {
    "qr-url": "https://pay.example/qr/abc123",
    "amount": 25000,
    "expire-seconds": 60
  }
}
```

### Required Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header.category` | number | yes | Use `4` for dynamic QR display. |
| `data.qr-url` | string | yes | Content encoded into the QR code. This can be a URL or any QR payload string. |
| `data.amount` | number | yes | Amount shown on the QR page. Must be greater than `0`. |
| `data.expire-seconds` | number | no | QR validity duration in seconds. Defaults to `60` if missing or invalid. |

Behavior:

- The device displays the QR code immediately after receiving a valid category `4` payload.
- The QR page shows `data.qr-url` as QR content and displays `data.amount`.
- In the current Indonesian/IDR firmware, the amount is shown as `Nominal: Rp <amount>`.
- When `expire-seconds` elapses, the device returns to the default `Bizone System` status screen.
- If a category `1` payment notification is received while the QR page is active, the device immediately hides the QR page and returns to the default status screen before playing the payment notification.
- This command only controls the display. It does not start the internal POS QR transaction flow.

## 6. MQTT Device Command Payload

The backend can ask the device to run a device-level command by publishing to `mqtt.subscribe-topic`.

Reboot command:

```json
{
  "header": {
    "category": 5
  },
  "data": {
    "command": "reboot"
  }
}
```

Power off command:

```json
{
  "header": {
    "category": 5
  },
  "data": {
    "command": "poweroff"
  }
}
```

### Required Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header.category` | number | yes | Use `5` for device command. |
| `data.command` | string | yes | Supported values: `reboot`, `poweroff`. |

Behavior:

- The device validates `data.command` before executing the command.
- If `data.command` is missing or not one of the supported values, the payload is ignored.
- For `reboot`, the device plays the reboot audio, shows `Rebooting...`, waits about 2 seconds, then restarts.
- For `poweroff`, the device plays the power-off audio, shows `Shutting down...`, waits about 2 seconds, then powers off.
- This command is backend-to-device only.

## 7. MQTT Device Heartbeat

The firmware publishes an application-level heartbeat over MQTT after it has connected and subscribed successfully.

Publish topic used by the device:

```text
{mqtt.subscribe-topic}/heartbeat
```

Example:

```text
soundbox/QF100123456/down/heartbeat
```

Payload:

```json
{
  "header": {
    "category": 3
  },
  "data": {
    "dev-sn": "QF100123456",
    "client-id": "soundbox-QF100123456",
    "fw-version": "1.0.0",
    "fw-build": 1,
    "time": "20260607123045",
    "battery-level": 80,
    "wifi-ap": {
      "ssid": "testap1",
      "mac": "00:0f:e2:4e:aa:3e",
      "rssi": -13
    },
    "main-cell-info": {
      "mcc": 460,
      "mnc": 0,
      "rssi": 20
    }
  }
}
```

Timing:

- The device publishes one heartbeat immediately after MQTT subscribe succeeds.
- The device then publishes periodically using `mqtt.keep-alive` seconds from the config response.
- If `mqtt.keep-alive` is `0`, firmware falls back to `60` seconds.
- Current firmware synchronizes device time with NTP timezone `UTC+7`, so `data.time` represents WIB local time.

Backend handling:

- Subscribe to `{mqtt.subscribe-topic}/heartbeat`, or a wildcard such as `soundbox/+/down/heartbeat`.
- Treat each heartbeat message as the device's last-seen timestamp.
- This is separate from MQTT protocol `PINGREQ`/`PINGRESP`, which is handled by the broker and is not delivered as a subscribed message.

### Heartbeat Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header.category` | number | yes | Always `3` for device heartbeat. |
| `data.dev-sn` | string | yes | Device serial number. |
| `data.client-id` | string | yes | MQTT client ID from config response. |
| `data.fw-version` | string | yes | Firmware version. |
| `data.fw-build` | number | yes | Firmware build number. |
| `data.time` | string | yes | Device local time in `YYYYMMDDHHMMSS` format, currently WIB/UTC+7. |
| `data.battery-level` | number | yes | Battery percentage calculated by firmware, `0` to `100`. |
| `data.wifi-ap` | object | optional | Present only when WiFi data is available. |
| `data.wifi-ap.ssid` | string | optional | Current configured WiFi SSID. |
| `data.wifi-ap.mac` | string | optional | Reserved for AP MAC/BSSID. Current stability-focused firmware does not scan AP list during heartbeat, so this is usually absent. |
| `data.wifi-ap.rssi` | number | optional | Connected WiFi RSSI from SDK. |
| `data.main-cell-info` | object | optional | Present only when GPRS/cellular data is available. |
| `data.main-cell-info.mcc` | number | optional | Parsed from IMSI when available. |
| `data.main-cell-info.mnc` | number | optional | Parsed from IMSI when available. |
| `data.main-cell-info.rssi` | number | optional | Cellular signal quality reported by the modem API. Current firmware does not expose LAC/cell-id through available SDK headers. |

Important:

- `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent.
- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. The firmware avoids WiFi scanning inside routine heartbeat to keep heartbeat stable.
- `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo.
- On the device status screen, GPRS signal quality `1..31` is converted to approximate dBm with `-113 + 2 * rssi` before display. The heartbeat payload still sends the SDK/modem `rssi` value.

## 8. Unsupported Categories

For unsupported categories, the firmware still requires:

```json
{
  "header": {
    "category": 99
  },
  "data": {}
}
```

The current firmware handles categories `1`, `2`, `4`, and `5` from backend-to-device messages. Category `5` supports `data.command` values `reboot` and `poweroff`. Category `3` is used by device-to-backend heartbeat. Other categories do not perform any action.

## 9. OTA Check API Response

After an OTA trigger, the device calls the update check API configured by `UPDATE_ADDR`.

The response parsed by firmware is:

```json
{
  "error-code": 0,
  "version": "1.0.1",
  "build": 2,
  "file-length": 123456,
  "download-url": "https://example.com/app_fota.bin"
}
```

### Required Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `error-code` | number | yes | Must be `0` for update available. |
| `version` | string | yes | New firmware version. |
| `build` | number | yes | New firmware build. |
| `file-length` | number | yes | Must be greater than `0`. |
| `download-url` | string | yes | Firmware download URL. |

Special error codes:

- `1002`: no update needed
- `1005`: download version not found

The firmware treats both as no-update conditions.

## 10. OTA Result Upload

The device uploads OTA result to `RESULT_ADDR`.

Request body:

```json
{
  "dev-sn": "DEVICE_SN",
  "result": 0
}
```

The current firmware sends the request but does not parse the response body.

## 11. Recommended Topic Design

Use one downlink topic per device:

```text
soundbox/{dev-sn}/down
```

Example:

```text
soundbox/QF100123456/down
```

Return the topic in config response:

```json
{
  "error-code": 0,
  "mqtt": {
    "broker-ip": "broker.example.com",
    "broker-port": 1883,
    "client-id": "soundbox-QF100123456",
    "user-name": "soundbox_user",
    "password": "secret",
    "subscribe-topic": "soundbox/QF100123456/down",
    "keep-alive": 60
  }
}
```

Backend can listen for heartbeat on:

```text
soundbox/{dev-sn}/down/heartbeat
```

## 12. End-to-End Example

### Config Response

```json
{
  "error-code": 0,
  "mqtt": {
    "broker-ip": "broker.bizone.id",
    "broker-port": 1883,
    "client-id": "soundbox-QF100123456",
    "user-name": "soundbox_user",
    "password": "secret",
    "subscribe-topic": "soundbox/QF100123456/down",
    "keep-alive": 60
  }
}
```

### Payment Publish

Publish to:

```text
soundbox/QF100123456/down
```

Payload:

```json
{
  "header": {
    "category": 1
  },
  "data": {
    "pay-amount": 25000
  }
}
```

### OTA Trigger Publish

Publish to:

```text
soundbox/QF100123456/down
```

Payload:

```json
{
  "header": {
    "category": 2
  },
  "data": {
    "fw-version": "1.0.1",
    "fw-build": 2
  }
}
```

### Dynamic QR Publish

Publish to:

```text
soundbox/QF100123456/down
```

Payload:

```json
{
  "header": {
    "category": 4
  },
  "data": {
    "qr-url": "https://pay.example/qr/abc123",
    "amount": 25000,
    "expire-seconds": 60
  }
}
```

### Reboot Command Publish

Publish to:

```text
soundbox/QF100123456/down
```

Payload:

```json
{
  "header": {
    "category": 5
  },
  "data": {
    "command": "reboot"
  }
}
```

### Power Off Command Publish

Publish to:

```text
soundbox/QF100123456/down
```

Payload:

```json
{
  "header": {
    "category": 5
  },
  "data": {
    "command": "poweroff"
  }
}
```

### Heartbeat From Device

Subscribe to:

```text
soundbox/QF100123456/down/heartbeat
```

Payload received:

```json
{
  "header": {
    "category": 3
  },
  "data": {
    "dev-sn": "QF100123456",
    "client-id": "soundbox-QF100123456",
    "fw-version": "1.0.0",
    "fw-build": 1,
    "time": "20260607123045",
    "battery-level": 80
  }
}
```

## 13. Notes For Backend Implementation

- Always publish valid JSON.
- Always use JSON numbers for numeric fields.
- Do not use strings for `category`, `pay-amount`, `amount`, `expire-seconds`, `fw-build`, `broker-port`, or `keep-alive`.
- For device commands, send `data.command` exactly as one of the supported strings: `reboot` or `poweroff`.
- Keep `client-id` unique per device.
- Use the device serial number `dev-sn` as the main device identifier.
- The firmware logs MQTT payloads through CATStudio/DIAG, useful for debugging invalid payloads.
- Use MQTT broker ACLs carefully: the device must be allowed to publish to `{subscribe-topic}/heartbeat`.
