diff --git a/README.md b/README.md index aea2a04..135ff40 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - System Notice와 process CPU/MEM 후보 표시 - dmesg 로그 열람 - Home Assistant webhook 기반 Android 모바일 알림 -- 배터리 SOC 20% 이하 경고를 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리 +- 배터리 SOC 20% 이하 경고를 20/15/10/5% 단계로 구분하고 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리 +- 배터리 SOC가 20%를 초과해 복구되면 같은 `tag`의 Android 알림을 `clear_notification`으로 제거 +- 최근 HA 알림 발송 성공/실패 이력을 대시보드에 표시 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 - 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 @@ -46,6 +48,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수 - `apply_policy.php`: CLI/cron 팬 정책 적용 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 +- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 전송용 운영 CLI - `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI - `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 @@ -59,23 +62,29 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 - `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보 -- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, 선택적 HA 알림 설정 +- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정 - 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. ## HA 알림 구조 -Control은 브라우저 Web Push/VAPID 구독을 사용하지 않습니다. 서버가 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. +Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. -- 기본 webhook id: `control_notify_20260621` +- webhook id: `/home/seo/secret/control.php`의 `ha_notify.webhook_id` - 기본 발송 순서: 서울 HA `https://ha.seoul.chaegeon.com` 우선, 실패 시 목포 HA `https://ha.chaegeon.com` - payload 형식: `title`, `message`, `data` - Android 알림 속성은 `data`에 넣어 HA mobile app으로 전달 - 사용 속성: `tag`, `group`, `channel`, `importance`, `priority`, `ttl`, `sticky`, `persistent`, `renotify`, `color`, `notification_icon`, `vibrationPattern`, `ledColor`, `visibility`, `timeout`, `clickAction`, `url`, `actions` - 발송 결과는 `ha_notify_logs`에 저장 -배터리 SOC 20% 이하 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 경고 상태가 유지되면 짧은 쿨다운 뒤 다시 발송될 수 있습니다. +배터리 SOC 20% 이하 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 20% 이하에서는 `주의`, 15% 이하에서는 `경고`, 10% 이하에서는 `위험`, 5% 이하에서는 `긴급` 단계로 채널, 중요도, 아이콘, 진동 패턴을 조정합니다. SOC가 20%를 초과하면 `clear_notification`으로 해당 tag 알림을 제거합니다. -System Notice는 `control-system-notice` tag를 사용하며, 10분 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. +System Notice는 `control-system-notice` tag를 사용하며, 기본 10분 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. 쿨다운은 `/home/seo/secret/control.php`의 `ha_notify.cooldowns`에서 조정할 수 있습니다. + +Android 알림 채널은 생성 후 휴대폰 설정에 의해 중요도와 진동 패턴이 고정될 수 있습니다. 채널을 다시 만들 필요가 있을 때는 운영 CLI로 제거 명령을 보낸 뒤 다음 알림에서 재생성합니다. + +```bash +/usr/bin/php /var/www/control/bin/ha_notify_channel.php --remove=control_battery +``` ## WiFi 관측 자동화 @@ -109,6 +118,7 @@ control-wifi-observe.service 9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. +12. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 표시합니다. ## 주요 함수/모듈 @@ -121,7 +131,11 @@ control-wifi-observe.service - `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 - `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장 - `ha_notify_due()`: 동일 tag 기준 쿨다운 판정 +- `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송 +- `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택 - `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송 +- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회 +- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog @@ -135,7 +149,7 @@ control-wifi-observe.service - sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다. - 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다. - 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다. -- HA webhook은 외부 요청을 받으므로 webhook id는 저장소에 노출되는 값보다 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`로 교체해 운영할 수 있습니다. +- HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다. ## 운영 체크포인트 @@ -148,7 +162,7 @@ control-wifi-observe.service - WebSocket은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다. - WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다. - HA 알림 자동화는 서울 `/home/seo/homeassistant/automations.yaml`, 목포 `/mnt/synology-docker/homeassistant/config/automations.yaml` 양쪽의 `CONTROL_HA_NOTIFY_WEBHOOK` 계열 항목에서 관리합니다. -- 기존 `control-push-healthcheck.timer`와 `control-push-healthcheck.service`는 사용하지 않습니다. +- HA 알림 채널의 중요도나 진동이 예상과 다르면 휴대폰 알림 채널 설정을 확인하거나 `bin/ha_notify_channel.php`로 해당 채널 제거 명령을 보냅니다. - Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다. - 저전압/스로틀링이 `N/A`로 보이면 웹 서버 실행 계정의 `/dev/vcio` 접근 권한과 `/usr/bin/vcgencmd get_throttled` sudoers 허용 여부를 확인합니다. - 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다. diff --git a/bin/ha_notify_channel.php b/bin/ha_notify_channel.php new file mode 100644 index 0000000..e02af15 --- /dev/null +++ b/bin/ha_notify_channel.php @@ -0,0 +1,33 @@ + 'HA 알림 채널 제거', + 'message' => 'remove_channel', + 'level' => 'info', + 'tag' => 'control-channel-remove-' . preg_replace('/[^a-zA-Z0-9_-]+/', '-', $channel), + 'data' => [ + 'channel' => $channel, + 'notification_icon' => 'mdi:notification-clear-all', + 'persistent' => false, + 'sticky' => 'false', + 'renotify' => 'false', + ], +]); + +echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +exit(($result['sent'] ?? 0) > 0 ? 0 : 1); diff --git a/config/config.php b/config/config.php index 1c60bc9..0866f20 100644 --- a/config/config.php +++ b/config/config.php @@ -22,7 +22,10 @@ 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')); +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)); @@ -521,7 +524,7 @@ function ha_notify_default_data(string $level, string $tag, string $url = '/'): 'tag' => $tag, 'group' => 'control', 'sticky' => $isCritical ? 'true' : 'false', - 'persistent' => $isCritical ? 'true' : 'false', + 'persistent' => $isCritical, 'renotify' => 'true', 'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'), 'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'), @@ -541,18 +544,62 @@ function ha_notify_default_data(string $level, string $tag, string $url = '/'): ]; } +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, @@ -560,6 +607,25 @@ function send_ha_notify(array $payload): array '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'); @@ -587,7 +653,7 @@ function send_ha_notify(array $payload): array ha_notify_log('send_success', [ 'target' => $name, 'title' => $title, - 'tag' => $tag, + 'tag' => $logTag, 'http_code' => $httpCode, 'data' => $data, ]); @@ -603,7 +669,7 @@ function send_ha_notify(array $payload): array ha_notify_log('send_failed', [ 'target' => $name, 'title' => $title, - 'tag' => $tag, + 'tag' => $logTag, 'http_code' => $httpCode, 'error' => $error ?: ('HTTP ' . $httpCode), ]); @@ -618,41 +684,85 @@ function send_ha_notify(array $payload): array ]; } -function battery_soc_rising_from_history(float $percent): bool +function clear_ha_notification(string $tag, string $title = 'Control 알림 해제'): array { - $stmt = db()->query(" - SELECT battery_percent - FROM sensor_logs - WHERE battery_percent IS NOT NULL - ORDER BY id DESC - LIMIT 20 - "); - $rows = array_reverse($stmt->fetchAll()); + 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, + ], + ]); +} - if (count($rows) < 8) { - return false; +function battery_low_notify_profile(float $percent): array +{ + if ($percent <= 5.0) { + 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', + ]; } - $values = []; - foreach ($rows as $row) { - $value = $row['battery_percent'] ?? null; - if ($value === null || $value === '' || !is_numeric($value)) { - continue; - } - $values[] = (float)$value; + if ($percent <= 10.0) { + 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 (count($values) < 8) { - return false; + if ($percent <= 15.0) { + 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', + ]; } - $older = array_slice($values, 0, 5); - $recent = array_slice($values, -5); - $olderAvg = array_sum($older) / count($older); - $recentAvg = array_sum($recent) / count($recent); - - return ($recentAvg - $olderAvg) >= 0.20 - && ($percent - $olderAvg) >= 0.15; + 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 @@ -671,7 +781,18 @@ function send_battery_low_notify_if_needed(array $battery): array ? (float)$battery['voltage'] : null; + $tag = 'control-battery-low'; if ($percent > 20.0) { + $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', HA_NOTIFY_BATTERY_CLEAR_COOLDOWN) + ) { + return clear_ha_notification($tag, '배터리 SOC 복구'); + } + return [ 'sent' => 0, 'failed' => 0, @@ -679,8 +800,7 @@ function send_battery_low_notify_if_needed(array $battery): array ]; } - $tag = 'control-battery-low'; - if (!ha_notify_due($tag, 10)) { + if (!ha_notify_due($tag, HA_NOTIFY_BATTERY_LOW_COOLDOWN)) { return [ 'sent' => 0, 'failed' => 0, @@ -688,28 +808,31 @@ function send_battery_low_notify_if_needed(array $battery): array ]; } + $profile = battery_low_notify_profile($percent); $body = 'Battery SOC ' . number_format($percent, 2) . '%' . ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V') - . "\n20% 이하 상태입니다."; + . "\n" + . $profile['label'] + . ' 단계입니다.'; return send_ha_notify([ - 'title' => '배터리 SOC 경고', + 'title' => '배터리 SOC ' . $profile['label'], 'message' => $body, - 'level' => 'critical', + 'level' => $profile['level'], 'url' => '/', 'tag' => $tag, 'data' => [ - 'channel' => 'control_battery', - 'notification_icon' => 'mdi:battery-alert', - 'color' => '#ff3b30', - 'sticky' => 'true', - 'persistent' => 'true', + 'channel' => $profile['channel'], + 'notification_icon' => $profile['icon'], + 'color' => $profile['color'], + 'sticky' => $profile['sticky'], + 'persistent' => $profile['persistent'], 'renotify' => 'true', - 'importance' => 'high', - 'vibrationPattern' => '900,250,900,250,1400', - 'ledColor' => 'red', + 'importance' => $profile['importance'], + 'vibrationPattern' => $profile['vibration'], + 'ledColor' => $profile['led'], 'battery_percent' => round($percent, 2), 'battery_voltage' => $voltage === null ? null : round($voltage, 3), ], diff --git a/public/api.php b/public/api.php index 36cbef3..576f94f 100644 --- a/public/api.php +++ b/public/api.php @@ -1983,14 +1983,14 @@ function collect_snapshot(bool $applyFan = true): array $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); if ($spikeLogId > 0) { $fanSpike['log_id'] = $spikeLogId; - if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= 600) { + if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN) { $fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes); } else { $fanSpike['notify'] = [ 'sent' => 0, 'failed' => 0, 'skipped' => 'system_notice_cooldown', - 'cooldown_seconds' => 600, + 'cooldown_seconds' => HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN, ]; } } @@ -2010,6 +2010,7 @@ function collect_snapshot(bool $applyFan = true): array 'processes' => $processes, 'fan_spike' => $fanSpike, 'fan_spike_history' => fan_spike_history(100), + 'ha_notify_history' => ha_notify_log_rows(20), ]; return $snapshot; diff --git a/public/assets/app.js b/public/assets/app.js index 7157dd4..3d688de 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -33,6 +33,7 @@ statusBatterySoc: $('#statusBatterySoc'), statusBatteryRemaining: $('#statusBatteryRemaining'), spikeLogList: $('#spikeLogList'), + haNotifyList: $('#haNotifyList'), noticeBaseline: $('#noticeBaseline'), rebootBtn: $('#rebootBtn'), translateBtn: $('#translateBtn'), @@ -137,6 +138,10 @@ systemNotice: 'System Notice', noticeHistory: 'Notice History', noNoticeHistory: 'No system notice history.', + haNotifyHistory: 'HA Notify History', + noHaNotifyHistory: 'No HA notify history.', + notifySuccess: 'success', + notifyFailed: 'failed', show: 'Show', hide: 'Hide', translateButton: 'Translate', @@ -255,6 +260,10 @@ systemNotice: '시스템 알림', noticeHistory: '알림 이력', noNoticeHistory: '시스템 알림 이력이 없습니다.', + haNotifyHistory: 'HA 알림 이력', + noHaNotifyHistory: 'HA 알림 이력이 없습니다.', + notifySuccess: '성공', + notifyFailed: '실패', show: '보기', hide: '숨기기', translateButton: '번역', @@ -997,6 +1006,32 @@ : `