feat: MQTT integration + fix ESP32 payload handling

This commit is contained in:
Adamalfantino 2026-05-27 03:32:31 +07:00
parent 2b28403194
commit e12b29dde8
14 changed files with 1544 additions and 124 deletions

View File

@ -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

232
MQTT_INTEGRATION_GUIDE.md Normal file
View File

@ -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
```

View File

@ -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
*/

View File

@ -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
}
/**

View File

@ -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());
}
}
}

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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"

View File

@ -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) {

View File

@ -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,
};
}
}

57
config/mqtt.php Normal file
View File

@ -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),
];

View File

@ -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;

View File

@ -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();

View File

@ -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;