Use Home Assistant notifications
This commit is contained in:
@@ -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` 값을 확인합니다.
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$minHours = 24;
|
||||
$force = false;
|
||||
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if ($arg === '--force') {
|
||||
$force = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($arg, '--min-hours=')) {
|
||||
$minHours = (int)substr($arg, 12);
|
||||
}
|
||||
}
|
||||
|
||||
$result = send_push_healthcheck_if_due($minHours, $force);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
'summary' => push_health_summary(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"require": {
|
||||
"minishlink/web-push": "^10.0",
|
||||
"cboden/ratchet": "^0.4",
|
||||
"react/event-loop": "^1.5"
|
||||
}
|
||||
|
||||
Generated
+12
-714
@@ -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"
|
||||
}
|
||||
|
||||
+165
-631
@@ -14,6 +14,7 @@ if (!is_file($controlSecretFile)) {
|
||||
$controlSecretConfig = require $controlSecretFile;
|
||||
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
|
||||
$controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : [];
|
||||
$controlHaNotifyConfig = is_array($controlSecretConfig['ha_notify'] ?? null) ? $controlSecretConfig['ha_notify'] : [];
|
||||
|
||||
define('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? ''));
|
||||
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
|
||||
@@ -21,6 +22,7 @@ define('DB_PORT', (int)($controlDbConfig['port'] ?? 3306));
|
||||
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
|
||||
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
|
||||
define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
|
||||
define('HA_NOTIFY_WEBHOOK_ID', (string)($controlHaNotifyConfig['webhook_id'] ?? 'control_notify_20260621'));
|
||||
|
||||
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
|
||||
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
|
||||
@@ -35,10 +37,6 @@ define('BATTERY_CAPACITY_WH', isset($controlBatteryConfig['capacity_wh']) ? max(
|
||||
|
||||
date_default_timezone_set('Asia/Seoul');
|
||||
|
||||
if (is_file(__DIR__ . '/vapid.php')) {
|
||||
require_once __DIR__ . '/vapid.php';
|
||||
}
|
||||
|
||||
if (is_file(__DIR__ . '/../vendor/autoload.php')) {
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
}
|
||||
@@ -273,55 +271,22 @@ function bootstrap_db(): void
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
endpoint_hash CHAR(64) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh VARCHAR(255) NOT NULL,
|
||||
auth VARCHAR(255) NOT NULL,
|
||||
content_encoding VARCHAR(32) NOT NULL DEFAULT 'aes128gcm',
|
||||
|
||||
device_name VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
actor_ip VARCHAR(64) NULL,
|
||||
last_send_success_at DATETIME NULL,
|
||||
last_send_failed_at DATETIME NULL,
|
||||
last_received_at DATETIME NULL,
|
||||
last_notification_at DATETIME NULL,
|
||||
last_click_at DATETIME NULL,
|
||||
failure_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_failure_reason VARCHAR(255) NULL,
|
||||
|
||||
UNIQUE KEY uniq_endpoint_hash (endpoint_hash),
|
||||
INDEX idx_last_seen_at (last_seen_at),
|
||||
INDEX idx_last_received_at (last_received_at),
|
||||
INDEX idx_last_send_success_at (last_send_success_at)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS push_event_logs (
|
||||
CREATE TABLE IF NOT EXISTS ha_notify_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
event VARCHAR(32) NOT NULL,
|
||||
endpoint_hash CHAR(64) NULL,
|
||||
device_name VARCHAR(64) NULL,
|
||||
endpoint_host VARCHAR(128) NULL,
|
||||
target VARCHAR(16) NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
tag VARCHAR(128) NULL,
|
||||
actor_ip VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
message VARCHAR(255) NULL,
|
||||
http_code INT NULL,
|
||||
error VARCHAR(255) NULL,
|
||||
meta JSON NULL,
|
||||
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_event (event),
|
||||
INDEX idx_endpoint_hash (endpoint_hash)
|
||||
INDEX idx_tag (tag)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
@@ -349,19 +314,6 @@ function bootstrap_db(): void
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS battery_push_state (
|
||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
last_sent_at DATETIME NULL,
|
||||
last_percent DECIMAL(6,2) NULL,
|
||||
last_voltage DECIMAL(6,3) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
INSERT IGNORE INTO control_state
|
||||
(id, mode, manual_pwm)
|
||||
@@ -388,16 +340,6 @@ function bootstrap_db(): void
|
||||
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
|
||||
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
|
||||
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN device_name VARCHAR(64) NULL AFTER content_encoding",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_send_success_at DATETIME NULL AFTER actor_ip",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_send_failed_at DATETIME NULL AFTER last_send_success_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_received_at DATETIME NULL AFTER last_send_failed_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_notification_at DATETIME NULL AFTER last_received_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_click_at DATETIME NULL AFTER last_notification_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN failure_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_click_at",
|
||||
"ALTER TABLE push_subscriptions ADD COLUMN last_failure_reason VARCHAR(255) NULL AFTER failure_count",
|
||||
"ALTER TABLE push_subscriptions ADD INDEX idx_last_received_at (last_received_at)",
|
||||
"ALTER TABLE push_subscriptions ADD INDEX idx_last_send_success_at (last_send_success_at)",
|
||||
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
|
||||
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
||||
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
|
||||
@@ -489,596 +431,191 @@ function json_out(array $payload, int $status = 200): never
|
||||
exit;
|
||||
}
|
||||
|
||||
function vapid_public_key(): string
|
||||
function ha_notify_targets(): array
|
||||
{
|
||||
return defined('VAPID_PUBLIC_KEY') ? VAPID_PUBLIC_KEY : '';
|
||||
global $controlHaNotifyConfig;
|
||||
|
||||
$configured = is_array($controlHaNotifyConfig['targets'] ?? null) ? $controlHaNotifyConfig['targets'] : [];
|
||||
if ($configured !== []) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return [
|
||||
['name' => 'seoul', 'base' => 'https://ha.seoul.chaegeon.com'],
|
||||
['name' => 'main', 'base' => 'https://ha.chaegeon.com'],
|
||||
];
|
||||
}
|
||||
|
||||
function push_subscription_from_json(string $raw): array
|
||||
{
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
function push_log_event(string $event, array $context = []): void
|
||||
function ha_notify_log(string $event, array $context = []): void
|
||||
{
|
||||
try {
|
||||
$endpoint = (string)($context['endpoint'] ?? '');
|
||||
$endpointHash = (string)($context['endpoint_hash'] ?? '');
|
||||
if ($endpointHash === '' && $endpoint !== '') {
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO push_event_logs (
|
||||
INSERT INTO ha_notify_logs (
|
||||
event,
|
||||
endpoint_hash,
|
||||
device_name,
|
||||
endpoint_host,
|
||||
target,
|
||||
title,
|
||||
tag,
|
||||
actor_ip,
|
||||
user_agent,
|
||||
message,
|
||||
http_code,
|
||||
error,
|
||||
meta
|
||||
) VALUES (
|
||||
:event,
|
||||
:endpoint_hash,
|
||||
:device_name,
|
||||
:endpoint_host,
|
||||
:target,
|
||||
:title,
|
||||
:tag,
|
||||
:actor_ip,
|
||||
:user_agent,
|
||||
:message,
|
||||
:http_code,
|
||||
:error,
|
||||
:meta
|
||||
)
|
||||
");
|
||||
|
||||
unset($context['endpoint'], $context['p256dh'], $context['auth']);
|
||||
$stmt->execute([
|
||||
':event' => mb_substr($event, 0, 32),
|
||||
':endpoint_hash' => $endpointHash !== '' ? $endpointHash : null,
|
||||
':device_name' => isset($context['device_name']) ? mb_substr((string)$context['device_name'], 0, 64) : null,
|
||||
':endpoint_host' => $endpoint !== '' ? mb_substr((string)(parse_url($endpoint, PHP_URL_HOST) ?: ''), 0, 128) : null,
|
||||
':target' => isset($context['target']) ? mb_substr((string)$context['target'], 0, 16) : null,
|
||||
':title' => isset($context['title']) ? mb_substr((string)$context['title'], 0, 255) : null,
|
||||
':tag' => isset($context['tag']) ? mb_substr((string)$context['tag'], 0, 128) : null,
|
||||
':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64),
|
||||
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null,
|
||||
':http_code' => isset($context['http_code']) ? (int)$context['http_code'] : null,
|
||||
':error' => isset($context['error']) ? mb_substr((string)$context['error'], 0, 255) : null,
|
||||
':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
|
||||
update_push_subscription_health($event, $endpointHash, $context);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
function update_push_subscription_health(string $event, string $endpointHash, array $context = []): void
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reason = mb_substr((string)($context['reason'] ?? ''), 0, 255);
|
||||
|
||||
try {
|
||||
if (in_array($event, ['register', 'register_update'], true)) {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET last_seen_at = CURRENT_TIMESTAMP,
|
||||
failure_count = 0,
|
||||
last_failure_reason = NULL
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event === 'send_success') {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET last_send_success_at = CURRENT_TIMESTAMP,
|
||||
failure_count = 0,
|
||||
last_failure_reason = NULL
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event === 'send_failed') {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET last_send_failed_at = CURRENT_TIMESTAMP,
|
||||
failure_count = failure_count + 1,
|
||||
last_failure_reason = :reason
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([
|
||||
':endpoint_hash' => $endpointHash,
|
||||
':reason' => $reason !== '' ? $reason : 'send_failed',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$columnByEvent = [
|
||||
'push_received' => 'last_received_at',
|
||||
'notification_shown' => 'last_notification_at',
|
||||
'notification_click' => 'last_click_at',
|
||||
];
|
||||
if (isset($columnByEvent[$event])) {
|
||||
$column = $columnByEvent[$event];
|
||||
$stmt = db()->prepare("
|
||||
UPDATE push_subscriptions
|
||||
SET {$column} = CURRENT_TIMESTAMP,
|
||||
failure_count = 0,
|
||||
last_failure_reason = NULL
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
function save_push_subscription(array $subscription): void
|
||||
{
|
||||
$endpoint = (string)($subscription['endpoint'] ?? '');
|
||||
$keys = is_array($subscription['keys'] ?? null) ? $subscription['keys'] : [];
|
||||
$p256dh = (string)($keys['p256dh'] ?? '');
|
||||
$auth = (string)($keys['auth'] ?? '');
|
||||
$deviceName = trim((string)($subscription['device_name'] ?? ''));
|
||||
preg_match_all('/[\x{AC00}-\x{D7A3}\x{3131}-\x{318E}]/u', $deviceName, $hangulMatches);
|
||||
$hangulCount = count($hangulMatches[0] ?? []);
|
||||
|
||||
if ($endpoint === '' || $p256dh === '' || $auth === '') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_subscription',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($deviceName === '' || $hangulCount < 2) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_device_name',
|
||||
'message' => '기기 이름은 한글 2글자 이상이어야 합니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
$exists = false;
|
||||
try {
|
||||
$check = db()->prepare("SELECT 1 FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash LIMIT 1");
|
||||
$check->execute([':endpoint_hash' => $endpointHash]);
|
||||
$exists = (bool)$check->fetchColumn();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO push_subscriptions (
|
||||
endpoint_hash,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
content_encoding,
|
||||
device_name,
|
||||
user_agent,
|
||||
actor_ip
|
||||
) VALUES (
|
||||
:endpoint_hash,
|
||||
:endpoint,
|
||||
:p256dh,
|
||||
:auth,
|
||||
:content_encoding,
|
||||
:device_name,
|
||||
:user_agent,
|
||||
:actor_ip
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
endpoint = VALUES(endpoint),
|
||||
p256dh = VALUES(p256dh),
|
||||
auth = VALUES(auth),
|
||||
content_encoding = VALUES(content_encoding),
|
||||
device_name = VALUES(device_name),
|
||||
user_agent = VALUES(user_agent),
|
||||
actor_ip = VALUES(actor_ip),
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':endpoint_hash' => $endpointHash,
|
||||
':endpoint' => $endpoint,
|
||||
':p256dh' => $p256dh,
|
||||
':auth' => $auth,
|
||||
':content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
|
||||
':device_name' => mb_substr($deviceName, 0, 64),
|
||||
':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
]);
|
||||
|
||||
push_log_event($exists ? 'register_update' : 'register', [
|
||||
'endpoint' => $endpoint,
|
||||
'device_name' => $deviceName,
|
||||
'content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
|
||||
]);
|
||||
}
|
||||
|
||||
function push_device_rows(): array
|
||||
{
|
||||
$stmt = db()->query("
|
||||
SELECT
|
||||
endpoint_hash,
|
||||
endpoint,
|
||||
device_name,
|
||||
content_encoding,
|
||||
user_agent,
|
||||
actor_ip,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
last_send_success_at,
|
||||
last_send_failed_at,
|
||||
last_received_at,
|
||||
last_notification_at,
|
||||
last_click_at,
|
||||
failure_count,
|
||||
last_failure_reason,
|
||||
TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS last_seen_seconds,
|
||||
TIMESTAMPDIFF(SECOND, last_send_success_at, NOW()) AS last_send_success_seconds,
|
||||
TIMESTAMPDIFF(SECOND, last_received_at, NOW()) AS last_received_seconds,
|
||||
TIMESTAMPDIFF(SECOND, last_notification_at, NOW()) AS last_notification_seconds
|
||||
FROM push_subscriptions
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT 200
|
||||
");
|
||||
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$endpoint = (string)($row['endpoint'] ?? '');
|
||||
$failureCount = (int)($row['failure_count'] ?? 0);
|
||||
$lastReceivedSeconds = isset($row['last_received_seconds']) ? (int)$row['last_received_seconds'] : null;
|
||||
$lastSendSuccessSeconds = isset($row['last_send_success_seconds']) ? (int)$row['last_send_success_seconds'] : null;
|
||||
$status = 'pending';
|
||||
$statusText = '수신 대기';
|
||||
|
||||
if ($failureCount >= 3) {
|
||||
$status = 'failed';
|
||||
$statusText = '발송 실패 누적';
|
||||
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 86400) {
|
||||
$status = 'healthy';
|
||||
$statusText = '정상';
|
||||
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 604800) {
|
||||
$status = 'watch';
|
||||
$statusText = '수신 지연';
|
||||
} elseif ($lastSendSuccessSeconds !== null) {
|
||||
$status = 'stale';
|
||||
$statusText = '장기 미수신';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)),
|
||||
'endpoint' => $endpoint,
|
||||
'host' => parse_url($endpoint, PHP_URL_HOST) ?: 'unknown',
|
||||
'device_name' => (string)($row['device_name'] ?? ''),
|
||||
'content_encoding' => (string)($row['content_encoding'] ?? ''),
|
||||
'user_agent' => (string)($row['user_agent'] ?? ''),
|
||||
'actor_ip' => (string)($row['actor_ip'] ?? ''),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
'last_seen_at' => (string)($row['last_seen_at'] ?? ''),
|
||||
'last_send_success_at' => (string)($row['last_send_success_at'] ?? ''),
|
||||
'last_send_failed_at' => (string)($row['last_send_failed_at'] ?? ''),
|
||||
'last_received_at' => (string)($row['last_received_at'] ?? ''),
|
||||
'last_notification_at' => (string)($row['last_notification_at'] ?? ''),
|
||||
'last_click_at' => (string)($row['last_click_at'] ?? ''),
|
||||
'last_seen_seconds' => $row['last_seen_seconds'] !== null ? (int)$row['last_seen_seconds'] : null,
|
||||
'last_send_success_seconds' => $lastSendSuccessSeconds,
|
||||
'last_received_seconds' => $lastReceivedSeconds,
|
||||
'last_notification_seconds' => $row['last_notification_seconds'] !== null ? (int)$row['last_notification_seconds'] : null,
|
||||
'failure_count' => $failureCount,
|
||||
'last_failure_reason' => (string)($row['last_failure_reason'] ?? ''),
|
||||
'health_status' => $status,
|
||||
'health_text' => $statusText,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function push_health_summary(): array
|
||||
{
|
||||
$devices = push_device_rows();
|
||||
$summary = [
|
||||
'total' => count($devices),
|
||||
'healthy' => 0,
|
||||
'watch' => 0,
|
||||
'stale' => 0,
|
||||
'failed' => 0,
|
||||
'pending' => 0,
|
||||
];
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$status = (string)($device['health_status'] ?? 'pending');
|
||||
if (!array_key_exists($status, $summary)) {
|
||||
$status = 'pending';
|
||||
}
|
||||
$summary[$status]++;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function push_subscription_status(string $endpoint): array
|
||||
{
|
||||
$endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : '';
|
||||
$matched = null;
|
||||
|
||||
if ($endpointHash !== '') {
|
||||
$stmt = db()->prepare("
|
||||
SELECT device_name
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint_hash = :endpoint_hash
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
$matched = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$count = (int)db()->query("SELECT COUNT(*) FROM push_subscriptions")->fetchColumn();
|
||||
|
||||
return [
|
||||
'subscriber_count' => $count,
|
||||
'subscribed' => $matched !== null,
|
||||
'device_name' => $matched['device_name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function delete_push_device(string $endpointHash): void
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_push_device',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
push_log_event('delete_device', [
|
||||
'endpoint_hash' => $endpointHash,
|
||||
'deleted' => $stmt->rowCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
function delete_push_endpoint(string $endpoint): void
|
||||
{
|
||||
if ($endpoint === '') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'invalid_push_endpoint',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
$stmt->execute([':endpoint_hash' => $endpointHash]);
|
||||
push_log_event('unregister', [
|
||||
'endpoint' => $endpoint,
|
||||
'endpoint_hash' => $endpointHash,
|
||||
'deleted' => $stmt->rowCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
function send_push_payload(array $payload): array
|
||||
{
|
||||
$pushId = (string)($payload['push_id'] ?? '');
|
||||
if ($pushId === '') {
|
||||
$pushId = bin2hex(random_bytes(12));
|
||||
$payload['push_id'] = $pushId;
|
||||
}
|
||||
$tag = (string)($payload['tag'] ?? '');
|
||||
|
||||
if (
|
||||
!class_exists(\Minishlink\WebPush\WebPush::class)
|
||||
|| !defined('VAPID_PUBLIC_KEY')
|
||||
|| !defined('VAPID_PRIVATE_KEY')
|
||||
) {
|
||||
push_log_event('send_config_missing', [
|
||||
'message' => 'web_push_not_configured',
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
]);
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'error' => 'web_push_not_configured',
|
||||
];
|
||||
}
|
||||
|
||||
$stmt = db()->query("
|
||||
SELECT endpoint, p256dh, auth, content_encoding
|
||||
FROM push_subscriptions
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT 200
|
||||
");
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
if ($rows === []) {
|
||||
push_log_event('send_no_subscribers', [
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
]);
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$webPush = new \Minishlink\WebPush\WebPush([
|
||||
'VAPID' => [
|
||||
'subject' => 'mailto:admin@' . APP_HOST,
|
||||
'publicKey' => VAPID_PUBLIC_KEY,
|
||||
'privateKey' => VAPID_PRIVATE_KEY,
|
||||
],
|
||||
]);
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
push_log_event('send_request', [
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
'subscriber_count' => count($rows),
|
||||
]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$subscription = \Minishlink\WebPush\Subscription::create([
|
||||
'endpoint' => (string)$row['endpoint'],
|
||||
'publicKey' => (string)$row['p256dh'],
|
||||
'authToken' => (string)$row['auth'],
|
||||
'contentEncoding' => (string)($row['content_encoding'] ?: 'aes128gcm'),
|
||||
]);
|
||||
|
||||
$webPush->queueNotification($subscription, $json);
|
||||
}
|
||||
|
||||
foreach ($webPush->flush() as $report) {
|
||||
$endpoint = $report->getEndpoint();
|
||||
if ($report->isSuccess()) {
|
||||
$sent++;
|
||||
push_log_event('send_success', [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$failed++;
|
||||
$reason = method_exists($report, 'getReason') ? (string)$report->getReason() : 'send_failed';
|
||||
|
||||
if ($report->isSubscriptionExpired()) {
|
||||
$delete = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
|
||||
$delete->execute([':endpoint_hash' => hash('sha256', $endpoint)]);
|
||||
push_log_event('send_expired', [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
push_log_event('send_failed', [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => $pushId,
|
||||
'push_id' => $pushId,
|
||||
'tag' => $tag,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'sent' => $sent,
|
||||
'failed' => $failed,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
function latest_push_send_epoch_by_tag(string $tagPrefix): int
|
||||
function latest_ha_notify_epoch_by_tag(string $tag): int
|
||||
{
|
||||
$stmt = db()->prepare("
|
||||
SELECT UNIX_TIMESTAMP(MAX(created_at))
|
||||
FROM push_event_logs
|
||||
FROM ha_notify_logs
|
||||
WHERE event = 'send_success'
|
||||
AND meta LIKE :tag
|
||||
AND tag = :tag
|
||||
");
|
||||
$stmt->execute([
|
||||
':tag' => '%\"tag\":\"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagPrefix) . '%',
|
||||
]);
|
||||
$stmt->execute([':tag' => $tag]);
|
||||
|
||||
return (int)($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
function send_push_healthcheck_if_due(int $minHours = 24, bool $force = false): array
|
||||
function ha_notify_due(string $tag, int $cooldownSeconds): bool
|
||||
{
|
||||
$minHours = max(1, min(168, $minHours));
|
||||
$latest = latest_push_send_epoch_by_tag('control-healthcheck-');
|
||||
$latest = latest_ha_notify_epoch_by_tag($tag);
|
||||
return $latest <= 0 || time() - $latest >= $cooldownSeconds;
|
||||
}
|
||||
|
||||
if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) {
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'error' => null,
|
||||
'skipped' => true,
|
||||
'message' => 'healthcheck_cooldown',
|
||||
'next_after_seconds' => ($minHours * 3600) - (time() - $latest),
|
||||
];
|
||||
}
|
||||
function ha_notify_default_data(string $level, string $tag, string $url = '/'): array
|
||||
{
|
||||
$level = in_array($level, ['critical', 'warning', 'info'], true) ? $level : 'info';
|
||||
$isCritical = $level === 'critical';
|
||||
$isWarning = $level === 'warning';
|
||||
$absoluteUrl = str_starts_with($url, 'http://') || str_starts_with($url, 'https://')
|
||||
? $url
|
||||
: 'https://' . APP_HOST . '/' . ltrim($url, '/');
|
||||
|
||||
return send_push_payload([
|
||||
'title' => 'Seoul Control Center',
|
||||
'body' => '푸시 기기 상태 확인 알림입니다.',
|
||||
'url' => '/',
|
||||
'tag' => 'control-healthcheck-' . date('YmdHi'),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'silent' => true,
|
||||
'data' => [
|
||||
'kind' => 'push_healthcheck',
|
||||
return [
|
||||
'ttl' => 0,
|
||||
'priority' => 'high',
|
||||
'channel' => $isCritical ? 'control_critical' : ($isWarning ? 'control_warning' : 'control_status'),
|
||||
'importance' => $isCritical ? 'max' : 'high',
|
||||
'tag' => $tag,
|
||||
'group' => 'control',
|
||||
'sticky' => $isCritical ? 'true' : 'false',
|
||||
'persistent' => $isCritical ? 'true' : 'false',
|
||||
'renotify' => 'true',
|
||||
'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'),
|
||||
'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'),
|
||||
'vibrationPattern' => $isCritical ? '900,250,900,250,1400' : '250,150,250',
|
||||
'ledColor' => $isCritical ? 'red' : ($isWarning ? 'yellow' : 'blue'),
|
||||
'visibility' => 'public',
|
||||
'timeout' => $isCritical ? 0 : 1800,
|
||||
'clickAction' => $absoluteUrl,
|
||||
'url' => $absoluteUrl,
|
||||
'actions' => [
|
||||
[
|
||||
'action' => 'URI',
|
||||
'title' => 'Control',
|
||||
'uri' => $absoluteUrl,
|
||||
],
|
||||
],
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
function reset_battery_low_push_state(): void
|
||||
function send_ha_notify(array $payload): array
|
||||
{
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO battery_push_state
|
||||
(id, last_sent_at, last_percent, last_voltage)
|
||||
VALUES
|
||||
(1, NULL, NULL, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sent_at = NULL,
|
||||
last_percent = NULL,
|
||||
last_voltage = NULL
|
||||
");
|
||||
$stmt->execute();
|
||||
}
|
||||
$title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
|
||||
$message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
|
||||
$level = (string)($payload['level'] ?? 'info');
|
||||
$tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
|
||||
$url = (string)($payload['url'] ?? '/');
|
||||
$data = ha_notify_default_data($level, $tag, $url);
|
||||
|
||||
function battery_low_push_due(float $percent, ?float $voltage): bool
|
||||
{
|
||||
$stmt = db()->query("
|
||||
SELECT last_sent_at
|
||||
FROM battery_push_state
|
||||
WHERE id = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$row = $stmt->fetch() ?: [];
|
||||
$lastSent = strtotime((string)($row['last_sent_at'] ?? '')) ?: 0;
|
||||
|
||||
if ($lastSent > 0 && time() - $lastSent < 10) {
|
||||
return false;
|
||||
if (is_array($payload['data'] ?? null)) {
|
||||
$data = array_replace_recursive($data, $payload['data']);
|
||||
}
|
||||
|
||||
$save = db()->prepare("
|
||||
INSERT INTO battery_push_state
|
||||
(id, last_sent_at, last_percent, last_voltage)
|
||||
VALUES
|
||||
(1, CURRENT_TIMESTAMP, :last_percent, :last_voltage)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sent_at = CURRENT_TIMESTAMP,
|
||||
last_percent = VALUES(last_percent),
|
||||
last_voltage = VALUES(last_voltage)
|
||||
");
|
||||
$save->execute([
|
||||
':last_percent' => round($percent, 2),
|
||||
':last_voltage' => $voltage === null ? null : round($voltage, 3),
|
||||
]);
|
||||
$body = json_encode([
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return true;
|
||||
$attempts = 0;
|
||||
foreach (ha_notify_targets() as $target) {
|
||||
$name = (string)($target['name'] ?? 'ha');
|
||||
$base = rtrim((string)($target['base'] ?? $target['base_url'] ?? $target['url'] ?? ''), '/');
|
||||
if ($base === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attempts++;
|
||||
$ch = curl_init($base . '/api/webhook/' . rawurlencode(HA_NOTIFY_WEBHOOK_ID));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = $response === false ? curl_error($ch) : null;
|
||||
curl_close($ch);
|
||||
|
||||
if ($error === null && $httpCode >= 200 && $httpCode < 300) {
|
||||
ha_notify_log('send_success', [
|
||||
'target' => $name,
|
||||
'title' => $title,
|
||||
'tag' => $tag,
|
||||
'http_code' => $httpCode,
|
||||
'data' => $data,
|
||||
]);
|
||||
return [
|
||||
'sent' => 1,
|
||||
'failed' => 0,
|
||||
'target' => $name,
|
||||
'http_code' => $httpCode,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
ha_notify_log('send_failed', [
|
||||
'target' => $name,
|
||||
'title' => $title,
|
||||
'tag' => $tag,
|
||||
'http_code' => $httpCode,
|
||||
'error' => $error ?: ('HTTP ' . $httpCode),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => $attempts,
|
||||
'target' => null,
|
||||
'http_code' => null,
|
||||
'error' => 'ha_notify_failed',
|
||||
];
|
||||
}
|
||||
|
||||
function battery_soc_rising_from_history(float $percent): bool
|
||||
@@ -1118,7 +655,7 @@ function battery_soc_rising_from_history(float $percent): bool
|
||||
&& ($percent - $olderAvg) >= 0.15;
|
||||
}
|
||||
|
||||
function send_battery_low_push_if_needed(array $battery): array
|
||||
function send_battery_low_notify_if_needed(array $battery): array
|
||||
{
|
||||
$percent = $battery['percent'] ?? null;
|
||||
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
||||
@@ -1135,7 +672,6 @@ function send_battery_low_push_if_needed(array $battery): array
|
||||
: null;
|
||||
|
||||
if ($percent > 20.0) {
|
||||
reset_battery_low_push_state();
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
@@ -1143,15 +679,8 @@ function send_battery_low_push_if_needed(array $battery): array
|
||||
];
|
||||
}
|
||||
|
||||
if (battery_soc_rising_from_history($percent)) {
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 'battery_soc_rising',
|
||||
];
|
||||
}
|
||||
|
||||
if (!battery_low_push_due($percent, $voltage)) {
|
||||
$tag = 'control-battery-low';
|
||||
if (!ha_notify_due($tag, 10)) {
|
||||
return [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
@@ -1165,17 +694,22 @@ function send_battery_low_push_if_needed(array $battery): array
|
||||
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
|
||||
. "\n20% 이하 상태입니다.";
|
||||
|
||||
return send_push_payload([
|
||||
return send_ha_notify([
|
||||
'title' => '배터리 SOC 경고',
|
||||
'body' => $body,
|
||||
'message' => $body,
|
||||
'level' => 'critical',
|
||||
'url' => '/',
|
||||
'tag' => 'control-battery-low',
|
||||
'renotify' => false,
|
||||
'require_interaction' => true,
|
||||
'silent' => false,
|
||||
'vibrate' => [900, 250, 900, 250, 1400],
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'tag' => $tag,
|
||||
'data' => [
|
||||
'channel' => 'control_battery',
|
||||
'notification_icon' => 'mdi:battery-alert',
|
||||
'color' => '#ff3b30',
|
||||
'sticky' => 'true',
|
||||
'persistent' => 'true',
|
||||
'renotify' => 'true',
|
||||
'importance' => 'high',
|
||||
'vibrationPattern' => '900,250,900,250,1400',
|
||||
'ledColor' => 'red',
|
||||
'battery_percent' => round($percent, 2),
|
||||
'battery_voltage' => $voltage === null ? null : round($voltage, 3),
|
||||
],
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$controlSecretConfig = $controlSecretConfig ?? (is_file('/home/seo/secret/control.php') ? require '/home/seo/secret/control.php' : []);
|
||||
$controlVapidConfig = is_array($controlSecretConfig['vapid'] ?? null) ? $controlSecretConfig['vapid'] : [];
|
||||
|
||||
if (!defined('VAPID_PUBLIC_KEY')) {
|
||||
define('VAPID_PUBLIC_KEY', (string)($controlVapidConfig['public_key'] ?? ''));
|
||||
}
|
||||
|
||||
if (!defined('VAPID_PRIVATE_KEY')) {
|
||||
define('VAPID_PRIVATE_KEY', (string)($controlVapidConfig['private_key'] ?? ''));
|
||||
}
|
||||
+22
-78
@@ -1275,7 +1275,7 @@ function signed_delta_compact(float $value, int $decimals, string $suffix = ''):
|
||||
return $sign . number_format($value, $decimals) . $suffix;
|
||||
}
|
||||
|
||||
function push_process_name(array $row): string
|
||||
function display_process_name(array $row): string
|
||||
{
|
||||
$service = (string)($row['service'] ?? '');
|
||||
if ($service !== '' && $service !== 'N/A') {
|
||||
@@ -1344,7 +1344,7 @@ function expected_process_text(array $processes): string
|
||||
}
|
||||
|
||||
if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 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)));
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out(['ok' => 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]);
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (!signed_in()) {
|
||||
json_out([
|
||||
'ok' => 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,
|
||||
]);
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (!signed_in()) {
|
||||
json_out([
|
||||
'ok' => 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),
|
||||
]);
|
||||
@@ -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 @@
|
||||
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="push-device-row ${escapeHtml(healthStatus)}">
|
||||
<div class="push-device-main">
|
||||
<strong>${escapeHtml(device.device_name || t('unnamed'))}</strong>
|
||||
<span class="push-device-badge ${escapeHtml(healthStatus)}">${escapeHtml(healthText)}</span>
|
||||
<span>${escapeHtml(t('hostLabel'))}: ${escapeHtml(device.host || 'unknown')}</span>
|
||||
<span>${escapeHtml(t('ipLabel'))}: ${escapeHtml(device.actor_ip || '-')}</span>
|
||||
<span>${escapeHtml(t('created'))}: ${escapeHtml(device.created_at || '-')}</span>
|
||||
<span>${escapeHtml(t('registeredRefresh'))}: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastSendSuccess'))}: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastReceived'))}: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastShown'))}: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastClick'))}: ${escapeHtml(device.last_click_at || '-')}</span>
|
||||
<span>${escapeHtml(t('failures'))}: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span>
|
||||
<span>${escapeHtml(t('encoding'))}: ${escapeHtml(device.content_encoding || '-')}</span>
|
||||
<span>${escapeHtml(t('hash'))}: ${escapeHtml(device.hash || '-')}</span>
|
||||
<span>${escapeHtml(t('endpoint'))}: ${escapeHtml(device.endpoint || '-')}</span>
|
||||
<span>${escapeHtml(t('userAgent'))}: ${escapeHtml(device.user_agent || t('unknownDevice'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')
|
||||
: `<div class="spike-log-empty">${escapeHtml(t('noPushDevices'))}</div>`;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
|
||||
+1
-33
@@ -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;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
@@ -61,7 +45,6 @@ if ($loggedIn && array_key_exists('push', $_GET)) {
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="<?= e($csrf) ?>">
|
||||
<meta name="vapid-public-key" content="<?= e(vapid_public_key()) ?>">
|
||||
<title><?= e(APP_NAME) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -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
|
||||
<p><span data-i18n="updated">Updated</span>: <span id="updatedAt">loading...</span></p>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button id="pushEnableBtn" class="btn secondary" type="button">Push</button>
|
||||
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
|
||||
<button id="translateBtn" class="btn secondary" type="button">Translate</button>
|
||||
<button id="themeBtn" class="btn secondary" type="button">Theme</button>
|
||||
@@ -289,19 +271,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<div class="push-device-head">
|
||||
<h3 data-i18n="pushDevices">Push Devices</h3>
|
||||
<div class="push-device-actions">
|
||||
<button class="btn secondary" id="pushHealthcheckBtn" type="button" data-i18n="healthCheck">Health Check</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pushStatus" class="spike-log-empty" data-i18n="pushStatusChecking">Push status checking...</div>
|
||||
<div id="pushDeviceList" class="push-device-list">
|
||||
<div class="spike-log-empty" data-i18n="noPushDevices">No push devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<div class="dmesg-head">
|
||||
<h3>dmesg</h3>
|
||||
@@ -326,7 +295,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
</div>
|
||||
<script src="/assets/app.js?v=20260618_throttling2"></script>
|
||||
<script src="/assets/wakelock.js?v=20260607_prefs1"></script>
|
||||
<script src="/push_subscribe.js?v=20260607_prefs1"></script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
-123
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user