diff --git a/README.md b/README.md index 0c2f0cd..e6a12bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Control -팬 제어, 시스템 모니터링, WiFi 제어, WakeLock, Home Assistant 알림을 제공하는 PHP 기반 단일 관리 패널입니다. +팬 제어, 시스템 모니터링, WiFi 제어, WakeLock을 제공하는 PHP 기반 단일 관리 패널입니다. ## 프로젝트 성격 @@ -26,10 +26,6 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시 - `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시 - dmesg 로그는 진단 접힘 영역에서 필요할 때 열람 -- Home Assistant webhook 기반 Android 모바일 알림 -- 배터리 SOC 20% 이하 경고를 20/15/10/5% 단계로 구분하고 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리 -- 배터리 SOC가 20%를 초과해 복구되면 같은 `tag`의 Android 알림을 `clear_notification`으로 제거 -- 최근 HA 알림 발송 성공/실패 이력은 진단 접힘 영역에 표시 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 - 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 @@ -56,10 +52,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `public/api.php`: 상태 조회와 조작 API - `public/assets/app.js`: 대시보드 렌더링, WebSocket, 차트, 조작 이벤트 - `public/assets/wakelock.js`: Screen Wake Lock API 제어 -- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수 +- `config/config.php`: DB, 인증, CSRF, 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 예시 @@ -74,8 +69,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 - `wifi_client_aliases`: MAC별 수동 WiFi 호스트명 - `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드 -- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보 -- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정 +- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정 - 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. ## 배터리 잔여 시간 계산 @@ -116,8 +110,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 -- 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운 -- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수 +- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 - 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부 - 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치, 잔여시간 현실 상한, 최소 시스템 전력, 상한 초과 후보 감쇠, 에너지/전압 모델 사용 여부, 상태/잔여시간 차트 표본 수 @@ -136,38 +129,20 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 - 팬 슬라이더 자동 적용 debounce: 약 450ms -## HA 알림 구조 +## System Notice 구조 -Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. - -- 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 낮음 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 주의/경고/위험/긴급/복구 기준과 알림 쿨다운은 설정 모달의 알림 정책에서 조정합니다. - -System Notice는 `control-system-notice` tag를 사용하며, 설정된 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. alert 상태는 신규 진입과 유지 상태를 분리해 관리하며, alert 유지 중 반복 기록 여부와 기록 간격도 설정 모달에서 조정합니다. +Control은 급격한 팬/온도 변화를 외부 푸시로 보내지 않고 `system_notice_logs`에 로컬 이력으로 저장합니다. 대시보드는 최근 이력을 진단 영역에서 보여주며, 배터리 SOC 낮음 같은 모바일 푸시 발송은 더 이상 수행하지 않습니다. ### 시스템 알림 조건 -배터리 알림과 System Notice는 서로 다른 조건으로 동작합니다. +System Notice는 다음 조건으로 동작합니다. -- 배터리 알림: 현재 배터리 SOC가 설정값 이하로 내려가면 발송합니다. 기본 단계는 20% 이하 주의, 15% 이하 경고, 10% 이하 위험, 5% 이하 긴급입니다. 같은 `control-battery-low` tag를 계속 갱신하므로 SOC 값이 1%씩 바뀌어도 알림 카드가 계속 쌓이지 않습니다. SOC가 복구 기준을 넘으면 같은 tag를 `clear_notification`으로 제거합니다. - System Notice 시작: 팬 모드가 `off`가 아닐 때, 최근 안정 기준선 대비 팬 RPM 차이가 `팬 RPM 이상 감지 차이` 이상이거나 온도 차이가 `온도 이상 감지 차이` 이상이면 alert로 진입합니다. RPM 감시는 현재 PWM 또는 기준 PWM이 `RPM 감시 시작 PWM` 이상일 때만 의미 있는 변화로 봅니다. - System Notice 기준선: normal 상태에서는 최근 sensor history에서 최신 몇 개 표본을 제외하고 절사 평균으로 기준선을 계속 갱신합니다. alert로 들어가면 진입 당시 온도/RPM/PWM 기준선을 고정해, alert 중 기준선이 문제 상태를 따라가며 사라지는 일을 막습니다. - System Notice 복구: 고정 기준선 또는 최신 rolling 기준선 대비 RPM/온도 차이가 각각 복구 차이 이하로 내려가면 normal로 돌아갑니다. 팬 모드가 `off`이면 fan off 기준선을 별도로 고정하고 System Notice alert를 만들지 않습니다. -- System Notice 기록/발송: alert 신규 진입 시 `system_notice_logs`에 기록합니다. `Alert 유지 중 반복 기록`을 켜면 유지 중에도 설정한 반복 기록 간격마다 다시 기록할 수 있습니다. HA 알림은 `시스템 유의사항 알림 쿨다운`을 통과할 때만 발송합니다. +- System Notice 기록: alert 신규 진입 시 `system_notice_logs`에 기록합니다. `Alert 유지 중 반복 기록`을 켜면 유지 중에도 설정한 반복 기록 간격마다 다시 기록할 수 있습니다. - 저전압/스로틀링 표시: `vcgencmd get_throttled`의 현재 비트와 부팅 후 이력 비트를 읽어 현재 상태, 최근 감지, 지속시간, 최근 10분 통계를 표시합니다. 이 값은 System Notice와 별개이며, 팬/온도 alert 조건에는 직접 섞지 않습니다. -Android 알림 채널은 생성 후 휴대폰 설정에 의해 중요도와 진동 패턴이 고정될 수 있습니다. 채널을 다시 만들 필요가 있을 때는 운영 CLI로 제거 명령을 보낸 뒤 다음 알림에서 재생성합니다. - -```bash -/usr/bin/php /var/www/control/bin/ha_notify_channel.php --remove=control_battery -``` - ## WiFi 관측 자동화 5G 외부 WiFi 모듈은 `iw station dump`에 `connected time`을 제공하지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. @@ -185,7 +160,7 @@ control-wifi-observe.service /usr/bin/php /var/www/control/bin/wifi_observe.php ``` -이 작업은 팬 정책, 차트 로그 저장, HA 알림 발송과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. +이 작업은 팬 정책, 차트 로그 저장, System Notice 기록과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. 호스트명은 dnsmasq lease 값을 우선 사용합니다. lease hostname이 `N/A`이면 MAC 주소 끝 6자리 기반 임시명 `기기-XXXXXX`을 표시하고, 표의 호스트명을 클릭해 저장한 수동 이름은 `wifi_client_aliases`에 보관해 이후에도 우선 표시합니다. @@ -207,9 +182,8 @@ control-wifi-observe.service 12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화와 현실 상한을 반영합니다. 14. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다. -15. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. -16. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. -17. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. +15. System Notice 조건이 맞으면 Control이 `system_notice_logs`에 이력을 저장합니다. +16. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, dmesg는 접힘 영역으로 분리합니다. ## 주요 함수/모듈 @@ -224,12 +198,6 @@ control-wifi-observe.service - `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 - `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장 - `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정 -- `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 기준에 따라 낮음/복구 알림 발송 -- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회 - `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계 - `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관 - `weighted_linear_regression()`, `numeric_mad()`: SOC 기울기 계산과 회귀 잔차 튐 제거 @@ -245,7 +213,6 @@ control-wifi-observe.service - `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화 - `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신 - `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화 -- `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 @@ -261,8 +228,7 @@ control-wifi-observe.service - 로그인 화면은 CSRF 검사를 수행하고, 설정한 실패 횟수/집계 시간/잠금 시간에 따라 세션 단위 잠금을 적용합니다. - Remember login은 설정에 따라 Secure/SameSite, 토큰 길이, 만료 기간, User-Agent 고정, IPv4 접두사 고정을 조정할 수 있습니다. - WiFi restart/reload 허용 여부와 root 명령 timeout은 설정 모달에서 조정합니다. -- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다. -- HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다. +- 앱 비밀번호와 DB/배터리 설정은 저장소 밖 secret 파일로 관리합니다. ## 운영 체크포인트 @@ -276,9 +242,7 @@ control-wifi-observe.service - `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다. - 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` 계열 항목에서 관리합니다. - SmartThings 재로드 자동화는 서울 전력분전반과 목포 누전차단기 전력 센서의 값이 10초 이상 변하지 않으면 통합을 재로드합니다. -- 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/apply_policy.php b/apply_policy.php index 48a00b8..f0fceea 100644 --- a/apply_policy.php +++ b/apply_policy.php @@ -240,8 +240,6 @@ try { 'hostname' => gethostname() ?: null, ]); - send_battery_low_notify_if_needed($battery); - if (!$ok) { add_fan_action( 'apply_policy_failed', diff --git a/bin/ha_notify_channel.php b/bin/ha_notify_channel.php deleted file mode 100644 index e02af15..0000000 --- a/bin/ha_notify_channel.php +++ /dev/null @@ -1,33 +0,0 @@ - '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 4bf721f..a5710e8 100644 --- a/config/config.php +++ b/config/config.php @@ -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) { diff --git a/public/api.php b/public/api.php index 07d0db2..fc9782e 100644 --- a/public/api.php +++ b/public/api.php @@ -2464,69 +2464,6 @@ function notice_downward_only(float $tempDelta, float $rpmDelta): bool return true; } -function send_fan_spike_notify(array $spike, array $fan, array $system, array $processes): array -{ - $tempDelta = (float)($spike['temp_delta'] ?? 0); - $rpmDelta = (float)($spike['rpm_delta'] ?? 0); - $tempAvg = (float)($spike['temp_avg'] ?? 0); - $rpmAvg = (float)($spike['rpm_avg'] ?? 0); - $pwmAvg = (float)($spike['pwm_avg'] ?? 0); - $reasons = []; - - if (abs($tempDelta) >= (float)setting_value('notice.temp_delta_threshold')) { - $reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강'); - } - - if (abs($rpmDelta) >= (float)setting_value('notice.rpm_delta_threshold')) { - $reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강'); - } - - if ($reasons === []) { - $reasons[] = '순간 변화'; - } - - $body = '기록된 이유: ' - . implode(', ', $reasons) - . "\n평균: " - . number_format($tempAvg, 1) - . '°C / ' - . number_format($rpmAvg, 0) - . ' RPM / PWM ' - . number_format($pwmAvg, 0) - . "\n현재: " - . number_format((float)($system['temp_c'] ?? 0), 1) - . '°C / ' - . number_format((float)($fan['rpm'] ?? 0), 0) - . ' RPM / PWM ' - . number_format((float)($fan['pwm'] ?? 0), 0) - . (((bool)setting_value('notice.downward_only_process_hide') && notice_downward_only($tempDelta, $rpmDelta)) ? '' : "\n원인 후보: " . expected_process_detail_text($processes)); - - return send_ha_notify([ - 'title' => '시스템 유의사항', - 'message' => $body, - 'level' => 'warning', - 'url' => '/', - 'tag' => 'control-system-notice', - 'data' => [ - 'channel' => 'control_system', - 'notification_icon' => 'mdi:fan-alert', - 'color' => '#f59e0b', - 'sticky' => 'false', - 'renotify' => 'true', - 'summary' => $spike['summary'] ?? '', - 'rpm_delta' => $spike['rpm_delta'] ?? 0, - 'pwm_delta' => $spike['pwm_delta'] ?? 0, - 'temp_delta' => $spike['temp_delta'] ?? 0, - 'expected_process' => expected_process_text($processes), - ], - ]); -} - -function latest_system_notice_notify_epoch(): int -{ - return latest_ha_notify_epoch_by_tag('control-system-notice'); -} - function fan_spike_history(int $limit = 100): array { $limit = max(1, min(500, $limit)); @@ -3347,21 +3284,9 @@ function collect_snapshot(bool $applyFan = true): array $shouldLogNotice = !empty($fanSpike['alert_started']) || (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert')); if ($shouldLogNotice) { - $latestNoticeNotify = latest_system_notice_notify_epoch(); $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); if ($spikeLogId > 0) { $fanSpike['log_id'] = $spikeLogId; - $noticeCooldown = (int)setting_value('notify.system_notice_cooldown_seconds'); - if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= $noticeCooldown) { - $fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes); - } else { - $fanSpike['notify'] = [ - 'sent' => 0, - 'failed' => 0, - 'skipped' => 'system_notice_cooldown', - 'cooldown_seconds' => $noticeCooldown, - ]; - } } } @@ -3380,7 +3305,6 @@ function collect_snapshot(bool $applyFan = true): array 'custom_services' => custom_systemd_services(), 'fan_spike' => $fanSpike, 'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')), - 'ha_notify_history' => ha_notify_log_rows((int)setting_value('display.ha_notify_history_limit')), 'settings' => settings_payload(), ]; diff --git a/public/assets/app.js b/public/assets/app.js index 436d1d9..23b902a 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -35,7 +35,6 @@ statusBatteryRemaining: $('#statusBatteryRemaining'), batteryRuntimeTooltip: $('#batteryRuntimeTooltip'), spikeLogList: $('#spikeLogList'), - haNotifyList: $('#haNotifyList'), customServiceList: $('#customServiceList'), noticeBaseline: $('#noticeBaseline'), rebootBtn: $('#rebootBtn'), @@ -173,8 +172,6 @@ diagnostics: 'Diagnostics', noticeHistory: 'Notice History', noNoticeHistory: 'No system notice history.', - haNotifyHistory: 'HA Notify History', - noHaNotifyHistory: 'No HA notify history.', customServices: 'Custom Services', noCustomServices: 'No custom services.', serviceLogs: 'Logs', @@ -184,8 +181,6 @@ wifiAliasTitle: 'WiFi Hostname', wifiAliasPrompt: 'Enter a name to keep for this MAC address.', wifiAliasSaved: 'WiFi hostname saved.', - notifySuccess: 'success', - notifyFailed: 'failed', show: 'Show', hide: 'Hide', translateButton: 'Translate', @@ -324,8 +319,6 @@ diagnostics: '진단', noticeHistory: '알림 이력', noNoticeHistory: '시스템 알림 이력이 없습니다.', - haNotifyHistory: 'HA 알림 이력', - noHaNotifyHistory: 'HA 알림 이력이 없습니다.', customServices: '사용자 서비스', noCustomServices: '사용자 생성 서비스가 없습니다.', serviceLogs: '로그', @@ -335,8 +328,6 @@ wifiAliasTitle: 'WiFi 호스트명', wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.', wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.', - notifySuccess: '성공', - notifyFailed: '실패', show: '보기', hide: '숨기기', translateButton: '번역', @@ -1624,32 +1615,6 @@ : `