HA 푸시 알림 경로를 정리

This commit is contained in:
seo
2026-06-29 06:02:30 +09:00
parent cd14bb2e5e
commit c5ab60a6e6
7 changed files with 12 additions and 652 deletions
-450
View File
@@ -14,7 +14,6 @@ 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'));
@@ -22,10 +21,6 @@ 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'] ?? ''));
define('HA_NOTIFY_BATTERY_LOW_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['battery_low'] ?? 10)));
define('HA_NOTIFY_BATTERY_CLEAR_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['battery_clear'] ?? 300)));
define('HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['system_notice'] ?? 600)));
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
@@ -171,18 +166,10 @@ function setting_definitions(): array
'notice.log_during_alert' => ['group' => 'notice', 'label' => 'Alert 유지 중 반복 기록', 'type' => 'boolean', 'default' => 0],
'notice.log_interval_seconds' => ['group' => 'notice', 'label' => 'Alert 반복 기록 간격', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'],
'notice.downward_only_process_hide' => ['group' => 'notice', 'label' => '하강 변화 원인 후보 숨김', 'type' => 'boolean', 'default' => 1],
'notify.battery_emergency_percent' => ['group' => 'notify', 'label' => '배터리 긴급 기준', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 30, 'step' => 0.5, 'unit' => '%'],
'notify.battery_critical_percent' => ['group' => 'notify', 'label' => '배터리 위험 기준', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 40, 'step' => 0.5, 'unit' => '%'],
'notify.battery_warning_percent' => ['group' => 'notify', 'label' => '배터리 경고 기준', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 50, 'step' => 0.5, 'unit' => '%'],
'notify.battery_clear_percent' => ['group' => 'notify', 'label' => '배터리 복구 기준', 'type' => 'number', 'default' => 20, 'min' => 2, 'max' => 80, 'step' => 0.5, 'unit' => '%'],
'notify.battery_low_cooldown_seconds' => ['group' => 'notify', 'label' => '배터리 낮음 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_BATTERY_LOW_COOLDOWN, 'min' => 1, 'max' => 86400, 'step' => 1, 'unit' => '초'],
'notify.battery_clear_cooldown_seconds' => ['group' => 'notify', 'label' => '배터리 복구 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_BATTERY_CLEAR_COOLDOWN, 'min' => 1, 'max' => 86400, 'step' => 1, 'unit' => '초'],
'notify.system_notice_cooldown_seconds' => ['group' => 'notify', 'label' => '시스템 유의사항 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN, 'min' => 10, 'max' => 86400, 'step' => 10, 'unit' => '초'],
'display.process_limit' => ['group' => 'display', 'label' => '프로세스 후보 표시 수', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '개'],
'display.custom_service_log_lines' => ['group' => 'display', 'label' => '사용자 서비스 로그 줄 수', 'type' => 'number', 'default' => 12, 'min' => 1, 'max' => 50, 'step' => 1, 'unit' => '줄'],
'display.custom_service_cache_seconds' => ['group' => 'display', 'label' => '사용자 서비스 캐시 시간', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 60, 'step' => 1, 'unit' => '초'],
'display.fan_notice_history_limit' => ['group' => 'display', 'label' => '팬 이상 이력 표시 수', 'type' => 'number', 'default' => 100, 'min' => 10, 'max' => 500, 'step' => 10, 'unit' => '개'],
'display.ha_notify_history_limit' => ['group' => 'display', 'label' => 'HA 알림 이력 표시 수', 'type' => 'number', 'default' => 20, 'min' => 5, 'max' => 100, 'step' => 5, 'unit' => '개'],
'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'],
'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'],
'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
@@ -542,28 +529,6 @@ function bootstrap_db(): void
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
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,
target VARCHAR(16) NULL,
title VARCHAR(255) NULL,
tag VARCHAR(128) NULL,
actor_ip VARCHAR(64) NULL,
http_code INT NULL,
error VARCHAR(255) NULL,
meta JSON NULL,
INDEX idx_created_at (created_at),
INDEX idx_event (event),
INDEX idx_tag (tag)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS system_notice_state (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
@@ -704,421 +669,6 @@ function json_out(array $payload, int $status = 200): never
exit;
}
function ha_notify_targets(): array
{
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 ha_notify_log(string $event, array $context = []): void
{
try {
$stmt = db()->prepare("
INSERT INTO ha_notify_logs (
event,
target,
title,
tag,
actor_ip,
http_code,
error,
meta
) VALUES (
:event,
:target,
:title,
:tag,
:actor_ip,
:http_code,
:error,
:meta
)
");
$stmt->execute([
':event' => mb_substr($event, 0, 32),
':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),
':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),
]);
} catch (Throwable) {
}
}
function latest_ha_notify_epoch_by_tag(string $tag): int
{
$stmt = db()->prepare("
SELECT UNIX_TIMESTAMP(MAX(created_at))
FROM ha_notify_logs
WHERE event = 'send_success'
AND tag = :tag
");
$stmt->execute([':tag' => $tag]);
return (int)($stmt->fetchColumn() ?: 0);
}
function ha_notify_due(string $tag, int $cooldownSeconds): bool
{
$latest = latest_ha_notify_epoch_by_tag($tag);
return $latest <= 0 || time() - $latest >= $cooldownSeconds;
}
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 [
'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,
'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 ha_notify_log_rows(int $limit = 20): array
{
$limit = max(1, min(100, $limit));
$stmt = db()->query("
SELECT
id,
created_at,
event,
target,
title,
tag,
http_code,
error,
meta
FROM ha_notify_logs
ORDER BY id DESC
LIMIT " . $limit . "
");
$rows = [];
foreach ($stmt->fetchAll() as $row) {
$meta = json_decode((string)($row['meta'] ?? ''), true);
$data = is_array($meta['data'] ?? null) ? $meta['data'] : [];
$rows[] = [
'id' => (int)$row['id'],
'created_at' => (string)$row['created_at'],
'event' => (string)$row['event'],
'target' => $row['target'],
'title' => $row['title'],
'tag' => $row['tag'],
'http_code' => $row['http_code'] === null ? null : (int)$row['http_code'],
'error' => $row['error'],
'level' => (string)($data['level'] ?? ''),
'channel' => (string)($data['channel'] ?? ''),
'icon' => (string)($data['notification_icon'] ?? ''),
];
}
return $rows;
}
function send_ha_notify(array $payload): array
{
$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);
$logTag = mb_substr((string)($payload['log_tag'] ?? $tag), 0, 128);
$url = (string)($payload['url'] ?? '/');
$data = ha_notify_default_data($level, $tag, $url);
if (is_array($payload['data'] ?? null)) {
$data = array_replace_recursive($data, $payload['data']);
}
$data['level'] = $level;
$body = json_encode([
'title' => $title,
'message' => $message,
'data' => $data,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (HA_NOTIFY_WEBHOOK_ID === '') {
ha_notify_log('send_failed', [
'target' => null,
'title' => $title,
'tag' => $logTag,
'http_code' => null,
'error' => 'ha_notify_webhook_missing',
'data' => $data,
]);
return [
'sent' => 0,
'failed' => 0,
'target' => null,
'http_code' => null,
'error' => 'ha_notify_webhook_missing',
];
}
$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' => $logTag,
'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' => $logTag,
'http_code' => $httpCode,
'error' => $error ?: ('HTTP ' . $httpCode),
]);
}
return [
'sent' => 0,
'failed' => $attempts,
'target' => null,
'http_code' => null,
'error' => 'ha_notify_failed',
];
}
function clear_ha_notification(string $tag, string $title = 'Control 알림 해제'): array
{
return send_ha_notify([
'title' => $title,
'message' => 'clear_notification',
'level' => 'info',
'tag' => $tag,
'log_tag' => $tag . '-clear',
'data' => [
'tag' => $tag,
'channel' => 'control_status',
'notification_icon' => 'mdi:bell-check',
'persistent' => false,
'sticky' => 'false',
'renotify' => 'false',
'timeout' => 1,
],
]);
}
function battery_low_notify_profile(float $percent): array
{
$emergency = (float)setting_value('notify.battery_emergency_percent');
$critical = max($emergency, (float)setting_value('notify.battery_critical_percent'));
$warning = max($critical, (float)setting_value('notify.battery_warning_percent'));
if ($percent <= $emergency) {
return [
'label' => '긴급',
'level' => 'critical',
'channel' => 'control_critical',
'importance' => 'max',
'icon' => 'mdi:battery-alert-variant-outline',
'color' => '#b91c1c',
'sticky' => 'true',
'persistent' => true,
'vibration' => '900,200,900,200,1400,250,1800',
'led' => 'red',
];
}
if ($percent <= $critical) {
return [
'label' => '위험',
'level' => 'critical',
'channel' => 'control_battery_critical',
'importance' => 'max',
'icon' => 'mdi:battery-10',
'color' => '#dc2626',
'sticky' => 'true',
'persistent' => true,
'vibration' => '900,250,900,250,1400',
'led' => 'red',
];
}
if ($percent <= $warning) {
return [
'label' => '경고',
'level' => 'warning',
'channel' => 'control_battery_warning',
'importance' => 'high',
'icon' => 'mdi:battery-20',
'color' => '#f97316',
'sticky' => 'true',
'persistent' => true,
'vibration' => '600,200,900,200,900',
'led' => 'yellow',
];
}
return [
'label' => '주의',
'level' => 'warning',
'channel' => 'control_battery',
'importance' => 'high',
'icon' => 'mdi:battery-alert',
'color' => '#f59e0b',
'sticky' => 'false',
'persistent' => false,
'vibration' => '250,150,250',
'led' => 'yellow',
];
}
function send_battery_low_notify_if_needed(array $battery): array
{
$percent = $battery['percent'] ?? null;
if ($percent === null || $percent === '' || !is_numeric($percent)) {
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_soc_missing',
];
}
$percent = (float)$percent;
$voltage = isset($battery['voltage']) && is_numeric($battery['voltage'])
? (float)$battery['voltage']
: null;
$tag = 'control-battery-low';
$warning = max(
(float)setting_value('notify.battery_emergency_percent'),
(float)setting_value('notify.battery_critical_percent'),
(float)setting_value('notify.battery_warning_percent')
);
$clearPercent = max($warning + 0.5, (float)setting_value('notify.battery_clear_percent'));
if ($percent > $clearPercent) {
$latestLow = latest_ha_notify_epoch_by_tag($tag);
$latestClear = latest_ha_notify_epoch_by_tag($tag . '-clear');
if (
$latestLow > 0
&& $latestLow > $latestClear
&& ha_notify_due($tag . '-clear', (int)setting_value('notify.battery_clear_cooldown_seconds'))
) {
return clear_ha_notification($tag, '배터리 SOC 복구');
}
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_soc_ok',
];
}
if (!ha_notify_due($tag, (int)setting_value('notify.battery_low_cooldown_seconds'))) {
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_low_cooldown',
];
}
$profile = battery_low_notify_profile($percent);
$body = 'Battery SOC '
. number_format($percent, 2)
. '%'
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
. "\n"
. $profile['label']
. ' 단계입니다.';
return send_ha_notify([
'title' => '배터리 SOC ' . $profile['label'],
'message' => $body,
'level' => $profile['level'],
'url' => '/',
'tag' => $tag,
'data' => [
'channel' => $profile['channel'],
'notification_icon' => $profile['icon'],
'color' => $profile['color'],
'sticky' => $profile['sticky'],
'persistent' => $profile['persistent'],
'renotify' => 'true',
'importance' => $profile['importance'],
'vibrationPattern' => $profile['vibration'],
'ledColor' => $profile['led'],
'battery_percent' => round($percent, 2),
'battery_voltage' => $voltage === null ? null : round($voltage, 3),
],
]);
}
function signed_in(): bool
{
if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) {