Added distributed MQTT recovery backend and device governance
This commit is contained in:
parent
269ea239f2
commit
a028fc6497
@ -3,7 +3,8 @@ declare(strict_types=1);
|
||||
require __DIR__ . "/db.php";
|
||||
|
||||
$rows = $pdo->query("
|
||||
SELECT uid, entradas, saidas, online, blocked,
|
||||
SELECT uid, note, entradas, saidas, online, blocked,
|
||||
current_broker, rssi, heap, uptime,
|
||||
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
|
||||
FROM contadores
|
||||
ORDER BY online DESC, uid ASC
|
||||
|
||||
@ -9,17 +9,27 @@ $LOG_LEVEL = LOG_DEBUG;
|
||||
//$LOG_LEVEL = LOG_INFO;
|
||||
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
|
||||
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('error_log', '/var/log/mqtt-listener/recovery-error.log');
|
||||
|
||||
set_time_limit(0);
|
||||
ignore_user_abort(true);
|
||||
|
||||
function logmsg(string $msg, int $level = LOG_INFO): void {
|
||||
function logmsg(string $msg, int $level = LOG_INFO): void
|
||||
{
|
||||
global $LOG_LEVEL;
|
||||
if ($level > $LOG_LEVEL) return;
|
||||
|
||||
if ($level > $LOG_LEVEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . "] $msg\n";
|
||||
if (function_exists('flush')) @flush();
|
||||
|
||||
if (function_exists('flush')) {
|
||||
@flush();
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
@ -27,28 +37,53 @@ require __DIR__ . '/../vendor/autoload.php';
|
||||
use PhpMqtt\Client\MqttClient;
|
||||
use PhpMqtt\Client\ConnectionSettings;
|
||||
|
||||
// =====================================================
|
||||
// DB
|
||||
// =====================================================
|
||||
|
||||
$db = new PDO(
|
||||
"mysql:host=localhost;dbname=cagalhao;charset=utf8",
|
||||
"master",
|
||||
"master",
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
|
||||
]
|
||||
);
|
||||
|
||||
// RECOVERY BROKER
|
||||
// =====================================================
|
||||
// RECOVERY MQTT
|
||||
// =====================================================
|
||||
|
||||
$recoveryHost = 'mqtt-recovery.xupas.mywire.org';
|
||||
$recoveryPort = 8884;
|
||||
|
||||
$recoveryUser = 'recovery';
|
||||
$recoveryPass = 'recovery123';
|
||||
|
||||
// MAIN BROKER TEST
|
||||
// =====================================================
|
||||
// MAIN MQTT
|
||||
// =====================================================
|
||||
|
||||
$mainHost = 'mqtt.xupas.mywire.org';
|
||||
$mainPort = 8883;
|
||||
|
||||
// Cert principal a enviar aos ESPs
|
||||
$certPath = "/etc/mosquitto/certs/server.crt";
|
||||
// =====================================================
|
||||
// CERT
|
||||
// =====================================================
|
||||
|
||||
$certPath = '/etc/mosquitto/certs/chain.pem'; // cert Let's Encrypt do broker principal
|
||||
$certUrl = 'https://mqtt.xupas.mywire.org/cert.pem';
|
||||
|
||||
// =====================================================
|
||||
// GLOBAL SECRET
|
||||
// =====================================================
|
||||
|
||||
$authSecret = 'K82A91F0';
|
||||
|
||||
// =====================================================
|
||||
// MQTT SETTINGS
|
||||
// =====================================================
|
||||
|
||||
// MQTT recovery settings
|
||||
$settings = (new ConnectionSettings())
|
||||
->setUsername($recoveryUser)
|
||||
->setPassword($recoveryPass)
|
||||
@ -59,22 +94,38 @@ $settings = (new ConnectionSettings())
|
||||
->setSocketTimeout(1)
|
||||
->setReconnectAutomatically(true);
|
||||
|
||||
// =====================================================
|
||||
// MQTT CLIENT
|
||||
// =====================================================
|
||||
|
||||
$mqtt = new MqttClient(
|
||||
$recoveryHost,
|
||||
$recoveryPort,
|
||||
'listener-recovery-' . getmypid()
|
||||
);
|
||||
|
||||
function main_broker_online(string $host, int $port): bool {
|
||||
// =====================================================
|
||||
// MAIN BROKER CHECK
|
||||
// =====================================================
|
||||
|
||||
function main_broker_online(string $host, int $port): bool
|
||||
{
|
||||
$fp = @fsockopen($host, $port, $errno, $errstr, 2);
|
||||
|
||||
if ($fp) {
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function send_switch_primary(MqttClient $mqtt, PDO $db): void {
|
||||
// =====================================================
|
||||
// SEND SWITCH PRIMARY
|
||||
// =====================================================
|
||||
|
||||
function send_switch_primary(MqttClient $mqtt, PDO $db): void
|
||||
{
|
||||
$stmt = $db->query("
|
||||
SELECT uid
|
||||
FROM contadores
|
||||
@ -82,11 +133,14 @@ function send_switch_primary(MqttClient $mqtt, PDO $db): void {
|
||||
");
|
||||
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
|
||||
$uid = $row['uid'];
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode(['cmd' => 'switch_primary']),
|
||||
json_encode([
|
||||
'cmd' => 'SWITCH_PRIMARY'
|
||||
]),
|
||||
1
|
||||
);
|
||||
|
||||
@ -94,7 +148,17 @@ function send_switch_primary(MqttClient $mqtt, PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
function send_cert_update(MqttClient $mqtt, string $uid, string $certPath): void {
|
||||
// =====================================================
|
||||
// SEND CERT
|
||||
// =====================================================
|
||||
|
||||
function send_cert_chunks(
|
||||
MqttClient $mqtt,
|
||||
string $uid,
|
||||
string $certPath,
|
||||
int $linesPerChunk = 16
|
||||
): void {
|
||||
|
||||
if (!file_exists($certPath)) {
|
||||
logmsg("Cert não encontrado: $certPath", LOG_ERROR);
|
||||
return;
|
||||
@ -102,47 +166,317 @@ function send_cert_update(MqttClient $mqtt, string $uid, string $certPath): void
|
||||
|
||||
$cert = file_get_contents($certPath);
|
||||
|
||||
// validar com openssl antes de enviar
|
||||
$parsed = openssl_x509_read($cert);
|
||||
if ($parsed === false) {
|
||||
logmsg("Cert inválido (openssl): $certPath", LOG_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = explode("\n", rtrim($cert));
|
||||
$chunks = array_chunk($lines, $linesPerChunk);
|
||||
$total = count($chunks);
|
||||
|
||||
logmsg("CERT_BEGIN $uid total=$total chunks");
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode([
|
||||
'cmd' => 'UPDATE_CERT',
|
||||
'pem' => $cert
|
||||
]),
|
||||
json_encode(['cmd' => 'CERT_BEGIN', 'total' => $total]),
|
||||
1
|
||||
);
|
||||
|
||||
logmsg("UPDATE_CERT enviado para $uid");
|
||||
foreach ($chunks as $seq => $chunk) {
|
||||
$data = implode("\n", $chunk) . "\n";
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode(['cmd' => 'CERT_CHUNK', 'seq' => $seq, 'data' => $data]),
|
||||
1
|
||||
);
|
||||
logmsg("CERT_CHUNK $uid seq=$seq");
|
||||
usleep(100000); // 100ms entre chunks
|
||||
}
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode(['cmd' => 'CERT_COMMIT']),
|
||||
1
|
||||
);
|
||||
|
||||
logmsg("CERT_COMMIT enviado para $uid");
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// SUBSCRIPTIONS
|
||||
// =====================================================
|
||||
|
||||
$subscribe = function () use (
|
||||
$mqtt,
|
||||
$db,
|
||||
$certPath,
|
||||
$certUrl,
|
||||
$mainHost,
|
||||
$mainPort
|
||||
$mainPort,
|
||||
$authSecret
|
||||
) {
|
||||
|
||||
$mqtt->subscribe(
|
||||
'esp/+/status',
|
||||
function (string $topic, string $msg)
|
||||
use ($db, $mqtt, $mainHost, $mainPort) {
|
||||
// =================================================
|
||||
// AUTH
|
||||
// =================================================
|
||||
|
||||
$uid = explode('/', $topic)[1] ?? null;
|
||||
if (!$uid) return;
|
||||
$mqtt->subscribe(
|
||||
'esp/+/auth',
|
||||
function (string $topic, string $msg)
|
||||
use ($db, $mqtt, $authSecret)
|
||||
{
|
||||
|
||||
$uid = explode('/', $topic)[1] ?? null;
|
||||
|
||||
if (!$uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
logmsg("AUTH RX $uid => $msg");
|
||||
|
||||
$json = json_decode($msg, true);
|
||||
|
||||
if (!$json) {
|
||||
|
||||
logmsg(
|
||||
"JSON AUTH inválido",
|
||||
LOG_ERROR
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// HELLO
|
||||
// =============================================
|
||||
|
||||
if (($json['cmd'] ?? '') === 'HELLO') {
|
||||
|
||||
$nonce =
|
||||
strtoupper(
|
||||
bin2hex(random_bytes(16))
|
||||
);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO contadores
|
||||
(uid, entradas, saidas, entradas_total, saidas_total, last_seen, online)
|
||||
(uid, auth_nonce, last_seen, online, blocked)
|
||||
VALUES
|
||||
(:uid,0,0,0,0,NOW(),1)
|
||||
(:uid, :nonce, NOW(), 1, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
auth_nonce=:nonce,
|
||||
last_seen=NOW(),
|
||||
online=1
|
||||
");
|
||||
/* novos ESPs entram com blocked=1 (pendente aprovação)
|
||||
ESPs existentes mantêm o blocked actual */
|
||||
|
||||
$stmt->execute([
|
||||
'uid' => $uid,
|
||||
'nonce' => $nonce
|
||||
]);
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode([
|
||||
'cmd' => 'AUTH_CHALLENGE',
|
||||
'nonce' => $nonce
|
||||
]),
|
||||
1
|
||||
);
|
||||
|
||||
logmsg(
|
||||
"AUTH_CHALLENGE enviado para $uid"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// AUTH RESPONSE
|
||||
// =============================================
|
||||
|
||||
if (($json['cmd'] ?? '') === 'AUTH_RESPONSE') {
|
||||
|
||||
$hash =
|
||||
strtoupper(
|
||||
$json['hash'] ?? ''
|
||||
);
|
||||
|
||||
if (!$hash) {
|
||||
|
||||
logmsg(
|
||||
"AUTH sem hash",
|
||||
LOG_ERROR
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT auth_nonce, blocked
|
||||
FROM contadores
|
||||
WHERE uid=:uid
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'uid' => $uid
|
||||
]);
|
||||
|
||||
$row =
|
||||
$stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
|
||||
logmsg(
|
||||
"UID desconhecido: $uid",
|
||||
LOG_ERROR
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$nonce =
|
||||
$row['auth_nonce'] ?? '';
|
||||
|
||||
if (!$nonce) {
|
||||
|
||||
logmsg(
|
||||
"Nonce vazio para $uid",
|
||||
LOG_ERROR
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$expected =
|
||||
strtoupper(
|
||||
hash_hmac(
|
||||
'sha256',
|
||||
$nonce,
|
||||
$authSecret
|
||||
)
|
||||
);
|
||||
|
||||
if (hash_equals($expected, $hash)) {
|
||||
|
||||
// verificar se está aprovado
|
||||
if ((int)($row['blocked'] ?? 1) === 1) {
|
||||
|
||||
logmsg("AUTH PENDENTE/BLOQUEADO $uid — aguarda aprovação", LOG_ERROR);
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode(['cmd' => 'AUTH_FAIL']),
|
||||
1
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logmsg("AUTH OK $uid");
|
||||
|
||||
$stmt = $db->prepare("
|
||||
UPDATE contadores
|
||||
SET
|
||||
auth_nonce=NULL,
|
||||
auth_ok=1,
|
||||
online=1,
|
||||
last_seen=NOW()
|
||||
WHERE uid=:uid
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'uid' => $uid
|
||||
]);
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode([
|
||||
'cmd' => 'AUTH_OK'
|
||||
]),
|
||||
1
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
logmsg(
|
||||
"AUTH FAIL $uid",
|
||||
LOG_ERROR
|
||||
);
|
||||
|
||||
$mqtt->publish(
|
||||
"esp/$uid/cmd",
|
||||
json_encode([
|
||||
'cmd' => 'AUTH_FAIL'
|
||||
]),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
}, 1);
|
||||
|
||||
// =================================================
|
||||
// STATUS
|
||||
// =================================================
|
||||
|
||||
$mqtt->subscribe(
|
||||
'esp/+/status',
|
||||
function (
|
||||
string $topic,
|
||||
string $msg
|
||||
) use (
|
||||
$db,
|
||||
$mqtt,
|
||||
$mainHost,
|
||||
$mainPort
|
||||
) {
|
||||
|
||||
$uid = explode('/', $topic)[1] ?? null;
|
||||
|
||||
if (!$uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO contadores
|
||||
(
|
||||
uid,
|
||||
entradas,
|
||||
saidas,
|
||||
entradas_total,
|
||||
saidas_total,
|
||||
last_seen,
|
||||
online
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:uid,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
NOW(),
|
||||
1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen=NOW(),
|
||||
online=1
|
||||
");
|
||||
|
||||
$stmt->execute(['uid' => $uid]);
|
||||
$stmt->execute([
|
||||
'uid' => $uid
|
||||
]);
|
||||
|
||||
logmsg("RECOVERY STATUS $uid => $msg");
|
||||
logmsg(
|
||||
"RECOVERY STATUS $uid => $msg"
|
||||
);
|
||||
|
||||
static $lastCheck = 0;
|
||||
|
||||
@ -152,15 +486,27 @@ $subscribe = function () use (
|
||||
|
||||
logmsg("CHECK broker");
|
||||
|
||||
if (main_broker_online($mainHost, $mainPort)) {
|
||||
if (
|
||||
main_broker_online(
|
||||
$mainHost,
|
||||
$mainPort
|
||||
)
|
||||
) {
|
||||
|
||||
logmsg("MAIN broker online");
|
||||
logmsg(
|
||||
"MAIN broker online"
|
||||
);
|
||||
|
||||
send_switch_primary($mqtt, $db);
|
||||
send_switch_primary(
|
||||
$mqtt,
|
||||
$db
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
logmsg("MAIN broker offline");
|
||||
logmsg(
|
||||
"MAIN broker offline"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,61 +514,140 @@ $subscribe = function () use (
|
||||
0
|
||||
);
|
||||
|
||||
// =================================================
|
||||
// EVENT
|
||||
// =================================================
|
||||
|
||||
$mqtt->subscribe('esp/+/event', function (string $topic, string $msg) use ($db) {
|
||||
$uid = explode('/', $topic)[1] ?? null;
|
||||
if (!$uid) return;
|
||||
$mqtt->subscribe(
|
||||
'esp/+/event',
|
||||
function (
|
||||
string $topic,
|
||||
string $msg
|
||||
) {
|
||||
|
||||
logmsg("RECOVERY EVENT $uid => $msg");
|
||||
}, 0);
|
||||
$uid =
|
||||
explode('/', $topic)[1] ?? null;
|
||||
|
||||
$mqtt->subscribe('esp/+/cert_request', function (string $topic, string $msg) use ($mqtt, $certPath) {
|
||||
$uid = explode('/', $topic)[1] ?? null;
|
||||
if (!$uid) return;
|
||||
if (!$uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
logmsg("CERT_REQUEST de $uid");
|
||||
send_cert_update($mqtt, $uid, $certPath);
|
||||
}, 0);
|
||||
logmsg(
|
||||
"RECOVERY EVENT $uid => $msg"
|
||||
);
|
||||
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
// =================================================
|
||||
// CERT REQUEST
|
||||
// =================================================
|
||||
|
||||
$mqtt->subscribe(
|
||||
'esp/+/cert_request',
|
||||
function (
|
||||
string $topic,
|
||||
string $msg
|
||||
) use (
|
||||
$mqtt,
|
||||
$certPath,
|
||||
$certUrl
|
||||
) {
|
||||
|
||||
$uid =
|
||||
explode('/', $topic)[1] ?? null;
|
||||
|
||||
if (!$uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
logmsg("CERT_REQUEST de $uid → chunks");
|
||||
|
||||
send_cert_chunks($mqtt, $uid, $certPath);
|
||||
|
||||
},
|
||||
1
|
||||
);
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// MAIN LOOP
|
||||
// =====================================================
|
||||
|
||||
while (true) {
|
||||
|
||||
try {
|
||||
logmsg("Ligar recovery $recoveryHost:$recoveryPort");
|
||||
|
||||
logmsg(
|
||||
"Ligar recovery $recoveryHost:$recoveryPort"
|
||||
);
|
||||
|
||||
$mqtt->connect($settings, false);
|
||||
|
||||
$subscribe();
|
||||
|
||||
logmsg("RECOVERY ligado e subscrito");
|
||||
logmsg(
|
||||
"RECOVERY ligado e subscrito"
|
||||
);
|
||||
|
||||
$nextMainCheck = time() + 10;
|
||||
|
||||
while ($mqtt->isConnected()) {
|
||||
|
||||
$mqtt->loop(false);
|
||||
logmsg("tick");
|
||||
|
||||
if (time() >= $nextMainCheck) {
|
||||
|
||||
$nextMainCheck = time() + 15;
|
||||
|
||||
logmsg("LOOP recovery", LOG_DEBUG);
|
||||
logmsg(
|
||||
"LOOP recovery",
|
||||
LOG_DEBUG
|
||||
);
|
||||
|
||||
if (main_broker_online($mainHost, $mainPort)) {
|
||||
if (
|
||||
main_broker_online(
|
||||
$mainHost,
|
||||
$mainPort
|
||||
)
|
||||
) {
|
||||
|
||||
logmsg("MAIN broker online");
|
||||
logmsg(
|
||||
"MAIN broker online"
|
||||
);
|
||||
|
||||
logmsg(
|
||||
"Mandar switch_primary"
|
||||
);
|
||||
|
||||
send_switch_primary(
|
||||
$mqtt,
|
||||
$db
|
||||
);
|
||||
|
||||
logmsg("MAIN broker online. Mandar switch_primary.");
|
||||
send_switch_primary($mqtt, $db);
|
||||
} else {
|
||||
logmsg("MAIN broker ainda offline", LOG_DEBUG);
|
||||
|
||||
logmsg(
|
||||
"MAIN broker ainda offline",
|
||||
LOG_DEBUG
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
|
||||
$mqtt->disconnect();
|
||||
|
||||
} catch (Throwable $e) {
|
||||
logmsg("ERRO recovery: " . $e->getMessage(), LOG_ERROR);
|
||||
|
||||
logmsg(
|
||||
"ERRO recovery: " .
|
||||
$e->getMessage(),
|
||||
LOG_ERROR
|
||||
);
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
}
|
||||
@ -92,6 +92,9 @@ if (isset($_GET['vpn'])) {
|
||||
<div class="col-12">
|
||||
<a href="../console.php" class="btn btn-outline-light w-100">📊 Estado dos Dispositivos</a>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<a href="../devices.php" class="btn btn-outline-warning w-100">🔌 Gerir Dispositivos</a>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<a href="../comandos.php" class="btn btn-outline-light w-100">🛠️ Enviar Comando</a>
|
||||
</div>
|
||||
|
||||
@ -63,6 +63,8 @@ $mqttHost = 'mqtt.xupas.mywire.org';
|
||||
$mqttUser = 'xupa';
|
||||
$mqttPass = 'xupa';
|
||||
|
||||
$deviceSecret = 'K82A91F0';
|
||||
|
||||
$bootstrapSecret = "XUPA_TEMP_SECRET_2026";
|
||||
$certPath = "/etc/mosquitto/certs/server.crt";
|
||||
|
||||
@ -98,24 +100,70 @@ $mqttBootstrap = new MqttClient($mqttHost, 1883, 'listener-bootstrap');
|
||||
/* =========================
|
||||
* SUBSCRIPTIONS TLS
|
||||
* ========================= */
|
||||
$subscribeTls = function () use ($mqttTls, $db) {
|
||||
$subscribeTls = function () use ($mqttTls, $db, $deviceSecret) {
|
||||
|
||||
// =========================
|
||||
// STATUS ESP
|
||||
// =========================
|
||||
$mqttTls->subscribe('esp/+/status', function (string $topic, string $msg) use ($db) {
|
||||
|
||||
$mqttTls->subscribe('esp/+/status', function (string $topic, string $msg)
|
||||
use ($db, $mqttTls, $deviceSecret)
|
||||
{
|
||||
$uid = explode('/', $topic)[1] ?? null;
|
||||
if (!$uid) return;
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO contadores (uid, entradas, saidas, entradas_total, saidas_total, last_seen, online)
|
||||
VALUES (:uid,0,0,0,0,NOW(),1)
|
||||
ON DUPLICATE KEY UPDATE last_seen=NOW(), online=1
|
||||
");
|
||||
$stmt->execute(['uid' => $uid]);
|
||||
$json = json_decode($msg, true);
|
||||
$uptime = isset($json['uptime']) ? (int)$json['uptime'] : null;
|
||||
$heap = isset($json['heap']) ? (int)$json['heap'] : null;
|
||||
$rssi = isset($json['rssi']) ? (int)$json['rssi'] : null;
|
||||
$broker = isset($json['broker']) ? (string)$json['broker'] : null;
|
||||
$hmac = isset($json['hmac']) ? strtoupper((string)$json['hmac']) : null;
|
||||
|
||||
logmsg("STATUS $uid");
|
||||
// Validar HMAC — apenas aceitar heartbeats assinados
|
||||
if ($hmac === null || $uptime === null) {
|
||||
logmsg("STATUS sem HMAC/uptime $uid — ignorado", LOG_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
$expected = strtoupper(hash_hmac('sha256', $uid . '|' . $uptime, $deviceSecret));
|
||||
|
||||
if (!hash_equals($expected, $hmac)) {
|
||||
logmsg("STATUS HMAC INVÁLIDO $uid — ignorado", LOG_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// HMAC OK → actualizar DB
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO contadores
|
||||
(uid, entradas, saidas, entradas_total, saidas_total, last_seen, online,
|
||||
uptime, heap, rssi, current_broker)
|
||||
VALUES
|
||||
(:uid, 0, 0, 0, 0, NOW(), 1,
|
||||
:uptime, :heap, :rssi, :broker)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen=NOW(),
|
||||
online=1,
|
||||
uptime=COALESCE(:uptime2, uptime),
|
||||
heap=COALESCE(:heap2, heap),
|
||||
rssi=COALESCE(:rssi2, rssi),
|
||||
current_broker=COALESCE(:broker2, current_broker)
|
||||
");
|
||||
$stmt->execute([
|
||||
'uid' => $uid,
|
||||
'uptime' => $uptime,
|
||||
'heap' => $heap,
|
||||
'rssi' => $rssi,
|
||||
'broker' => $broker,
|
||||
'uptime2' => $uptime,
|
||||
'heap2' => $heap,
|
||||
'rssi2' => $rssi,
|
||||
'broker2' => $broker,
|
||||
]);
|
||||
|
||||
// Responder com HEARTBEAT_OK → ESP arranca serviços (sem retain)
|
||||
$mqttTls->publish("esp/$uid/cmd",
|
||||
json_encode(['cmd' => 'HEARTBEAT_OK']), 1, false);
|
||||
|
||||
logmsg("STATUS OK $uid uptime=$uptime heap=$heap rssi=$rssi broker=$broker → HEARTBEAT_OK");
|
||||
|
||||
}, 0);
|
||||
|
||||
@ -217,11 +265,28 @@ while (true) {
|
||||
$subscribeBootstrap();
|
||||
logmsg("Bootstrap ligado");
|
||||
|
||||
$nextOfflineCheck = time() + 15;
|
||||
|
||||
while ($mqttTls->isConnected() && $mqttBootstrap->isConnected()) {
|
||||
|
||||
$mqttTls->loop(true, false);
|
||||
$mqttBootstrap->loop(true, false);
|
||||
|
||||
if (time() >= $nextOfflineCheck) {
|
||||
$nextOfflineCheck = time() + 15;
|
||||
|
||||
$affected = $db->exec("
|
||||
UPDATE contadores
|
||||
SET online=0
|
||||
WHERE online=1
|
||||
AND TIMESTAMPDIFF(SECOND, last_seen, NOW()) > 35
|
||||
");
|
||||
|
||||
if ($affected > 0) {
|
||||
logmsg("OFFLINE sweep: $affected dispositivo(s) marcado(s) offline");
|
||||
}
|
||||
}
|
||||
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,6 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
header("Location: login.php");
|
||||
header("Location: /api/login.php");
|
||||
exit;
|
||||
}
|
||||
@ -27,7 +27,7 @@ mqtt_publish($topic, json_encode($msg, JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// ----------------------- BD -------------------------
|
||||
if ($cmd === 'BLOCK') {
|
||||
$stmt = $db->prepare("UPDATE contadores SET blocked = ? WHERE uid = ?");
|
||||
$stmt = $pdo->prepare("UPDATE contadores SET blocked = ? WHERE uid = ?");
|
||||
$stmt->execute([intval($value), $uid]);
|
||||
}
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ try {
|
||||
$_SESSION['username'] = $user['username'];
|
||||
|
||||
// Redireciona para o menu principal
|
||||
header("Location: menu.php");
|
||||
header("Location: /api/menu.php");
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
@ -22,7 +22,7 @@ try {
|
||||
}
|
||||
|
||||
// Buscar lista de devices
|
||||
$devices = $pdo->query("SELECT uid FROM devices ORDER BY uid ASC")->fetchAll();
|
||||
$devices = $pdo->query("SELECT uid FROM contadores ORDER BY uid ASC")->fetchAll();
|
||||
|
||||
} catch (Exception $e) {
|
||||
die("Erro: " . $e->getMessage());
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
return [
|
||||
'host' => '192.168.10.200', // depois trocas para 'estica.bot.nu'
|
||||
'port' => 1883, // 8883 se fores usar TLS mais tarde
|
||||
'host' => 'mqtt.xupas.mywire.org',
|
||||
'port' => 8883,
|
||||
'clientId' => 'php-web-' . bin2hex(random_bytes(3)),
|
||||
'username' => 'xupa',
|
||||
'password' => 'xupa',
|
||||
|
||||
638
console.php
638
console.php
@ -1,199 +1,482 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . "/api/protecao.php";
|
||||
require __DIR__ . "/api/db.php";
|
||||
|
||||
// ---------- SORT ----------
|
||||
$sort = $_GET['sort'] ?? 'state';
|
||||
$orderSql = ($sort === 'uid')
|
||||
? "ORDER BY uid ASC"
|
||||
: "ORDER BY online DESC, uid ASC";
|
||||
// =====================================================
|
||||
// GUARDAR NOTA
|
||||
// =====================================================
|
||||
|
||||
// ---------- OFFLINE AUTOMÁTICO (20s) ----------
|
||||
$pdo->query("
|
||||
UPDATE contadores
|
||||
SET online = 0
|
||||
WHERE TIMESTAMPDIFF(SECOND, last_seen, NOW()) > 20
|
||||
");
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_note'])) {
|
||||
|
||||
$uid = $_POST['uid'] ?? '';
|
||||
$note = trim($_POST['note'] ?? '');
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE contadores
|
||||
SET note = ?
|
||||
WHERE uid = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$note, $uid]);
|
||||
|
||||
// ---------- BLOQUEIO ----------
|
||||
if (isset($_GET['toggle'])) {
|
||||
$uid = $_GET['toggle'];
|
||||
$pdo->prepare("UPDATE contadores SET blocked = 1 - blocked WHERE uid = ?")
|
||||
->execute([$uid]);
|
||||
header("Location: console.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- PRIMEIRA QUERY (para totals) ----------
|
||||
// =====================================================
|
||||
// REMOVER
|
||||
// =====================================================
|
||||
|
||||
if (isset($_GET['remove'])) {
|
||||
|
||||
$uid = $_GET['remove'];
|
||||
|
||||
$pdo->prepare("
|
||||
DELETE FROM contadores
|
||||
WHERE uid = ?
|
||||
")->execute([$uid]);
|
||||
|
||||
header("Location: console.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// BLOQUEAR / DESBLOQUEAR
|
||||
// =====================================================
|
||||
|
||||
if (isset($_GET['toggle'])) {
|
||||
|
||||
$uid = $_GET['toggle'];
|
||||
|
||||
$pdo->prepare("
|
||||
UPDATE contadores
|
||||
SET blocked = 1 - blocked
|
||||
WHERE uid = ?
|
||||
")->execute([$uid]);
|
||||
|
||||
header("Location: console.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// SORT
|
||||
// =====================================================
|
||||
|
||||
$sort = $_GET['sort'] ?? 'state';
|
||||
|
||||
$orderSql = ($sort === 'uid')
|
||||
? "ORDER BY uid ASC"
|
||||
: "ORDER BY online DESC, uid ASC";
|
||||
|
||||
// =====================================================
|
||||
// QUERY
|
||||
// =====================================================
|
||||
|
||||
$sql = "
|
||||
SELECT uid, entradas, saidas, online, blocked,
|
||||
last_seen,
|
||||
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
|
||||
SELECT
|
||||
uid,
|
||||
note,
|
||||
entradas,
|
||||
saidas,
|
||||
online,
|
||||
blocked,
|
||||
current_broker,
|
||||
rssi,
|
||||
heap,
|
||||
uptime,
|
||||
last_seen,
|
||||
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
|
||||
FROM contadores
|
||||
$orderSql
|
||||
";
|
||||
|
||||
$stmt = $pdo->query($sql);
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Totais
|
||||
// =====================================================
|
||||
// TOTAIS
|
||||
// =====================================================
|
||||
|
||||
$sumEntradas = 0;
|
||||
$sumSaidas = 0;
|
||||
$onlineCount = 0;
|
||||
$sumSaidas = 0;
|
||||
|
||||
$onlineCount = 0;
|
||||
$offlineCount = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
|
||||
$sumEntradas += (int)$r['entradas'];
|
||||
$sumSaidas += (int)$r['saidas'];
|
||||
if ($r['online'] == 1) $onlineCount++;
|
||||
else $offlineCount++;
|
||||
|
||||
if ((int)$r['online'] === 1) {
|
||||
$onlineCount++;
|
||||
} else {
|
||||
$offlineCount++;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="pt" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<title>Painel de Contadores</title>
|
||||
|
||||
<title>MQTT Swarm Console</title>
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
|
||||
<style>
|
||||
|
||||
body {
|
||||
background-color: #0f0f0f;
|
||||
background: #0b0b0b;
|
||||
color: #e0e0e0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #1a1a1a;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(odd) {
|
||||
background-color: #1c1c1c;
|
||||
background-color: #171717;
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(even) {
|
||||
background-color: #151515;
|
||||
background-color: #101010;
|
||||
}
|
||||
.table thead {
|
||||
background-color: #222;
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #202020;
|
||||
}
|
||||
h1 {
|
||||
color: #f0f0f0;
|
||||
|
||||
.note-input {
|
||||
min-width: 220px;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
color: #fff;
|
||||
}
|
||||
a {
|
||||
color: #58a6ff;
|
||||
|
||||
.note-input:focus {
|
||||
background: #181818;
|
||||
color: #fff;
|
||||
border-color: #58a6ff;
|
||||
box-shadow: none;
|
||||
}
|
||||
a:hover {
|
||||
color: #8cc5ff;
|
||||
|
||||
.pulse-cell {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #a32424;
|
||||
border-color: #6b0000;
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0% {
|
||||
background-color: rgba(0,255,0,0.03);
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: rgba(0,255,0,0.16);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: rgba(0,255,0,0.03);
|
||||
}
|
||||
}
|
||||
.btn-success {
|
||||
background-color: #1f6f33;
|
||||
border-color: #0f4d1f;
|
||||
|
||||
.header-title {
|
||||
color: #00ffcc;
|
||||
text-shadow: 0 0 8px rgba(0,255,180,0.4);
|
||||
}
|
||||
|
||||
.small-info {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<h1 class="mb-4">Painel de Contadores</h1>
|
||||
<h1 class="mb-4 header-title">
|
||||
🌋 MQTT SWARM CONSOLE
|
||||
</h1>
|
||||
|
||||
<!-- RESUMO -->
|
||||
<div class="row mb-4 text-center">
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 card rounded">
|
||||
<h4><?= $onlineCount ?></h4>
|
||||
<div class="text-success fw-bold">Online</div>
|
||||
<h4 id="total_online"><?= $onlineCount ?></h4>
|
||||
<div class="text-success fw-bold">
|
||||
🟢 ONLINE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 card rounded">
|
||||
<h4><?= $offlineCount ?></h4>
|
||||
<div class="text-danger fw-bold">Offline</div>
|
||||
<h4 id="total_offline"><?= $offlineCount ?></h4>
|
||||
<div class="text-danger fw-bold">
|
||||
🔴 OFFLINE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 card rounded">
|
||||
<h4><?= $sumEntradas ?></h4>
|
||||
<div class="fw-bold">Entradas</div>
|
||||
<h4 id="total_entradas"><?= $sumEntradas ?></h4>
|
||||
<div class="fw-bold">
|
||||
💰 ENTRADAS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 card rounded">
|
||||
<h4><?= $sumSaidas ?></h4>
|
||||
<div class="fw-bold">Saídas</div>
|
||||
<h4 id="total_saidas"><?= $sumSaidas ?></h4>
|
||||
<div class="fw-bold">
|
||||
💸 SAÍDAS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TABELA -->
|
||||
<div class="card shadow-sm">
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
<th><a href="?sort=uid">UID</a></th>
|
||||
<th>Entradas</th>
|
||||
<th>Saídas</th>
|
||||
<th><a href="?sort=state">Estado</a></th>
|
||||
<th>Último Sinal</th>
|
||||
<th>Ações</th>
|
||||
|
||||
<th>UID</th>
|
||||
<th>NOTA</th>
|
||||
<th>ENTRADAS</th>
|
||||
<th>SAÍDAS</th>
|
||||
<th>ESTADO</th>
|
||||
<th>BROKER</th>
|
||||
<th>RSSI</th>
|
||||
<th>HEAP</th>
|
||||
<th>UPTIME</th>
|
||||
<th>ÚLTIMO</th>
|
||||
<th>AÇÕES</th>
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody id="tabela_corpo">
|
||||
|
||||
<?php foreach ($rows as $r): ?>
|
||||
|
||||
<?php
|
||||
$online = (int)$r['online'] === 1;
|
||||
$blocked = (int)$r['blocked'] === 1;
|
||||
|
||||
if ($blocked) {
|
||||
$state = '<span class="badge bg-danger">Bloqueado</span>';
|
||||
} elseif ($online) {
|
||||
$state = '<span class="badge bg-success">Online</span>';
|
||||
} else {
|
||||
$state = '<span class="badge bg-secondary">Offline</span>';
|
||||
}
|
||||
$online = (int)$r['online'] === 1;
|
||||
$blocked = (int)$r['blocked'] === 1;
|
||||
|
||||
if ($blocked) {
|
||||
|
||||
$state =
|
||||
'<span class="badge bg-danger">
|
||||
BLOQUEADO
|
||||
</span>';
|
||||
|
||||
} elseif ($online) {
|
||||
|
||||
$state =
|
||||
'<span class="badge bg-success">
|
||||
ONLINE
|
||||
</span>';
|
||||
|
||||
} else {
|
||||
|
||||
$state =
|
||||
'<span class="badge bg-secondary">
|
||||
OFFLINE
|
||||
</span>';
|
||||
}
|
||||
|
||||
if (($r['current_broker'] ?? '') === 'recovery') {
|
||||
|
||||
$broker =
|
||||
'<span class="badge bg-warning text-dark">
|
||||
RECOVERY
|
||||
</span>';
|
||||
|
||||
} else {
|
||||
|
||||
$broker =
|
||||
'<span class="badge bg-primary">
|
||||
PRIMARY
|
||||
</span>';
|
||||
}
|
||||
|
||||
$ago = (int)$r['ago'];
|
||||
|
||||
$lastSeen = ($ago < 60)
|
||||
? "{$ago}s"
|
||||
: round($ago / 60) . "m";
|
||||
|
||||
$pulseClass =
|
||||
($ago <= 2 && $online)
|
||||
? "pulse-cell"
|
||||
: "";
|
||||
|
||||
$ago = (int)$r['ago'];
|
||||
$lastSeen = ($ago < 60)
|
||||
? "{$ago}s"
|
||||
: round($ago / 60) . "m";
|
||||
?>
|
||||
|
||||
<tr>
|
||||
<td class="fw-bold"><?= htmlspecialchars($r['uid']) ?></td>
|
||||
<td><?= $r['entradas'] ?></td>
|
||||
<td><?= $r['saidas'] ?></td>
|
||||
<td><?= $state ?></td>
|
||||
<td><?= $lastSeen ?></td>
|
||||
<td>
|
||||
<a href="?toggle=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
|
||||
<?= $blocked ? 'Desbloquear' : 'Bloquear' ?>
|
||||
</a>
|
||||
<tr class="<?= $pulseClass ?>">
|
||||
|
||||
<td class="fw-bold">
|
||||
<?= htmlspecialchars($r['uid']) ?>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<form method="post">
|
||||
|
||||
<input type="hidden"
|
||||
name="uid"
|
||||
value="<?= htmlspecialchars($r['uid']) ?>">
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
|
||||
<input type="text"
|
||||
name="note"
|
||||
value="<?= htmlspecialchars($r['note'] ?? '') ?>"
|
||||
class="form-control form-control-sm note-input"
|
||||
placeholder="nota...">
|
||||
|
||||
<button type="submit"
|
||||
name="save_note"
|
||||
class="btn btn-sm btn-primary">
|
||||
|
||||
Save
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</td>
|
||||
|
||||
<td><?= (int)$r['entradas'] ?></td>
|
||||
|
||||
<td><?= (int)$r['saidas'] ?></td>
|
||||
|
||||
<td><?= $state ?></td>
|
||||
|
||||
<td><?= $broker ?></td>
|
||||
|
||||
<td>
|
||||
|
||||
<?= isset($r['rssi'])
|
||||
? $r['rssi'] . " dBm"
|
||||
: "-" ?>
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<?= isset($r['heap'])
|
||||
? number_format((int)$r['heap'])
|
||||
: "-" ?>
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<?php
|
||||
|
||||
if (isset($r['uptime'])) {
|
||||
|
||||
$up = (int)$r['uptime'];
|
||||
|
||||
echo gmdate("H:i:s", $up);
|
||||
|
||||
} else {
|
||||
|
||||
echo "-";
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
</td>
|
||||
|
||||
<td><?= $lastSeen ?></td>
|
||||
|
||||
<td>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
|
||||
<a href="?toggle=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
|
||||
|
||||
<?= $blocked
|
||||
? 'Desbloquear'
|
||||
: 'Bloquear' ?>
|
||||
|
||||
</a>
|
||||
|
||||
<a href="?remove=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm btn-warning"
|
||||
onclick="return confirm('Remover device?')">
|
||||
|
||||
Remover
|
||||
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<?php endforeach; ?>
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ------------ AUTO-REFRESH AJAX ---------------- -->
|
||||
<script>
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function refreshTable() {
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/contadores_json.php?x=' + Date.now());
|
||||
|
||||
const res =
|
||||
await fetch(
|
||||
'/api/contadores_json.php?x='
|
||||
+ Date.now()
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
@ -201,62 +484,173 @@ async function refreshTable() {
|
||||
|
||||
for (const r of data.rows) {
|
||||
|
||||
// Estado
|
||||
// Estado baseado APENAS no valor online vindo da DB
|
||||
let state = "";
|
||||
let state = "";
|
||||
|
||||
if (r.blocked == 1) {
|
||||
state = '<span class="badge bg-danger">Bloqueado</span>';
|
||||
}
|
||||
else if (r.online == 1) {
|
||||
state = '<span class="badge bg-success">Online</span>';
|
||||
}
|
||||
else {
|
||||
state = '<span class="badge bg-secondary">Offline</span>';
|
||||
}
|
||||
if (r.blocked == 1) {
|
||||
|
||||
// Tempo
|
||||
let lastSeen = (r.ago < 60) ? `${r.ago}s` : `${Math.round(r.ago/60)}m`;
|
||||
state =
|
||||
'<span class="badge bg-danger">BLOQUEADO</span>';
|
||||
|
||||
// Animação pulse quando r.ago <= 2
|
||||
let pulseClass = (r.ago <= 2 && r.online == 1) ? "pulse-cell" : "";
|
||||
} else if (r.online == 1) {
|
||||
|
||||
state =
|
||||
'<span class="badge bg-success">ONLINE</span>';
|
||||
|
||||
} else {
|
||||
|
||||
state =
|
||||
'<span class="badge bg-secondary">OFFLINE</span>';
|
||||
}
|
||||
|
||||
let broker = "";
|
||||
|
||||
if (r.current_broker === 'recovery') {
|
||||
|
||||
broker =
|
||||
'<span class="badge bg-warning text-dark">RECOVERY</span>';
|
||||
|
||||
} else {
|
||||
|
||||
broker =
|
||||
'<span class="badge bg-primary">PRIMARY</span>';
|
||||
}
|
||||
|
||||
let lastSeen =
|
||||
(r.ago < 60)
|
||||
? `${r.ago}s`
|
||||
: `${Math.round(r.ago / 60)}m`;
|
||||
|
||||
let pulseClass =
|
||||
(r.ago <= 2 && r.online == 1)
|
||||
? "pulse-cell"
|
||||
: "";
|
||||
|
||||
let uptime = "-";
|
||||
|
||||
if (r.uptime && !isNaN(r.uptime)) {
|
||||
|
||||
let sec = parseInt(r.uptime);
|
||||
|
||||
let h =
|
||||
String(Math.floor(sec / 3600))
|
||||
.padStart(2, '0');
|
||||
|
||||
let m =
|
||||
String(Math.floor((sec % 3600) / 60))
|
||||
.padStart(2, '0');
|
||||
|
||||
let s =
|
||||
String(sec % 60)
|
||||
.padStart(2, '0');
|
||||
|
||||
uptime = `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr class="${pulseClass}">
|
||||
<td class="fw-bold">${r.uid}</td>
|
||||
<td>${r.entradas}</td>
|
||||
<td>${r.saidas}</td>
|
||||
<td>${state}</td>
|
||||
<td>${lastSeen}</td>
|
||||
<td>
|
||||
<a href="?toggle=${r.uid}"
|
||||
class="btn btn-sm ${r.blocked ? 'btn-success' : 'btn-danger'}">
|
||||
${r.blocked ? 'Desbloquear' : 'Bloquear'}
|
||||
</a>
|
||||
<tr class="${esc(pulseClass)}">
|
||||
|
||||
<td class="fw-bold">
|
||||
${esc(r.uid)}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<form method="post">
|
||||
|
||||
<input type="hidden"
|
||||
name="uid"
|
||||
value="${esc(r.uid)}">
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
|
||||
<input type="text"
|
||||
name="note"
|
||||
value="${esc(r.note)}"
|
||||
class="form-control form-control-sm note-input"
|
||||
placeholder="nota...">
|
||||
|
||||
<button type="submit"
|
||||
name="save_note"
|
||||
class="btn btn-sm btn-primary">
|
||||
|
||||
Save
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</td>
|
||||
|
||||
<td>${esc(r.entradas)}</td>
|
||||
|
||||
<td>${esc(r.saidas)}</td>
|
||||
|
||||
<td>${state}</td>
|
||||
|
||||
<td>${broker}</td>
|
||||
|
||||
<td>${r.rssi != null ? esc(r.rssi) + ' dBm' : '-'}</td>
|
||||
|
||||
<td>${r.heap != null ? Number(r.heap).toLocaleString() : '-'}</td>
|
||||
|
||||
<td>${uptime}</td>
|
||||
|
||||
<td>${esc(lastSeen)}</td>
|
||||
|
||||
<td>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
|
||||
<a href="?toggle=${esc(r.uid)}"
|
||||
class="btn btn-sm ${r.blocked ? 'btn-success' : 'btn-danger'}">
|
||||
|
||||
${r.blocked ? 'Desbloquear' : 'Bloquear'}
|
||||
|
||||
</a>
|
||||
|
||||
<a href="?remove=${esc(r.uid)}"
|
||||
class="btn btn-sm btn-warning"
|
||||
onclick="return confirm('Remover device?')">
|
||||
|
||||
Remover
|
||||
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById("tabela_corpo").innerHTML = html;
|
||||
|
||||
// Atualizar totais
|
||||
document.getElementById("total_online").innerText = data.online;
|
||||
document.getElementById("total_offline").innerText = data.offline;
|
||||
document.getElementById("total_entradas").innerText = data.entradas;
|
||||
document.getElementById("total_saidas").innerText = data.saidas;
|
||||
document.getElementById("total_online").innerText =
|
||||
data.online;
|
||||
|
||||
document.getElementById("total_offline").innerText =
|
||||
data.offline;
|
||||
|
||||
document.getElementById("total_entradas").innerText =
|
||||
data.entradas;
|
||||
|
||||
document.getElementById("total_saidas").innerText =
|
||||
data.saidas;
|
||||
|
||||
} catch (err) {
|
||||
|
||||
console.error("Erro AJAX:", err);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(refreshTable, 2000);
|
||||
refreshTable();
|
||||
</script>
|
||||
|
||||
refreshTable();
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
266
devices.php
Normal file
266
devices.php
Normal file
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . "/api/protecao.php";
|
||||
require __DIR__ . "/api/db.php";
|
||||
require_once __DIR__ . "/vendor/autoload.php";
|
||||
|
||||
use PhpMqtt\Client\MqttClient;
|
||||
use PhpMqtt\Client\ConnectionSettings;
|
||||
|
||||
$mqttCfg = require __DIR__ . "/config.php";
|
||||
|
||||
function mqtt_publish(array $cfg, string $topic, array $payload): void
|
||||
{
|
||||
try {
|
||||
$settings = (new ConnectionSettings())
|
||||
->setUsername($cfg['username'])
|
||||
->setPassword($cfg['password'])
|
||||
->setUseTls(true)
|
||||
->setTlsCertificateAuthorityFile('/etc/mosquitto/certs/chain.pem')
|
||||
->setConnectTimeout(3);
|
||||
|
||||
$client = new MqttClient(
|
||||
$cfg['host'], $cfg['port'],
|
||||
'web-devices-' . getmypid()
|
||||
);
|
||||
|
||||
$client->connect($settings, true);
|
||||
$client->publish($topic, json_encode($payload), 1);
|
||||
$client->disconnect();
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("MQTT publish error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// APROVAR — desbloqueia e manda REJOIN via MQTT
|
||||
// =====================================================
|
||||
|
||||
if (isset($_GET['approve'])) {
|
||||
|
||||
$uid = $_GET['approve'];
|
||||
|
||||
$pdo->prepare("
|
||||
UPDATE contadores
|
||||
SET blocked=0
|
||||
WHERE uid=?
|
||||
")->execute([$uid]);
|
||||
|
||||
// REJOIN para o ESP fazer auth imediatamente
|
||||
mqtt_publish($mqttCfg, "esp/$uid/cmd", ['cmd' => 'REJOIN']);
|
||||
|
||||
header("Location: devices.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// REJEITAR / REMOVER
|
||||
// =====================================================
|
||||
|
||||
if (isset($_GET['reject'])) {
|
||||
|
||||
$uid = $_GET['reject'];
|
||||
|
||||
$pdo->prepare("
|
||||
DELETE FROM contadores
|
||||
WHERE uid=?
|
||||
")->execute([$uid]);
|
||||
|
||||
header("Location: devices.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// BLOQUEAR / DESBLOQUEAR
|
||||
// =====================================================
|
||||
|
||||
if (isset($_GET['toggle'])) {
|
||||
|
||||
$uid = $_GET['toggle'];
|
||||
|
||||
$pdo->prepare("
|
||||
UPDATE contadores
|
||||
SET blocked = 1 - blocked
|
||||
WHERE uid=?
|
||||
")->execute([$uid]);
|
||||
|
||||
header("Location: devices.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// DESPROMOVER — volta a pendente
|
||||
// =====================================================
|
||||
|
||||
if (isset($_GET['demote'])) {
|
||||
|
||||
$uid = $_GET['demote'];
|
||||
|
||||
$pdo->prepare("
|
||||
UPDATE contadores
|
||||
SET blocked=1, auth_ok=0
|
||||
WHERE uid=?
|
||||
")->execute([$uid]);
|
||||
|
||||
header("Location: devices.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERY
|
||||
// =====================================================
|
||||
|
||||
$pending = $pdo->query("
|
||||
SELECT uid, note, last_seen, blocked, auth_ok,
|
||||
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
|
||||
FROM contadores
|
||||
WHERE blocked=1 AND auth_ok=0
|
||||
ORDER BY last_seen DESC
|
||||
")->fetchAll();
|
||||
|
||||
$approved = $pdo->query("
|
||||
SELECT uid, note, last_seen, online, blocked, auth_ok, current_broker,
|
||||
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
|
||||
FROM contadores
|
||||
WHERE blocked=0 OR auth_ok=1
|
||||
ORDER BY online DESC, last_seen DESC
|
||||
")->fetchAll();
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="pt" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dispositivos</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<style>
|
||||
body { background:#0b0b0b; color:#e0e0e0; font-family:Arial,sans-serif; }
|
||||
.card { background:#151515; border:1px solid #333; }
|
||||
.header-title { color:#00ffcc; text-shadow:0 0 8px rgba(0,255,180,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<h1 class="mb-4 header-title">🔌 Gestão de Dispositivos</h1>
|
||||
|
||||
<!-- PENDENTES -->
|
||||
<h4 class="text-warning mb-3">⏳ Pendentes de Aprovação (<?= count($pending) ?>)</h4>
|
||||
|
||||
<?php if (empty($pending)): ?>
|
||||
<div class="alert alert-secondary mb-4">Nenhum dispositivo pendente.</div>
|
||||
<?php else: ?>
|
||||
<div class="card shadow-sm mb-5">
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UID</th>
|
||||
<th>NOTA</th>
|
||||
<th>ÚLTIMO HELLO</th>
|
||||
<th>AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($pending as $r): ?>
|
||||
<?php
|
||||
$ago = (int)$r['ago'];
|
||||
$lastSeen = $ago < 60 ? "{$ago}s" : round($ago/60) . "m";
|
||||
?>
|
||||
<tr>
|
||||
<td class="fw-bold text-warning"><?= htmlspecialchars($r['uid']) ?></td>
|
||||
<td><?= htmlspecialchars($r['note'] ?? '') ?></td>
|
||||
<td><?= $lastSeen ?></td>
|
||||
<td>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="?approve=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm btn-success"
|
||||
onclick="return confirm('Aprovar <?= htmlspecialchars($r['uid']) ?>?')">
|
||||
✅ Aprovar
|
||||
</a>
|
||||
<a href="?reject=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Rejeitar e apagar <?= htmlspecialchars($r['uid']) ?>?')">
|
||||
❌ Rejeitar
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- APROVADOS -->
|
||||
<h4 class="text-success mb-3">✅ Dispositivos Aprovados (<?= count($approved) ?>)</h4>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-striped table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UID</th>
|
||||
<th>NOTA</th>
|
||||
<th>ESTADO</th>
|
||||
<th>BROKER</th>
|
||||
<th>ÚLTIMO</th>
|
||||
<th>AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($approved as $r): ?>
|
||||
<?php
|
||||
$ago = (int)$r['ago'];
|
||||
$lastSeen = $ago < 60 ? "{$ago}s" : round($ago/60) . "m";
|
||||
$online = (int)$r['online'] === 1;
|
||||
$blocked = (int)$r['blocked'] === 1;
|
||||
|
||||
if ($blocked) {
|
||||
$state = '<span class="badge bg-danger">BLOQUEADO</span>';
|
||||
} elseif ($online) {
|
||||
$state = '<span class="badge bg-success">ONLINE</span>';
|
||||
} else {
|
||||
$state = '<span class="badge bg-secondary">OFFLINE</span>';
|
||||
}
|
||||
|
||||
$broker = ($r['current_broker'] ?? '') === 'recovery'
|
||||
? '<span class="badge bg-warning text-dark">RECOVERY</span>'
|
||||
: '<span class="badge bg-primary">PRIMARY</span>';
|
||||
?>
|
||||
<tr>
|
||||
<td class="fw-bold"><?= htmlspecialchars($r['uid']) ?></td>
|
||||
<td><?= htmlspecialchars($r['note'] ?? '') ?></td>
|
||||
<td><?= $state ?></td>
|
||||
<td><?= $broker ?></td>
|
||||
<td><?= $lastSeen ?></td>
|
||||
<td>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="?toggle=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
|
||||
<?= $blocked ? 'Desbloquear' : 'Bloquear' ?>
|
||||
</a>
|
||||
<a href="?demote=<?= urlencode($r['uid']) ?>"
|
||||
class="btn btn-sm btn-warning"
|
||||
onclick="return confirm('Despromover para pendente?')">
|
||||
↩ Pendente
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="api/menu.php" class="btn btn-outline-secondary">← Menu</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
16
save_note.php
Normal file
16
save_note.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
require_once __DIR__ . "/api/protecao.php";
|
||||
require __DIR__ . "/api/db.php";
|
||||
|
||||
$uid = $_POST['uid'] ?? '';
|
||||
$note = $_POST['note'] ?? '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE contadores
|
||||
SET note = ?
|
||||
WHERE uid = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$note, $uid]);
|
||||
|
||||
header("Location: console.php");
|
||||
Loading…
Reference in New Issue
Block a user