feat: MQTT integration + fix ESP32 payload handling
This commit is contained in:
parent
2b28403194
commit
e12b29dde8
18
.env.example
18
.env.example
|
|
@ -26,3 +26,21 @@ QUEUE_CONNECTION=sync
|
|||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# ============================================================
|
||||
# MQTT Configuration (HiveMQ)
|
||||
# ============================================================
|
||||
MQTT_BROKER=broker.hivemq.com
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_REST_API=https://broker.hivemq.com:8888/api/v1
|
||||
MQTT_WEBHOOK_SECRET=
|
||||
|
||||
# MQTT Topics (harus sama dengan yang ada di ESP32)
|
||||
MQTT_TOPIC_PIR=keamanan/pir
|
||||
MQTT_TOPIC_REED=keamanan/reed
|
||||
MQTT_TOPIC_VIBRATION=keamanan/vibration
|
||||
MQTT_TOPIC_HEARTBEAT=keamanan/heartbeat
|
||||
MQTT_TOPIC_COMMAND=keamanan/command
|
||||
MQTT_TOPIC_STATUS=keamanan/status
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
# MQTT Integration Guide
|
||||
|
||||
## Arsitektur
|
||||
|
||||
```
|
||||
ESP32 (Publisher)
|
||||
│
|
||||
│ MQTT Publish (broker.hivemq.com:1883)
|
||||
▼
|
||||
HiveMQ Broker
|
||||
│
|
||||
│ MQTT Subscribe → HTTP Forward
|
||||
▼
|
||||
MQTT Bridge (Node-RED / Python / MQTTx Webhook)
|
||||
│
|
||||
│ HTTP POST /api/mqtt/ingest
|
||||
▼
|
||||
Laravel Backend → Database
|
||||
```
|
||||
|
||||
Karena PHP/Laravel tidak bisa subscribe MQTT secara native, kita butuh **MQTT Bridge** yang:
|
||||
1. Subscribe ke topic di HiveMQ
|
||||
2. Forward setiap pesan ke endpoint `/api/mqtt/ingest`
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Backend
|
||||
|
||||
### 1. Terima data dari MQTT Bridge
|
||||
```
|
||||
POST /api/mqtt/ingest
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"topic": "keamanan/pir",
|
||||
"payload": "{\"device_id\":1,\"node\":\"NODE_001\",\"type\":\"PIR\",\"motion\":true}"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Terima batch data
|
||||
```
|
||||
POST /api/mqtt/ingest-batch
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"messages": [
|
||||
{"topic": "keamanan/pir", "payload": "{...}"},
|
||||
{"topic": "keamanan/vibration", "payload": "{...}"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Publish command ke ESP32
|
||||
```
|
||||
POST /api/mqtt/command
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"node_id": "NODE_001",
|
||||
"command": "REBOOT",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cara Setup MQTT Bridge
|
||||
|
||||
### Opsi A: Node-RED (Paling Mudah)
|
||||
|
||||
1. Install Node-RED: `npm install -g node-red`
|
||||
2. Buka `http://localhost:1880`
|
||||
3. Tambah node: **mqtt in** → **function** → **http request**
|
||||
|
||||
Node **mqtt in**:
|
||||
- Server: `broker.hivemq.com:1883`
|
||||
- Topic: `keamanan/#` (subscribe semua topic keamanan)
|
||||
|
||||
Node **function** (transform payload):
|
||||
```javascript
|
||||
msg.url = "https://keamanan-rak-barang-production.up.railway.app/api/mqtt/ingest";
|
||||
msg.method = "POST";
|
||||
msg.headers = { "Content-Type": "application/json" };
|
||||
msg.payload = JSON.stringify({
|
||||
topic: msg.topic,
|
||||
payload: typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload)
|
||||
});
|
||||
return msg;
|
||||
```
|
||||
|
||||
Node **http request**:
|
||||
- Method: POST
|
||||
- URL: (dari msg.url)
|
||||
|
||||
---
|
||||
|
||||
### Opsi B: Python Script (Ringan, bisa di-deploy di server)
|
||||
|
||||
```python
|
||||
# mqtt_bridge.py
|
||||
import paho.mqtt.client as mqtt
|
||||
import requests
|
||||
import json
|
||||
|
||||
BACKEND_URL = "https://keamanan-rak-barang-production.up.railway.app/api/mqtt/ingest"
|
||||
BROKER = "broker.hivemq.com"
|
||||
PORT = 1883
|
||||
TOPICS = [
|
||||
"keamanan/pir",
|
||||
"keamanan/reed",
|
||||
"keamanan/vibration",
|
||||
"keamanan/heartbeat",
|
||||
]
|
||||
|
||||
def on_connect(client, userdata, flags, rc):
|
||||
print(f"Connected to MQTT broker, rc={rc}")
|
||||
for topic in TOPICS:
|
||||
client.subscribe(topic)
|
||||
print(f"Subscribed to: {topic}")
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode("utf-8")
|
||||
print(f"[{topic}] {payload}")
|
||||
|
||||
try:
|
||||
response = requests.post(BACKEND_URL, json={
|
||||
"topic": topic,
|
||||
"payload": payload
|
||||
}, timeout=10)
|
||||
print(f" → Backend: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f" → Error: {e}")
|
||||
|
||||
client = mqtt.Client(client_id=f"bridge-{__import__('random').randint(1000,9999)}")
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
client.connect(BROKER, PORT, 60)
|
||||
client.loop_forever()
|
||||
```
|
||||
|
||||
Install dependency: `pip install paho-mqtt requests`
|
||||
Jalankan: `python mqtt_bridge.py`
|
||||
|
||||
---
|
||||
|
||||
### Opsi C: MQTTx CLI (Untuk Testing)
|
||||
|
||||
MQTTx bisa subscribe dan forward ke webhook. Atau gunakan untuk test manual:
|
||||
|
||||
```bash
|
||||
# Subscribe dan lihat pesan
|
||||
mqttx sub -h broker.hivemq.com -p 1883 -t "keamanan/#" -v
|
||||
|
||||
# Publish test (simulasi ESP32)
|
||||
mqttx pub -h broker.hivemq.com -p 1883 \
|
||||
-t "keamanan/pir" \
|
||||
-m '{"device_id":1,"node":"NODE_001","type":"PIR","motion":true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Payload dari ESP32
|
||||
|
||||
ESP32 mengirim dua format tergantung dari mana datanya:
|
||||
|
||||
### Format 1: buildPayload (MQTT publish dari ESP32)
|
||||
```json
|
||||
{
|
||||
"device_id": 1,
|
||||
"node": "NODE_001",
|
||||
"type": "PIR",
|
||||
"motion": true
|
||||
}
|
||||
```
|
||||
|
||||
### Format 2: Raw LoRa JSON (dikirim langsung via HTTP)
|
||||
```json
|
||||
{
|
||||
"node_id": "NODE_001",
|
||||
"gateway_id": "GATEWAY_001",
|
||||
"type": "PIR",
|
||||
"motion_detected": true,
|
||||
"device_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
Backend mendukung **kedua format** ini secara otomatis.
|
||||
|
||||
---
|
||||
|
||||
## Topic Mapping
|
||||
|
||||
| Topic MQTT | Tipe Sensor | Endpoint HTTP Alternatif |
|
||||
|---------------------|-------------|--------------------------|
|
||||
| `keamanan/pir` | PIR | `POST /api/pir/data` |
|
||||
| `keamanan/reed` | Reed Switch | `POST /api/door-access/data` |
|
||||
| `keamanan/vibration`| Vibration | `POST /api/vibration/data` |
|
||||
| `keamanan/heartbeat`| Heartbeat | `POST /api/lora/receive` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting: Data Tidak Masuk Database
|
||||
|
||||
### Cek 1: Device ada di database?
|
||||
```sql
|
||||
SELECT id, name, device_id, is_active FROM devices;
|
||||
```
|
||||
Jika kosong, jalankan seeder:
|
||||
```bash
|
||||
php artisan db:seed --class=DeviceSeeder
|
||||
```
|
||||
|
||||
### Cek 2: Test endpoint langsung
|
||||
```bash
|
||||
curl -X POST https://keamanan-rak-barang-production.up.railway.app/api/pir/data \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":1,"motion_detected":true}'
|
||||
```
|
||||
|
||||
### Cek 3: Test MQTT ingest
|
||||
```bash
|
||||
curl -X POST https://keamanan-rak-barang-production.up.railway.app/api/mqtt/ingest \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic":"keamanan/pir","payload":"{\"device_id\":1,\"node\":\"NODE_001\",\"type\":\"PIR\",\"motion\":true}"}'
|
||||
```
|
||||
|
||||
### Cek 4: Lihat Laravel logs
|
||||
```bash
|
||||
tail -f storage/logs/laravel.log
|
||||
```
|
||||
|
|
@ -17,13 +17,20 @@ class DoorAccessController extends Controller
|
|||
{
|
||||
/**
|
||||
* Terima data sensor Reed Switch (Door Access) dari IoT device
|
||||
*
|
||||
* Mendukung dua format payload:
|
||||
* 1. Format ESP32 LoRa raw: { node_id, gateway_id, type, door_opened, device_id }
|
||||
* 2. Format MQTT buildPayload: { device_id, node, type, door }
|
||||
*/
|
||||
public function receiveData(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Normalisasi payload dari ESP32
|
||||
$input = $this->normalizeEsp32Payload($request->all());
|
||||
|
||||
// Validasi input
|
||||
$validator = Validator::make($request->all(), [
|
||||
'device_id' => 'required|exists:devices,id',
|
||||
$validator = Validator::make($input, [
|
||||
'device_id' => 'nullable|integer',
|
||||
'door_opened' => 'required|boolean',
|
||||
'access_method' => 'nullable|string|in:keycard,manual,force,emergency,maintenance,unknown',
|
||||
'user_id_card' => 'nullable|string|max:50',
|
||||
|
|
@ -31,7 +38,7 @@ public function receiveData(Request $request): JsonResponse
|
|||
'door_location' => 'nullable|string|in:front_door,back_door,side_door,main_entrance',
|
||||
'is_forced_entry' => 'nullable|boolean',
|
||||
'door_opened_at' => 'nullable|date',
|
||||
'door_closed_at' => 'nullable|date|after:door_opened_at',
|
||||
'door_closed_at' => 'nullable|date',
|
||||
'metadata' => 'nullable|array'
|
||||
]);
|
||||
|
||||
|
|
@ -44,6 +51,17 @@ public function receiveData(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Resolve device
|
||||
$device = $this->resolveDevice($data, $input);
|
||||
if (!$device) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Device not found. Pastikan device sudah terdaftar di database.',
|
||||
], 422);
|
||||
}
|
||||
$data['device_id'] = $device->id;
|
||||
|
||||
$recordedAt = now();
|
||||
|
||||
// Hitung durasi jika ada door_opened_at dan door_closed_at
|
||||
|
|
@ -240,6 +258,53 @@ public function getStatistics(Request $request): JsonResponse
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisasi payload dari ESP32 ke format yang diharapkan controller.
|
||||
*
|
||||
* ESP32 LoRa raw: { node_id, gateway_id, type, door_opened, device_id }
|
||||
* ESP32 MQTT buildPayload: { device_id, node, type, door }
|
||||
*/
|
||||
private function normalizeEsp32Payload(array $input): array
|
||||
{
|
||||
// Normalisasi door_opened: bisa dari "door" (MQTT buildPayload)
|
||||
if (!isset($input['door_opened']) && isset($input['door'])) {
|
||||
$input['door_opened'] = $input['door'];
|
||||
}
|
||||
// Atau dari "door_open" (format lain)
|
||||
if (!isset($input['door_opened']) && isset($input['door_open'])) {
|
||||
$input['door_opened'] = $input['door_open'];
|
||||
}
|
||||
|
||||
// Pastikan boolean
|
||||
if (isset($input['door_opened'])) {
|
||||
$input['door_opened'] = filter_var($input['door_opened'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
if (isset($input['is_forced_entry'])) {
|
||||
$input['is_forced_entry'] = filter_var($input['is_forced_entry'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve device dari payload — cari by id, fallback ke device pertama aktif
|
||||
*/
|
||||
private function resolveDevice(array $data, array $rawInput): ?\App\Models\Device
|
||||
{
|
||||
if (!empty($data['device_id'])) {
|
||||
$device = \App\Models\Device::find((int) $data['device_id']);
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
$nodeId = $rawInput['node_id'] ?? $rawInput['node'] ?? null;
|
||||
if ($nodeId) {
|
||||
$device = \App\Models\Device::where('device_id', $nodeId)->first();
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
return \App\Models\Device::where('is_active', true)->first() ?? \App\Models\Device::first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cek apakah waktu dalam jam kerja
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -22,65 +22,84 @@ public function __construct(LoRaProcessingService $loraProcessor)
|
|||
}
|
||||
|
||||
/**
|
||||
* Terima data LoRa dari gateway
|
||||
* Terima data LoRa dari gateway ESP32
|
||||
*
|
||||
* ESP32 mengirim JSON mentah langsung sebagai body, contoh:
|
||||
* { "node_id":"NODE_001", "gateway_id":"GATEWAY_001", "type":"HEARTBEAT", "device_id":1 }
|
||||
*
|
||||
* Atau format lama dengan field payload terpisah:
|
||||
* { "node_id":"NODE_001", "payload":"HEARTBEAT|85|90|3600" }
|
||||
*/
|
||||
public function receiveMessage(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$input = $request->all();
|
||||
|
||||
// Normalisasi: jika ESP32 kirim JSON mentah (ada field "type" tapi tidak ada "payload"),
|
||||
// jadikan seluruh body sebagai payload string
|
||||
if (isset($input['type']) && !isset($input['payload'])) {
|
||||
$input['node_id'] = $input['node_id'] ?? $input['node'] ?? 'NODE_001';
|
||||
$input['gateway_id'] = $input['gateway_id'] ?? 'GATEWAY_001';
|
||||
$input['payload'] = json_encode($input); // simpan JSON asli sebagai payload
|
||||
}
|
||||
|
||||
// Validasi input
|
||||
$validator = Validator::make($request->all(), [
|
||||
'node_id' => 'required|string|max:50',
|
||||
'gateway_id' => 'nullable|string|max:50',
|
||||
'payload' => 'required|string',
|
||||
'rssi' => 'nullable|numeric',
|
||||
'snr' => 'nullable|numeric',
|
||||
$validator = Validator::make($input, [
|
||||
'node_id' => 'required|string|max:50',
|
||||
'gateway_id' => 'nullable|string|max:50',
|
||||
'payload' => 'required|string',
|
||||
'rssi' => 'nullable|numeric',
|
||||
'snr' => 'nullable|numeric',
|
||||
'spreading_factor' => 'nullable|integer|min:7|max:12',
|
||||
'frequency' => 'nullable|numeric',
|
||||
'bandwidth' => 'nullable|integer',
|
||||
'received_at' => 'nullable|date',
|
||||
'metadata' => 'nullable|array'
|
||||
'frequency' => 'nullable|numeric',
|
||||
'bandwidth' => 'nullable|integer',
|
||||
'received_at' => 'nullable|date',
|
||||
'metadata' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors()
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
|
||||
// Tentukan message type berdasarkan payload
|
||||
$messageType = $this->determineMessageType($data['payload']);
|
||||
|
||||
// Cari device berdasarkan node_id atau device_id
|
||||
|
||||
// Cari device berdasarkan node_id, device_id, atau fallback ke device pertama
|
||||
$device = Device::where('device_id', $data['node_id'])
|
||||
->orWhere('name', 'like', '%' . $data['node_id'] . '%')
|
||||
->first();
|
||||
|
||||
// Jika tidak ketemu, pakai device pertama yang ada
|
||||
|
||||
if (!$device && isset($input['device_id'])) {
|
||||
$device = Device::find((int) $input['device_id']);
|
||||
}
|
||||
|
||||
if (!$device) {
|
||||
$device = Device::where('type', 'sensor_node')->first();
|
||||
$device = Device::where('is_active', true)->first() ?? Device::first();
|
||||
}
|
||||
|
||||
// Simpan LoRa message
|
||||
$loraMessage = LoRaMessage::create([
|
||||
'device_id' => $device?->id,
|
||||
'node_id' => $data['node_id'],
|
||||
'gateway_id' => $data['gateway_id'] ?? 'GATEWAY_001',
|
||||
'direction' => 'inbound',
|
||||
'message_type' => $messageType,
|
||||
'payload' => $data['payload'],
|
||||
'rssi' => $data['rssi'] ?? null,
|
||||
'snr' => $data['snr'] ?? null,
|
||||
'device_id' => $device?->id,
|
||||
'node_id' => $data['node_id'],
|
||||
'gateway_id' => $data['gateway_id'] ?? 'GATEWAY_001',
|
||||
'direction' => 'inbound',
|
||||
'message_type' => $messageType,
|
||||
'payload' => $data['payload'],
|
||||
'rssi' => $data['rssi'] ?? null,
|
||||
'snr' => $data['snr'] ?? null,
|
||||
'spreading_factor' => $data['spreading_factor'] ?? null,
|
||||
'frequency' => $data['frequency'] ?? null,
|
||||
'bandwidth' => $data['bandwidth'] ?? null,
|
||||
'is_processed' => false,
|
||||
'status' => 'received',
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
'received_at' => isset($data['received_at']) ? Carbon::parse($data['received_at']) : now()
|
||||
'frequency' => $data['frequency'] ?? null,
|
||||
'bandwidth' => $data['bandwidth'] ?? null,
|
||||
'is_processed' => false,
|
||||
'status' => 'received',
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
'received_at' => isset($data['received_at']) ? Carbon::parse($data['received_at']) : now(),
|
||||
]);
|
||||
|
||||
// Process message secara asynchronous
|
||||
|
|
@ -408,24 +427,36 @@ public function processUnprocessedMessages(): JsonResponse
|
|||
|
||||
/**
|
||||
* Tentukan message type berdasarkan payload
|
||||
*
|
||||
* Mendukung dua format:
|
||||
* 1. Pipe-delimited: "SENSOR|PIR|...", "HEARTBEAT|85|..."
|
||||
* 2. JSON dari ESP32: {"type":"HEARTBEAT",...}, {"type":"PIR",...}
|
||||
*/
|
||||
private function determineMessageType(string $payload): string
|
||||
{
|
||||
$upperPayload = strtoupper($payload);
|
||||
|
||||
if (str_starts_with($upperPayload, 'SENSOR|')) {
|
||||
return 'sensor_data';
|
||||
} elseif (str_starts_with($upperPayload, 'HEARTBEAT|')) {
|
||||
return 'heartbeat';
|
||||
} elseif (str_starts_with($upperPayload, 'COMMAND|')) {
|
||||
return 'command';
|
||||
} elseif (str_starts_with($upperPayload, 'ACK|')) {
|
||||
return 'ack';
|
||||
} elseif (str_starts_with($upperPayload, 'CONFIG|')) {
|
||||
return 'config';
|
||||
} else {
|
||||
return 'sensor_data'; // Default assumption
|
||||
// Coba parse sebagai JSON dulu
|
||||
$json = json_decode($payload, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && isset($json['type'])) {
|
||||
$type = strtoupper($json['type']);
|
||||
return match ($type) {
|
||||
'HEARTBEAT' => 'heartbeat',
|
||||
'PIR', 'REED', 'VIBRATION', 'DOOR' => 'sensor_data',
|
||||
'ACK' => 'ack',
|
||||
'COMMAND' => 'command',
|
||||
'CONFIG' => 'config',
|
||||
default => 'sensor_data',
|
||||
};
|
||||
}
|
||||
|
||||
// Format pipe-delimited
|
||||
$upperPayload = strtoupper($payload);
|
||||
if (str_starts_with($upperPayload, 'SENSOR|')) return 'sensor_data';
|
||||
if (str_starts_with($upperPayload, 'HEARTBEAT|')) return 'heartbeat';
|
||||
if (str_starts_with($upperPayload, 'COMMAND|')) return 'command';
|
||||
if (str_starts_with($upperPayload, 'ACK|')) return 'ack';
|
||||
if (str_starts_with($upperPayload, 'CONFIG|')) return 'config';
|
||||
|
||||
return 'sensor_data'; // default
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,568 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\PirReading;
|
||||
use App\Models\ReedSwitchReading;
|
||||
use App\Models\VibrationReading;
|
||||
use App\Models\DoorAccessReading;
|
||||
use App\Models\LoRaMessage;
|
||||
use App\Models\Alert;
|
||||
use App\Services\MqttService;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* MqttController
|
||||
*
|
||||
* Menangani dua skenario:
|
||||
*
|
||||
* 1. POST /api/mqtt/ingest
|
||||
* Endpoint ini dipanggil oleh MQTT bridge/webhook ketika ada pesan
|
||||
* masuk dari ESP32 ke broker HiveMQ. Bridge (misal: Node-RED, MQTT-HTTP
|
||||
* bridge, atau script Python) subscribe ke topic dan forward ke sini.
|
||||
*
|
||||
* 2. POST /api/mqtt/publish
|
||||
* Publish pesan dari backend ke MQTT broker (untuk kirim command ke ESP32).
|
||||
*
|
||||
* Format payload dari ESP32 (buildPayload di kode ESP32):
|
||||
* {
|
||||
* "device_id": 1,
|
||||
* "node": "NODE_001",
|
||||
* "type": "PIR|REED|VIBRATION|HEARTBEAT",
|
||||
* "motion": true/false, (untuk PIR)
|
||||
* "door": true/false, (untuk REED)
|
||||
* "x": 1.5, (untuk VIBRATION)
|
||||
* ... field asli dari LoRa packet
|
||||
* }
|
||||
*/
|
||||
class MqttController extends Controller
|
||||
{
|
||||
protected MqttService $mqttService;
|
||||
protected NotificationService $notificationService;
|
||||
|
||||
public function __construct(MqttService $mqttService, NotificationService $notificationService)
|
||||
{
|
||||
$this->mqttService = $mqttService;
|
||||
$this->notificationService = $notificationService;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INGEST — Terima data dari MQTT bridge/webhook
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/ingest
|
||||
*
|
||||
* Dipanggil oleh MQTT bridge ketika ada pesan masuk dari ESP32.
|
||||
* Body request:
|
||||
* {
|
||||
* "topic": "keamanan/pir",
|
||||
* "payload": "{\"device_id\":1,\"node\":\"NODE_001\",\"type\":\"PIR\",\"motion\":true}"
|
||||
* }
|
||||
*/
|
||||
public function ingest(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verifikasi webhook secret (opsional)
|
||||
$secret = config('mqtt.webhook_secret');
|
||||
if ($secret) {
|
||||
$headerSecret = $request->header('X-MQTT-Secret');
|
||||
if ($headerSecret !== $secret) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
||||
}
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'topic' => 'required|string',
|
||||
'payload' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$topic = $request->input('topic');
|
||||
$rawPayload = $request->input('payload');
|
||||
|
||||
// Parse JSON payload
|
||||
$data = $this->mqttService->parseIncomingPayload($topic, $rawPayload);
|
||||
|
||||
if (!$data) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid payload format',
|
||||
], 422);
|
||||
}
|
||||
|
||||
Log::info('[MQTT] Ingest received', ['topic' => $topic, 'data' => $data]);
|
||||
|
||||
// Route berdasarkan topic
|
||||
$sensorType = $this->mqttService->getSensorTypeFromTopic($topic);
|
||||
|
||||
$result = match ($sensorType) {
|
||||
'PIR' => $this->processPirData($data),
|
||||
'REED' => $this->processReedData($data),
|
||||
'VIBRATION' => $this->processVibrationData($data),
|
||||
'HEARTBEAT' => $this->processHeartbeat($data),
|
||||
default => $this->processUnknown($topic, $data),
|
||||
};
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'MQTT data processed',
|
||||
'topic' => $topic,
|
||||
'result' => $result,
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[MQTT] Ingest error: ' . $e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to process MQTT data',
|
||||
'error' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/ingest-batch
|
||||
*
|
||||
* Untuk bridge yang mengirim banyak pesan sekaligus.
|
||||
* Body: { "messages": [ {"topic": "...", "payload": "..."}, ... ] }
|
||||
*/
|
||||
public function ingestBatch(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validator = Validator::make($request->all(), [
|
||||
'messages' => 'required|array|min:1',
|
||||
'messages.*.topic' => 'required|string',
|
||||
'messages.*.payload'=> 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($request->input('messages') as $msg) {
|
||||
$data = $this->mqttService->parseIncomingPayload($msg['topic'], $msg['payload']);
|
||||
if (!$data) {
|
||||
$results[] = ['topic' => $msg['topic'], 'success' => false, 'error' => 'Invalid JSON'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$sensorType = $this->mqttService->getSensorTypeFromTopic($msg['topic']);
|
||||
$result = match ($sensorType) {
|
||||
'PIR' => $this->processPirData($data),
|
||||
'REED' => $this->processReedData($data),
|
||||
'VIBRATION' => $this->processVibrationData($data),
|
||||
'HEARTBEAT' => $this->processHeartbeat($data),
|
||||
default => $this->processUnknown($msg['topic'], $data),
|
||||
};
|
||||
|
||||
$results[] = ['topic' => $msg['topic'], 'success' => true, 'result' => $result];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'processed' => count($results),
|
||||
'results' => $results,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[MQTT] Batch ingest error: ' . $e->getMessage());
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PUBLISH — Kirim pesan ke MQTT broker
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/publish
|
||||
*
|
||||
* Publish pesan ke MQTT broker dari backend.
|
||||
* Body: { "topic": "keamanan/command", "payload": {...} }
|
||||
*/
|
||||
public function publish(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validator = Validator::make($request->all(), [
|
||||
'topic' => 'required|string',
|
||||
'payload' => 'required|array',
|
||||
'qos' => 'nullable|integer|in:0,1,2',
|
||||
'retain' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$success = $this->mqttService->publish(
|
||||
$request->input('topic'),
|
||||
$request->input('payload'),
|
||||
$request->input('qos', 0),
|
||||
$request->input('retain', false)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => $success,
|
||||
'message' => $success ? 'Message published' : 'Publish failed',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mqtt/command
|
||||
*
|
||||
* Kirim command ke ESP32 node via MQTT.
|
||||
* Body: { "node_id": "NODE_001", "command": "REBOOT", "params": {} }
|
||||
*/
|
||||
public function sendCommand(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validator = Validator::make($request->all(), [
|
||||
'node_id' => 'required|string',
|
||||
'command' => 'required|string',
|
||||
'params' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$success = $this->mqttService->sendCommand(
|
||||
$request->input('node_id'),
|
||||
$request->input('command'),
|
||||
$request->input('params', [])
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => $success,
|
||||
'message' => $success ? 'Command sent via MQTT' : 'Failed to send command',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROCESSOR — Proses data per tipe sensor
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Proses data PIR dari MQTT
|
||||
*
|
||||
* Payload dari ESP32 (buildPayload):
|
||||
* { "device_id":1, "node":"NODE_001", "type":"PIR", "motion":true }
|
||||
*
|
||||
* Payload asli LoRa (routePacket):
|
||||
* { "node_id":"NODE_001", "gateway_id":"GATEWAY_001", "type":"PIR",
|
||||
* "motion_detected":true, "device_id":1 }
|
||||
*/
|
||||
private function processPirData(array $data): array
|
||||
{
|
||||
$device = $this->resolveDevice($data);
|
||||
|
||||
if (!$device) {
|
||||
Log::warning('[MQTT] PIR: Device not found', $data);
|
||||
return ['success' => false, 'error' => 'Device not found'];
|
||||
}
|
||||
|
||||
// Normalisasi field — ESP32 bisa kirim "motion" atau "motion_detected"
|
||||
$motionDetected = $data['motion'] ?? $data['motion_detected'] ?? false;
|
||||
$motionDetected = filter_var($motionDetected, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$recordedAt = now();
|
||||
$isAuthorizedTime = $this->checkAuthorizedTime($recordedAt);
|
||||
$motionIntensity = $data['motion_intensity'] ?? ($motionDetected ? 50 : 0);
|
||||
$durationSeconds = $data['duration_seconds'] ?? 0;
|
||||
|
||||
$isSuspicious = $motionDetected && !$isAuthorizedTime;
|
||||
$motionType = !$motionDetected ? 'none' : (!$isAuthorizedTime ? 'unauthorized' : ($isSuspicious ? 'suspicious' : 'normal'));
|
||||
|
||||
$reading = PirReading::create([
|
||||
'device_id' => $device->id,
|
||||
'motion_detected' => $motionDetected,
|
||||
'motion_intensity' => (int) $motionIntensity,
|
||||
'duration_seconds' => (int) $durationSeconds,
|
||||
'is_authorized_time'=> $isAuthorizedTime,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
'motion_type' => $motionType,
|
||||
'detection_zone' => $data['detection_zone'] ?? 'center',
|
||||
'metadata' => $this->buildMetadata($data),
|
||||
'recorded_at' => $recordedAt,
|
||||
]);
|
||||
|
||||
Device::markOnline($device->id);
|
||||
|
||||
if ($isSuspicious || $motionType === 'unauthorized') {
|
||||
$this->createAlert($device, 'motion_detected', 'high',
|
||||
'Gerakan Mencurigakan via MQTT',
|
||||
"Gerakan {$motionType} terdeteksi pada {$device->name} (via MQTT).",
|
||||
['pir_reading_id' => $reading->id, 'motion_type' => $motionType]
|
||||
);
|
||||
}
|
||||
|
||||
Log::info('[MQTT] PIR saved', ['id' => $reading->id, 'device' => $device->name]);
|
||||
|
||||
return ['success' => true, 'reading_id' => $reading->id, 'motion_detected' => $motionDetected];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proses data Reed Switch dari MQTT
|
||||
*
|
||||
* Payload dari ESP32 (buildPayload):
|
||||
* { "device_id":1, "node":"NODE_001", "type":"REED", "door":true }
|
||||
*
|
||||
* Payload asli LoRa:
|
||||
* { "node_id":"NODE_001", "gateway_id":"GATEWAY_001", "type":"REED",
|
||||
* "door_opened":true, "device_id":1 }
|
||||
*/
|
||||
private function processReedData(array $data): array
|
||||
{
|
||||
$device = $this->resolveDevice($data);
|
||||
|
||||
if (!$device) {
|
||||
Log::warning('[MQTT] REED: Device not found', $data);
|
||||
return ['success' => false, 'error' => 'Device not found'];
|
||||
}
|
||||
|
||||
// Normalisasi field
|
||||
$doorOpen = $data['door'] ?? $data['door_opened'] ?? $data['door_open'] ?? false;
|
||||
$doorOpen = filter_var($doorOpen, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$recordedAt = now();
|
||||
$isAuthorizedTime = $this->checkAuthorizedTime($recordedAt);
|
||||
$accessMethod = $data['access_method'] ?? 'unknown';
|
||||
$isForcedEntry = filter_var($data['is_forced_entry'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$openDuration = (int) ($data['open_duration_seconds'] ?? $data['duration_seconds'] ?? 0);
|
||||
|
||||
// Deteksi paksa masuk
|
||||
if ($accessMethod === 'force' || $accessMethod === 'unknown') {
|
||||
$isForcedEntry = true;
|
||||
}
|
||||
|
||||
$isAuthorized = !$isForcedEntry && ($accessMethod === 'emergency' || $isAuthorizedTime);
|
||||
$accessLevel = $isForcedEntry ? 'emergency' : (!$isAuthorizedTime ? 'unauthorized' : 'normal');
|
||||
$doorStatus = $isForcedEntry ? 'forced' : ($doorOpen ? 'open' : 'closed');
|
||||
|
||||
$reading = ReedSwitchReading::create([
|
||||
'device_id' => $device->id,
|
||||
'door_open' => $doorOpen,
|
||||
'is_authorized' => $isAuthorized,
|
||||
'is_forced_entry' => $isForcedEntry,
|
||||
'access_method' => $accessMethod,
|
||||
'door_status' => $doorStatus,
|
||||
'open_duration_seconds'=> $openDuration,
|
||||
'access_level' => $accessLevel,
|
||||
'door_location' => $data['door_location'] ?? 'main',
|
||||
'metadata' => $this->buildMetadata($data),
|
||||
'door_opened_at' => $doorOpen ? $recordedAt : null,
|
||||
'recorded_at' => $recordedAt,
|
||||
]);
|
||||
|
||||
Device::markOnline($device->id);
|
||||
|
||||
if ($isForcedEntry || !$isAuthorized) {
|
||||
$this->createAlert($device, 'door_access_alert', $isForcedEntry ? 'critical' : 'high',
|
||||
'Akses Pintu Mencurigakan via MQTT',
|
||||
"Akses {$accessLevel} terdeteksi pada {$device->name} (via MQTT).",
|
||||
['reed_reading_id' => $reading->id, 'access_level' => $accessLevel, 'door_open' => $doorOpen]
|
||||
);
|
||||
}
|
||||
|
||||
Log::info('[MQTT] REED saved', ['id' => $reading->id, 'device' => $device->name]);
|
||||
|
||||
return ['success' => true, 'reading_id' => $reading->id, 'door_open' => $doorOpen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proses data Vibration dari MQTT
|
||||
*
|
||||
* Payload dari ESP32 (buildPayload):
|
||||
* { "device_id":1, "node":"NODE_001", "type":"VIBRATION", "x":1.5 }
|
||||
*
|
||||
* Payload asli LoRa:
|
||||
* { "node_id":"NODE_001", "gateway_id":"GATEWAY_001", "type":"VIBRATION",
|
||||
* "x_axis":1.5, "y_axis":0.3, "z_axis":0.8, "device_id":1 }
|
||||
*/
|
||||
private function processVibrationData(array $data): array
|
||||
{
|
||||
$device = $this->resolveDevice($data);
|
||||
|
||||
if (!$device) {
|
||||
Log::warning('[MQTT] VIBRATION: Device not found', $data);
|
||||
return ['success' => false, 'error' => 'Device not found'];
|
||||
}
|
||||
|
||||
// Normalisasi field — ESP32 buildPayload hanya kirim "x", tapi LoRa raw kirim "x_axis"
|
||||
$xAxis = (float) ($data['x'] ?? $data['x_axis'] ?? 0);
|
||||
$yAxis = (float) ($data['y'] ?? $data['y_axis'] ?? 0);
|
||||
$zAxis = (float) ($data['z'] ?? $data['z_axis'] ?? 0);
|
||||
|
||||
$threshold = (float) ($data['threshold'] ?? 2.0);
|
||||
$magnitude = sqrt(pow($xAxis, 2) + pow($yAxis, 2) + pow($zAxis, 2));
|
||||
$isAbnormal = $magnitude > $threshold;
|
||||
$status = $magnitude <= $threshold ? 'normal' : ($magnitude <= $threshold * 1.5 ? 'warning' : 'critical');
|
||||
|
||||
$reading = VibrationReading::create([
|
||||
'device_id' => $device->id,
|
||||
'x_axis' => $xAxis,
|
||||
'y_axis' => $yAxis,
|
||||
'z_axis' => $zAxis,
|
||||
'magnitude' => $magnitude,
|
||||
'is_abnormal' => $isAbnormal,
|
||||
'threshold' => $threshold,
|
||||
'status' => $status,
|
||||
'metadata' => $this->buildMetadata($data),
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
Device::markOnline($device->id);
|
||||
|
||||
if ($isAbnormal) {
|
||||
$this->createAlert($device, 'vibration_abnormal', $status === 'critical' ? 'high' : 'medium',
|
||||
'Getaran Abnormal via MQTT',
|
||||
"Getaran {$status} terdeteksi pada {$device->name}. Magnitude: " . round($magnitude, 2) . " (via MQTT).",
|
||||
['vibration_reading_id' => $reading->id, 'magnitude' => $magnitude, 'status' => $status]
|
||||
);
|
||||
}
|
||||
|
||||
Log::info('[MQTT] VIBRATION saved', ['id' => $reading->id, 'magnitude' => $magnitude]);
|
||||
|
||||
return ['success' => true, 'reading_id' => $reading->id, 'magnitude' => $magnitude, 'status' => $status];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proses heartbeat dari MQTT
|
||||
*/
|
||||
private function processHeartbeat(array $data): array
|
||||
{
|
||||
$device = $this->resolveDevice($data);
|
||||
|
||||
if (!$device) {
|
||||
Log::warning('[MQTT] HEARTBEAT: Device not found', $data);
|
||||
return ['success' => false, 'error' => 'Device not found'];
|
||||
}
|
||||
|
||||
Device::markOnline($device->id);
|
||||
|
||||
Log::info('[MQTT] HEARTBEAT received', ['device' => $device->name, 'node' => $data['node'] ?? $data['node_id'] ?? 'unknown']);
|
||||
|
||||
return ['success' => true, 'device' => $device->name, 'status' => 'online'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proses topic yang tidak dikenal
|
||||
*/
|
||||
private function processUnknown(string $topic, array $data): array
|
||||
{
|
||||
Log::warning('[MQTT] Unknown topic', ['topic' => $topic, 'data' => $data]);
|
||||
return ['success' => false, 'error' => "Unknown topic: {$topic}"];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPER METHODS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Resolve device dari payload.
|
||||
* ESP32 mengirim device_id = 1 (integer), coba cari by id dulu,
|
||||
* fallback ke device pertama yang aktif.
|
||||
*/
|
||||
private function resolveDevice(array $data): ?Device
|
||||
{
|
||||
// Coba by numeric id
|
||||
if (!empty($data['device_id']) && is_numeric($data['device_id'])) {
|
||||
$device = Device::find((int) $data['device_id']);
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
// Coba by node_id / node string
|
||||
$nodeId = $data['node'] ?? $data['node_id'] ?? null;
|
||||
if ($nodeId) {
|
||||
$device = Device::where('device_id', $nodeId)
|
||||
->orWhere('name', 'like', '%' . $nodeId . '%')
|
||||
->first();
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
// Fallback: device pertama yang aktif
|
||||
return Device::where('is_active', true)->first() ?? Device::first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cek apakah waktu dalam jam kerja (Senin-Jumat 07:00-18:00)
|
||||
*/
|
||||
private function checkAuthorizedTime(Carbon $timestamp): bool
|
||||
{
|
||||
$hour = $timestamp->hour;
|
||||
return $timestamp->isWeekday() && $hour >= 7 && $hour < 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata dari data MQTT (simpan field extra untuk audit)
|
||||
*/
|
||||
private function buildMetadata(array $data): array
|
||||
{
|
||||
return [
|
||||
'source' => 'mqtt',
|
||||
'node' => $data['node'] ?? $data['node_id'] ?? null,
|
||||
'gateway' => $data['gateway_id'] ?? null,
|
||||
'mqtt_topic' => $data['_mqtt_topic'] ?? null,
|
||||
'received_at' => $data['_received_at'] ?? now()->toISOString(),
|
||||
'raw' => array_diff_key($data, array_flip([
|
||||
'device_id', 'node', 'node_id', 'gateway_id', 'type',
|
||||
'motion', 'motion_detected', 'door', 'door_opened', 'door_open',
|
||||
'x', 'y', 'z', 'x_axis', 'y_axis', 'z_axis',
|
||||
'_mqtt_topic', '_received_at',
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buat alert record
|
||||
*/
|
||||
private function createAlert(Device $device, string $type, string $priority, string $title, string $message, array $extraData = []): void
|
||||
{
|
||||
try {
|
||||
Alert::create([
|
||||
'device_id' => $device->id,
|
||||
'type' => $type,
|
||||
'priority' => $priority,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'data' => $extraData,
|
||||
'is_read' => false,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[MQTT] Failed to create alert: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,19 +17,26 @@ class PirController extends Controller
|
|||
{
|
||||
/**
|
||||
* Terima data sensor PIR dari IoT device
|
||||
*
|
||||
* Mendukung dua format payload:
|
||||
* 1. Format ESP32 LoRa raw: { node_id, gateway_id, type, motion_detected, device_id }
|
||||
* 2. Format HTTP langsung: { device_id, motion_detected, motion_intensity, ... }
|
||||
*/
|
||||
public function receiveData(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Validasi input
|
||||
$validator = Validator::make($request->all(), [
|
||||
'device_id' => 'required|exists:devices,id',
|
||||
// Normalisasi payload dari ESP32 sebelum validasi
|
||||
$input = $this->normalizeEsp32Payload($request->all(), 'PIR');
|
||||
|
||||
// Validasi input — device_id tidak wajib ada di DB, fallback ke device pertama
|
||||
$validator = Validator::make($input, [
|
||||
'device_id' => 'nullable|integer',
|
||||
'motion_detected' => 'required|boolean',
|
||||
'motion_intensity' => 'nullable|integer|min:0|max:100',
|
||||
'duration_seconds' => 'nullable|integer|min:0',
|
||||
'detection_zone' => 'nullable|string|in:front,back,side,center',
|
||||
'motion_start' => 'nullable|date',
|
||||
'motion_end' => 'nullable|date|after:motion_start',
|
||||
'motion_end' => 'nullable|date',
|
||||
'metadata' => 'nullable|array'
|
||||
]);
|
||||
|
||||
|
|
@ -42,6 +49,17 @@ public function receiveData(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Resolve device — cari by id, fallback ke device pertama
|
||||
$device = $this->resolveDevice($data, $input);
|
||||
if (!$device) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Device not found. Pastikan device sudah terdaftar di database.',
|
||||
], 422);
|
||||
}
|
||||
$data['device_id'] = $device->id;
|
||||
|
||||
$recordedAt = now();
|
||||
|
||||
// Cek apakah dalam jam kerja
|
||||
|
|
@ -222,6 +240,53 @@ public function getStatistics(Request $request): JsonResponse
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisasi payload dari ESP32 ke format yang diharapkan controller.
|
||||
*
|
||||
* ESP32 mengirim JSON LoRa mentah:
|
||||
* { "node_id":"NODE_001", "gateway_id":"GATEWAY_001", "type":"PIR",
|
||||
* "motion_detected":true, "device_id":1 }
|
||||
*
|
||||
* Atau via buildPayload (MQTT):
|
||||
* { "device_id":1, "node":"NODE_001", "type":"PIR", "motion":true }
|
||||
*/
|
||||
private function normalizeEsp32Payload(array $input, string $expectedType): array
|
||||
{
|
||||
// Normalisasi motion_detected: bisa dari "motion" (MQTT buildPayload)
|
||||
if (!isset($input['motion_detected']) && isset($input['motion'])) {
|
||||
$input['motion_detected'] = $input['motion'];
|
||||
}
|
||||
|
||||
// Pastikan boolean
|
||||
if (isset($input['motion_detected'])) {
|
||||
$input['motion_detected'] = filter_var($input['motion_detected'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve device dari payload — cari by id, fallback ke device pertama aktif
|
||||
*/
|
||||
private function resolveDevice(array $data, array $rawInput): ?\App\Models\Device
|
||||
{
|
||||
// Coba by numeric device_id
|
||||
if (!empty($data['device_id'])) {
|
||||
$device = \App\Models\Device::find((int) $data['device_id']);
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
// Coba by node_id dari raw input
|
||||
$nodeId = $rawInput['node_id'] ?? $rawInput['node'] ?? null;
|
||||
if ($nodeId) {
|
||||
$device = \App\Models\Device::where('device_id', $nodeId)->first();
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
// Fallback: device pertama yang aktif
|
||||
return \App\Models\Device::where('is_active', true)->first() ?? \App\Models\Device::first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cek apakah waktu dalam jam kerja
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,13 +16,20 @@ class VibrationController extends Controller
|
|||
{
|
||||
/**
|
||||
* Terima data sensor getar dari IoT device
|
||||
*
|
||||
* Mendukung dua format payload:
|
||||
* 1. Format ESP32 LoRa raw: { node_id, gateway_id, type, x_axis, y_axis, z_axis, device_id }
|
||||
* 2. Format MQTT buildPayload: { device_id, node, type, x, y, z }
|
||||
*/
|
||||
public function receiveData(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Normalisasi payload dari ESP32
|
||||
$input = $this->normalizeEsp32Payload($request->all());
|
||||
|
||||
// Validasi input
|
||||
$validator = Validator::make($request->all(), [
|
||||
'device_id' => 'required|exists:devices,id',
|
||||
$validator = Validator::make($input, [
|
||||
'device_id' => 'nullable|integer',
|
||||
'x_axis' => 'required|numeric',
|
||||
'y_axis' => 'required|numeric',
|
||||
'z_axis' => 'required|numeric',
|
||||
|
|
@ -39,6 +46,16 @@ public function receiveData(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Resolve device
|
||||
$device = $this->resolveDevice($data, $input);
|
||||
if (!$device) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Device not found. Pastikan device sudah terdaftar di database.',
|
||||
], 422);
|
||||
}
|
||||
$data['device_id'] = $device->id;
|
||||
|
||||
// Hitung magnitude getaran
|
||||
$magnitude = sqrt(
|
||||
|
|
@ -183,6 +200,55 @@ public function getStatistics(Request $request): JsonResponse
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisasi payload dari ESP32 ke format yang diharapkan controller.
|
||||
*
|
||||
* ESP32 LoRa raw: { node_id, gateway_id, type, x_axis, y_axis, z_axis, device_id }
|
||||
* ESP32 MQTT buildPayload: { device_id, node, type, x, y, z }
|
||||
*/
|
||||
private function normalizeEsp32Payload(array $input): array
|
||||
{
|
||||
// Normalisasi axis: "x" -> "x_axis", "y" -> "y_axis", "z" -> "z_axis"
|
||||
if (!isset($input['x_axis']) && isset($input['x'])) {
|
||||
$input['x_axis'] = $input['x'];
|
||||
}
|
||||
if (!isset($input['y_axis']) && isset($input['y'])) {
|
||||
$input['y_axis'] = $input['y'];
|
||||
}
|
||||
if (!isset($input['z_axis']) && isset($input['z'])) {
|
||||
$input['z_axis'] = $input['z'];
|
||||
}
|
||||
|
||||
// Jika hanya ada x_axis tapi tidak y dan z (ESP32 hanya kirim x dari buildPayload)
|
||||
if (isset($input['x_axis']) && !isset($input['y_axis'])) {
|
||||
$input['y_axis'] = 0;
|
||||
}
|
||||
if (isset($input['x_axis']) && !isset($input['z_axis'])) {
|
||||
$input['z_axis'] = 0;
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve device dari payload — cari by id, fallback ke device pertama aktif
|
||||
*/
|
||||
private function resolveDevice(array $data, array $rawInput): ?\App\Models\Device
|
||||
{
|
||||
if (!empty($data['device_id'])) {
|
||||
$device = \App\Models\Device::find((int) $data['device_id']);
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
$nodeId = $rawInput['node_id'] ?? $rawInput['node'] ?? null;
|
||||
if ($nodeId) {
|
||||
$device = \App\Models\Device::where('device_id', $nodeId)->first();
|
||||
if ($device) return $device;
|
||||
}
|
||||
|
||||
return \App\Models\Device::where('is_active', true)->first() ?? \App\Models\Device::first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tentukan status berdasarkan magnitude dan threshold
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -50,10 +50,21 @@ public function device(): BelongsTo
|
|||
|
||||
/**
|
||||
* Parse LoRa payload berdasarkan message type
|
||||
*
|
||||
* Mendukung dua format:
|
||||
* 1. JSON dari ESP32: {"node_id":"NODE_001","type":"PIR","motion_detected":true,...}
|
||||
* 2. Pipe-delimited: "SENSOR|PIR|1|85|120|front"
|
||||
*/
|
||||
public function parsePayload(): array
|
||||
{
|
||||
try {
|
||||
// Coba parse sebagai JSON dulu (format ESP32 baru)
|
||||
$json = json_decode($this->payload, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($json)) {
|
||||
return $this->parseJsonPayload($json);
|
||||
}
|
||||
|
||||
// Fallback ke format pipe-delimited (format lama)
|
||||
switch ($this->message_type) {
|
||||
case 'sensor_data':
|
||||
return $this->parseSensorData();
|
||||
|
|
@ -71,12 +82,73 @@ public function parsePayload(): array
|
|||
} catch (\Exception $e) {
|
||||
Log::error("LoRa payload parsing failed: " . $e->getMessage(), [
|
||||
'message_id' => $this->id,
|
||||
'payload' => $this->payload
|
||||
'payload' => $this->payload,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON payload dari ESP32
|
||||
*
|
||||
* Format ESP32 routePacket (raw LoRa JSON):
|
||||
* { "node_id":"NODE_001", "gateway_id":"GATEWAY_001", "type":"PIR",
|
||||
* "motion_detected":true, "device_id":1 }
|
||||
*
|
||||
* Format ESP32 buildPayload (MQTT):
|
||||
* { "device_id":1, "node":"NODE_001", "type":"PIR", "motion":true }
|
||||
*/
|
||||
private function parseJsonPayload(array $json): array
|
||||
{
|
||||
$type = strtoupper($json['type'] ?? '');
|
||||
|
||||
switch ($type) {
|
||||
case 'PIR':
|
||||
return [
|
||||
'sensor_type' => 'PIR',
|
||||
'motion_detected' => filter_var($json['motion_detected'] ?? $json['motion'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'motion_intensity' => (int) ($json['motion_intensity'] ?? 50),
|
||||
'duration_seconds' => (int) ($json['duration_seconds'] ?? 0),
|
||||
'detection_zone' => $json['detection_zone'] ?? 'center',
|
||||
];
|
||||
|
||||
case 'REED':
|
||||
case 'DOOR':
|
||||
return [
|
||||
'sensor_type' => 'DOOR',
|
||||
'door_opened' => filter_var($json['door_opened'] ?? $json['door'] ?? $json['door_open'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'access_method' => $json['access_method'] ?? 'unknown',
|
||||
'user_id_card' => $json['user_id_card'] ?? null,
|
||||
'duration_seconds' => (int) ($json['duration_seconds'] ?? 0),
|
||||
'door_location' => $json['door_location'] ?? 'main_entrance',
|
||||
];
|
||||
|
||||
case 'VIBRATION':
|
||||
$x = (float) ($json['x_axis'] ?? $json['x'] ?? 0);
|
||||
$y = (float) ($json['y_axis'] ?? $json['y'] ?? 0);
|
||||
$z = (float) ($json['z_axis'] ?? $json['z'] ?? 0);
|
||||
return [
|
||||
'sensor_type' => 'VIBRATION',
|
||||
'x_axis' => $x,
|
||||
'y_axis' => $y,
|
||||
'z_axis' => $z,
|
||||
'threshold' => (float) ($json['threshold'] ?? 2.0),
|
||||
];
|
||||
|
||||
case 'HEARTBEAT':
|
||||
return [
|
||||
'message_type' => 'heartbeat',
|
||||
'battery_level' => $json['battery_level'] ?? null,
|
||||
'signal_strength' => $json['signal_strength'] ?? null,
|
||||
'uptime_seconds' => $json['uptime'] ?? null,
|
||||
'timestamp' => now()->toISOString(),
|
||||
];
|
||||
|
||||
default:
|
||||
return array_merge(['sensor_type' => $type], $json);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sensor data payload
|
||||
* Format: "SENSOR|VIBRATION|1.5,2.3,1.8|2.0" atau "SENSOR|PIR|1|85|120|front" atau "SENSOR|DOOR|1|EMP-1234|45|keycard"
|
||||
|
|
|
|||
|
|
@ -110,16 +110,15 @@ private function processSensorData(LoRaMessage $message, array $parsedData): arr
|
|||
private function processVibrationData(LoRaMessage $message, array $data): array
|
||||
{
|
||||
try {
|
||||
// Hitung magnitude
|
||||
$magnitude = sqrt(
|
||||
pow($data['x_axis'] ?? 0, 2) +
|
||||
pow($data['y_axis'] ?? 0, 2) +
|
||||
pow($data['z_axis'] ?? 0, 2)
|
||||
);
|
||||
// Normalisasi field — JSON ESP32 bisa kirim "x" atau "x_axis"
|
||||
$xAxis = (float) ($data['x_axis'] ?? $data['x'] ?? 0);
|
||||
$yAxis = (float) ($data['y_axis'] ?? $data['y'] ?? 0);
|
||||
$zAxis = (float) ($data['z_axis'] ?? $data['z'] ?? 0);
|
||||
|
||||
$threshold = $data['threshold'] ?? 2.0;
|
||||
$magnitude = sqrt(pow($xAxis, 2) + pow($yAxis, 2) + pow($zAxis, 2));
|
||||
$threshold = (float) ($data['threshold'] ?? 2.0);
|
||||
$isAbnormal = $magnitude > $threshold;
|
||||
|
||||
|
||||
$status = 'normal';
|
||||
if ($magnitude > $threshold * 1.5) {
|
||||
$status = 'critical';
|
||||
|
|
@ -127,32 +126,42 @@ private function processVibrationData(LoRaMessage $message, array $data): array
|
|||
$status = 'warning';
|
||||
}
|
||||
|
||||
// Resolve device_id
|
||||
$deviceId = $message->device_id ?? Device::where('is_active', true)->first()?->id ?? Device::first()?->id;
|
||||
|
||||
if (!$deviceId) {
|
||||
return ['success' => false, 'error' => 'No device found'];
|
||||
}
|
||||
|
||||
// Simpan ke VibrationReading
|
||||
$vibrationReading = VibrationReading::create([
|
||||
'device_id' => $message->device_id,
|
||||
'x_axis' => $data['x_axis'] ?? 0,
|
||||
'y_axis' => $data['y_axis'] ?? 0,
|
||||
'z_axis' => $data['z_axis'] ?? 0,
|
||||
'magnitude' => $magnitude,
|
||||
'device_id' => $deviceId,
|
||||
'x_axis' => $xAxis,
|
||||
'y_axis' => $yAxis,
|
||||
'z_axis' => $zAxis,
|
||||
'magnitude' => $magnitude,
|
||||
'is_abnormal' => $isAbnormal,
|
||||
'threshold' => $threshold,
|
||||
'status' => $status,
|
||||
'metadata' => [
|
||||
'threshold' => $threshold,
|
||||
'status' => $status,
|
||||
'metadata' => [
|
||||
'lora_message_id' => $message->id,
|
||||
'node_id' => $message->node_id,
|
||||
'rssi' => $message->rssi,
|
||||
'snr' => $message->snr
|
||||
'node_id' => $message->node_id,
|
||||
'rssi' => $message->rssi,
|
||||
'snr' => $message->snr,
|
||||
'source' => 'lora',
|
||||
],
|
||||
'recorded_at' => $message->received_at ?? now()
|
||||
'recorded_at' => $message->received_at ?? now(),
|
||||
]);
|
||||
|
||||
Device::markOnline($deviceId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'vibration_data_saved',
|
||||
'success' => true,
|
||||
'action' => 'vibration_data_saved',
|
||||
'vibration_reading_id' => $vibrationReading->id,
|
||||
'magnitude' => $magnitude,
|
||||
'status' => $status,
|
||||
'is_abnormal' => $isAbnormal
|
||||
'magnitude' => $magnitude,
|
||||
'status' => $status,
|
||||
'is_abnormal' => $isAbnormal,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -167,6 +176,13 @@ private function processPirData(LoRaMessage $message, array $data): array
|
|||
{
|
||||
try {
|
||||
$isAuthorizedTime = $this->checkAuthorizedTime();
|
||||
|
||||
// Normalisasi field — JSON ESP32 bisa kirim "motion" atau "motion_detected"
|
||||
$motionDetected = filter_var(
|
||||
$data['motion_detected'] ?? $data['motion'] ?? false,
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
|
||||
$isSuspicious = $this->determineSuspiciousPirMotion($data, $isAuthorizedTime);
|
||||
|
||||
$motionType = 'normal';
|
||||
|
|
@ -176,31 +192,41 @@ private function processPirData(LoRaMessage $message, array $data): array
|
|||
$motionType = 'suspicious';
|
||||
}
|
||||
|
||||
// Resolve device_id — gunakan dari message atau fallback
|
||||
$deviceId = $message->device_id ?? Device::where('is_active', true)->first()?->id ?? Device::first()?->id;
|
||||
|
||||
if (!$deviceId) {
|
||||
return ['success' => false, 'error' => 'No device found'];
|
||||
}
|
||||
|
||||
// Simpan ke PirReading
|
||||
$pirReading = PirReading::create([
|
||||
'device_id' => $message->device_id,
|
||||
'motion_detected' => $data['motion_detected'] ?? false,
|
||||
'motion_intensity' => $data['motion_intensity'] ?? 0,
|
||||
'duration_seconds' => $data['duration_seconds'] ?? 0,
|
||||
'device_id' => $deviceId,
|
||||
'motion_detected' => $motionDetected,
|
||||
'motion_intensity' => (int) ($data['motion_intensity'] ?? ($motionDetected ? 50 : 0)),
|
||||
'duration_seconds' => (int) ($data['duration_seconds'] ?? 0),
|
||||
'is_authorized_time' => $isAuthorizedTime,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
'motion_type' => $motionType,
|
||||
'detection_zone' => $data['detection_zone'] ?? 'center',
|
||||
'metadata' => [
|
||||
'is_suspicious' => $isSuspicious,
|
||||
'motion_type' => $motionType,
|
||||
'detection_zone' => $data['detection_zone'] ?? 'center',
|
||||
'metadata' => [
|
||||
'lora_message_id' => $message->id,
|
||||
'node_id' => $message->node_id,
|
||||
'rssi' => $message->rssi,
|
||||
'snr' => $message->snr
|
||||
'node_id' => $message->node_id,
|
||||
'rssi' => $message->rssi,
|
||||
'snr' => $message->snr,
|
||||
'source' => 'lora',
|
||||
],
|
||||
'recorded_at' => $message->received_at ?? now()
|
||||
'recorded_at' => $message->received_at ?? now(),
|
||||
]);
|
||||
|
||||
Device::markOnline($deviceId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'pir_data_saved',
|
||||
'success' => true,
|
||||
'action' => 'pir_data_saved',
|
||||
'pir_reading_id' => $pirReading->id,
|
||||
'motion_type' => $motionType,
|
||||
'is_suspicious' => $isSuspicious
|
||||
'motion_type' => $motionType,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -215,38 +241,54 @@ private function processDoorAccessData(LoRaMessage $message, array $data): array
|
|||
{
|
||||
try {
|
||||
$isAuthorizedTime = $this->checkAuthorizedTime();
|
||||
|
||||
// Normalisasi field — JSON ESP32 bisa kirim "door", "door_opened", atau "door_open"
|
||||
$doorOpened = filter_var(
|
||||
$data['door_opened'] ?? $data['door'] ?? $data['door_open'] ?? false,
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
|
||||
$isAuthorizedAccess = $this->determineAuthorizedDoorAccess($data, $isAuthorizedTime);
|
||||
$isSuspicious = $this->determineSuspiciousDoorAccess($data, $isAuthorizedAccess);
|
||||
|
||||
$accessType = $this->determineDoorAccessType($data, $isAuthorizedAccess, $isAuthorizedTime);
|
||||
$isSuspicious = $this->determineSuspiciousDoorAccess($data, $isAuthorizedAccess);
|
||||
$accessType = $this->determineDoorAccessType($data, $isAuthorizedAccess, $isAuthorizedTime);
|
||||
|
||||
// Resolve device_id
|
||||
$deviceId = $message->device_id ?? Device::where('is_active', true)->first()?->id ?? Device::first()?->id;
|
||||
|
||||
if (!$deviceId) {
|
||||
return ['success' => false, 'error' => 'No device found'];
|
||||
}
|
||||
|
||||
// Simpan ke DoorAccessReading
|
||||
$doorReading = DoorAccessReading::create([
|
||||
'device_id' => $message->device_id,
|
||||
'door_opened' => $data['door_opened'] ?? false,
|
||||
'is_authorized_access' => $isAuthorizedAccess,
|
||||
'access_type' => $accessType,
|
||||
'access_method' => $data['access_method'] ?? 'unknown',
|
||||
'user_id_card' => $data['user_id_card'] ?? null,
|
||||
'duration_seconds' => $data['duration_seconds'] ?? 0,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
'door_location' => $data['door_location'] ?? 'main_entrance',
|
||||
'is_forced_entry' => ($data['access_method'] ?? '') === 'force',
|
||||
'metadata' => [
|
||||
'device_id' => $deviceId,
|
||||
'door_opened' => $doorOpened,
|
||||
'is_authorized_access'=> $isAuthorizedAccess,
|
||||
'access_type' => $accessType,
|
||||
'access_method' => $data['access_method'] ?? 'unknown',
|
||||
'user_id_card' => $data['user_id_card'] ?? null,
|
||||
'duration_seconds' => (int) ($data['duration_seconds'] ?? 0),
|
||||
'is_suspicious' => $isSuspicious,
|
||||
'door_location' => $data['door_location'] ?? 'main_entrance',
|
||||
'is_forced_entry' => ($data['access_method'] ?? '') === 'force',
|
||||
'metadata' => [
|
||||
'lora_message_id' => $message->id,
|
||||
'node_id' => $message->node_id,
|
||||
'rssi' => $message->rssi,
|
||||
'snr' => $message->snr
|
||||
'node_id' => $message->node_id,
|
||||
'rssi' => $message->rssi,
|
||||
'snr' => $message->snr,
|
||||
'source' => 'lora',
|
||||
],
|
||||
'recorded_at' => $message->received_at ?? now()
|
||||
'recorded_at' => $message->received_at ?? now(),
|
||||
]);
|
||||
|
||||
Device::markOnline($deviceId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'action' => 'door_access_data_saved',
|
||||
'success' => true,
|
||||
'action' => 'door_access_data_saved',
|
||||
'door_reading_id' => $doorReading->id,
|
||||
'access_type' => $accessType,
|
||||
'is_suspicious' => $isSuspicious
|
||||
'access_type' => $accessType,
|
||||
'is_suspicious' => $isSuspicious,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* MqttService — Integrasi dengan HiveMQ broker
|
||||
*
|
||||
* Karena PHP/Laravel tidak bisa subscribe MQTT secara native,
|
||||
* kita gunakan HiveMQ REST API untuk publish pesan ke broker.
|
||||
* Untuk subscribe (menerima data dari ESP32 via MQTT), gunakan
|
||||
* endpoint /api/mqtt/ingest yang dipanggil oleh MQTT bridge/webhook.
|
||||
*/
|
||||
class MqttService
|
||||
{
|
||||
protected string $broker;
|
||||
protected int $port;
|
||||
protected string $restApiBase;
|
||||
protected ?string $username;
|
||||
protected ?string $password;
|
||||
|
||||
// Topic constants — harus sama dengan yang ada di ESP32
|
||||
const TOPIC_PIR = 'keamanan/pir';
|
||||
const TOPIC_REED = 'keamanan/reed';
|
||||
const TOPIC_VIBRATION = 'keamanan/vibration';
|
||||
const TOPIC_HEARTBEAT = 'keamanan/heartbeat';
|
||||
const TOPIC_COMMAND = 'keamanan/command';
|
||||
const TOPIC_STATUS = 'keamanan/status';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->broker = config('mqtt.broker', 'broker.hivemq.com');
|
||||
$this->port = config('mqtt.port', 1883);
|
||||
$this->restApiBase = config('mqtt.rest_api', 'https://broker.hivemq.com:8888/api/v1');
|
||||
$this->username = config('mqtt.username');
|
||||
$this->password = config('mqtt.password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish pesan ke MQTT broker via HiveMQ REST API
|
||||
* Digunakan untuk mengirim command/status dari backend ke ESP32
|
||||
*/
|
||||
public function publish(string $topic, array $payload, int $qos = 0, bool $retain = false): bool
|
||||
{
|
||||
try {
|
||||
$message = json_encode($payload);
|
||||
|
||||
// Coba via HiveMQ REST API (jika tersedia)
|
||||
$result = $this->publishViaRestApi($topic, $message, $qos, $retain);
|
||||
|
||||
if ($result) {
|
||||
Log::info('[MQTT] Published', ['topic' => $topic, 'payload' => $payload]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: log saja (untuk development)
|
||||
Log::info('[MQTT] Publish (simulated - no REST API)', [
|
||||
'topic' => $topic,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[MQTT] Publish failed: ' . $e->getMessage(), [
|
||||
'topic' => $topic,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish via HiveMQ REST API
|
||||
*/
|
||||
private function publishViaRestApi(string $topic, string $message, int $qos, bool $retain): bool
|
||||
{
|
||||
try {
|
||||
$url = $this->restApiBase . '/mqtt/publish';
|
||||
|
||||
$body = [
|
||||
'topic' => $topic,
|
||||
'payload' => base64_encode($message),
|
||||
'qos' => $qos,
|
||||
'retain' => $retain,
|
||||
];
|
||||
|
||||
$request = Http::timeout(5);
|
||||
|
||||
if ($this->username && $this->password) {
|
||||
$request = $request->withBasicAuth($this->username, $this->password);
|
||||
}
|
||||
|
||||
$response = $request->post($url, $body);
|
||||
|
||||
return $response->successful();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('[MQTT] REST API publish failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim command ke ESP32 node
|
||||
*/
|
||||
public function sendCommand(string $nodeId, string $command, array $params = []): bool
|
||||
{
|
||||
$payload = [
|
||||
'node_id' => $nodeId,
|
||||
'command' => $command,
|
||||
'params' => $params,
|
||||
'timestamp' => now()->toISOString(),
|
||||
];
|
||||
|
||||
return $this->publish(self::TOPIC_COMMAND, $payload, qos: 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim status update ke semua subscriber
|
||||
*/
|
||||
public function broadcastStatus(array $status): bool
|
||||
{
|
||||
return $this->publish(self::TOPIC_STATUS, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse payload MQTT yang masuk dari ESP32
|
||||
* Payload dari ESP32 sudah dalam format JSON
|
||||
*/
|
||||
public function parseIncomingPayload(string $topic, string $rawPayload): ?array
|
||||
{
|
||||
try {
|
||||
$data = json_decode($rawPayload, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::warning('[MQTT] Invalid JSON payload', [
|
||||
'topic' => $topic,
|
||||
'payload' => $rawPayload,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tambahkan metadata topic
|
||||
$data['_mqtt_topic'] = $topic;
|
||||
$data['_received_at'] = now()->toISOString();
|
||||
|
||||
return $data;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[MQTT] Parse payload failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tentukan tipe sensor dari topic MQTT
|
||||
*/
|
||||
public function getSensorTypeFromTopic(string $topic): ?string
|
||||
{
|
||||
return match ($topic) {
|
||||
self::TOPIC_PIR => 'PIR',
|
||||
self::TOPIC_REED => 'REED',
|
||||
self::TOPIC_VIBRATION => 'VIBRATION',
|
||||
self::TOPIC_HEARTBEAT => 'HEARTBEAT',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MQTT Broker Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Konfigurasi untuk koneksi ke MQTT broker (HiveMQ).
|
||||
| ESP32 gateway menggunakan broker.hivemq.com port 1883 (tanpa auth).
|
||||
|
|
||||
*/
|
||||
|
||||
'broker' => env('MQTT_BROKER', 'broker.hivemq.com'),
|
||||
'port' => env('MQTT_PORT', 1883),
|
||||
'username' => env('MQTT_USERNAME', null),
|
||||
'password' => env('MQTT_PASSWORD', null),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HiveMQ REST API
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| HiveMQ menyediakan REST API untuk publish pesan tanpa koneksi MQTT.
|
||||
| Untuk HiveMQ Cloud, gunakan endpoint yang sesuai.
|
||||
| Untuk broker.hivemq.com (public), REST API tidak tersedia —
|
||||
| publish akan di-log saja (simulasi).
|
||||
|
|
||||
*/
|
||||
'rest_api' => env('MQTT_REST_API', 'https://broker.hivemq.com:8888/api/v1'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MQTT Topics
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'topics' => [
|
||||
'pir' => env('MQTT_TOPIC_PIR', 'keamanan/pir'),
|
||||
'reed' => env('MQTT_TOPIC_REED', 'keamanan/reed'),
|
||||
'vibration' => env('MQTT_TOPIC_VIBRATION', 'keamanan/vibration'),
|
||||
'heartbeat' => env('MQTT_TOPIC_HEARTBEAT', 'keamanan/heartbeat'),
|
||||
'command' => env('MQTT_TOPIC_COMMAND', 'keamanan/command'),
|
||||
'status' => env('MQTT_TOPIC_STATUS', 'keamanan/status'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Webhook Secret
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Secret key untuk memverifikasi bahwa request ke /api/mqtt/ingest
|
||||
| benar-benar berasal dari MQTT bridge yang terpercaya.
|
||||
| Kosongkan untuk menonaktifkan verifikasi (tidak disarankan di production).
|
||||
|
|
||||
*/
|
||||
'webhook_secret' => env('MQTT_WEBHOOK_SECRET', null),
|
||||
];
|
||||
|
|
@ -734,7 +734,9 @@ function toggleSidebar() {
|
|||
if (reedRes?.success && reedRes.data?.length > 0) {
|
||||
const latest = reedRes.data[0];
|
||||
const age = (now - new Date(latest.recorded_at).getTime()) / 1000;
|
||||
reedActive = latest.door_open && age <= DETECTION_WINDOW;
|
||||
// DoorAccessReading pakai "door_opened", ReedSwitchReading pakai "door_open"
|
||||
const isOpen = latest.door_opened ?? latest.door_open ?? false;
|
||||
reedActive = isOpen && age <= DETECTION_WINDOW;
|
||||
}
|
||||
|
||||
const anyActive = pirActive || sw420Active || reedActive;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Http\Controllers\Api\PirController;
|
||||
use App\Http\Controllers\Api\DoorAccessController;
|
||||
use App\Http\Controllers\Api\LoRaController;
|
||||
use App\Http\Controllers\Api\MqttController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -79,6 +80,27 @@
|
|||
Route::post('/process-messages', [LoRaController::class, 'processUnprocessedMessages']);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// MQTT Integration Routes
|
||||
// ============================================================
|
||||
Route::prefix('mqtt')->group(function () {
|
||||
// Terima data dari MQTT bridge/webhook (dipanggil oleh bridge yang subscribe ke HiveMQ)
|
||||
// POST body: { "topic": "keamanan/pir", "payload": "{...json...}" }
|
||||
Route::post('/ingest', [MqttController::class, 'ingest']);
|
||||
|
||||
// Terima batch data dari MQTT bridge
|
||||
// POST body: { "messages": [ {"topic":"...", "payload":"..."}, ... ] }
|
||||
Route::post('/ingest-batch', [MqttController::class, 'ingestBatch']);
|
||||
|
||||
// Publish pesan ke MQTT broker dari backend
|
||||
// POST body: { "topic": "keamanan/command", "payload": {...} }
|
||||
Route::post('/publish', [MqttController::class, 'publish']);
|
||||
|
||||
// Kirim command ke ESP32 node via MQTT
|
||||
// POST body: { "node_id": "NODE_001", "command": "REBOOT", "params": {} }
|
||||
Route::post('/command', [MqttController::class, 'sendCommand']);
|
||||
});
|
||||
|
||||
// Device Status API — untuk polling online/offline dari frontend
|
||||
Route::get('/device/status', function () {
|
||||
$device = \App\Models\Device::orderBy('last_seen', 'desc')->first();
|
||||
|
|
|
|||
|
|
@ -130,7 +130,11 @@
|
|||
->where('motion_detected', true)->exists();
|
||||
$vibAktif = \App\Models\VibrationReading::where('recorded_at', '>=', now()->subSeconds(30))
|
||||
->where('is_abnormal', true)->exists();
|
||||
$reedAktif = \App\Models\ReedSwitchReading::where('recorded_at', '>=', now()->subSeconds(30))
|
||||
// Reed Switch: cek DoorAccessReading (data dari ESP32 via /api/door-access/data)
|
||||
// DAN ReedSwitchReading (data dari endpoint /api/door-access/data lama)
|
||||
$reedAktif = \App\Models\DoorAccessReading::where('recorded_at', '>=', now()->subSeconds(30))
|
||||
->where('door_opened', true)->exists()
|
||||
|| \App\Models\ReedSwitchReading::where('recorded_at', '>=', now()->subSeconds(30))
|
||||
->where('door_open', true)->exists();
|
||||
|
||||
// Status device online — berdasarkan last_seen dalam 2 menit terakhir
|
||||
|
|
@ -150,7 +154,11 @@
|
|||
->orderBy('recorded_at', 'desc')->first();
|
||||
$vibrationLatest = \App\Models\VibrationReading::with('device')
|
||||
->orderBy('recorded_at', 'desc')->first();
|
||||
$reedLatest = \App\Models\ReedSwitchReading::with('device')
|
||||
// Reed Switch: ambil dari DoorAccessReading (data ESP32 terbaru)
|
||||
// fallback ke ReedSwitchReading jika tidak ada
|
||||
$reedLatest = \App\Models\DoorAccessReading::with('device')
|
||||
->orderBy('recorded_at', 'desc')->first()
|
||||
?? \App\Models\ReedSwitchReading::with('device')
|
||||
->orderBy('recorded_at', 'desc')->first();
|
||||
|
||||
// Ambil data sensor dari tabel sensors untuk info nama & status
|
||||
|
|
@ -163,13 +171,15 @@
|
|||
->where('motion_detected', true)->count();
|
||||
$vibrationCount24h = \App\Models\VibrationReading::where('recorded_at', '>=', now()->subHours(24))
|
||||
->where('is_abnormal', true)->count();
|
||||
$reedCount24h = \App\Models\ReedSwitchReading::where('recorded_at', '>=', now()->subHours(24))
|
||||
$reedCount24h = \App\Models\DoorAccessReading::where('recorded_at', '>=', now()->subHours(24))
|
||||
->where('door_opened', true)->count()
|
||||
+ \App\Models\ReedSwitchReading::where('recorded_at', '>=', now()->subHours(24))
|
||||
->where('door_open', true)->count();
|
||||
|
||||
// Riwayat 5 data terakhir tiap sensor
|
||||
$pirHistory = \App\Models\PirReading::orderBy('recorded_at', 'desc')->limit(5)->get();
|
||||
$vibrationHistory = \App\Models\VibrationReading::orderBy('recorded_at', 'desc')->limit(5)->get();
|
||||
$reedHistory = \App\Models\ReedSwitchReading::orderBy('recorded_at', 'desc')->limit(5)->get();
|
||||
$reedHistory = \App\Models\DoorAccessReading::orderBy('recorded_at', 'desc')->limit(5)->get();
|
||||
|
||||
$device = \App\Models\Device::where('status', 'online')->first();
|
||||
$lastUpdate = \App\Models\PirReading::orderBy('recorded_at', 'desc')->first()?->recorded_at;
|
||||
|
|
|
|||
Loading…
Reference in New Issue