HA 푸시 알림 경로를 정리
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Control
|
# Control
|
||||||
|
|
||||||
팬 제어, 시스템 모니터링, WiFi 제어, WakeLock, Home Assistant 알림을 제공하는 PHP 기반 단일 관리 패널입니다.
|
팬 제어, 시스템 모니터링, WiFi 제어, WakeLock을 제공하는 PHP 기반 단일 관리 패널입니다.
|
||||||
|
|
||||||
## 프로젝트 성격
|
## 프로젝트 성격
|
||||||
|
|
||||||
@@ -26,10 +26,6 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시
|
- process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시
|
||||||
- `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시
|
- `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시
|
||||||
- dmesg 로그는 진단 접힘 영역에서 필요할 때 열람
|
- dmesg 로그는 진단 접힘 영역에서 필요할 때 열람
|
||||||
- Home Assistant webhook 기반 Android 모바일 알림
|
|
||||||
- 배터리 SOC 20% 이하 경고를 20/15/10/5% 단계로 구분하고 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리
|
|
||||||
- 배터리 SOC가 20%를 초과해 복구되면 같은 `tag`의 Android 알림을 `clear_notification`으로 제거
|
|
||||||
- 최근 HA 알림 발송 성공/실패 이력은 진단 접힘 영역에 표시
|
|
||||||
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
|
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
|
||||||
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
|
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
|
||||||
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
|
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
|
||||||
@@ -56,10 +52,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `public/api.php`: 상태 조회와 조작 API
|
- `public/api.php`: 상태 조회와 조작 API
|
||||||
- `public/assets/app.js`: 대시보드 렌더링, WebSocket, 차트, 조작 이벤트
|
- `public/assets/app.js`: 대시보드 렌더링, WebSocket, 차트, 조작 이벤트
|
||||||
- `public/assets/wakelock.js`: Screen Wake Lock API 제어
|
- `public/assets/wakelock.js`: Screen Wake Lock API 제어
|
||||||
- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
|
- `config/config.php`: DB, 인증, CSRF, shell 실행 공통 함수
|
||||||
- `apply_policy.php`: CLI/cron 팬 정책 적용
|
- `apply_policy.php`: CLI/cron 팬 정책 적용
|
||||||
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
|
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
|
||||||
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 전송용 운영 CLI
|
|
||||||
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
|
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
|
||||||
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
|
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
|
||||||
|
|
||||||
@@ -74,8 +69,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
|
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
|
||||||
- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명
|
- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명
|
||||||
- `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드
|
- `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드
|
||||||
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정
|
||||||
- `/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`를 사용합니다.
|
- 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다.
|
||||||
|
|
||||||
## 배터리 잔여 시간 계산
|
## 배터리 잔여 시간 계산
|
||||||
@@ -116,8 +110,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
|
|
||||||
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
||||||
- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부
|
- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부
|
||||||
- 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운
|
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수
|
||||||
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수
|
|
||||||
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
||||||
- 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부
|
- 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부
|
||||||
- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치, 잔여시간 현실 상한, 최소 시스템 전력, 상한 초과 후보 감쇠, 에너지/전압 모델 사용 여부, 상태/잔여시간 차트 표본 수
|
- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치, 잔여시간 현실 상한, 최소 시스템 전력, 상한 초과 후보 감쇠, 에너지/전압 모델 사용 여부, 상태/잔여시간 차트 표본 수
|
||||||
@@ -136,38 +129,20 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
|
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
|
||||||
- 팬 슬라이더 자동 적용 debounce: 약 450ms
|
- 팬 슬라이더 자동 적용 debounce: 약 450ms
|
||||||
|
|
||||||
## HA 알림 구조
|
## System Notice 구조
|
||||||
|
|
||||||
Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다.
|
Control은 급격한 팬/온도 변화를 외부 푸시로 보내지 않고 `system_notice_logs`에 로컬 이력으로 저장합니다. 대시보드는 최근 이력을 진단 영역에서 보여주며, 배터리 SOC 낮음 같은 모바일 푸시 발송은 더 이상 수행하지 않습니다.
|
||||||
|
|
||||||
- webhook id: `/home/seo/secret/control.php`의 `ha_notify.webhook_id`
|
|
||||||
- 기본 발송 순서: 서울 HA `https://ha.seoul.chaegeon.com` 우선, 실패 시 목포 HA `https://ha.chaegeon.com`
|
|
||||||
- payload 형식: `title`, `message`, `data`
|
|
||||||
- Android 알림 속성은 `data`에 넣어 HA mobile app으로 전달
|
|
||||||
- 사용 속성: `tag`, `group`, `channel`, `importance`, `priority`, `ttl`, `sticky`, `persistent`, `renotify`, `color`, `notification_icon`, `vibrationPattern`, `ledColor`, `visibility`, `timeout`, `clickAction`, `url`, `actions`
|
|
||||||
- 발송 결과는 `ha_notify_logs`에 저장
|
|
||||||
|
|
||||||
배터리 SOC 낮음 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 주의/경고/위험/긴급/복구 기준과 알림 쿨다운은 설정 모달의 알림 정책에서 조정합니다.
|
|
||||||
|
|
||||||
System Notice는 `control-system-notice` tag를 사용하며, 설정된 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. alert 상태는 신규 진입과 유지 상태를 분리해 관리하며, alert 유지 중 반복 기록 여부와 기록 간격도 설정 모달에서 조정합니다.
|
|
||||||
|
|
||||||
### 시스템 알림 조건
|
### 시스템 알림 조건
|
||||||
|
|
||||||
배터리 알림과 System Notice는 서로 다른 조건으로 동작합니다.
|
System Notice는 다음 조건으로 동작합니다.
|
||||||
|
|
||||||
- 배터리 알림: 현재 배터리 SOC가 설정값 이하로 내려가면 발송합니다. 기본 단계는 20% 이하 주의, 15% 이하 경고, 10% 이하 위험, 5% 이하 긴급입니다. 같은 `control-battery-low` tag를 계속 갱신하므로 SOC 값이 1%씩 바뀌어도 알림 카드가 계속 쌓이지 않습니다. SOC가 복구 기준을 넘으면 같은 tag를 `clear_notification`으로 제거합니다.
|
|
||||||
- System Notice 시작: 팬 모드가 `off`가 아닐 때, 최근 안정 기준선 대비 팬 RPM 차이가 `팬 RPM 이상 감지 차이` 이상이거나 온도 차이가 `온도 이상 감지 차이` 이상이면 alert로 진입합니다. RPM 감시는 현재 PWM 또는 기준 PWM이 `RPM 감시 시작 PWM` 이상일 때만 의미 있는 변화로 봅니다.
|
- System Notice 시작: 팬 모드가 `off`가 아닐 때, 최근 안정 기준선 대비 팬 RPM 차이가 `팬 RPM 이상 감지 차이` 이상이거나 온도 차이가 `온도 이상 감지 차이` 이상이면 alert로 진입합니다. RPM 감시는 현재 PWM 또는 기준 PWM이 `RPM 감시 시작 PWM` 이상일 때만 의미 있는 변화로 봅니다.
|
||||||
- System Notice 기준선: normal 상태에서는 최근 sensor history에서 최신 몇 개 표본을 제외하고 절사 평균으로 기준선을 계속 갱신합니다. alert로 들어가면 진입 당시 온도/RPM/PWM 기준선을 고정해, alert 중 기준선이 문제 상태를 따라가며 사라지는 일을 막습니다.
|
- System Notice 기준선: normal 상태에서는 최근 sensor history에서 최신 몇 개 표본을 제외하고 절사 평균으로 기준선을 계속 갱신합니다. alert로 들어가면 진입 당시 온도/RPM/PWM 기준선을 고정해, alert 중 기준선이 문제 상태를 따라가며 사라지는 일을 막습니다.
|
||||||
- System Notice 복구: 고정 기준선 또는 최신 rolling 기준선 대비 RPM/온도 차이가 각각 복구 차이 이하로 내려가면 normal로 돌아갑니다. 팬 모드가 `off`이면 fan off 기준선을 별도로 고정하고 System Notice alert를 만들지 않습니다.
|
- System Notice 복구: 고정 기준선 또는 최신 rolling 기준선 대비 RPM/온도 차이가 각각 복구 차이 이하로 내려가면 normal로 돌아갑니다. 팬 모드가 `off`이면 fan off 기준선을 별도로 고정하고 System Notice alert를 만들지 않습니다.
|
||||||
- System Notice 기록/발송: alert 신규 진입 시 `system_notice_logs`에 기록합니다. `Alert 유지 중 반복 기록`을 켜면 유지 중에도 설정한 반복 기록 간격마다 다시 기록할 수 있습니다. HA 알림은 `시스템 유의사항 알림 쿨다운`을 통과할 때만 발송합니다.
|
- System Notice 기록: alert 신규 진입 시 `system_notice_logs`에 기록합니다. `Alert 유지 중 반복 기록`을 켜면 유지 중에도 설정한 반복 기록 간격마다 다시 기록할 수 있습니다.
|
||||||
- 저전압/스로틀링 표시: `vcgencmd get_throttled`의 현재 비트와 부팅 후 이력 비트를 읽어 현재 상태, 최근 감지, 지속시간, 최근 10분 통계를 표시합니다. 이 값은 System Notice와 별개이며, 팬/온도 alert 조건에는 직접 섞지 않습니다.
|
- 저전압/스로틀링 표시: `vcgencmd get_throttled`의 현재 비트와 부팅 후 이력 비트를 읽어 현재 상태, 최근 감지, 지속시간, 최근 10분 통계를 표시합니다. 이 값은 System Notice와 별개이며, 팬/온도 alert 조건에는 직접 섞지 않습니다.
|
||||||
|
|
||||||
Android 알림 채널은 생성 후 휴대폰 설정에 의해 중요도와 진동 패턴이 고정될 수 있습니다. 채널을 다시 만들 필요가 있을 때는 운영 CLI로 제거 명령을 보낸 뒤 다음 알림에서 재생성합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/usr/bin/php /var/www/control/bin/ha_notify_channel.php --remove=control_battery
|
|
||||||
```
|
|
||||||
|
|
||||||
## WiFi 관측 자동화
|
## WiFi 관측 자동화
|
||||||
|
|
||||||
5G 외부 WiFi 모듈은 `iw station dump`에 `connected time`을 제공하지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다.
|
5G 외부 WiFi 모듈은 `iw station dump`에 `connected time`을 제공하지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다.
|
||||||
@@ -185,7 +160,7 @@ control-wifi-observe.service
|
|||||||
/usr/bin/php /var/www/control/bin/wifi_observe.php
|
/usr/bin/php /var/www/control/bin/wifi_observe.php
|
||||||
```
|
```
|
||||||
|
|
||||||
이 작업은 팬 정책, 차트 로그 저장, HA 알림 발송과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다.
|
이 작업은 팬 정책, 차트 로그 저장, System Notice 기록과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다.
|
||||||
|
|
||||||
호스트명은 dnsmasq lease 값을 우선 사용합니다. lease hostname이 `N/A`이면 MAC 주소 끝 6자리 기반 임시명 `기기-XXXXXX`을 표시하고, 표의 호스트명을 클릭해 저장한 수동 이름은 `wifi_client_aliases`에 보관해 이후에도 우선 표시합니다.
|
호스트명은 dnsmasq lease 값을 우선 사용합니다. lease hostname이 `N/A`이면 MAC 주소 끝 6자리 기반 임시명 `기기-XXXXXX`을 표시하고, 표의 호스트명을 클릭해 저장한 수동 이름은 `wifi_client_aliases`에 보관해 이후에도 우선 표시합니다.
|
||||||
|
|
||||||
@@ -207,9 +182,8 @@ control-wifi-observe.service
|
|||||||
12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
||||||
13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화와 현실 상한을 반영합니다.
|
13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화와 현실 상한을 반영합니다.
|
||||||
14. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다.
|
14. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 잔여 시간과 같은 값을 사용하도록 history 응답을 동기화합니다.
|
||||||
15. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
15. System Notice 조건이 맞으면 Control이 `system_notice_logs`에 이력을 저장합니다.
|
||||||
16. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
16. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, dmesg는 접힘 영역으로 분리합니다.
|
||||||
17. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
|
||||||
|
|
||||||
## 주요 함수/모듈
|
## 주요 함수/모듈
|
||||||
|
|
||||||
@@ -224,12 +198,6 @@ control-wifi-observe.service
|
|||||||
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
|
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
|
||||||
- `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장
|
- `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장
|
||||||
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
|
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
|
||||||
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
|
|
||||||
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
|
|
||||||
- `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송
|
|
||||||
- `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택
|
|
||||||
- `send_battery_low_notify_if_needed()`: 설정된 배터리 SOC 기준에 따라 낮음/복구 알림 발송
|
|
||||||
- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회
|
|
||||||
- `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계
|
- `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계
|
||||||
- `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관
|
- `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관
|
||||||
- `weighted_linear_regression()`, `numeric_mad()`: SOC 기울기 계산과 회귀 잔차 튐 제거
|
- `weighted_linear_regression()`, `numeric_mad()`: SOC 기울기 계산과 회귀 잔차 튐 제거
|
||||||
@@ -245,7 +213,6 @@ control-wifi-observe.service
|
|||||||
- `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화
|
- `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화
|
||||||
- `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신
|
- `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신
|
||||||
- `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화
|
- `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화
|
||||||
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI
|
|
||||||
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
|
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
|
||||||
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
|
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
|
||||||
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
|
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
|
||||||
@@ -261,8 +228,7 @@ control-wifi-observe.service
|
|||||||
- 로그인 화면은 CSRF 검사를 수행하고, 설정한 실패 횟수/집계 시간/잠금 시간에 따라 세션 단위 잠금을 적용합니다.
|
- 로그인 화면은 CSRF 검사를 수행하고, 설정한 실패 횟수/집계 시간/잠금 시간에 따라 세션 단위 잠금을 적용합니다.
|
||||||
- Remember login은 설정에 따라 Secure/SameSite, 토큰 길이, 만료 기간, User-Agent 고정, IPv4 접두사 고정을 조정할 수 있습니다.
|
- Remember login은 설정에 따라 Secure/SameSite, 토큰 길이, 만료 기간, User-Agent 고정, IPv4 접두사 고정을 조정할 수 있습니다.
|
||||||
- WiFi restart/reload 허용 여부와 root 명령 timeout은 설정 모달에서 조정합니다.
|
- WiFi restart/reload 허용 여부와 root 명령 timeout은 설정 모달에서 조정합니다.
|
||||||
- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다.
|
- 앱 비밀번호와 DB/배터리 설정은 저장소 밖 secret 파일로 관리합니다.
|
||||||
- HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다.
|
|
||||||
|
|
||||||
## 운영 체크포인트
|
## 운영 체크포인트
|
||||||
|
|
||||||
@@ -276,9 +242,7 @@ control-wifi-observe.service
|
|||||||
- `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다.
|
- `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은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다.
|
||||||
- WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다.
|
- WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다.
|
||||||
- HA 알림 자동화는 서울 `/home/seo/homeassistant/automations.yaml`, 목포 `/mnt/synology-docker/homeassistant/config/automations.yaml` 양쪽의 `CONTROL_HA_NOTIFY_WEBHOOK` 계열 항목에서 관리합니다.
|
|
||||||
- SmartThings 재로드 자동화는 서울 전력분전반과 목포 누전차단기 전력 센서의 값이 10초 이상 변하지 않으면 통합을 재로드합니다.
|
- SmartThings 재로드 자동화는 서울 전력분전반과 목포 누전차단기 전력 센서의 값이 10초 이상 변하지 않으면 통합을 재로드합니다.
|
||||||
- HA 알림 채널의 중요도나 진동이 예상과 다르면 휴대폰 알림 채널 설정을 확인하거나 `bin/ha_notify_channel.php`로 해당 채널 제거 명령을 보냅니다.
|
|
||||||
- Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다.
|
- Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다.
|
||||||
- 저전압/스로틀링이 `N/A`로 보이면 웹 서버 실행 계정의 `/dev/vcio` 접근 권한과 `/usr/bin/vcgencmd get_throttled` sudoers 허용 여부를 확인합니다.
|
- 저전압/스로틀링이 `N/A`로 보이면 웹 서버 실행 계정의 `/dev/vcio` 접근 권한과 `/usr/bin/vcgencmd get_throttled` sudoers 허용 여부를 확인합니다.
|
||||||
- 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다.
|
- 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다.
|
||||||
|
|||||||
@@ -240,8 +240,6 @@ try {
|
|||||||
'hostname' => gethostname() ?: null,
|
'hostname' => gethostname() ?: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
send_battery_low_notify_if_needed($battery);
|
|
||||||
|
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
add_fan_action(
|
add_fan_action(
|
||||||
'apply_policy_failed',
|
'apply_policy_failed',
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../config/config.php';
|
|
||||||
|
|
||||||
$channel = '';
|
|
||||||
foreach (array_slice($argv, 1) as $arg) {
|
|
||||||
if (str_starts_with($arg, '--remove=')) {
|
|
||||||
$channel = trim(substr($arg, 9));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($channel === '') {
|
|
||||||
fwrite(STDERR, "Usage: php bin/ha_notify_channel.php --remove=control_battery\n");
|
|
||||||
exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = send_ha_notify([
|
|
||||||
'title' => 'HA 알림 채널 제거',
|
|
||||||
'message' => 'remove_channel',
|
|
||||||
'level' => 'info',
|
|
||||||
'tag' => 'control-channel-remove-' . preg_replace('/[^a-zA-Z0-9_-]+/', '-', $channel),
|
|
||||||
'data' => [
|
|
||||||
'channel' => $channel,
|
|
||||||
'notification_icon' => 'mdi:notification-clear-all',
|
|
||||||
'persistent' => false,
|
|
||||||
'sticky' => 'false',
|
|
||||||
'renotify' => 'false',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
|
||||||
exit(($result['sent'] ?? 0) > 0 ? 0 : 1);
|
|
||||||
@@ -14,7 +14,6 @@ if (!is_file($controlSecretFile)) {
|
|||||||
$controlSecretConfig = require $controlSecretFile;
|
$controlSecretConfig = require $controlSecretFile;
|
||||||
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
|
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
|
||||||
$controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : [];
|
$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('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? ''));
|
||||||
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
|
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
|
||||||
@@ -22,10 +21,6 @@ define('DB_PORT', (int)($controlDbConfig['port'] ?? 3306));
|
|||||||
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
|
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
|
||||||
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
|
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
|
||||||
define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
|
define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
|
||||||
define('HA_NOTIFY_WEBHOOK_ID', (string)($controlHaNotifyConfig['webhook_id'] ?? ''));
|
|
||||||
define('HA_NOTIFY_BATTERY_LOW_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['battery_low'] ?? 10)));
|
|
||||||
define('HA_NOTIFY_BATTERY_CLEAR_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['battery_clear'] ?? 300)));
|
|
||||||
define('HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['system_notice'] ?? 600)));
|
|
||||||
|
|
||||||
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
|
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
|
||||||
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
|
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
|
||||||
@@ -171,18 +166,10 @@ function setting_definitions(): array
|
|||||||
'notice.log_during_alert' => ['group' => 'notice', 'label' => 'Alert 유지 중 반복 기록', 'type' => 'boolean', 'default' => 0],
|
'notice.log_during_alert' => ['group' => 'notice', 'label' => 'Alert 유지 중 반복 기록', 'type' => 'boolean', 'default' => 0],
|
||||||
'notice.log_interval_seconds' => ['group' => 'notice', 'label' => 'Alert 반복 기록 간격', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'],
|
'notice.log_interval_seconds' => ['group' => 'notice', 'label' => 'Alert 반복 기록 간격', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'],
|
||||||
'notice.downward_only_process_hide' => ['group' => 'notice', 'label' => '하강 변화 원인 후보 숨김', 'type' => 'boolean', 'default' => 1],
|
'notice.downward_only_process_hide' => ['group' => 'notice', 'label' => '하강 변화 원인 후보 숨김', 'type' => 'boolean', 'default' => 1],
|
||||||
'notify.battery_emergency_percent' => ['group' => 'notify', 'label' => '배터리 긴급 기준', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 30, 'step' => 0.5, 'unit' => '%'],
|
|
||||||
'notify.battery_critical_percent' => ['group' => 'notify', 'label' => '배터리 위험 기준', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 40, 'step' => 0.5, 'unit' => '%'],
|
|
||||||
'notify.battery_warning_percent' => ['group' => 'notify', 'label' => '배터리 경고 기준', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 50, 'step' => 0.5, 'unit' => '%'],
|
|
||||||
'notify.battery_clear_percent' => ['group' => 'notify', 'label' => '배터리 복구 기준', 'type' => 'number', 'default' => 20, 'min' => 2, 'max' => 80, 'step' => 0.5, 'unit' => '%'],
|
|
||||||
'notify.battery_low_cooldown_seconds' => ['group' => 'notify', 'label' => '배터리 낮음 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_BATTERY_LOW_COOLDOWN, 'min' => 1, 'max' => 86400, 'step' => 1, 'unit' => '초'],
|
|
||||||
'notify.battery_clear_cooldown_seconds' => ['group' => 'notify', 'label' => '배터리 복구 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_BATTERY_CLEAR_COOLDOWN, 'min' => 1, 'max' => 86400, 'step' => 1, 'unit' => '초'],
|
|
||||||
'notify.system_notice_cooldown_seconds' => ['group' => 'notify', 'label' => '시스템 유의사항 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN, 'min' => 10, 'max' => 86400, 'step' => 10, 'unit' => '초'],
|
|
||||||
'display.process_limit' => ['group' => 'display', 'label' => '프로세스 후보 표시 수', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '개'],
|
'display.process_limit' => ['group' => 'display', 'label' => '프로세스 후보 표시 수', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '개'],
|
||||||
'display.custom_service_log_lines' => ['group' => 'display', 'label' => '사용자 서비스 로그 줄 수', 'type' => 'number', 'default' => 12, 'min' => 1, 'max' => 50, 'step' => 1, 'unit' => '줄'],
|
'display.custom_service_log_lines' => ['group' => 'display', 'label' => '사용자 서비스 로그 줄 수', 'type' => 'number', 'default' => 12, 'min' => 1, 'max' => 50, 'step' => 1, 'unit' => '줄'],
|
||||||
'display.custom_service_cache_seconds' => ['group' => 'display', 'label' => '사용자 서비스 캐시 시간', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 60, 'step' => 1, 'unit' => '초'],
|
'display.custom_service_cache_seconds' => ['group' => 'display', 'label' => '사용자 서비스 캐시 시간', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 60, 'step' => 1, 'unit' => '초'],
|
||||||
'display.fan_notice_history_limit' => ['group' => 'display', 'label' => '팬 이상 이력 표시 수', 'type' => 'number', 'default' => 100, 'min' => 10, 'max' => 500, 'step' => 10, 'unit' => '개'],
|
'display.fan_notice_history_limit' => ['group' => 'display', 'label' => '팬 이상 이력 표시 수', 'type' => 'number', 'default' => 100, 'min' => 10, 'max' => 500, 'step' => 10, 'unit' => '개'],
|
||||||
'display.ha_notify_history_limit' => ['group' => 'display', 'label' => 'HA 알림 이력 표시 수', 'type' => 'number', 'default' => 20, 'min' => 5, 'max' => 100, 'step' => 5, 'unit' => '개'],
|
|
||||||
'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'],
|
'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'],
|
||||||
'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'],
|
'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'],
|
||||||
'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
||||||
@@ -542,28 +529,6 @@ function bootstrap_db(): void
|
|||||||
COLLATE=utf8mb4_unicode_ci
|
COLLATE=utf8mb4_unicode_ci
|
||||||
");
|
");
|
||||||
|
|
||||||
$pdo->exec("
|
|
||||||
CREATE TABLE IF NOT EXISTS ha_notify_logs (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
event VARCHAR(32) NOT NULL,
|
|
||||||
target VARCHAR(16) NULL,
|
|
||||||
title VARCHAR(255) NULL,
|
|
||||||
tag VARCHAR(128) NULL,
|
|
||||||
actor_ip VARCHAR(64) NULL,
|
|
||||||
http_code INT NULL,
|
|
||||||
error VARCHAR(255) NULL,
|
|
||||||
meta JSON NULL,
|
|
||||||
|
|
||||||
INDEX idx_created_at (created_at),
|
|
||||||
INDEX idx_event (event),
|
|
||||||
INDEX idx_tag (tag)
|
|
||||||
) ENGINE=InnoDB
|
|
||||||
DEFAULT CHARSET=utf8mb4
|
|
||||||
COLLATE=utf8mb4_unicode_ci
|
|
||||||
");
|
|
||||||
|
|
||||||
$pdo->exec("
|
$pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS system_notice_state (
|
CREATE TABLE IF NOT EXISTS system_notice_state (
|
||||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||||
@@ -704,421 +669,6 @@ function json_out(array $payload, int $status = 200): never
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ha_notify_targets(): array
|
|
||||||
{
|
|
||||||
global $controlHaNotifyConfig;
|
|
||||||
|
|
||||||
$configured = is_array($controlHaNotifyConfig['targets'] ?? null) ? $controlHaNotifyConfig['targets'] : [];
|
|
||||||
if ($configured !== []) {
|
|
||||||
return $configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
['name' => 'seoul', 'base' => 'https://ha.seoul.chaegeon.com'],
|
|
||||||
['name' => 'main', 'base' => 'https://ha.chaegeon.com'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ha_notify_log(string $event, array $context = []): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$stmt = db()->prepare("
|
|
||||||
INSERT INTO ha_notify_logs (
|
|
||||||
event,
|
|
||||||
target,
|
|
||||||
title,
|
|
||||||
tag,
|
|
||||||
actor_ip,
|
|
||||||
http_code,
|
|
||||||
error,
|
|
||||||
meta
|
|
||||||
) VALUES (
|
|
||||||
:event,
|
|
||||||
:target,
|
|
||||||
:title,
|
|
||||||
:tag,
|
|
||||||
:actor_ip,
|
|
||||||
:http_code,
|
|
||||||
:error,
|
|
||||||
:meta
|
|
||||||
)
|
|
||||||
");
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
':event' => mb_substr($event, 0, 32),
|
|
||||||
':target' => isset($context['target']) ? mb_substr((string)$context['target'], 0, 16) : null,
|
|
||||||
':title' => isset($context['title']) ? mb_substr((string)$context['title'], 0, 255) : null,
|
|
||||||
':tag' => isset($context['tag']) ? mb_substr((string)$context['tag'], 0, 128) : null,
|
|
||||||
':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64),
|
|
||||||
':http_code' => isset($context['http_code']) ? (int)$context['http_code'] : null,
|
|
||||||
':error' => isset($context['error']) ? mb_substr((string)$context['error'], 0, 255) : null,
|
|
||||||
':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
||||||
]);
|
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function latest_ha_notify_epoch_by_tag(string $tag): int
|
|
||||||
{
|
|
||||||
$stmt = db()->prepare("
|
|
||||||
SELECT UNIX_TIMESTAMP(MAX(created_at))
|
|
||||||
FROM ha_notify_logs
|
|
||||||
WHERE event = 'send_success'
|
|
||||||
AND tag = :tag
|
|
||||||
");
|
|
||||||
$stmt->execute([':tag' => $tag]);
|
|
||||||
|
|
||||||
return (int)($stmt->fetchColumn() ?: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ha_notify_due(string $tag, int $cooldownSeconds): bool
|
|
||||||
{
|
|
||||||
$latest = latest_ha_notify_epoch_by_tag($tag);
|
|
||||||
return $latest <= 0 || time() - $latest >= $cooldownSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ha_notify_default_data(string $level, string $tag, string $url = '/'): array
|
|
||||||
{
|
|
||||||
$level = in_array($level, ['critical', 'warning', 'info'], true) ? $level : 'info';
|
|
||||||
$isCritical = $level === 'critical';
|
|
||||||
$isWarning = $level === 'warning';
|
|
||||||
$absoluteUrl = str_starts_with($url, 'http://') || str_starts_with($url, 'https://')
|
|
||||||
? $url
|
|
||||||
: 'https://' . APP_HOST . '/' . ltrim($url, '/');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ttl' => 0,
|
|
||||||
'priority' => 'high',
|
|
||||||
'channel' => $isCritical ? 'control_critical' : ($isWarning ? 'control_warning' : 'control_status'),
|
|
||||||
'importance' => $isCritical ? 'max' : 'high',
|
|
||||||
'tag' => $tag,
|
|
||||||
'group' => 'control',
|
|
||||||
'sticky' => $isCritical ? 'true' : 'false',
|
|
||||||
'persistent' => $isCritical,
|
|
||||||
'renotify' => 'true',
|
|
||||||
'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'),
|
|
||||||
'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'),
|
|
||||||
'vibrationPattern' => $isCritical ? '900,250,900,250,1400' : '250,150,250',
|
|
||||||
'ledColor' => $isCritical ? 'red' : ($isWarning ? 'yellow' : 'blue'),
|
|
||||||
'visibility' => 'public',
|
|
||||||
'timeout' => $isCritical ? 0 : 1800,
|
|
||||||
'clickAction' => $absoluteUrl,
|
|
||||||
'url' => $absoluteUrl,
|
|
||||||
'actions' => [
|
|
||||||
[
|
|
||||||
'action' => 'URI',
|
|
||||||
'title' => 'Control',
|
|
||||||
'uri' => $absoluteUrl,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ha_notify_log_rows(int $limit = 20): array
|
|
||||||
{
|
|
||||||
$limit = max(1, min(100, $limit));
|
|
||||||
|
|
||||||
$stmt = db()->query("
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
created_at,
|
|
||||||
event,
|
|
||||||
target,
|
|
||||||
title,
|
|
||||||
tag,
|
|
||||||
http_code,
|
|
||||||
error,
|
|
||||||
meta
|
|
||||||
FROM ha_notify_logs
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT " . $limit . "
|
|
||||||
");
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$meta = json_decode((string)($row['meta'] ?? ''), true);
|
|
||||||
$data = is_array($meta['data'] ?? null) ? $meta['data'] : [];
|
|
||||||
$rows[] = [
|
|
||||||
'id' => (int)$row['id'],
|
|
||||||
'created_at' => (string)$row['created_at'],
|
|
||||||
'event' => (string)$row['event'],
|
|
||||||
'target' => $row['target'],
|
|
||||||
'title' => $row['title'],
|
|
||||||
'tag' => $row['tag'],
|
|
||||||
'http_code' => $row['http_code'] === null ? null : (int)$row['http_code'],
|
|
||||||
'error' => $row['error'],
|
|
||||||
'level' => (string)($data['level'] ?? ''),
|
|
||||||
'channel' => (string)($data['channel'] ?? ''),
|
|
||||||
'icon' => (string)($data['notification_icon'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function send_ha_notify(array $payload): array
|
|
||||||
{
|
|
||||||
$title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
|
|
||||||
$message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
|
|
||||||
$level = (string)($payload['level'] ?? 'info');
|
|
||||||
$tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
|
|
||||||
$logTag = mb_substr((string)($payload['log_tag'] ?? $tag), 0, 128);
|
|
||||||
$url = (string)($payload['url'] ?? '/');
|
|
||||||
$data = ha_notify_default_data($level, $tag, $url);
|
|
||||||
|
|
||||||
if (is_array($payload['data'] ?? null)) {
|
|
||||||
$data = array_replace_recursive($data, $payload['data']);
|
|
||||||
}
|
|
||||||
$data['level'] = $level;
|
|
||||||
|
|
||||||
$body = json_encode([
|
|
||||||
'title' => $title,
|
|
||||||
'message' => $message,
|
|
||||||
'data' => $data,
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
|
|
||||||
if (HA_NOTIFY_WEBHOOK_ID === '') {
|
|
||||||
ha_notify_log('send_failed', [
|
|
||||||
'target' => null,
|
|
||||||
'title' => $title,
|
|
||||||
'tag' => $logTag,
|
|
||||||
'http_code' => null,
|
|
||||||
'error' => 'ha_notify_webhook_missing',
|
|
||||||
'data' => $data,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'sent' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'target' => null,
|
|
||||||
'http_code' => null,
|
|
||||||
'error' => 'ha_notify_webhook_missing',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$attempts = 0;
|
|
||||||
foreach (ha_notify_targets() as $target) {
|
|
||||||
$name = (string)($target['name'] ?? 'ha');
|
|
||||||
$base = rtrim((string)($target['base'] ?? $target['base_url'] ?? $target['url'] ?? ''), '/');
|
|
||||||
if ($base === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attempts++;
|
|
||||||
$ch = curl_init($base . '/api/webhook/' . rawurlencode(HA_NOTIFY_WEBHOOK_ID));
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
||||||
CURLOPT_POSTFIELDS => $body,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => 3,
|
|
||||||
CURLOPT_TIMEOUT => 8,
|
|
||||||
]);
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
$error = $response === false ? curl_error($ch) : null;
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($error === null && $httpCode >= 200 && $httpCode < 300) {
|
|
||||||
ha_notify_log('send_success', [
|
|
||||||
'target' => $name,
|
|
||||||
'title' => $title,
|
|
||||||
'tag' => $logTag,
|
|
||||||
'http_code' => $httpCode,
|
|
||||||
'data' => $data,
|
|
||||||
]);
|
|
||||||
return [
|
|
||||||
'sent' => 1,
|
|
||||||
'failed' => 0,
|
|
||||||
'target' => $name,
|
|
||||||
'http_code' => $httpCode,
|
|
||||||
'error' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_notify_log('send_failed', [
|
|
||||||
'target' => $name,
|
|
||||||
'title' => $title,
|
|
||||||
'tag' => $logTag,
|
|
||||||
'http_code' => $httpCode,
|
|
||||||
'error' => $error ?: ('HTTP ' . $httpCode),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'sent' => 0,
|
|
||||||
'failed' => $attempts,
|
|
||||||
'target' => null,
|
|
||||||
'http_code' => null,
|
|
||||||
'error' => 'ha_notify_failed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear_ha_notification(string $tag, string $title = 'Control 알림 해제'): array
|
|
||||||
{
|
|
||||||
return send_ha_notify([
|
|
||||||
'title' => $title,
|
|
||||||
'message' => 'clear_notification',
|
|
||||||
'level' => 'info',
|
|
||||||
'tag' => $tag,
|
|
||||||
'log_tag' => $tag . '-clear',
|
|
||||||
'data' => [
|
|
||||||
'tag' => $tag,
|
|
||||||
'channel' => 'control_status',
|
|
||||||
'notification_icon' => 'mdi:bell-check',
|
|
||||||
'persistent' => false,
|
|
||||||
'sticky' => 'false',
|
|
||||||
'renotify' => 'false',
|
|
||||||
'timeout' => 1,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function battery_low_notify_profile(float $percent): array
|
|
||||||
{
|
|
||||||
$emergency = (float)setting_value('notify.battery_emergency_percent');
|
|
||||||
$critical = max($emergency, (float)setting_value('notify.battery_critical_percent'));
|
|
||||||
$warning = max($critical, (float)setting_value('notify.battery_warning_percent'));
|
|
||||||
|
|
||||||
if ($percent <= $emergency) {
|
|
||||||
return [
|
|
||||||
'label' => '긴급',
|
|
||||||
'level' => 'critical',
|
|
||||||
'channel' => 'control_critical',
|
|
||||||
'importance' => 'max',
|
|
||||||
'icon' => 'mdi:battery-alert-variant-outline',
|
|
||||||
'color' => '#b91c1c',
|
|
||||||
'sticky' => 'true',
|
|
||||||
'persistent' => true,
|
|
||||||
'vibration' => '900,200,900,200,1400,250,1800',
|
|
||||||
'led' => 'red',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($percent <= $critical) {
|
|
||||||
return [
|
|
||||||
'label' => '위험',
|
|
||||||
'level' => 'critical',
|
|
||||||
'channel' => 'control_battery_critical',
|
|
||||||
'importance' => 'max',
|
|
||||||
'icon' => 'mdi:battery-10',
|
|
||||||
'color' => '#dc2626',
|
|
||||||
'sticky' => 'true',
|
|
||||||
'persistent' => true,
|
|
||||||
'vibration' => '900,250,900,250,1400',
|
|
||||||
'led' => 'red',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($percent <= $warning) {
|
|
||||||
return [
|
|
||||||
'label' => '경고',
|
|
||||||
'level' => 'warning',
|
|
||||||
'channel' => 'control_battery_warning',
|
|
||||||
'importance' => 'high',
|
|
||||||
'icon' => 'mdi:battery-20',
|
|
||||||
'color' => '#f97316',
|
|
||||||
'sticky' => 'true',
|
|
||||||
'persistent' => true,
|
|
||||||
'vibration' => '600,200,900,200,900',
|
|
||||||
'led' => 'yellow',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => '주의',
|
|
||||||
'level' => 'warning',
|
|
||||||
'channel' => 'control_battery',
|
|
||||||
'importance' => 'high',
|
|
||||||
'icon' => 'mdi:battery-alert',
|
|
||||||
'color' => '#f59e0b',
|
|
||||||
'sticky' => 'false',
|
|
||||||
'persistent' => false,
|
|
||||||
'vibration' => '250,150,250',
|
|
||||||
'led' => 'yellow',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function send_battery_low_notify_if_needed(array $battery): array
|
|
||||||
{
|
|
||||||
$percent = $battery['percent'] ?? null;
|
|
||||||
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
|
||||||
return [
|
|
||||||
'sent' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'skipped' => 'battery_soc_missing',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$percent = (float)$percent;
|
|
||||||
$voltage = isset($battery['voltage']) && is_numeric($battery['voltage'])
|
|
||||||
? (float)$battery['voltage']
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$tag = 'control-battery-low';
|
|
||||||
$warning = max(
|
|
||||||
(float)setting_value('notify.battery_emergency_percent'),
|
|
||||||
(float)setting_value('notify.battery_critical_percent'),
|
|
||||||
(float)setting_value('notify.battery_warning_percent')
|
|
||||||
);
|
|
||||||
$clearPercent = max($warning + 0.5, (float)setting_value('notify.battery_clear_percent'));
|
|
||||||
if ($percent > $clearPercent) {
|
|
||||||
$latestLow = latest_ha_notify_epoch_by_tag($tag);
|
|
||||||
$latestClear = latest_ha_notify_epoch_by_tag($tag . '-clear');
|
|
||||||
if (
|
|
||||||
$latestLow > 0
|
|
||||||
&& $latestLow > $latestClear
|
|
||||||
&& ha_notify_due($tag . '-clear', (int)setting_value('notify.battery_clear_cooldown_seconds'))
|
|
||||||
) {
|
|
||||||
return clear_ha_notification($tag, '배터리 SOC 복구');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'sent' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'skipped' => 'battery_soc_ok',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ha_notify_due($tag, (int)setting_value('notify.battery_low_cooldown_seconds'))) {
|
|
||||||
return [
|
|
||||||
'sent' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'skipped' => 'battery_low_cooldown',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$profile = battery_low_notify_profile($percent);
|
|
||||||
$body = 'Battery SOC '
|
|
||||||
. number_format($percent, 2)
|
|
||||||
. '%'
|
|
||||||
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
|
|
||||||
. "\n"
|
|
||||||
. $profile['label']
|
|
||||||
. ' 단계입니다.';
|
|
||||||
|
|
||||||
return send_ha_notify([
|
|
||||||
'title' => '배터리 SOC ' . $profile['label'],
|
|
||||||
'message' => $body,
|
|
||||||
'level' => $profile['level'],
|
|
||||||
'url' => '/',
|
|
||||||
'tag' => $tag,
|
|
||||||
'data' => [
|
|
||||||
'channel' => $profile['channel'],
|
|
||||||
'notification_icon' => $profile['icon'],
|
|
||||||
'color' => $profile['color'],
|
|
||||||
'sticky' => $profile['sticky'],
|
|
||||||
'persistent' => $profile['persistent'],
|
|
||||||
'renotify' => 'true',
|
|
||||||
'importance' => $profile['importance'],
|
|
||||||
'vibrationPattern' => $profile['vibration'],
|
|
||||||
'ledColor' => $profile['led'],
|
|
||||||
'battery_percent' => round($percent, 2),
|
|
||||||
'battery_voltage' => $voltage === null ? null : round($voltage, 3),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function signed_in(): bool
|
function signed_in(): bool
|
||||||
{
|
{
|
||||||
if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) {
|
if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) {
|
||||||
|
|||||||
@@ -2464,69 +2464,6 @@ function notice_downward_only(float $tempDelta, float $rpmDelta): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function send_fan_spike_notify(array $spike, array $fan, array $system, array $processes): array
|
|
||||||
{
|
|
||||||
$tempDelta = (float)($spike['temp_delta'] ?? 0);
|
|
||||||
$rpmDelta = (float)($spike['rpm_delta'] ?? 0);
|
|
||||||
$tempAvg = (float)($spike['temp_avg'] ?? 0);
|
|
||||||
$rpmAvg = (float)($spike['rpm_avg'] ?? 0);
|
|
||||||
$pwmAvg = (float)($spike['pwm_avg'] ?? 0);
|
|
||||||
$reasons = [];
|
|
||||||
|
|
||||||
if (abs($tempDelta) >= (float)setting_value('notice.temp_delta_threshold')) {
|
|
||||||
$reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abs($rpmDelta) >= (float)setting_value('notice.rpm_delta_threshold')) {
|
|
||||||
$reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reasons === []) {
|
|
||||||
$reasons[] = '순간 변화';
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = '기록된 이유: '
|
|
||||||
. implode(', ', $reasons)
|
|
||||||
. "\n평균: "
|
|
||||||
. number_format($tempAvg, 1)
|
|
||||||
. '°C / '
|
|
||||||
. number_format($rpmAvg, 0)
|
|
||||||
. ' RPM / PWM '
|
|
||||||
. number_format($pwmAvg, 0)
|
|
||||||
. "\n현재: "
|
|
||||||
. number_format((float)($system['temp_c'] ?? 0), 1)
|
|
||||||
. '°C / '
|
|
||||||
. number_format((float)($fan['rpm'] ?? 0), 0)
|
|
||||||
. ' RPM / PWM '
|
|
||||||
. number_format((float)($fan['pwm'] ?? 0), 0)
|
|
||||||
. (((bool)setting_value('notice.downward_only_process_hide') && notice_downward_only($tempDelta, $rpmDelta)) ? '' : "\n원인 후보: " . expected_process_detail_text($processes));
|
|
||||||
|
|
||||||
return send_ha_notify([
|
|
||||||
'title' => '시스템 유의사항',
|
|
||||||
'message' => $body,
|
|
||||||
'level' => 'warning',
|
|
||||||
'url' => '/',
|
|
||||||
'tag' => 'control-system-notice',
|
|
||||||
'data' => [
|
|
||||||
'channel' => 'control_system',
|
|
||||||
'notification_icon' => 'mdi:fan-alert',
|
|
||||||
'color' => '#f59e0b',
|
|
||||||
'sticky' => 'false',
|
|
||||||
'renotify' => 'true',
|
|
||||||
'summary' => $spike['summary'] ?? '',
|
|
||||||
'rpm_delta' => $spike['rpm_delta'] ?? 0,
|
|
||||||
'pwm_delta' => $spike['pwm_delta'] ?? 0,
|
|
||||||
'temp_delta' => $spike['temp_delta'] ?? 0,
|
|
||||||
'expected_process' => expected_process_text($processes),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function latest_system_notice_notify_epoch(): int
|
|
||||||
{
|
|
||||||
return latest_ha_notify_epoch_by_tag('control-system-notice');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fan_spike_history(int $limit = 100): array
|
function fan_spike_history(int $limit = 100): array
|
||||||
{
|
{
|
||||||
$limit = max(1, min(500, $limit));
|
$limit = max(1, min(500, $limit));
|
||||||
@@ -3347,21 +3284,9 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
$shouldLogNotice = !empty($fanSpike['alert_started'])
|
$shouldLogNotice = !empty($fanSpike['alert_started'])
|
||||||
|| (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert'));
|
|| (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert'));
|
||||||
if ($shouldLogNotice) {
|
if ($shouldLogNotice) {
|
||||||
$latestNoticeNotify = latest_system_notice_notify_epoch();
|
|
||||||
$spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes);
|
$spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes);
|
||||||
if ($spikeLogId > 0) {
|
if ($spikeLogId > 0) {
|
||||||
$fanSpike['log_id'] = $spikeLogId;
|
$fanSpike['log_id'] = $spikeLogId;
|
||||||
$noticeCooldown = (int)setting_value('notify.system_notice_cooldown_seconds');
|
|
||||||
if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= $noticeCooldown) {
|
|
||||||
$fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes);
|
|
||||||
} else {
|
|
||||||
$fanSpike['notify'] = [
|
|
||||||
'sent' => 0,
|
|
||||||
'failed' => 0,
|
|
||||||
'skipped' => 'system_notice_cooldown',
|
|
||||||
'cooldown_seconds' => $noticeCooldown,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3380,7 +3305,6 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
'custom_services' => custom_systemd_services(),
|
'custom_services' => custom_systemd_services(),
|
||||||
'fan_spike' => $fanSpike,
|
'fan_spike' => $fanSpike,
|
||||||
'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')),
|
'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')),
|
||||||
'ha_notify_history' => ha_notify_log_rows((int)setting_value('display.ha_notify_history_limit')),
|
|
||||||
'settings' => settings_payload(),
|
'settings' => settings_payload(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
||||||
batteryRuntimeTooltip: $('#batteryRuntimeTooltip'),
|
batteryRuntimeTooltip: $('#batteryRuntimeTooltip'),
|
||||||
spikeLogList: $('#spikeLogList'),
|
spikeLogList: $('#spikeLogList'),
|
||||||
haNotifyList: $('#haNotifyList'),
|
|
||||||
customServiceList: $('#customServiceList'),
|
customServiceList: $('#customServiceList'),
|
||||||
noticeBaseline: $('#noticeBaseline'),
|
noticeBaseline: $('#noticeBaseline'),
|
||||||
rebootBtn: $('#rebootBtn'),
|
rebootBtn: $('#rebootBtn'),
|
||||||
@@ -173,8 +172,6 @@
|
|||||||
diagnostics: 'Diagnostics',
|
diagnostics: 'Diagnostics',
|
||||||
noticeHistory: 'Notice History',
|
noticeHistory: 'Notice History',
|
||||||
noNoticeHistory: 'No system notice history.',
|
noNoticeHistory: 'No system notice history.',
|
||||||
haNotifyHistory: 'HA Notify History',
|
|
||||||
noHaNotifyHistory: 'No HA notify history.',
|
|
||||||
customServices: 'Custom Services',
|
customServices: 'Custom Services',
|
||||||
noCustomServices: 'No custom services.',
|
noCustomServices: 'No custom services.',
|
||||||
serviceLogs: 'Logs',
|
serviceLogs: 'Logs',
|
||||||
@@ -184,8 +181,6 @@
|
|||||||
wifiAliasTitle: 'WiFi Hostname',
|
wifiAliasTitle: 'WiFi Hostname',
|
||||||
wifiAliasPrompt: 'Enter a name to keep for this MAC address.',
|
wifiAliasPrompt: 'Enter a name to keep for this MAC address.',
|
||||||
wifiAliasSaved: 'WiFi hostname saved.',
|
wifiAliasSaved: 'WiFi hostname saved.',
|
||||||
notifySuccess: 'success',
|
|
||||||
notifyFailed: 'failed',
|
|
||||||
show: 'Show',
|
show: 'Show',
|
||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
translateButton: 'Translate',
|
translateButton: 'Translate',
|
||||||
@@ -324,8 +319,6 @@
|
|||||||
diagnostics: '진단',
|
diagnostics: '진단',
|
||||||
noticeHistory: '알림 이력',
|
noticeHistory: '알림 이력',
|
||||||
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
||||||
haNotifyHistory: 'HA 알림 이력',
|
|
||||||
noHaNotifyHistory: 'HA 알림 이력이 없습니다.',
|
|
||||||
customServices: '사용자 서비스',
|
customServices: '사용자 서비스',
|
||||||
noCustomServices: '사용자 생성 서비스가 없습니다.',
|
noCustomServices: '사용자 생성 서비스가 없습니다.',
|
||||||
serviceLogs: '로그',
|
serviceLogs: '로그',
|
||||||
@@ -335,8 +328,6 @@
|
|||||||
wifiAliasTitle: 'WiFi 호스트명',
|
wifiAliasTitle: 'WiFi 호스트명',
|
||||||
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
|
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
|
||||||
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
|
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
|
||||||
notifySuccess: '성공',
|
|
||||||
notifyFailed: '실패',
|
|
||||||
show: '보기',
|
show: '보기',
|
||||||
hide: '숨기기',
|
hide: '숨기기',
|
||||||
translateButton: '번역',
|
translateButton: '번역',
|
||||||
@@ -1624,32 +1615,6 @@
|
|||||||
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
|
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHaNotifyHistory(rows = []) {
|
|
||||||
if (!els.haNotifyList) return;
|
|
||||||
|
|
||||||
els.haNotifyList.innerHTML = rows.length
|
|
||||||
? rows.map((row, index) => {
|
|
||||||
const ok = row.event === 'send_success';
|
|
||||||
const statusText = ok ? t('notifySuccess') : t('notifyFailed');
|
|
||||||
const detail = [
|
|
||||||
row.target ? `${t('hostLabel')}: ${row.target}` : '',
|
|
||||||
row.http_code ? `HTTP ${row.http_code}` : '',
|
|
||||||
row.channel || '',
|
|
||||||
row.error || '',
|
|
||||||
].filter(Boolean).join(' / ');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="spike-log-item ${index === 0 ? 'latest' : ''} ${ok ? '' : 'alert'}">
|
|
||||||
<strong>${escapeHtml(row.created_at || '-')} · ${escapeHtml(statusText)}</strong>
|
|
||||||
<span>${escapeHtml(row.title || '-')}</span>
|
|
||||||
<span>${escapeHtml(row.tag || '-')}</span>
|
|
||||||
${detail ? `<span>${escapeHtml(detail)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')
|
|
||||||
: `<div class="spike-log-empty">${escapeHtml(t('noHaNotifyHistory'))}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCustomServices(rows = []) {
|
function renderCustomServices(rows = []) {
|
||||||
if (!els.customServiceList) return;
|
if (!els.customServiceList) return;
|
||||||
|
|
||||||
@@ -1696,7 +1661,6 @@
|
|||||||
? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}`
|
? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}`
|
||||||
: stateText;
|
: stateText;
|
||||||
renderSpikeHistory(data.fan_spike_history || []);
|
renderSpikeHistory(data.fan_spike_history || []);
|
||||||
renderHaNotifyHistory(data.ha_notify_history || []);
|
|
||||||
renderCustomServices(data.custom_services || []);
|
renderCustomServices(data.custom_services || []);
|
||||||
setText(els.noticeBaseline, baselineText);
|
setText(els.noticeBaseline, baselineText);
|
||||||
|
|
||||||
|
|||||||
@@ -325,13 +325,6 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spike-log-box">
|
|
||||||
<h3 data-i18n="haNotifyHistory">HA Notify History</h3>
|
|
||||||
<div id="haNotifyList" class="spike-log-list">
|
|
||||||
<div class="spike-log-empty" data-i18n="noHaNotifyHistory">No HA notify history.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="spike-log-box">
|
<div class="spike-log-box">
|
||||||
<div class="dmesg-head">
|
<div class="dmesg-head">
|
||||||
<h3>dmesg</h3>
|
<h3>dmesg</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user