Use Home Assistant notifications
This commit is contained in:
+165
-631
@@ -14,6 +14,7 @@ if (!is_file($controlSecretFile)) {
|
||||
$controlSecretConfig = require $controlSecretFile;
|
||||
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
|
||||
$controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : [];
|
||||
$controlHaNotifyConfig = is_array($controlSecretConfig['ha_notify'] ?? null) ? $controlSecretConfig['ha_notify'] : [];
|
||||
|
||||
define('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? ''));
|
||||
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
|
||||
@@ -21,6 +22,7 @@ define('DB_PORT', (int)($controlDbConfig['port'] ?? 3306));
|
||||
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
|
||||
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
|
||||
define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
|
||||
define('HA_NOTIFY_WEBHOOK_ID', (string)($controlHaNotifyConfig['webhook_id'] ?? 'control_notify_20260621'));
|
||||
|
||||
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
|
||||
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
|
||||
@@ -35,10 +37,6 @@ define('BATTERY_CAPACITY_WH', isset($controlBatteryConfig['capacity_wh']) ? max(
|
||||
|
||||
date_default_timezone_set('Asia/Seoul');
|
||||
|
||||
if (is_file(__DIR__ . '/vapid.php')) {
|
||||
require_once __DIR__ . '/vapid.php';
|
||||
}
|
||||
|
||||
if (is_file(__DIR__ . '/../vendor/autoload.php')) {
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
}
|
||||
@@ -273,55 +271,22 @@ function bootstrap_db(): void
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
endpoint_hash CHAR(64) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh VARCHAR(255) NOT NULL,
|
||||
auth VARCHAR(255) NOT NULL,
|
||||
content_encoding VARCHAR(32) NOT NULL DEFAULT 'aes128gcm',
|
||||
|
||||
device_name VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
actor_ip VARCHAR(64) NULL,
|
||||
last_send_success_at DATETIME NULL,
|
||||
last_send_failed_at DATETIME NULL,
|
||||
last_received_at DATETIME NULL,
|
||||
last_notification_at DATETIME NULL,
|
||||
last_click_at DATETIME NULL,
|
||||
failure_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_failure_reason VARCHAR(255) NULL,
|
||||
|
||||
UNIQUE KEY uniq_endpoint_hash (endpoint_hash),
|
||||
INDEX idx_last_seen_at (last_seen_at),
|
||||
INDEX idx_last_received_at (last_received_at),
|
||||
INDEX idx_last_send_success_at (last_send_success_at)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS push_event_logs (
|
||||
CREATE TABLE IF NOT EXISTS ha_notify_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
event VARCHAR(32) NOT NULL,
|
||||
endpoint_hash CHAR(64) NULL,
|
||||
device_name VARCHAR(64) NULL,
|
||||
endpoint_host VARCHAR(128) NULL,
|
||||
target VARCHAR(16) NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
tag VARCHAR(128) NULL,
|
||||
actor_ip VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
message VARCHAR(255) NULL,
|
||||
http_code INT NULL,
|
||||
error VARCHAR(255) NULL,
|
||||
meta JSON NULL,
|
||||
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_event (event),
|
||||
INDEX idx_endpoint_hash (endpoint_hash)
|
||||
INDEX idx_tag (tag)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
@@ -349,19 +314,6 @@ function bootstrap_db(): void
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS battery_push_state (
|
||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
last_sent_at DATETIME NULL,
|
||||
last_percent DECIMAL(6,2) NULL,
|
||||
last_voltage DECIMAL(6,3) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
INSERT IGNORE INTO control_state
|
||||
(id, mode, manual_pwm)
|
||||
@@ -388,16 +340,6 @@ function bootstrap_db(): void
|
||||
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
|
||||
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
|
||||
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN device_name VARCHAR(64) NULL AFTER content_encoding",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_send_success_at DATETIME NULL AFTER actor_ip",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_send_failed_at DATETIME NULL AFTER last_send_success_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_received_at DATETIME NULL AFTER last_send_failed_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_notification_at DATETIME NULL AFTER last_received_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_click_at DATETIME NULL AFTER last_notification_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN failure_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_click_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_failure_reason VARCHAR(255) NULL AFTER failure_count",
|
||||
"ALTER TABLE push_subscriptions ADD INDEX idx_last_received_at (last_received_at)",
|
||||
"ALTER TABLE push_subscriptions ADD INDEX idx_last_send_success_at (last_send_success_at)",
|
||||
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
|
||||
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
||||
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
|
||||
@@ -489,596 +431,191 @@ function json_out(array $payload, int $status = 200): never
|
||||
exit;
|
||||
}
|
||||
|
||||
function vapid_public_key(): string
|
||||
function ha_notify_targets(): array
|
||||
{
|
||||
return defined('VAPID_PUBLIC_KEY') ? VAPID_PUBLIC_KEY : '';
|
||||
global $controlHaNotifyConfig;
|
||||
|
||||
$configured = is_array($controlHaNotifyConfig['targets'] ?? null) ? $controlHaNotifyConfig['targets'] : [];
|
||||
if ($configured !== []) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return [
|
||||
['name' => 'seoul', 'base' => 'https://ha.seoul.chaegeon.com'],
|
||||
['name' => 'main', 'base' => 'https://ha.chaegeon.com'],
|
||||
];
|
||||
}
|
||||
|
||||
function push_subscription_from_json(string $raw): array
|
||||
{
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
function push_log_event(string $event, array $context = []): void
|
||||
function ha_notify_log(string $event, array $context = []): void
|
||||
{
|
||||
try {
|
||||
$endpoint = (string)($context['endpoint'] ?? '');
|
||||
$endpointHash = (string)($context['endpoint_hash'] ?? '');
|
||||
if ($endpointHash === '' && $endpoint !== '') {
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO push_event_logs (
|
||||
INSERT INTO ha_notify_logs (
|
||||
event,
|
||||
endpoint_hash,
|
||||
device_name,
|
||||
endpoint_host,
|
||||
target,
|
||||
title,
|
||||
tag,
|
||||
actor_ip,
|
||||
user_agent,
|
||||
message,
|
||||
http_code,
|
||||
error,
|
||||
meta
|
||||
) VALUES (
|
||||
:event,
|
||||
:endpoint_hash,
|
||||
:device_name,
|
||||
:endpoint_host,
|
||||
:target,
|
||||
:title,
|
||||
:tag,
|
||||
:actor_ip,
|
||||
:user_agent,
|
||||
:message,
|
||||
:http_code,
|
||||
:error,
|
||||
:meta
|
||||
)
|
||||
");
|
||||
|
||||
unset($context['endpoint'], $context['p256dh'], $context['auth']);
|
||||
$stmt->execute([
|
||||
':event' => mb_substr($event, 0, 32),
|
||||
':endpoint_hash' => $endpointHash !== '' ? $endpointHash : null,
|
||||
':device_name' => isset($context['device_name']) ? mb_substr((string)$context['device_name'], 0, 64) : null,
|
||||
':endpoint_host' => $endpoint !== '' ? mb_substr((string)(parse_url($endpoint, PHP_URL_HOST) ?: ''), 0, 128) : null,
|
||||
':target' => isset($context['target']) ? mb_substr((string)$context['target'], 0, 16) : null,
|
||||
':title' => isset($context['title']) ? mb_substr((string)$context['title'], 0, 255) : null,
|
||||
':tag' => isset($context['tag']) ? mb_substr((string)$context['tag'], 0, 128) : null,
|
||||
':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64),
|
||||
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null,
|
||||
':http_code' => isset($context['http_code']) ? (int)$context['http_code'] : null,
|
||||
':error' => isset($context['error']) ? mb_substr((string)$context['error'], 0, 255) : null,
|
||||
':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
|
||||
update_push_subscription_health($event, $endpointHash, $context);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
function update_push_subscription_health(string $event, string $endpointHash, array $context = []): void
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reason = mb_substr((string)($context['reason'] ?? ''), 0, 255);
|
||||
|
||||
try {
|
||||
if (in_array($event, ['register', 'register_update'], true)) {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET last_seen_at = CURRENT_TIMESTAMP,
|
||||
failure_count = 0,
|
||||
last_failure_reason = NULL
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event === 'send_success') {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET last_send_success_at = CURRENT_TIMESTAMP,
|
||||
failure_count = 0,
|
||||
last_failure_reason = NULL
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event === 'send_failed') {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET last_send_failed_at = CURRENT_TIMESTAMP,
|
||||
failure_count = failure_count + 1,
|
||||
last_failure_reason = :reason
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([
|
||||
':endpoint_hash' => $endpointHash,
|
||||
':reason' => $reason !== '' ? $reason : 'send_failed',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$columnByEvent = [
|
||||
'push_received' => 'last_received_at',
|
||||
'notification_shown' => 'last_notification_at',
|
||||
'notification_click' => 'last_click_at',
|
||||
];
|
||||
if (isset($columnByEvent[$event])) {
|
||||
$column = $columnByEvent[$event];
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET {$column} = CURRENT_TIMESTAMP,
|
||||
failure_count = 0,
|
||||
last_failure_reason = NULL
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
function save_push_subscription(array $subscription): void
|
||||
{
|
||||
$endpoint = (string)($subscription['endpoint'] ?? '');
|
||||
$keys = is_array($subscription['keys'] ?? null) ? $subscription['keys'] : [];
|
||||
$p256dh = (string)($keys['p256dh'] ?? '');
|
||||
$auth = (string)($keys['auth'] ?? '');
|
||||
$deviceName = trim((string)($subscription['device_name'] ?? ''));
|
||||
preg_match_all('/[\x{AC00}-\x{D7A3}\x{3131}-\x{318E}]/u', $deviceName, $hangulMatches);
|
||||
$hangulCount = count($hangulMatches[0] ?? []);
|
||||
|
||||
if ($endpoint === '' || $p256dh === '' || $auth === '') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_subscription',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($deviceName === '' || $hangulCount < 2) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_device_name',
|
||||
'message' => '기기 이름은 한글 2글자 이상이어야 합니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
$exists = false;
|
||||
try {
|
||||
$check = db()->prepare("SELECT 1 FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash LIMIT 1");
|
||||
$check->execute([':endpoint_hash' => $endpointHash]);
|
||||
$exists = (bool)$check->fetchColumn();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO push_subscriptions (
|
||||
endpoint_hash,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
content_encoding,
|
||||
device_name,
|
||||
user_agent,
|
||||
actor_ip
|
||||
) VALUES (
|
||||
:endpoint_hash,
|
||||
:endpoint,
|
||||
:p256dh,
|
||||
:auth,
|
||||
:content_encoding,
|
||||
:device_name,
|
||||
:user_agent,
|
||||
:actor_ip
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
endpoint = VALUES(endpoint),
|
||||
p256dh = VALUES(p256dh),
|
||||
auth = VALUES(auth),
|
||||
content_encoding = VALUES(content_encoding),
|
||||
device_name = VALUES(device_name),
|
||||
user_agent = VALUES(user_agent),
|
||||
actor_ip = VALUES(actor_ip),
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':endpoint_hash' => $endpointHash,
|
||||
':endpoint' => $endpoint,
|
||||
':p256dh' => $p256dh,
|
||||
':auth' => $auth,
|
||||
':content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
|
||||
':device_name' => mb_substr($deviceName, 0, 64),
|
||||
':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
]);
|
||||
|
||||
push_log_event($exists ? 'register_update' : 'register', [
|
||||
'endpoint' => $endpoint,
|
||||
'device_name' => $deviceName,
|
||||
'content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
|
||||
]);
|
||||
}
|
||||
|
||||
function push_device_rows(): array
|
||||
{
|
||||
$stmt = db()->query("
|
||||
SELECT
|
||||
endpoint_hash,
|
||||
endpoint,
|
||||
device_name,
|
||||
content_encoding,
|
||||
user_agent,
|
||||
actor_ip,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
last_send_success_at,
|
||||
last_send_failed_at,
|
||||
last_received_at,
|
||||
last_notification_at,
|
||||
last_click_at,
|
||||
failure_count,
|
||||
last_failure_reason,
|
||||
TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS last_seen_seconds,
|
||||
TIMESTAMPDIFF(SECOND, last_send_success_at, NOW()) AS last_send_success_seconds,
|
||||
TIMESTAMPDIFF(SECOND, last_received_at, NOW()) AS last_received_seconds,
|
||||
TIMESTAMPDIFF(SECOND, last_notification_at, NOW()) AS last_notification_seconds
|
||||
FROM push_subscriptions
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT 200
|
||||
");
|
||||
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$endpoint = (string)($row['endpoint'] ?? '');
|
||||
$failureCount = (int)($row['failure_count'] ?? 0);
|
||||
$lastReceivedSeconds = isset($row['last_received_seconds']) ? (int)$row['last_received_seconds'] : null;
|
||||
$lastSendSuccessSeconds = isset($row['last_send_success_seconds']) ? (int)$row['last_send_success_seconds'] : null;
|
||||
$status = 'pending';
|
||||
$statusText = '수신 대기';
|
||||
|
||||
if ($failureCount >= 3) {
|
||||
$status = 'failed';
|
||||
$statusText = '발송 실패 누적';
|
||||
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 86400) {
|
||||
$status = 'healthy';
|
||||
$statusText = '정상';
|
||||
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 604800) {
|
||||
$status = 'watch';
|
||||
$statusText = '수신 지연';
|
||||
} elseif ($lastSendSuccessSeconds !== null) {
|
||||
$status = 'stale';
|
||||
$statusText = '장기 미수신';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)),
|
||||
'endpoint' => $endpoint,
|
||||
'host' => parse_url($endpoint, PHP_URL_HOST) ?: 'unknown',
|
||||
'device_name' => (string)($row['device_name'] ?? ''),
|
||||
'content_encoding' => (string)($row['content_encoding'] ?? ''),
|
||||
'user_agent' => (string)($row['user_agent'] ?? ''),
|
||||
'actor_ip' => (string)($row['actor_ip'] ?? ''),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
'last_seen_at' => (string)($row['last_seen_at'] ?? ''),
|
||||
'last_send_success_at' => (string)($row['last_send_success_at'] ?? ''),
|
||||
'last_send_failed_at' => (string)($row['last_send_failed_at'] ?? ''),
|
||||
'last_received_at' => (string)($row['last_received_at'] ?? ''),
|
||||
'last_notification_at' => (string)($row['last_notification_at'] ?? ''),
|
||||
'last_click_at' => (string)($row['last_click_at'] ?? ''),
|
||||
'last_seen_seconds' => $row['last_seen_seconds'] !== null ? (int)$row['last_seen_seconds'] : null,
|
||||
'last_send_success_seconds' => $lastSendSuccessSeconds,
|
||||
'last_received_seconds' => $lastReceivedSeconds,
|
||||
'last_notification_seconds' => $row['last_notification_seconds'] !== null ? (int)$row['last_notification_seconds'] : null,
|
||||
'failure_count' => $failureCount,
|
||||
'last_failure_reason' => (string)($row['last_failure_reason'] ?? ''),
|
||||
'health_status' => $status,
|
||||
'health_text' => $statusText,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function push_health_summary(): array
|
||||
{
|
||||
$devices = push_device_rows();
|
||||
$summary = [
|
||||
'total' => count($devices),
|
||||
'healthy' => 0,
|
||||
'watch' => 0,
|
||||
'stale' => 0,
|
||||
'failed' => 0,
|
||||
'pending' => 0,
|
||||
];
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$status = (string)($device['health_status'] ?? 'pending');
|
||||
if (!array_key_exists($status, $summary)) {
|
||||
$status = 'pending';
|
||||
}
|
||||
$summary[$status]++;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function push_subscription_status(string $endpoint): array
|
||||
{
|
||||
$endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : '';
|
||||
$matched = null;
|
||||
|
||||
if ($endpointHash !== '') {
|
||||
$stmt = db()->prepare("
|
||||
SELECT device_name
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
$matched = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$count = (int)db()->query("SELECT COUNT(*) FROM push_subscriptions")->fetchColumn();
|
||||
|
||||
return [
|
||||
'subscriber_count' => $count,
|
||||
'subscribed' => $matched !== null,
|
||||
'device_name' => $matched['device_name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function delete_push_device(string $endpointHash): void
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_push_device',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
push_log_event('delete_device', [
|
||||
'endpoint_hash' => $endpointHash,
|
||||
'deleted' => $stmt->rowCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
function delete_push_endpoint(string $endpoint): void
|
||||
{
|
||||
if ($endpoint === '') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_push_endpoint',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
push_log_event('unregister', [
|
||||
'endpoint' => $endpoint,
|
||||
'endpoint_hash' => $endpointHash,
|
||||
'deleted' => $stmt->rowCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
function send_push_payload(array $payload): array
|
||||
{
|
||||
$pushId = (string)($payload['push_id'] ?? '');
|
||||
if ($pushId === '') {
|
||||
$pushId = bin2hex(random_bytes(12));
|
||||
$payload['push_id'] = $pushId;
|
||||
}
|
||||
$tag = (string)($payload['tag'] ?? '');
|
||||
|
||||
if (
|
||||
!class_exists(\Minishlink\WebPush\WebPush::class)
|
||||
|| !defined('VAPID_PUBLIC_KEY')
|
||||
|| !defined('VAPID_PRIVATE_KEY')
|
||||
) {
|
||||
push_log_event('send_config_missing', [
|
||||
'message' => 'web_push_not_configured',
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
]);
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'error' => 'web_push_not_configured',
|
||||
];
|
||||
}
|
||||
|
||||
$stmt = db()->query("
|
||||
SELECT endpoint, p256dh, auth, content_encoding
|
||||
FROM push_subscriptions
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT 200
|
||||
");
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
if ($rows === []) {
|
||||
push_log_event('send_no_subscribers', [
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
]);
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$webPush = new \Minishlink\WebPush\WebPush([
|
||||
'VAPID' => [
|
||||
'subject' => 'mailto:admin@' . APP_HOST,
|
||||
'publicKey' => VAPID_PUBLIC_KEY,
|
||||
'privateKey' => VAPID_PRIVATE_KEY,
|
||||
],
|
||||
]);
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
push_log_event('send_request', [
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
'subscriber_count' => count($rows),
|
||||
]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$subscription = \Minishlink\WebPush\Subscription::create([
|
||||
'endpoint' => (string)$row['endpoint'],
|
||||
'publicKey' => (string)$row['p256dh'],
|
||||
'authToken' => (string)$row['auth'],
|
||||
'contentEncoding' => (string)($row['content_encoding'] ?: 'aes128gcm'),
|
||||
]);
|
||||
|
||||
$webPush->queueNotification($subscription, $json);
|
||||
}
|
||||
|
||||
foreach ($webPush->flush() as $report) {
|
||||
$endpoint = $report->getEndpoint();
|
||||
if ($report->isSuccess()) {
|
||||
$sent++;
|
||||
push_log_event('send_success', [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$failed++;
|
||||
$reason = method_exists($report, 'getReason') ? (string)$report->getReason() : 'send_failed';
|
||||
|
||||
if ($report->isSubscriptionExpired()) {
|
||||
$delete = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
|
||||
$delete->execute([':endpoint_hash' => hash('sha256', $endpoint)]);
|
||||
push_log_event('send_expired', [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
push_log_event('send_failed', [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'sent' => $sent,
|
||||
'failed' => $failed,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
function latest_push_send_epoch_by_tag(string $tagPrefix): int
|
||||
function latest_ha_notify_epoch_by_tag(string $tag): int
|
||||
{
|
||||
$stmt = db()->prepare("
|
||||
SELECT UNIX_TIMESTAMP(MAX(created_at))
|
||||
FROM push_event_logs
|
||||
FROM ha_notify_logs
|
||||
WHERE event = 'send_success'
|
||||
AND meta LIKE :tag
|
||||
AND tag = :tag
|
||||
");
|
||||
$stmt->execute([
|
||||
':tag' => '%\"tag\":\"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagPrefix) . '%',
|
||||
]);
|
||||
$stmt->execute([':tag' => $tag]);
|
||||
|
||||
return (int)($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
function send_push_healthcheck_if_due(int $minHours = 24, bool $force = false): array
|
||||
function ha_notify_due(string $tag, int $cooldownSeconds): bool
|
||||
{
|
||||
$minHours = max(1, min(168, $minHours));
|
||||
$latest = latest_push_send_epoch_by_tag('control-healthcheck-');
|
||||
$latest = latest_ha_notify_epoch_by_tag($tag);
|
||||
return $latest <= 0 || time() - $latest >= $cooldownSeconds;
|
||||
}
|
||||
|
||||
if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) {
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'error' => null,
|
||||
'skipped' => true,
|
||||
'message' => 'healthcheck_cooldown',
|
||||
'next_after_seconds' => ($minHours * 3600) - (time() - $latest),
|
||||
];
|
||||
}
|
||||
function ha_notify_default_data(string $level, string $tag, string $url = '/'): array
|
||||
{
|
||||
$level = in_array($level, ['critical', 'warning', 'info'], true) ? $level : 'info';
|
||||
$isCritical = $level === 'critical';
|
||||
$isWarning = $level === 'warning';
|
||||
$absoluteUrl = str_starts_with($url, 'http://') || str_starts_with($url, 'https://')
|
||||
? $url
|
||||
: 'https://' . APP_HOST . '/' . ltrim($url, '/');
|
||||
|
||||
return send_push_payload([
|
||||
'title' => 'Seoul Control Center',
|
||||
'body' => '푸시 기기 상태 확인 알림입니다.',
|
||||
'url' => '/',
|
||||
'tag' => 'control-healthcheck-' . date('YmdHi'),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'silent' => true,
|
||||
'data' => [
|
||||
'kind' => 'push_healthcheck',
|
||||
return [
|
||||
'ttl' => 0,
|
||||
'priority' => 'high',
|
||||
'channel' => $isCritical ? 'control_critical' : ($isWarning ? 'control_warning' : 'control_status'),
|
||||
'importance' => $isCritical ? 'max' : 'high',
|
||||
'tag' => $tag,
|
||||
'group' => 'control',
|
||||
'sticky' => $isCritical ? 'true' : 'false',
|
||||
'persistent' => $isCritical ? 'true' : 'false',
|
||||
'renotify' => 'true',
|
||||
'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'),
|
||||
'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'),
|
||||
'vibrationPattern' => $isCritical ? '900,250,900,250,1400' : '250,150,250',
|
||||
'ledColor' => $isCritical ? 'red' : ($isWarning ? 'yellow' : 'blue'),
|
||||
'visibility' => 'public',
|
||||
'timeout' => $isCritical ? 0 : 1800,
|
||||
'clickAction' => $absoluteUrl,
|
||||
'url' => $absoluteUrl,
|
||||
'actions' => [
|
||||
[
|
||||
'action' => 'URI',
|
||||
'title' => 'Control',
|
||||
'uri' => $absoluteUrl,
|
||||
],
|
||||
],
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
function reset_battery_low_push_state(): void
|
||||
function send_ha_notify(array $payload): array
|
||||
{
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO battery_push_state
|
||||
(id, last_sent_at, last_percent, last_voltage)
|
||||
VALUES
|
||||
(1, NULL, NULL, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sent_at = NULL,
|
||||
last_percent = NULL,
|
||||
last_voltage = NULL
|
||||
");
|
||||
$stmt->execute();
|
||||
}
|
||||
$title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
|
||||
$message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
|
||||
$level = (string)($payload['level'] ?? 'info');
|
||||
$tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
|
||||
$url = (string)($payload['url'] ?? '/');
|
||||
$data = ha_notify_default_data($level, $tag, $url);
|
||||
|
||||
function battery_low_push_due(float $percent, ?float $voltage): bool
|
||||
{
|
||||
$stmt = db()->query("
|
||||
SELECT last_sent_at
|
||||
FROM battery_push_state
|
||||
WHERE id = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$row = $stmt->fetch() ?: [];
|
||||
$lastSent = strtotime((string)($row['last_sent_at'] ?? '')) ?: 0;
|
||||
|
||||
if ($lastSent > 0 && time() - $lastSent < 10) {
|
||||
return false;
|
||||
if (is_array($payload['data'] ?? null)) {
|
||||
$data = array_replace_recursive($data, $payload['data']);
|
||||
}
|
||||
|
||||
$save = db()->prepare("
|
||||
INSERT INTO battery_push_state
|
||||
(id, last_sent_at, last_percent, last_voltage)
|
||||
VALUES
|
||||
(1, CURRENT_TIMESTAMP, :last_percent, :last_voltage)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sent_at = CURRENT_TIMESTAMP,
|
||||
last_percent = VALUES(last_percent),
|
||||
last_voltage = VALUES(last_voltage)
|
||||
");
|
||||
$save->execute([
|
||||
':last_percent' => round($percent, 2),
|
||||
':last_voltage' => $voltage === null ? null : round($voltage, 3),
|
||||
]);
|
||||
$body = json_encode([
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return true;
|
||||
$attempts = 0;
|
||||
foreach (ha_notify_targets() as $target) {
|
||||
$name = (string)($target['name'] ?? 'ha');
|
||||
$base = rtrim((string)($target['base'] ?? $target['base_url'] ?? $target['url'] ?? ''), '/');
|
||||
if ($base === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attempts++;
|
||||
$ch = curl_init($base . '/api/webhook/' . rawurlencode(HA_NOTIFY_WEBHOOK_ID));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = $response === false ? curl_error($ch) : null;
|
||||
curl_close($ch);
|
||||
|
||||
if ($error === null && $httpCode >= 200 && $httpCode < 300) {
|
||||
ha_notify_log('send_success', [
|
||||
'target' => $name,
|
||||
'title' => $title,
|
||||
'tag' => $tag,
|
||||
'http_code' => $httpCode,
|
||||
'data' => $data,
|
||||
]);
|
||||
return [
|
||||
'sent' => 1,
|
||||
'failed' => 0,
|
||||
'target' => $name,
|
||||
'http_code' => $httpCode,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
ha_notify_log('send_failed', [
|
||||
'target' => $name,
|
||||
'title' => $title,
|
||||
'tag' => $tag,
|
||||
'http_code' => $httpCode,
|
||||
'error' => $error ?: ('HTTP ' . $httpCode),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => $attempts,
|
||||
'target' => null,
|
||||
'http_code' => null,
|
||||
'error' => 'ha_notify_failed',
|
||||
];
|
||||
}
|
||||
|
||||
function battery_soc_rising_from_history(float $percent): bool
|
||||
@@ -1118,7 +655,7 @@ function battery_soc_rising_from_history(float $percent): bool
|
||||
&& ($percent - $olderAvg) >= 0.15;
|
||||
}
|
||||
|
||||
function send_battery_low_push_if_needed(array $battery): array
|
||||
function send_battery_low_notify_if_needed(array $battery): array
|
||||
{
|
||||
$percent = $battery['percent'] ?? null;
|
||||
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
||||
@@ -1135,7 +672,6 @@ function send_battery_low_push_if_needed(array $battery): array
|
||||
: null;
|
||||
|
||||
if ($percent > 20.0) {
|
||||
reset_battery_low_push_state();
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
@@ -1143,15 +679,8 @@ function send_battery_low_push_if_needed(array $battery): array
|
||||
];
|
||||
}
|
||||
|
||||
if (battery_soc_rising_from_history($percent)) {
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 'battery_soc_rising',
|
||||
];
|
||||
}
|
||||
|
||||
if (!battery_low_push_due($percent, $voltage)) {
|
||||
$tag = 'control-battery-low';
|
||||
if (!ha_notify_due($tag, 10)) {
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
@@ -1165,17 +694,22 @@ function send_battery_low_push_if_needed(array $battery): array
|
||||
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
|
||||
. "\n20% 이하 상태입니다.";
|
||||
|
||||
return send_push_payload([
|
||||
return send_ha_notify([
|
||||
'title' => '배터리 SOC 경고',
|
||||
'body' => $body,
|
||||
'message' => $body,
|
||||
'level' => 'critical',
|
||||
'url' => '/',
|
||||
'tag' => 'control-battery-low',
|
||||
'renotify' => false,
|
||||
'require_interaction' => true,
|
||||
'silent' => false,
|
||||
'vibrate' => [900, 250, 900, 250, 1400],
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'tag' => $tag,
|
||||
'data' => [
|
||||
'channel' => 'control_battery',
|
||||
'notification_icon' => 'mdi:battery-alert',
|
||||
'color' => '#ff3b30',
|
||||
'sticky' => 'true',
|
||||
'persistent' => 'true',
|
||||
'renotify' => 'true',
|
||||
'importance' => 'high',
|
||||
'vibrationPattern' => '900,250,900,250,1400',
|
||||
'ledColor' => 'red',
|
||||
'battery_percent' => round($percent, 2),
|
||||
'battery_voltage' => $voltage === null ? null : round($voltage, 3),
|
||||
],
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$controlSecretConfig = $controlSecretConfig ?? (is_file('/home/seo/secret/control.php') ? require '/home/seo/secret/control.php' : []);
|
||||
$controlVapidConfig = is_array($controlSecretConfig['vapid'] ?? null) ? $controlSecretConfig['vapid'] : [];
|
||||
|
||||
if (!defined('VAPID_PUBLIC_KEY')) {
|
||||
define('VAPID_PUBLIC_KEY', (string)($controlVapidConfig['public_key'] ?? ''));
|
||||
}
|
||||
|
||||
if (!defined('VAPID_PRIVATE_KEY')) {
|
||||
define('VAPID_PRIVATE_KEY', (string)($controlVapidConfig['private_key'] ?? ''));
|
||||
}
|
||||
Reference in New Issue
Block a user