From 49394a8ee596bc7fe674ea9f9bec5ab1a48c66c5 Mon Sep 17 00:00:00 2001 From: seo Date: Sun, 21 Jun 2026 07:30:39 +0900 Subject: [PATCH] Use Home Assistant notifications --- README.md | 118 ++--- apply_policy.php | 2 +- bin/push_healthcheck.php | 26 - composer.json | 1 - composer.lock | 726 +--------------------------- config/config.php | 796 +++++++------------------------ config/vapid.php | 13 - public/api.php | 100 +--- public/api/push_event.php | 44 -- public/api/save_subscription.php | 31 -- public/api/send_push.php | 38 -- public/assets/app.js | 202 -------- public/index.php | 34 +- public/push_subscribe.js | 351 -------------- public/sw.js | 123 ----- 15 files changed, 234 insertions(+), 2371 deletions(-) delete mode 100644 bin/push_healthcheck.php delete mode 100644 config/vapid.php delete mode 100644 public/api/push_event.php delete mode 100644 public/api/save_subscription.php delete mode 100644 public/api/send_push.php delete mode 100644 public/push_subscribe.js delete mode 100644 public/sw.js diff --git a/README.md b/README.md index eaccf37..aea2a04 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Control -팬 제어, 시스템 모니터링, WiFi 제어, WakeLock, Web Push 알림을 제공하는 PHP 기반 단일 관리 패널입니다. +팬 제어, 시스템 모니터링, WiFi 제어, WakeLock, Home Assistant 알림을 제공하는 PHP 기반 단일 관리 패널입니다. ## 프로젝트 성격 -Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 웹에서 관리하기 위한 내부 운영 도구입니다. 로그인 후 대시보드에서 온도, 팬 RPM, PWM, WiFi client, 배터리, notice, dmesg, process 후보, Push 구독 상태를 확인하고 필요한 제어 명령을 실행합니다. +Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 웹에서 관리하기 위한 내부 운영 도구입니다. 로그인 후 대시보드에서 온도, 팬 RPM, PWM, WiFi client, 배터리, notice, dmesg, process 후보를 확인하고 필요한 제어 명령을 실행합니다. 상태 갱신은 WebSocket을 우선 사용하고, 연결 실패 시 HTTP fallback으로 전환합니다. 팬 정책 적용과 센서 수집은 백그라운드 작업과 API 호출을 통해 DB에 기록됩니다. @@ -16,18 +16,17 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - WiFi client 목록과 2.4G/5G client 수 표시 -- WiFi client 상태 표시 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 - System Notice와 process CPU/MEM 후보 표시 - dmesg 로그 열람 -- Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시 -- Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행 +- Home Assistant webhook 기반 Android 모바일 알림 +- 배터리 SOC 20% 이하 경고를 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 - 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 - custom dialog는 Enter/Escape 키 입력을 자체 처리하며, Reboot 확인 흐름은 중복 실행을 차단 -- Translate 버튼으로 `en`, `ko` UI 언어 토글. 버튼 표시는 상태 suffix 없이 고정 -- Theme 버튼으로 `dark`, `light` UI 테마 토글. 버튼 표시는 상태 suffix 없이 고정 +- Translate 버튼으로 `en`, `ko` UI 언어 토글 +- Theme 버튼으로 `dark`, `light` UI 테마 토글 ## 주요 API @@ -35,10 +34,6 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `public/api.php?action=collect`: 센서 snapshot 수집 후 팬 정책 적용 - `public/api.php?action=fan`: 팬 모드와 PWM 저장/적용 - `public/api.php?action=wifi`: 허용된 WiFi/DHCP service 제어 -- `public/api.php?action=push_devices`: Push 구독 기기 목록 조회 -- `public/api.php?action=push_status`: 브라우저 구독 endpoint의 서버 등록 상태 조회 -- `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송 -- `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제 - `public/api.php?action=dmesg`: dmesg 로그 조회 - `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행 @@ -48,13 +43,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `public/api.php`: 상태 조회와 조작 API - `public/assets/app.js`: 대시보드 렌더링, WebSocket, 차트, 조작 이벤트 - `public/assets/wakelock.js`: Screen Wake Lock API 제어 -- `public/push_subscribe.js`: Push 구독 등록, 삭제, 자동 복구 -- `public/sw.js`: Push service worker -- `config/config.php`: DB, 인증, CSRF, Push, shell 실행 공통 함수 -- `config/vapid.php`: 외부 secret 기반 VAPID 설정 bridge +- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수 - `apply_policy.php`: CLI/cron 팬 정책 적용 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 -- `bin/push_healthcheck.php`: 작업스케줄러용 Push 헬스체크 CLI - `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI - `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 @@ -67,61 +58,24 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 -- `push_subscriptions`: Web Push 구독 정보 -- `push_event_logs`: Push 발송, 수신, 표시, 클릭, 실패 이벤트 -- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, VAPID key +- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보 +- `/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`를 사용합니다. -## Push 누락 방지 구조 +## HA 알림 구조 -Control은 숨은 알림을 반복 발송하는 방식 대신 구독 자동 복구, 실제 수신 이벤트 기록, 발송 실패 추적, 저빈도 헬스체크를 조합합니다. +Control은 브라우저 Web Push/VAPID 구독을 사용하지 않습니다. 서버가 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. -### 클라이언트 자동 복구 +- 기본 webhook id: `control_notify_20260621` +- 기본 발송 순서: 서울 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`에 저장 -- 대시보드 로드 직후 Push 구독 상태를 확인합니다. -- 화면이 다시 보일 때 구독 상태를 재확인합니다. -- 5분마다 브라우저 구독과 서버 등록 상태를 비교합니다. -- 브라우저 구독이 없으면 VAPID 공개키로 재구독합니다. -- 브라우저 구독은 있으나 서버에 누락되어 있으면 다시 저장합니다. +배터리 SOC 20% 이하 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 경고 상태가 유지되면 짧은 쿨다운 뒤 다시 발송될 수 있습니다. -### 서버 상태 추적 - -`push_subscriptions`에는 구독 정보와 함께 다음 상태가 저장됩니다. - -- `last_send_success_at`: 서버가 endpoint로 Push 발송에 성공한 시간 -- `last_send_failed_at`: 서버 발송 실패 시간 -- `last_received_at`: Service Worker가 `push` 이벤트를 실제 수신한 시간 -- `last_notification_at`: 알림 표시 성공 시간 -- `last_click_at`: 사용자 알림 클릭 시간 -- `failure_count`: 연속 발송 실패 횟수 -- `last_failure_reason`: 마지막 실패 사유 - -### 상태 판정 - -- `healthy`: 최근 24시간 안에 실제 Push 수신 기록이 있음 -- `watch`: 최근 7일 안에 수신 기록은 있지만 24시간을 초과함 -- `stale`: 서버 발송 성공 기록은 있으나 장기간 실제 수신 기록이 없음 -- `failed`: 연속 발송 실패가 3회 이상 누적됨 -- `pending`: 등록은 되어 있으나 판단할 수 있는 수신/발송 기록이 부족함 - -### 자동 헬스체크 - -이 장치에는 systemd timer가 등록되어 있습니다. - -```bash -control-push-healthcheck.timer -control-push-healthcheck.service -``` - -실행 명령은 다음과 같습니다. - -```bash -/usr/bin/php /var/www/control/bin/push_healthcheck.php --min-hours=24 -``` - -타이머는 활성화 후 10분 뒤 첫 실행하고, 이후 service 실행 기준 24시간마다 실행합니다. 스크립트 내부에서도 `--min-hours=24` 쿨다운을 확인하므로 중복 실행되더라도 24시간 안이면 발송을 건너뜁니다. - -`silent: true`는 소리와 진동을 줄이는 옵션이며 완전히 보이지 않는 알림이 아닙니다. Web Push는 사용자에게 보이는 알림을 전제로 운영합니다. +System Notice는 `control-system-notice` tag를 사용하며, 10분 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. ## WiFi 관측 자동화 @@ -140,7 +94,7 @@ control-wifi-observe.service /usr/bin/php /var/www/control/bin/wifi_observe.php ``` -이 작업은 팬 정책, 차트 로그 저장, Push 발송과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. +이 작업은 팬 정책, 차트 로그 저장, HA 알림 발송과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. ## 처리 흐름 @@ -150,14 +104,11 @@ control-wifi-observe.service 4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. 6. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. -7. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다. -8. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. -9. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 확인 단어 입력창에서 Enter를 눌러도 같은 Reboot 흐름이 다시 열리지 않도록 키 이벤트 기본 동작과 중복 실행을 차단합니다. -10. Translate 버튼은 최초 접속 시 디바이스 언어를 기본값으로 사용하고, 변경값은 `localStorage`의 `controlLang`에 저장합니다. 라벨은 `Translate` 또는 `번역`으로만 표시합니다. -11. Theme 버튼은 기본값 `dark`로 시작하고, 변경값은 `localStorage`의 `controlTheme`에 저장합니다. 라벨은 `Theme` 또는 `테마`로만 표시합니다. -12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 웹 서버 실행 계정이 `/dev/vcio`를 직접 읽지 못하면 기존 sudo 실행 경로로 한 번 더 조회합니다. -13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 현재 감지가 꺼진 뒤 5분 이내는 `복구 중`, 최근 10분에 episode가 2회 이상이면 `반복 발생`으로 표시합니다. -14. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. +7. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. +8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. +9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. +10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. +11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. ## 주요 함수/모듈 @@ -165,14 +116,13 @@ control-wifi-observe.service - `read_throttled_flags()`: `vcgencmd get_throttled`를 직접 조회하고, 권한 문제로 실패하면 sudo 경유 조회를 시도 - `throttled_event_status()`: 저전압/스로틀링 episode 이력, 복구 중/반복 발생 판정, 최근 10분 통계를 계산 - `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 상태 파일로 추적 -- `low_voltage_status()`: 기존 호출 호환을 위한 저전압 상태 wrapper - `apply_fan_policy()`: 팬 목표값 계산과 적용 - `json_out()`: API JSON 응답 표준화 - `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 +- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장 +- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정 +- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송 - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 -- `push_subscribe.js`: Push 구독 등록과 복구 -- `push_health_summary()`: 상태별 Push 기기 수 집계 -- `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog - `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key @@ -184,7 +134,8 @@ control-wifi-observe.service - POST 조작 API는 CSRF 검증을 통과해야 합니다. - sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다. - 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다. -- VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다. +- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다. +- HA webhook은 외부 요청을 받으므로 webhook id는 저장소에 노출되는 값보다 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`로 교체해 운영할 수 있습니다. ## 운영 체크포인트 @@ -192,15 +143,12 @@ control-wifi-observe.service - 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`입니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. -- 2026-06-07 조사 기준 `disassoc_low_ack=1`은 로그에 직접 원인으로 남지 않았고, 채널 변경 후 단말이 안정적으로 붙은 상태에서는 제거하지 않고 관찰합니다. 동일 단말이 짧게 재연결을 반복하면 그때 `disassoc_low_ack` 비활성화를 검토합니다. -- 2.4G WiFi 연결 시간은 내장 AP가 제공하는 `connected time`을 그대로 사용합니다. 2026-06-08 확인 기준 2.4G 연결 시간 누락은 없습니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. -- `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다. 상태는 `systemctl status control-wifi-observe.timer`와 `/var/log/control-wifi-observe.log`에서 확인합니다. +- `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 로그를 확인합니다. -- Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다. -- Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다. -- `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다. +- 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`는 사용하지 않습니다. - 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 4a090cb..48a00b8 100644 --- a/apply_policy.php +++ b/apply_policy.php @@ -240,7 +240,7 @@ try { 'hostname' => gethostname() ?: null, ]); - send_battery_low_push_if_needed($battery); + send_battery_low_notify_if_needed($battery); if (!$ok) { add_fan_action( diff --git a/bin/push_healthcheck.php b/bin/push_healthcheck.php deleted file mode 100644 index f1d7c66..0000000 --- a/bin/push_healthcheck.php +++ /dev/null @@ -1,26 +0,0 @@ - true, - 'result' => $result, - 'summary' => push_health_summary(), -], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/composer.json b/composer.json index 94c82f7..a54b3b6 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,5 @@ { "require": { - "minishlink/web-push": "^10.0", "cboden/ratchet": "^0.4", "react/event-loop": "^1.5" } diff --git a/composer.lock b/composer.lock index 5ae09a9..98ea9ae 100644 --- a/composer.lock +++ b/composer.lock @@ -6,65 +6,6 @@ ], "content-hash": "883a7a92feed43973abe8d0f61398105", "packages": [ - { - "name": "brick/math", - "version": "0.17.1", - "source": { - "type": "git", - "url": "https://github.com/brick/math.git", - "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", - "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", - "shasum": "" - }, - "require": { - "php": "^8.2" - }, - "require-dev": { - "phpstan/phpstan": "2.1.22", - "phpunit/phpunit": "^11.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Brick\\Math\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Arbitrary-precision arithmetic library", - "keywords": [ - "Arbitrary-precision", - "BigInteger", - "BigRational", - "arithmetic", - "bigdecimal", - "bignum", - "bignumber", - "brick", - "decimal", - "integer", - "math", - "mathematics", - "rational" - ], - "support": { - "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.1" - }, - "funding": [ - { - "url": "https://github.com/BenMorel", - "type": "github" - } - ], - "time": "2026-04-19T20:55:20+00:00" - }, { "name": "cboden/ratchet", "version": "v0.4.4", @@ -164,7 +105,7 @@ "email": "igor@wiedler.ch" } ], - "description": "Événement is a very simple event dispatching library for PHP", + "description": "\u00c9v\u00e9nement is a very simple event dispatching library for PHP", "keywords": [ "event-dispatcher", "event-emitter" @@ -175,215 +116,6 @@ }, "time": "2023-08-08T05:53:35+00:00" }, - { - "name": "guzzlehttp/guzzle", - "version": "7.10.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2025-08-23T22:36:01+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2025-08-22T14:34:08+00:00" - }, { "name": "guzzlehttp/psr7", "version": "2.9.0", @@ -455,7 +187,7 @@ "homepage": "https://github.com/Nyholm" }, { - "name": "Márk Sági-Kazár", + "name": "M\u00e1rk S\u00e1gi-Kaz\u00e1r", "email": "mark.sagikazar@gmail.com", "homepage": "https://github.com/sagikazarmark" }, @@ -465,7 +197,7 @@ "homepage": "https://github.com/Tobion" }, { - "name": "Márk Sági-Kazár", + "name": "M\u00e1rk S\u00e1gi-Kaz\u00e1r", "email": "mark.sagikazar@gmail.com", "homepage": "https://sagikazarmark.hu" } @@ -501,176 +233,6 @@ ], "time": "2026-03-10T16:41:02+00:00" }, - { - "name": "minishlink/web-push", - "version": "v10.0.3", - "source": { - "type": "git", - "url": "https://github.com/web-push-libs/web-push-php.git", - "reference": "547695eb42b062517fc604c85d6f7bb8174d31b0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/547695eb42b062517fc604c85d6f7bb8174d31b0", - "reference": "547695eb42b062517fc604c85d6f7bb8174d31b0", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "ext-openssl": "*", - "guzzlehttp/guzzle": "^7.9.2", - "php": ">=8.2", - "spomky-labs/base64url": "^2.0.4", - "symfony/polyfill-php83": "^1.33", - "web-token/jwt-library": "^3.4.9|^4.0.6" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^v3.92.2", - "phpstan/phpstan": "^2.1.33", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^11.5.46|^12.5.2", - "symfony/polyfill-iconv": "^1.33" - }, - "suggest": { - "ext-bcmath": "Optional for performance.", - "ext-gmp": "Optional for performance." - }, - "type": "library", - "autoload": { - "psr-4": { - "Minishlink\\WebPush\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Louis Lagrange", - "email": "lagrange.louis@gmail.com", - "homepage": "https://github.com/Minishlink" - } - ], - "description": "Web Push library for PHP", - "homepage": "https://github.com/web-push-libs/web-push-php", - "keywords": [ - "Push API", - "WebPush", - "notifications", - "push", - "web" - ], - "support": { - "issues": "https://github.com/web-push-libs/web-push-php/issues", - "source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.3" - }, - "time": "2026-03-09T23:16:02+00:00" - }, - { - "name": "psr/clock", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", - "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" - ], - "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" - }, - "time": "2022-11-25T14:36:26+00:00" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, { "name": "psr/http-factory", "version": "1.1.0", @@ -913,7 +475,7 @@ ], "authors": [ { - "name": "Christian Lück", + "name": "Christian L\u00fcck", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -989,7 +551,7 @@ ], "authors": [ { - "name": "Christian Lück", + "name": "Christian L\u00fcck", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -1063,7 +625,7 @@ ], "authors": [ { - "name": "Christian Lück", + "name": "Christian L\u00fcck", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -1141,7 +703,7 @@ "homepage": "https://sorgalla.com/" }, { - "name": "Christian Lück", + "name": "Christian L\u00fcck", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -1213,7 +775,7 @@ ], "authors": [ { - "name": "Christian Lück", + "name": "Christian L\u00fcck", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -1288,7 +850,7 @@ ], "authors": [ { - "name": "Christian Lück", + "name": "Christian L\u00fcck", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -1331,181 +893,6 @@ ], "time": "2024-06-11T12:45:25+00:00" }, - { - "name": "spomky-labs/base64url", - "version": "v2.0.4", - "source": { - "type": "git", - "url": "https://github.com/Spomky-Labs/base64url.git", - "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", - "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.11|^0.12", - "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", - "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", - "phpstan/phpstan-phpunit": "^0.11|^0.12", - "phpstan/phpstan-strict-rules": "^0.11|^0.12" - }, - "type": "library", - "autoload": { - "psr-4": { - "Base64Url\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florent Morselli", - "homepage": "https://github.com/Spomky-Labs/base64url/contributors" - } - ], - "description": "Base 64 URL Safe Encoding/Decoding PHP Library", - "homepage": "https://github.com/Spomky-Labs/base64url", - "keywords": [ - "base64", - "rfc4648", - "safe", - "url" - ], - "support": { - "issues": "https://github.com/Spomky-Labs/base64url/issues", - "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" - }, - "funding": [ - { - "url": "https://github.com/Spomky", - "type": "github" - }, - { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" - } - ], - "time": "2020-11-03T09:10:25+00:00" - }, - { - "name": "spomky-labs/pki-framework", - "version": "1.4.2", - "source": { - "type": "git", - "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", - "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", - "shasum": "" - }, - "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", - "ext-mbstring": "*", - "php": ">=8.1", - "psr/clock": "^1.0" - }, - "require-dev": { - "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", - "ext-gmp": "*", - "ext-openssl": "*", - "infection/infection": "^0.28|^0.29|^0.31|^0.32", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.3|^2.0", - "phpstan/phpstan": "^1.8|^2.0", - "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", - "phpstan/phpstan-phpunit": "^1.1|^2.0", - "phpstan/phpstan-strict-rules": "^1.3|^2.0", - "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", - "rector/rector": "^1.0|^2.0", - "roave/security-advisories": "dev-latest", - "symfony/string": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symplify/easy-coding-standard": "^12.0|^13.0" - }, - "suggest": { - "ext-bcmath": "For better performance (or GMP)", - "ext-gmp": "For better performance (or BCMath)", - "ext-openssl": "For OpenSSL based cyphering" - }, - "type": "library", - "autoload": { - "psr-4": { - "SpomkyLabs\\Pki\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Joni Eskelinen", - "email": "jonieske@gmail.com", - "role": "Original developer" - }, - { - "name": "Florent Morselli", - "email": "florent.morselli@spomky-labs.com", - "role": "Spomky-Labs PKI Framework developer" - } - ], - "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", - "homepage": "https://github.com/spomky-labs/pki-framework", - "keywords": [ - "DER", - "Private Key", - "ac", - "algorithm identifier", - "asn.1", - "asn1", - "attribute certificate", - "certificate", - "certification request", - "cryptography", - "csr", - "decrypt", - "ec", - "encrypt", - "pem", - "pkcs", - "public key", - "rsa", - "sign", - "signature", - "verify", - "x.509", - "x.690", - "x509", - "x690" - ], - "support": { - "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" - }, - "funding": [ - { - "url": "https://github.com/Spomky", - "type": "github" - }, - { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" - } - ], - "time": "2026-03-23T22:56:56+00:00" - }, { "name": "symfony/deprecation-contracts", "version": "v3.7.0", @@ -1909,104 +1296,15 @@ } ], "time": "2026-05-24T11:18:16+00:00" - }, - { - "name": "web-token/jwt-library", - "version": "4.1.6", - "source": { - "type": "git", - "url": "https://github.com/web-token/jwt-library.git", - "reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/e8ab00927a3856f3f0c8218226382cd6a58928a1", - "reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1", - "shasum": "" - }, - "require": { - "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", - "php": ">=8.2", - "psr/clock": "^1.0", - "spomky-labs/pki-framework": "^1.2.1" - }, - "conflict": { - "spomky-labs/jose": "*" - }, - "suggest": { - "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", - "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", - "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", - "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", - "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", - "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", - "symfony/console": "Needed to use console commands", - "symfony/http-client": "To enable JKU/X5U support." - }, - "type": "library", - "autoload": { - "psr-4": { - "Jose\\Component\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florent Morselli", - "homepage": "https://github.com/Spomky" - }, - { - "name": "All contributors", - "homepage": "https://github.com/web-token/jwt-framework/contributors" - } - ], - "description": "JWT library", - "homepage": "https://github.com/web-token", - "keywords": [ - "JOSE", - "JWE", - "JWK", - "JWKSet", - "JWS", - "Jot", - "RFC7515", - "RFC7516", - "RFC7517", - "RFC7518", - "RFC7519", - "RFC7520", - "bundle", - "jwa", - "jwt", - "symfony" - ], - "support": { - "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/4.1.6" - }, - "funding": [ - { - "url": "https://github.com/Spomky", - "type": "github" - }, - { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" - } - ], - "time": "2026-04-14T07:44:20+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, + "platform": [], + "platform-dev": [], "plugin-api-version": "2.9.0" } diff --git a/config/config.php b/config/config.php index 3d9afea..1c60bc9 100644 --- a/config/config.php +++ b/config/config.php @@ -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), ], diff --git a/config/vapid.php b/config/vapid.php deleted file mode 100644 index 8b52467..0000000 --- a/config/vapid.php +++ /dev/null @@ -1,13 +0,0 @@ -= 1.0) { - return 'CPU ' . push_process_name($cpu); + return 'CPU ' . display_process_name($cpu); } $mem = null; @@ -1358,7 +1358,7 @@ function expected_process_text(array $processes): string } if ($mem !== null) { - return 'MEM ' . push_process_name($mem); + return 'MEM ' . display_process_name($mem); } return 'CPU/MEM 원인 후보 없음'; @@ -1398,17 +1398,17 @@ function expected_process_detail_text(array $processes): string } if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) { - return push_process_name($cpu); + return display_process_name($cpu); } $parts = []; if ($cpu !== null) { - $parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), push_process_name($cpu)); + $parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), display_process_name($cpu)); } if ($mem !== null) { - $parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), push_process_name($mem)); + $parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), display_process_name($mem)); } return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts); @@ -1439,7 +1439,7 @@ function notice_downward_only(float $tempDelta, float $rpmDelta): bool return true; } -function send_fan_spike_push(array $spike, array $fan, array $system, array $processes): array +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); @@ -1476,13 +1476,18 @@ function send_fan_spike_push(array $spike, array $fan, array $system, array $pro . number_format((float)($fan['pwm'] ?? 0), 0) . (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes)); - return send_push_payload([ + return send_ha_notify([ 'title' => '시스템 유의사항', - 'body' => $body, + 'message' => $body, + 'level' => 'warning', 'url' => '/', - 'tag' => 'system-notice-' . date('YmdHi'), - 'created_at' => date('Y-m-d H:i:s'), + '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, @@ -1492,16 +1497,9 @@ function send_fan_spike_push(array $spike, array $fan, array $system, array $pro ]); } -function latest_system_notice_push_epoch(): int +function latest_system_notice_notify_epoch(): int { - $stmt = db()->query(" - SELECT UNIX_TIMESTAMP(MAX(created_at)) - FROM push_event_logs - WHERE event = 'send_success' - AND meta LIKE '%\"tag\":\"system-notice-%' - "); - - return (int)($stmt->fetchColumn() ?: 0); + return latest_ha_notify_epoch_by_tag('control-system-notice'); } function fan_spike_history(int $limit = 100): array @@ -1981,14 +1979,14 @@ function collect_snapshot(bool $applyFan = true): array $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); if (!empty($fanSpike['active'])) { - $latestNoticePush = latest_system_notice_push_epoch(); + $latestNoticeNotify = latest_system_notice_notify_epoch(); $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); if ($spikeLogId > 0) { $fanSpike['log_id'] = $spikeLogId; - if ($latestNoticePush <= 0 || time() - $latestNoticePush >= 600) { - $fanSpike['push'] = send_fan_spike_push($fanSpike, $fan, $system, $processes); + if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= 600) { + $fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes); } else { - $fanSpike['push'] = [ + $fanSpike['notify'] = [ 'sent' => 0, 'failed' => 0, 'skipped' => 'system_notice_cooldown', @@ -2040,60 +2038,6 @@ function control_api_dispatch(): void ]); } - if ($action === 'push_devices') { - json_out([ - 'ok' => true, - 'data' => [ - 'devices' => push_device_rows(), - 'summary' => push_health_summary(), - ], - ]); - } - - if ($action === 'send_push_healthcheck') { - require_csrf(); - - json_out([ - 'ok' => true, - 'data' => [ - 'result' => send_push_healthcheck_if_due(24, true), - 'devices' => push_device_rows(), - 'summary' => push_health_summary(), - ], - ]); - } - - if ($action === 'push_status') { - json_out([ - 'ok' => true, - 'data' => push_subscription_status((string)($_GET['endpoint'] ?? $_POST['endpoint'] ?? '')), - ]); - } - - if ($action === 'delete_push_device') { - delete_push_device((string)($_POST['endpoint_hash'] ?? '')); - - json_out([ - 'ok' => true, - 'data' => [ - 'devices' => push_device_rows(), - 'summary' => push_health_summary(), - ], - ]); - } - - if ($action === 'delete_push_endpoint') { - delete_push_endpoint((string)($_POST['endpoint'] ?? '')); - - json_out([ - 'ok' => true, - 'data' => [ - 'devices' => push_device_rows(), - 'summary' => push_health_summary(), - ], - ]); - } - if ($action === 'fan') { $mode = (string)($_POST['mode'] ?? 'auto'); $pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120))); diff --git a/public/api/push_event.php b/public/api/push_event.php deleted file mode 100644 index 116a949..0000000 --- a/public/api/push_event.php +++ /dev/null @@ -1,44 +0,0 @@ - false, 'error' => 'method_not_allowed'], 405); -} - -$raw = (string)file_get_contents('php://input'); -$data = json_decode($raw, true); -if (!is_array($data)) { - $data = []; -} - -$event = (string)($data['event'] ?? ''); -$allowedEvents = [ - 'push_received', - 'notification_shown', - 'notification_show_failed', - 'notification_click', - 'notification_close', - 'client_log_failed', -]; - -if (!in_array($event, $allowedEvents, true)) { - json_out(['ok' => false, 'error' => 'invalid_event'], 422); -} - -$endpoint = (string)($data['endpoint'] ?? ''); -$meta = is_array($data['meta'] ?? null) ? $data['meta'] : []; -$context = [ - 'endpoint' => $endpoint, - 'message' => mb_substr((string)($data['push_id'] ?? ''), 0, 255), - 'push_id' => (string)($data['push_id'] ?? ''), - 'tag' => (string)($data['tag'] ?? ''), - 'visibility_state' => (string)($data['visibility_state'] ?? ''), - 'client_count' => isset($data['client_count']) ? (int)$data['client_count'] : null, - 'meta' => $meta, -]; - -push_log_event($event, $context); - -json_out(['ok' => true]); diff --git a/public/api/save_subscription.php b/public/api/save_subscription.php deleted file mode 100644 index deed2c4..0000000 --- a/public/api/save_subscription.php +++ /dev/null @@ -1,31 +0,0 @@ - false, - 'error' => 'login_required', - ], 401); -} - -if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - json_out([ - 'ok' => false, - 'error' => 'method_not_allowed', - ], 405); -} - -require_csrf(); - -$subscription = push_subscription_from_json((string)file_get_contents('php://input')); -save_push_subscription($subscription); - -json_out([ - 'ok' => true, -]); diff --git a/public/api/send_push.php b/public/api/send_push.php deleted file mode 100644 index fd7adac..0000000 --- a/public/api/send_push.php +++ /dev/null @@ -1,38 +0,0 @@ - false, - 'error' => 'login_required', - ], 401); -} - -if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { - json_out([ - 'ok' => false, - 'error' => 'method_not_allowed', - ], 405); -} - -require_csrf(); - -$data = push_subscription_from_json((string)file_get_contents('php://input')); -$payload = [ - 'title' => (string)($data['title'] ?? 'Seoul Control Center'), - 'body' => (string)($data['body'] ?? 'Push notification test'), - 'url' => '/', - 'tag' => (string)($data['tag'] ?? 'control-test'), - 'created_at' => date('Y-m-d H:i:s'), -]; - -json_out([ - 'ok' => true, - 'data' => send_push_payload($payload), -]); diff --git a/public/assets/app.js b/public/assets/app.js index 15feca6..7157dd4 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -34,9 +34,6 @@ statusBatteryRemaining: $('#statusBatteryRemaining'), spikeLogList: $('#spikeLogList'), noticeBaseline: $('#noticeBaseline'), - pushStatus: $('#pushStatus'), - pushDeviceList: $('#pushDeviceList'), - pushHealthcheckBtn: $('#pushHealthcheckBtn'), rebootBtn: $('#rebootBtn'), translateBtn: $('#translateBtn'), themeBtn: $('#themeBtn'), @@ -70,7 +67,6 @@ wsFallbackTimer: null, wsReconnectTimer: null, wsReconnectDelay: 1000, - pushDevicesLastRefresh: 0, rebootRequesting: false, lang: 'en', theme: 'dark', @@ -141,10 +137,6 @@ systemNotice: 'System Notice', noticeHistory: 'Notice History', noNoticeHistory: 'No system notice history.', - pushDevices: 'Push Devices', - healthCheck: 'Health Check', - pushStatusChecking: 'Push status checking...', - noPushDevices: 'No push devices.', show: 'Show', hide: 'Hide', translateButton: 'Translate', @@ -179,34 +171,8 @@ avg: 'AVG', min: 'MIN', minuteAgo: ({ minutes }) => `${minutes}m ago`, - health_healthy: 'Healthy', - health_watch: 'Watch', - health_stale: 'Stale', - health_failed: 'Failed', - health_pending: 'Pending', - pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`, - statusUnsupported: 'Push status: browser unsupported', - statusDenied: 'Push status: permission denied', - statusUngrant: 'Push status: permission not granted', - statusManualOff: 'Push status: manually disabled', - browserHas: 'browser registered', - browserMissing: 'browser missing', - serverHas: 'server registered', - serverMissing: 'server missing', - serverPending: 'server not checked', - pushStatus: 'Push status', - registeredRefresh: 'Registered refresh', - lastSendSuccess: 'Last send success', - lastReceived: 'Last received', - lastShown: 'Last shown', - lastClick: 'Last click', - failures: 'Failures', - unknownDevice: 'unknown device', unnamed: 'Unnamed', - pendingReceive: 'Pending receive', ago: 'ago', - healthSent: ({ sent, failed }) => `Health check sent ${sent} / failed ${failed}`, - healthFailed: 'Health check failed', refreshFailed: 'refresh failed', applyingFan: 'Applying fan policy...', fanUpdated: 'Fan updated', @@ -225,16 +191,7 @@ rebootStarted: 'Reboot Started', rebootStartedBody: 'Reboot command sent. The connection may drop shortly.', rebootFailed: 'Reboot Failed', - pushDeviceNamePrompt: 'Enter a device name. Korean names require at least 2 Hangul characters.', - pushRegisterTitle: 'Push Register', - register: 'Register', - phone: 'Phone', - pushDeviceNameInvalid: 'Device name must contain at least 2 Hangul characters.', - autoRepair: 'Auto Repair', - pushDeletePrompt: 'Delete this browser Push subscription?', - pushDeleteTitle: 'Delete Push', delete: 'Delete', - pushErrorTitle: 'Push Error', wakeLockTitle: 'Prevent screen sleep', wakeLockUnsupported: 'WakeLock is not supported by this browser.', wakeLockErrorTitle: 'WakeLock Error', @@ -298,10 +255,6 @@ systemNotice: '시스템 알림', noticeHistory: '알림 이력', noNoticeHistory: '시스템 알림 이력이 없습니다.', - pushDevices: 'Push 기기', - healthCheck: '상태 점검', - pushStatusChecking: 'Push 상태 확인 중...', - noPushDevices: 'Push 기기가 없습니다.', show: '보기', hide: '숨기기', translateButton: '번역', @@ -336,34 +289,8 @@ avg: '평균', min: '최소', minuteAgo: ({ minutes }) => `${minutes}분 전`, - health_healthy: '정상', - health_watch: '주의', - health_stale: '장기 미수신', - health_failed: '실패', - health_pending: '수신 대기', - pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push 기기: 총 ${total} / 정상 ${healthy} / 주의 ${watch} / 장기 미수신 ${stale} / 실패 ${failed} / 대기 ${pending}`, - statusUnsupported: 'Push 상태: 브라우저 미지원', - statusDenied: 'Push 상태: 권한 꺼짐', - statusUngrant: 'Push 상태: 권한 미허용', - statusManualOff: 'Push 상태: 사용자가 직접 해제함', - browserHas: '브라우저에는 있음', - browserMissing: '브라우저에는 없음', - serverHas: '서버에는 있음', - serverMissing: '서버에는 없음', - serverPending: '서버 확인 전', - pushStatus: 'Push 상태', - registeredRefresh: '등록 갱신', - lastSendSuccess: '마지막 발송 성공', - lastReceived: '마지막 수신', - lastShown: '마지막 표시', - lastClick: '마지막 클릭', - failures: '실패', - unknownDevice: '알 수 없는 기기', unnamed: '이름 없음', - pendingReceive: '수신 대기', ago: '전', - healthSent: ({ sent, failed }) => `상태 점검 발송 ${sent} / 실패 ${failed}`, - healthFailed: '상태 점검 실패', refreshFailed: '갱신 실패', applyingFan: '팬 정책 적용 중...', fanUpdated: '팬 갱신 완료', @@ -382,16 +309,7 @@ rebootStarted: '재부팅 시작', rebootStartedBody: '재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', rebootFailed: '재부팅 실패', - pushDeviceNamePrompt: '등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.', - pushRegisterTitle: 'Push 등록', - register: '등록', - phone: '휴대폰', - pushDeviceNameInvalid: '기기 이름은 한글 2글자 이상이어야 합니다.', - autoRepair: '자동복구', - pushDeletePrompt: '현재 브라우저의 Push 구독을 삭제할까요?', - pushDeleteTitle: 'Push 삭제', delete: '삭제', - pushErrorTitle: 'Push 오류', wakeLockTitle: '화면 꺼짐 방지', wakeLockUnsupported: '현재 브라우저에서 WakeLock을 지원하지 않습니다.', wakeLockErrorTitle: 'WakeLock 오류', @@ -408,11 +326,6 @@ } window.controlT = t; - function pushHealthText(status, fallback = '') { - const key = `health_${status || 'pending'}`; - return messages[state.lang]?.[key] ?? messages.en[key] ?? fallback ?? t('pendingReceive'); - } - function applyTheme(theme) { state.theme = theme === 'light' ? 'light' : 'dark'; localStorage.setItem(storageKeys.theme, state.theme); @@ -1084,119 +997,6 @@ : `
${escapeHtml(t('noNoticeHistory'))}
`; } - function renderPushDevices(devices = [], summary = null) { - if (!els.pushDeviceList) return; - - if (summary && els.pushStatus) { - const total = Number(summary.total || 0); - const healthy = Number(summary.healthy || 0); - const watch = Number(summary.watch || 0); - const stale = Number(summary.stale || 0); - const failed = Number(summary.failed || 0); - const pending = Number(summary.pending || 0); - els.pushStatus.textContent = t('pushSummary', { total, healthy, watch, stale, failed, pending }); - } - - els.pushDeviceList.innerHTML = devices.length - ? devices.map(device => { - const healthStatus = device.health_status || 'pending'; - const healthText = pushHealthText(healthStatus, device.health_text); - - return ` -
-
- ${escapeHtml(device.device_name || t('unnamed'))} - ${escapeHtml(healthText)} - ${escapeHtml(t('hostLabel'))}: ${escapeHtml(device.host || 'unknown')} - ${escapeHtml(t('ipLabel'))}: ${escapeHtml(device.actor_ip || '-')} - ${escapeHtml(t('created'))}: ${escapeHtml(device.created_at || '-')} - ${escapeHtml(t('registeredRefresh'))}: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))}) - ${escapeHtml(t('lastSendSuccess'))}: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))}) - ${escapeHtml(t('lastReceived'))}: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))}) - ${escapeHtml(t('lastShown'))}: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))}) - ${escapeHtml(t('lastClick'))}: ${escapeHtml(device.last_click_at || '-')} - ${escapeHtml(t('failures'))}: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''} - ${escapeHtml(t('encoding'))}: ${escapeHtml(device.content_encoding || '-')} - ${escapeHtml(t('hash'))}: ${escapeHtml(device.hash || '-')} - ${escapeHtml(t('endpoint'))}: ${escapeHtml(device.endpoint || '-')} - ${escapeHtml(t('userAgent'))}: ${escapeHtml(device.user_agent || t('unknownDevice'))} -
-
- `; - }).join('') - : `
${escapeHtml(t('noPushDevices'))}
`; - } - - function renderPushStatus(detail = {}) { - if (!els.pushStatus) return; - - const supported = detail.supported === true; - const permission = detail.permission || 'unknown'; - const hasBrowserSubscription = detail.browser_subscription === true; - const hasServerSubscription = detail.server_subscription === true; - const manualDisabled = detail.manual_disabled === true; - const serverChecked = detail.server_checked === true; - - let text = ''; - if (!supported) { - text = t('statusUnsupported'); - } else if (permission === 'denied') { - text = t('statusDenied'); - } else if (permission !== 'granted') { - text = t('statusUngrant'); - } else if (manualDisabled) { - text = t('statusManualOff'); - } else { - const browserText = hasBrowserSubscription ? t('browserHas') : t('browserMissing'); - const serverText = serverChecked - ? (hasServerSubscription ? t('serverHas') : t('serverMissing')) - : t('serverPending'); - text = `${t('pushStatus')}: ${browserText} / ${serverText}`; - } - - els.pushStatus.textContent = text; - } - - async function refreshPushDevices(force = false) { - if (!force && Date.now() - state.pushDevicesLastRefresh < 30000) { - return; - } - - state.pushDevicesLastRefresh = Date.now(); - try { - const data = await api('push_devices'); - renderPushDevices(data.devices || [], data.summary || null); - } catch (e) { - console.error(e); - } - } - - async function sendPushHealthcheck() { - if (!els.pushHealthcheckBtn) return; - - els.pushHealthcheckBtn.disabled = true; - try { - const data = await api('send_push_healthcheck', {}); - renderPushDevices(data.devices || [], data.summary || null); - const result = data.result || {}; - notice(t('healthSent', { sent: Number(result.sent || 0), failed: Number(result.failed || 0) }), result.failed ? 'error' : 'success'); - } catch (e) { - notice(e.message || t('healthFailed'), 'error'); - } finally { - els.pushHealthcheckBtn.disabled = false; - } - } - - window.addEventListener('pushdevices:refresh', () => { - refreshPushDevices(true); - }); - - window.addEventListener('pushstatus:update', event => { - renderPushStatus(event.detail || {}); - }); - - els.pushHealthcheckBtn?.addEventListener('click', sendPushHealthcheck); - function renderFanCause(data) { const processes = data.processes || {}; const baseline = data.fan_spike || {}; @@ -1211,7 +1011,6 @@ renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu'); renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory'); - refreshPushDevices(); } function rollingStats(values, windowSize = 30) { @@ -1736,7 +1535,6 @@ }); els.translateBtn?.addEventListener('click', () => { applyLang(state.lang === 'ko' ? 'en' : 'ko'); - refreshPushDevices(true); refreshStatus(); }); els.themeBtn?.addEventListener('click', () => { diff --git a/public/index.php b/public/index.php index 7317d08..1e7d3b1 100644 --- a/public/index.php +++ b/public/index.php @@ -37,22 +37,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) { $loggedIn = signed_in(); $csrf = csrf_token(); -if ($loggedIn && array_key_exists('push', $_GET)) { - $testPushBody = trim((string)$_GET['push'], " \t\n\r\0\x0B\"'"); - - if ($testPushBody !== '') { - send_push_payload([ - 'title' => '푸시 알림 테스트', - 'body' => mb_substr($testPushBody, 0, 500), - 'url' => '/', - 'tag' => 'control-test-push-' . time(), - 'created_at' => date('Y-m-d H:i:s'), - ]); - } - - header('Location: /'); - exit; -} ?> @@ -61,7 +45,6 @@ if ($loggedIn && array_key_exists('push', $_GET)) { - <?= e(APP_NAME) ?> @@ -123,7 +106,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da .table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} .chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important} .resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:var(--text);font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--code-bg);color:var(--mono-text);padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none} -.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:var(--input);border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:var(--text);font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828} +.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto} .notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)} .dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @@ -153,7 +136,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da

Updated: loading...

- @@ -289,19 +271,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
-
-
-

Push Devices

-
- -
-
-
Push status checking...
-
-
No push devices.
-
-
-

dmesg

@@ -326,7 +295,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
- diff --git a/public/push_subscribe.js b/public/push_subscribe.js deleted file mode 100644 index 04a7648..0000000 --- a/public/push_subscribe.js +++ /dev/null @@ -1,351 +0,0 @@ -(() => { - 'use strict'; - - const button = document.querySelector('#pushEnableBtn'); - const publicKey = document.querySelector('meta[name="vapid-public-key"]')?.content || ''; - const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; - const pushDeviceNameStorageKey = 'controlPushDeviceName'; - const pushDisabledStorageKey = 'controlPushDisabled'; - let pushAutoRepairRunning = false; - - function tr(key, fallback) { - return typeof window.controlT === 'function' ? window.controlT(key) : fallback; - } - - function setButton(text, disabled = false, active = false) { - if (!button) return; - button.textContent = text || 'Push'; - button.disabled = disabled; - button.dataset.active = active ? '1' : '0'; - } - - function publishPushStatus(detail) { - window.dispatchEvent(new CustomEvent('pushstatus:update', { - detail: Object.assign({ - supported: ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window), - permission: ('Notification' in window) ? Notification.permission : 'unsupported', - browser_subscription: false, - server_subscription: false, - server_checked: false, - manual_disabled: localStorage.getItem(pushDisabledStorageKey) === '1', - }, detail || {}), - })); - } - - function hangulCount(value) { - return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length; - } - - async function deviceNameFromUser() { - const promptFn = window.customPrompt || (() => Promise.resolve(null)); - const name = await promptFn(tr('pushDeviceNamePrompt', 'Enter a device name. Korean names require at least 2 Hangul characters.'), { - title: tr('pushRegisterTitle', 'Push Register'), - okText: tr('register', 'Register'), - placeholder: tr('phone', 'Phone'), - autocomplete: 'off', - }); - - if (name === null) { - return null; - } - - const trimmed = String(name || '').trim(); - - if (hangulCount(trimmed) < 2) { - throw new Error(tr('pushDeviceNameInvalid', 'Device name must contain at least 2 Hangul characters.')); - } - - return trimmed; - } - - function rememberDeviceName(deviceName) { - const trimmed = String(deviceName || '').trim(); - if (hangulCount(trimmed) >= 2) { - localStorage.setItem(pushDeviceNameStorageKey, trimmed); - localStorage.removeItem(pushDisabledStorageKey); - } - return trimmed; - } - - function savedDeviceName() { - const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim(); - return hangulCount(stored) >= 2 ? stored : tr('autoRepair', 'Auto Repair'); - } - - function urlBase64ToUint8Array(value) { - const padding = '='.repeat((4 - value.length % 4) % 4); - const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/'); - const raw = atob(base64); - const output = new Uint8Array(raw.length); - - for (let i = 0; i < raw.length; i++) { - output[i] = raw.charCodeAt(i); - } - - return output; - } - - async function postForm(action, body) { - const fd = new URLSearchParams(); - Object.entries(body).forEach(([key, value]) => fd.append(key, String(value))); - fd.append('action', action); - fd.append('csrf', csrf); - - const res = await fetch('/api.php', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-CSRF-Token': csrf, - }, - body: fd.toString(), - }); - const json = await res.json(); - - if (!res.ok || !json.ok) { - throw new Error(json?.message || json?.error || 'push_request_failed'); - } - - return json.data; - } - - async function saveSubscription(subscription, deviceName) { - const payload = subscription.toJSON(); - payload.device_name = rememberDeviceName(deviceName); - - const res = await fetch('/api/save_subscription.php', { - method: 'POST', - credentials: 'same-origin', - cache: 'no-store', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrf, - }, - body: JSON.stringify(payload), - }); - const json = await res.json(); - - if (!res.ok || !json.ok) { - throw new Error(json?.message || json?.error || 'subscription_save_failed'); - } - } - - async function registration() { - const reg = await navigator.serviceWorker.register('/sw.js', { - scope: '/', - updateViaCache: 'none', - }); - await reg.update().catch(() => {}); - return reg; - } - - async function currentSubscription() { - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - return null; - } - - const existing = await navigator.serviceWorker.getRegistration('/'); - if (existing) { - await existing.update().catch(() => {}); - } - return existing ? existing.pushManager.getSubscription() : null; - } - - async function subscribePush(reg) { - return reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }); - } - - async function pushServerStatus(subscription) { - const endpoint = subscription ? subscription.endpoint : ''; - const res = await fetch('/api.php?action=push_status&endpoint=' + encodeURIComponent(endpoint), { - credentials: 'same-origin', - cache: 'no-store', - headers: { - 'X-CSRF-Token': csrf, - }, - }); - const json = await res.json(); - - if (!res.ok || !json.ok) { - throw new Error(json?.message || json?.error || 'push_status_failed'); - } - - return json.data || {}; - } - - async function repairSubscriptionIfNeeded() { - if (pushAutoRepairRunning) return; - if (!publicKey) return; - if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return; - if (Notification.permission !== 'granted') return; - if (localStorage.getItem(pushDisabledStorageKey) === '1') return; - - pushAutoRepairRunning = true; - - try { - const reg = await registration(); - let subscription = await reg.pushManager.getSubscription(); - - if (!subscription) { - subscription = await subscribePush(reg); - await saveSubscription(subscription, savedDeviceName()); - await refreshButton(); - window.dispatchEvent(new CustomEvent('pushdevices:refresh')); - return; - } - - const status = await pushServerStatus(subscription); - publishPushStatus({ - browser_subscription: true, - server_subscription: status.subscribed === true, - server_checked: true, - }); - if (status.device_name) { - rememberDeviceName(status.device_name); - } - - if (status.subscribed) { - await refreshButton(); - return; - } - - await saveSubscription(subscription, savedDeviceName()); - publishPushStatus({ - browser_subscription: true, - server_subscription: true, - server_checked: true, - }); - await refreshButton(); - window.dispatchEvent(new CustomEvent('pushdevices:refresh')); - } catch (error) { - console.warn('push auto repair failed', error); - } finally { - pushAutoRepairRunning = false; - } - } - - async function refreshButton() { - if (!button) return; - - if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { - setButton('Push', true, false); - publishPushStatus({ supported: false }); - return; - } - - const subscription = await currentSubscription(); - if (subscription) { - setButton('Push', false, true); - publishPushStatus({ - browser_subscription: true, - }); - } else if (Notification.permission === 'denied') { - setButton('Push', true, false); - publishPushStatus({ - browser_subscription: false, - }); - } else { - setButton('Push', false, false); - publishPushStatus({ - browser_subscription: false, - }); - } - } - - async function subscribe() { - if (!publicKey) { - setButton('Push', true, false); - return; - } - - const deviceName = await deviceNameFromUser(); - if (deviceName === null) { - await refreshButton(); - return; - } - - setButton('Push', true, false); - - const permission = await Notification.requestPermission(); - if (permission !== 'granted') { - setButton('Push', false, false); - return; - } - - const reg = await registration(); - let subscription = await reg.pushManager.getSubscription(); - if (!subscription) { - subscription = await subscribePush(reg); - } - - await saveSubscription(subscription, deviceName); - publishPushStatus({ - browser_subscription: true, - server_subscription: true, - server_checked: true, - manual_disabled: false, - }); - await refreshButton(); - window.dispatchEvent(new CustomEvent('pushdevices:refresh')); - } - - async function unsubscribe() { - const confirmFn = window.customConfirm || (() => Promise.resolve(false)); - if (!await confirmFn(tr('pushDeletePrompt', 'Delete this browser Push subscription?'), { - title: tr('pushDeleteTitle', 'Delete Push'), - okText: tr('delete', 'Delete'), - danger: true, - })) { - await refreshButton(); - return; - } - - const subscription = await currentSubscription(); - if (!subscription) { - await refreshButton(); - return; - } - - setButton('Push', true, true); - localStorage.setItem(pushDisabledStorageKey, '1'); - await subscription.unsubscribe(); - await postForm('delete_push_endpoint', { - endpoint: subscription.endpoint, - }); - publishPushStatus({ - browser_subscription: false, - server_subscription: false, - server_checked: true, - manual_disabled: true, - }); - await refreshButton(); - window.dispatchEvent(new CustomEvent('pushdevices:refresh')); - } - - if (!button) return; - - button.addEventListener('click', () => { - const active = button.dataset.active === '1'; - const job = active ? unsubscribe() : subscribe(); - job.catch(error => { - const alertFn = window.customAlert || (() => Promise.resolve()); - alertFn(error.message || 'Push failed', { - title: tr('pushErrorTitle', 'Push Error'), - danger: true, - }); - refreshButton().catch(() => {}); - }); - }); - - refreshButton().catch(() => setButton('Push', false, false)); - repairSubscriptionIfNeeded(); - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - repairSubscriptionIfNeeded(); - } - }); - setInterval(repairSubscriptionIfNeeded, 5 * 60 * 1000); -})(); diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index 2dcfbec..0000000 --- a/public/sw.js +++ /dev/null @@ -1,123 +0,0 @@ -async function currentEndpoint() { - const subscription = await self.registration.pushManager.getSubscription(); - return subscription ? subscription.endpoint : ''; -} - -async function windowClientCount() { - const allClients = await clients.matchAll({ - type: 'window', - includeUncontrolled: true, - }); - return allClients.length; -} - -function logPushEvent(eventName, payload = {}, extra = {}) { - return Promise.all([ - currentEndpoint().catch(() => ''), - windowClientCount().catch(() => 0), - ]).then(([endpoint, clientCount]) => fetch('/api/push_event.php', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - cache: 'no-store', - body: JSON.stringify({ - event: eventName, - endpoint, - push_id: payload.push_id || '', - tag: payload.tag || '', - client_count: clientCount, - meta: extra, - }), - })).catch(() => {}); -} - -self.addEventListener('push', event => { - let payload = {}; - - try { - payload = event.data ? event.data.json() : {}; - } catch (e) { - payload = { - body: event.data ? event.data.text() : '', - }; - } - - const title = payload.title || 'Seoul Control Center'; - const vibrate = Array.isArray(payload.vibrate) - ? payload.vibrate.map(value => Number(value)).filter(value => Number.isFinite(value) && value >= 0) - : undefined; - const options = { - body: payload.body || 'System notice detected', - icon: '/assets/icon-192.png', - badge: '/assets/icon-192.png', - tag: payload.tag || 'control-push', - renotify: payload.renotify === true, - requireInteraction: payload.require_interaction === true || payload.requireInteraction === true, - silent: payload.silent === true, - vibrate, - data: { - url: payload.url || '/', - push_id: payload.push_id || '', - tag: payload.tag || 'control-push', - }, - }; - - event.waitUntil((async () => { - await logPushEvent('push_received', payload); - try { - await self.registration.showNotification(title, options); - await logPushEvent('notification_shown', payload); - } catch (error) { - await logPushEvent('notification_show_failed', payload, { - reason: error && error.message ? error.message : 'show_failed', - }); - throw error; - } - })()); -}); - -self.addEventListener('notificationclick', event => { - event.notification.close(); - - const url = event.notification.data?.url || '/'; - const payload = { - push_id: event.notification.data?.push_id || '', - tag: event.notification.data?.tag || event.notification.tag || '', - }; - - event.waitUntil((async () => { - await logPushEvent('notification_click', payload, { - action: event.action || '', - }); - - const allClients = await clients.matchAll({ - type: 'window', - includeUncontrolled: true, - }); - - for (const client of allClients) { - if ('focus' in client) { - await client.focus(); - if ('navigate' in client) { - return client.navigate(url); - } - return; - } - } - - if (clients.openWindow) { - return clients.openWindow(url); - } - })()); -}); - -self.addEventListener('notificationclose', event => { - const payload = { - push_id: event.notification.data?.push_id || '', - tag: event.notification.data?.tag || event.notification.tag || '', - }; - - event.waitUntil(logPushEvent('notification_close', payload)); -});