569 lines
21 KiB
PHP
569 lines
21 KiB
PHP
<?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());
|
|
}
|
|
}
|
|
}
|