442 lines
13 KiB
PHP
442 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class LoRaMessage extends Model
|
|
{
|
|
protected $fillable = [
|
|
'device_id',
|
|
'node_id',
|
|
'gateway_id',
|
|
'direction',
|
|
'message_type',
|
|
'payload',
|
|
'parsed_data',
|
|
'rssi',
|
|
'snr',
|
|
'spreading_factor',
|
|
'frequency',
|
|
'bandwidth',
|
|
'is_processed',
|
|
'is_acknowledged',
|
|
'status',
|
|
'error_message',
|
|
'metadata',
|
|
'transmitted_at',
|
|
'received_at'
|
|
];
|
|
|
|
protected $casts = [
|
|
'parsed_data' => 'array',
|
|
'metadata' => 'array',
|
|
'is_processed' => 'boolean',
|
|
'is_acknowledged' => 'boolean',
|
|
'transmitted_at' => 'datetime',
|
|
'received_at' => 'datetime',
|
|
'rssi' => 'float',
|
|
'snr' => 'float',
|
|
'frequency' => 'float'
|
|
];
|
|
|
|
public function device(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Device::class);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
case 'heartbeat':
|
|
return $this->parseHeartbeat();
|
|
case 'command':
|
|
return $this->parseCommand();
|
|
case 'ack':
|
|
return $this->parseAcknowledgment();
|
|
case 'config':
|
|
return $this->parseConfig();
|
|
default:
|
|
return $this->parseGeneric();
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("LoRa payload parsing failed: " . $e->getMessage(), [
|
|
'message_id' => $this->id,
|
|
'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"
|
|
*/
|
|
private function parseSensorData(): array
|
|
{
|
|
$parts = explode('|', $this->payload);
|
|
|
|
if (count($parts) < 3) {
|
|
throw new \Exception('Invalid sensor data format');
|
|
}
|
|
|
|
$sensorType = strtoupper($parts[1]);
|
|
$data = ['sensor_type' => $sensorType];
|
|
|
|
switch ($sensorType) {
|
|
case 'VIBRATION':
|
|
// Format: SENSOR|VIBRATION|x,y,z|threshold
|
|
if (count($parts) >= 4) {
|
|
$axes = explode(',', $parts[2]);
|
|
$data = array_merge($data, [
|
|
'x_axis' => (float)($axes[0] ?? 0),
|
|
'y_axis' => (float)($axes[1] ?? 0),
|
|
'z_axis' => (float)($axes[2] ?? 0),
|
|
'threshold' => (float)($parts[3] ?? 2.0)
|
|
]);
|
|
}
|
|
break;
|
|
|
|
case 'PIR':
|
|
// Format: SENSOR|PIR|detected|intensity|duration|zone
|
|
if (count($parts) >= 6) {
|
|
$data = array_merge($data, [
|
|
'motion_detected' => (bool)$parts[2],
|
|
'motion_intensity' => (int)$parts[3],
|
|
'duration_seconds' => (int)$parts[4],
|
|
'detection_zone' => $parts[5]
|
|
]);
|
|
}
|
|
break;
|
|
|
|
case 'DOOR':
|
|
// Format: SENSOR|DOOR|opened|id_card|duration|method|location
|
|
if (count($parts) >= 7) {
|
|
$data = array_merge($data, [
|
|
'door_opened' => (bool)$parts[2],
|
|
'user_id_card' => $parts[3] !== 'NULL' ? $parts[3] : null,
|
|
'duration_seconds' => (int)$parts[4],
|
|
'access_method' => $parts[5],
|
|
'door_location' => $parts[6]
|
|
]);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
$data['raw_data'] = array_slice($parts, 2);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Parse heartbeat payload
|
|
* Format: "HEARTBEAT|battery_level|signal_strength|uptime"
|
|
*/
|
|
private function parseHeartbeat(): array
|
|
{
|
|
$parts = explode('|', $this->payload);
|
|
|
|
return [
|
|
'message_type' => 'heartbeat',
|
|
'battery_level' => isset($parts[1]) ? (int)$parts[1] : null,
|
|
'signal_strength' => isset($parts[2]) ? (int)$parts[2] : null,
|
|
'uptime_seconds' => isset($parts[3]) ? (int)$parts[3] : null,
|
|
'timestamp' => now()->toISOString()
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse command payload
|
|
* Format: "COMMAND|action|parameters"
|
|
*/
|
|
private function parseCommand(): array
|
|
{
|
|
$parts = explode('|', $this->payload);
|
|
|
|
return [
|
|
'message_type' => 'command',
|
|
'action' => $parts[1] ?? 'unknown',
|
|
'parameters' => isset($parts[2]) ? explode(',', $parts[2]) : [],
|
|
'command_id' => $parts[3] ?? null
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse acknowledgment payload
|
|
* Format: "ACK|command_id|status|message"
|
|
*/
|
|
private function parseAcknowledgment(): array
|
|
{
|
|
$parts = explode('|', $this->payload);
|
|
|
|
return [
|
|
'message_type' => 'ack',
|
|
'command_id' => $parts[1] ?? null,
|
|
'ack_status' => $parts[2] ?? 'unknown',
|
|
'ack_message' => $parts[3] ?? null
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse config payload
|
|
* Format: "CONFIG|parameter|value"
|
|
*/
|
|
private function parseConfig(): array
|
|
{
|
|
$parts = explode('|', $this->payload);
|
|
|
|
return [
|
|
'message_type' => 'config',
|
|
'parameter' => $parts[1] ?? null,
|
|
'value' => $parts[2] ?? null,
|
|
'config_id' => $parts[3] ?? null
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse generic payload
|
|
*/
|
|
private function parseGeneric(): array
|
|
{
|
|
return [
|
|
'message_type' => 'generic',
|
|
'raw_payload' => $this->payload,
|
|
'parts' => explode('|', $this->payload)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Generate command payload untuk dikirim ke LoRa node
|
|
*/
|
|
public static function generateCommandPayload(string $action, array $parameters = [], string $commandId = null): string
|
|
{
|
|
$commandId = $commandId ?? uniqid('CMD_');
|
|
$paramStr = implode(',', $parameters);
|
|
|
|
return "COMMAND|{$action}|{$paramStr}|{$commandId}";
|
|
}
|
|
|
|
/**
|
|
* Generate config payload untuk konfigurasi node
|
|
*/
|
|
public static function generateConfigPayload(string $parameter, $value, string $configId = null): string
|
|
{
|
|
$configId = $configId ?? uniqid('CFG_');
|
|
|
|
return "CONFIG|{$parameter}|{$value}|{$configId}";
|
|
}
|
|
|
|
/**
|
|
* Cek kualitas sinyal LoRa
|
|
*/
|
|
public function getSignalQuality(): string
|
|
{
|
|
if ($this->rssi === null) {
|
|
return 'unknown';
|
|
}
|
|
|
|
// RSSI thresholds (dBm)
|
|
if ($this->rssi >= -70) {
|
|
return 'excellent';
|
|
} elseif ($this->rssi >= -85) {
|
|
return 'good';
|
|
} elseif ($this->rssi >= -100) {
|
|
return 'fair';
|
|
} else {
|
|
return 'poor';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Estimate jarak berdasarkan RSSI (rough calculation)
|
|
*/
|
|
public function estimateDistance(): ?float
|
|
{
|
|
if ($this->rssi === null) {
|
|
return null;
|
|
}
|
|
|
|
// Simplified path loss calculation
|
|
// Distance (km) = 10^((Tx_Power - RSSI - 32.44 - 20*log10(frequency)) / 20)
|
|
$txPower = 14; // Typical LoRa TX power in dBm
|
|
$frequency = $this->frequency ?? 868.0; // MHz
|
|
|
|
$pathLoss = $txPower - $this->rssi - 32.44 - (20 * log10($frequency));
|
|
$distance = pow(10, $pathLoss / 20);
|
|
|
|
return round($distance, 2);
|
|
}
|
|
|
|
/**
|
|
* Scope untuk inbound messages
|
|
*/
|
|
public function scopeInbound($query)
|
|
{
|
|
return $query->where('direction', 'inbound');
|
|
}
|
|
|
|
/**
|
|
* Scope untuk outbound messages
|
|
*/
|
|
public function scopeOutbound($query)
|
|
{
|
|
return $query->where('direction', 'outbound');
|
|
}
|
|
|
|
/**
|
|
* Scope untuk unprocessed messages
|
|
*/
|
|
public function scopeUnprocessed($query)
|
|
{
|
|
return $query->where('is_processed', false);
|
|
}
|
|
|
|
/**
|
|
* Scope untuk message type tertentu
|
|
*/
|
|
public function scopeMessageType($query, $type)
|
|
{
|
|
return $query->where('message_type', $type);
|
|
}
|
|
|
|
/**
|
|
* Scope untuk node tertentu
|
|
*/
|
|
public function scopeFromNode($query, $nodeId)
|
|
{
|
|
return $query->where('node_id', $nodeId);
|
|
}
|
|
|
|
/**
|
|
* Scope untuk gateway tertentu
|
|
*/
|
|
public function scopeFromGateway($query, $gatewayId)
|
|
{
|
|
return $query->where('gateway_id', $gatewayId);
|
|
}
|
|
|
|
/**
|
|
* Mark message as processed
|
|
*/
|
|
public function markAsProcessed(bool $success = true, string $errorMessage = null): void
|
|
{
|
|
$this->update([
|
|
'is_processed' => true,
|
|
'status' => $success ? 'processed' : 'failed',
|
|
'error_message' => $errorMessage
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Mark outbound message as acknowledged
|
|
*/
|
|
public function markAsAcknowledged(): void
|
|
{
|
|
$this->update([
|
|
'is_acknowledged' => true,
|
|
'status' => 'acknowledged'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get message summary untuk dashboard
|
|
*/
|
|
public function getMessageSummary(): array
|
|
{
|
|
return [
|
|
'id' => $this->id,
|
|
'node_id' => $this->node_id,
|
|
'direction' => $this->direction,
|
|
'message_type' => $this->message_type,
|
|
'signal_quality' => $this->getSignalQuality(),
|
|
'estimated_distance' => $this->estimateDistance(),
|
|
'is_processed' => $this->is_processed,
|
|
'status' => $this->status,
|
|
'received_at' => $this->received_at?->format('Y-m-d H:i:s'),
|
|
'transmitted_at' => $this->transmitted_at?->format('Y-m-d H:i:s')
|
|
];
|
|
}
|
|
}
|