Control 설정 모달 확장
This commit is contained in:
@@ -12,6 +12,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
|
|
||||||
- 팬 모드 `auto`, `manual`, `off` 제어
|
- 팬 모드 `auto`, `manual`, `off` 제어
|
||||||
- PWM slider 기반 수동 팬 제어
|
- PWM slider 기반 수동 팬 제어
|
||||||
|
- 오버레이 설정 모달에서 팬 자동곡선, 이벤트 판정, 팬 이상감지, 배터리 예측 파라미터 조정
|
||||||
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
||||||
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
||||||
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정
|
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정
|
||||||
@@ -42,6 +43,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `public/api.php?action=fan`: 팬 모드와 PWM 저장/적용
|
- `public/api.php?action=fan`: 팬 모드와 PWM 저장/적용
|
||||||
- `public/api.php?action=wifi`: 허용된 WiFi/DHCP service 제어
|
- `public/api.php?action=wifi`: 허용된 WiFi/DHCP service 제어
|
||||||
- `public/api.php?action=wifi_alias`: MAC별 WiFi 호스트명 저장
|
- `public/api.php?action=wifi_alias`: MAC별 WiFi 호스트명 저장
|
||||||
|
- `public/api.php?action=settings`: 설정 가능한 Control 동작값 조회
|
||||||
|
- `public/api.php?action=settings_save`: 설정 모달 값 저장
|
||||||
|
- `public/api.php?action=settings_reset`: 설정 모달 값을 기본값으로 초기화
|
||||||
- `public/api.php?action=dmesg`: dmesg 로그 조회
|
- `public/api.php?action=dmesg`: dmesg 로그 조회
|
||||||
- `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행
|
- `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `system_notice_logs`: notice 발생 이력
|
- `system_notice_logs`: notice 발생 이력
|
||||||
- `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 동작값 오버라이드
|
||||||
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
||||||
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정
|
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정
|
||||||
- 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다.
|
- 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다.
|
||||||
@@ -99,6 +104,17 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
|
|
||||||
잔여 시간 차트의 마지막 지점은 현재 snapshot의 `battery.remaining.seconds`와 동기화합니다. DB에 저장된 마지막 센서 기록이 몇 초 늦어도 좌측 상태 영역과 차트의 최신 잔여 시간이 서로 다른 값을 보여주지 않도록, API 응답 history 끝에 현재 snapshot row를 반영합니다.
|
잔여 시간 차트의 마지막 지점은 현재 snapshot의 `battery.remaining.seconds`와 동기화합니다. DB에 저장된 마지막 센서 기록이 몇 초 늦어도 좌측 상태 영역과 차트의 최신 잔여 시간이 서로 다른 값을 보여주지 않도록, API 응답 history 끝에 현재 snapshot row를 반영합니다.
|
||||||
|
|
||||||
|
## 설정 모달
|
||||||
|
|
||||||
|
상단 `설정` 버튼은 전체 화면 오버레이 모달을 엽니다. 설정값은 `app_settings` 테이블에 저장되며, 저장 직후 다음 snapshot부터 반영됩니다. 민감정보가 들어 있는 `/home/seo/secret/control.php`는 직접 수정하지 않고 DB 오버라이드만 사용합니다.
|
||||||
|
|
||||||
|
설정 가능한 항목은 다음 범위입니다.
|
||||||
|
|
||||||
|
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
||||||
|
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
||||||
|
- 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이
|
||||||
|
- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치
|
||||||
|
|
||||||
## 갱신 주기
|
## 갱신 주기
|
||||||
|
|
||||||
- WebSocket 상태 갱신: 1초마다
|
- WebSocket 상태 갱신: 1초마다
|
||||||
@@ -207,6 +223,7 @@ control-wifi-observe.service
|
|||||||
- `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산
|
- `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산
|
||||||
- `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()`: 설정 모달 항목 정의, 조회, 저장, 초기화
|
||||||
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI
|
- `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 제어
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ function bootstrap_db(): void
|
|||||||
COLLATE=utf8mb4_unicode_ci
|
COLLATE=utf8mb4_unicode_ci
|
||||||
");
|
");
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
setting_key VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||||
|
setting_value VARCHAR(255) NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_updated_at (updated_at)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci
|
||||||
|
");
|
||||||
|
|
||||||
$pdo->exec("
|
$pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS wifi_client_aliases (
|
CREATE TABLE IF NOT EXISTS wifi_client_aliases (
|
||||||
mac VARCHAR(17) NOT NULL PRIMARY KEY,
|
mac VARCHAR(17) NOT NULL PRIMARY KEY,
|
||||||
|
|||||||
+213
-30
@@ -72,6 +72,144 @@ function human_remaining_seconds(int $seconds): string
|
|||||||
return sprintf('%dm', max(1, $minutes));
|
return sprintf('%dm', max(1, $minutes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setting_definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'],
|
||||||
|
'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
|
||||||
|
'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
|
||||||
|
'fan.ramp_up_step' => ['group' => 'fan', 'label' => '자동 상승 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'],
|
||||||
|
'fan.ramp_down_step' => ['group' => 'fan', 'label' => '자동 하강 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'],
|
||||||
|
'event.recent_window_seconds' => ['group' => 'event', 'label' => '저전압/스로틀링 최근 판정창', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 7200, 'step' => 30, 'unit' => '초'],
|
||||||
|
'event.recovery_seconds' => ['group' => 'event', 'label' => '복구 중 유지 시간', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'],
|
||||||
|
'event.repeated_count' => ['group' => 'event', 'label' => '반복 발생 기준 횟수', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 20, 'step' => 1, 'unit' => '회'],
|
||||||
|
'notice.baseline_samples' => ['group' => 'notice', 'label' => '팬 이상 기준 표본 수', 'type' => 'number', 'default' => 180, 'min' => 30, 'max' => 600, 'step' => 10, 'unit' => '개'],
|
||||||
|
'notice.baseline_skip_recent' => ['group' => 'notice', 'label' => '기준 계산 제외 최신 표본', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 30, 'step' => 1, 'unit' => '개'],
|
||||||
|
'notice.rpm_delta_threshold' => ['group' => 'notice', 'label' => '팬 RPM 이상 감지 차이', 'type' => 'number', 'default' => 1000, 'min' => 100, 'max' => 5000, 'step' => 50, 'unit' => 'RPM'],
|
||||||
|
'notice.temp_delta_threshold' => ['group' => 'notice', 'label' => '온도 이상 감지 차이', 'type' => 'number', 'default' => 3.0, 'min' => 0.5, 'max' => 15, 'step' => 0.1, 'unit' => 'C'],
|
||||||
|
'notice.recover_rpm_delta' => ['group' => 'notice', 'label' => '팬 RPM 복구 차이', 'type' => 'number', 'default' => 500, 'min' => 50, 'max' => 3000, 'step' => 50, 'unit' => 'RPM'],
|
||||||
|
'notice.recover_temp_delta' => ['group' => 'notice', 'label' => '온도 복구 차이', 'type' => 'number', 'default' => 1.5, 'min' => 0.2, 'max' => 10, 'step' => 0.1, 'unit' => 'C'],
|
||||||
|
'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'],
|
||||||
|
'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'],
|
||||||
|
'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
||||||
|
'battery.load_adjust_regression' => ['group' => 'battery', 'label' => '강건 회귀 부하 보정 강도', 'type' => 'number', 'default' => 0.38, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
||||||
|
'battery.load_adjust_voltage' => ['group' => 'battery', 'label' => '전압 후보 부하 보정 강도', 'type' => 'number', 'default' => 0.28, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
||||||
|
'battery.voltage_floor_high' => ['group' => 'battery', 'label' => '전압 하한 높음', 'type' => 'number', 'default' => 3.28, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'],
|
||||||
|
'battery.voltage_floor_mid' => ['group' => 'battery', 'label' => '전압 하한 중간', 'type' => 'number', 'default' => 3.18, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'],
|
||||||
|
'battery.voltage_floor_low' => ['group' => 'battery', 'label' => '전압 하한 낮음', 'type' => 'number', 'default' => 3.05, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'],
|
||||||
|
'battery.candidate_min_ratio' => ['group' => 'battery', 'label' => '후보 최소 속도 비율', 'type' => 'number', 'default' => 0.38, 'min' => 0.05, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
||||||
|
'battery.candidate_max_ratio' => ['group' => 'battery', 'label' => '후보 최대 속도 비율', 'type' => 'number', 'default' => 2.65, 'min' => 1, 'max' => 8, 'step' => 0.05, 'unit' => 'x'],
|
||||||
|
'battery.capacity_model_weight' => ['group' => 'battery', 'label' => '용량 모델 가중치', 'type' => 'number', 'default' => 0.18, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setting_rows(bool $reload = false): array
|
||||||
|
{
|
||||||
|
static $cache = null;
|
||||||
|
if (!$reload && $cache !== null) {
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rows = db()->query("SELECT setting_key, setting_value FROM app_settings")->fetchAll();
|
||||||
|
} catch (Throwable) {
|
||||||
|
$cache = [];
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$cache[(string)$row['setting_key']] = (string)$row['setting_value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setting_value(string $key): int|float|string|bool|null
|
||||||
|
{
|
||||||
|
$defs = setting_definitions();
|
||||||
|
if (!isset($defs[$key])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$def = $defs[$key];
|
||||||
|
$raw = setting_rows()[$key] ?? $def['default'];
|
||||||
|
|
||||||
|
if (($def['type'] ?? '') === 'number') {
|
||||||
|
$value = is_numeric($raw) ? (float)$raw : (float)$def['default'];
|
||||||
|
$value = clamp_float($value, (float)$def['min'], (float)$def['max']);
|
||||||
|
return fmod((float)$def['step'], 1.0) === 0.0 ? (int)round($value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settings_payload(): array
|
||||||
|
{
|
||||||
|
$values = setting_rows();
|
||||||
|
$groups = [
|
||||||
|
'fan' => '팬 자동 제어',
|
||||||
|
'battery' => '배터리 예측',
|
||||||
|
'event' => '저전압/스로틀링',
|
||||||
|
'notice' => '팬 이상 감지',
|
||||||
|
];
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach (setting_definitions() as $key => $def) {
|
||||||
|
$items[] = array_merge($def, [
|
||||||
|
'key' => $key,
|
||||||
|
'value' => setting_value($key),
|
||||||
|
'default' => $def['default'],
|
||||||
|
'custom' => array_key_exists($key, $values),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'groups' => $groups,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_settings(array $input): void
|
||||||
|
{
|
||||||
|
$defs = setting_definitions();
|
||||||
|
$stmt = db()->prepare("
|
||||||
|
INSERT INTO app_settings (setting_key, setting_value)
|
||||||
|
VALUES (:setting_key, :setting_value)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
setting_value = VALUES(setting_value),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($input as $key => $value) {
|
||||||
|
if (!isset($defs[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$def = $defs[$key];
|
||||||
|
if (($def['type'] ?? '') === 'number') {
|
||||||
|
if (!is_numeric($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = clamp_float((float)$value, (float)$def['min'], (float)$def['max']);
|
||||||
|
}
|
||||||
|
$stmt->execute([
|
||||||
|
':setting_key' => $key,
|
||||||
|
':setting_value' => (string)$value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setting_rows(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset_settings(): void
|
||||||
|
{
|
||||||
|
$keys = array_keys(setting_definitions());
|
||||||
|
if ($keys === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($keys), '?'));
|
||||||
|
db()->prepare("DELETE FROM app_settings WHERE setting_key IN ($placeholders)")->execute($keys);
|
||||||
|
setting_rows(true);
|
||||||
|
}
|
||||||
|
|
||||||
function dmesg_log(): array
|
function dmesg_log(): array
|
||||||
{
|
{
|
||||||
$path = '/tmp/dmesg.log';
|
$path = '/tmp/dmesg.log';
|
||||||
@@ -120,8 +258,8 @@ function dmesg_log(): array
|
|||||||
function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $statePath): array
|
function throttled_event_status(?int $flags, string $raw, int $activeBit, int $seenBit, string $statePath): array
|
||||||
{
|
{
|
||||||
$now = time();
|
$now = time();
|
||||||
$windowSeconds = 600;
|
$windowSeconds = (int)setting_value('event.recent_window_seconds');
|
||||||
$recoverySeconds = 300;
|
$recoverySeconds = (int)setting_value('event.recovery_seconds');
|
||||||
$active = $flags !== null && (bool)($flags & $activeBit);
|
$active = $flags !== null && (bool)($flags & $activeBit);
|
||||||
$seen = $flags !== null && (bool)($flags & $seenBit);
|
$seen = $flags !== null && (bool)($flags & $seenBit);
|
||||||
|
|
||||||
@@ -245,7 +383,7 @@ function throttled_event_status(?int $flags, string $raw, int $activeBit, int $s
|
|||||||
|
|
||||||
$lastClearedAt = is_numeric($state['last_cleared_at'] ?? null) ? (int)$state['last_cleared_at'] : null;
|
$lastClearedAt = is_numeric($state['last_cleared_at'] ?? null) ? (int)$state['last_cleared_at'] : null;
|
||||||
$recovering = !$active && $lastClearedAt !== null && ($now - $lastClearedAt) <= $recoverySeconds;
|
$recovering = !$active && $lastClearedAt !== null && ($now - $lastClearedAt) <= $recoverySeconds;
|
||||||
$repeated = $recentCount >= 2;
|
$repeated = $recentCount >= (int)setting_value('event.repeated_count');
|
||||||
$stateLabel = 'normal';
|
$stateLabel = 'normal';
|
||||||
if ($active) {
|
if ($active) {
|
||||||
$stateLabel = 'active';
|
$stateLabel = 'active';
|
||||||
@@ -400,31 +538,37 @@ function cpu_temp(): float
|
|||||||
|
|
||||||
function fan_target_pwm(float $temp): int
|
function fan_target_pwm(float $temp): int
|
||||||
{
|
{
|
||||||
if ($temp >= 80) {
|
$minTemp = (float)setting_value('fan.auto_min_temp');
|
||||||
|
$maxTemp = (float)setting_value('fan.auto_max_temp');
|
||||||
|
if ($maxTemp <= $minTemp) {
|
||||||
|
$maxTemp = $minTemp + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($temp >= $maxTemp) {
|
||||||
return 255;
|
return 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($temp <= 50) {
|
if ($temp <= $minTemp) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ratio = ($temp - 50) / 30;
|
$ratio = ($temp - $minTemp) / ($maxTemp - $minTemp);
|
||||||
|
|
||||||
return max(0, min(255, (int)round($ratio * 255)));
|
return max(0, min(255, (int)round($ratio * 255)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fan_ramped_pwm(int $current, int $desired, float $temp): int
|
function fan_ramped_pwm(int $current, int $desired, float $temp): int
|
||||||
{
|
{
|
||||||
if ($temp >= 80) {
|
if ($temp >= (float)setting_value('fan.full_temp')) {
|
||||||
return $desired;
|
return $desired;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($desired > $current) {
|
if ($desired > $current) {
|
||||||
return min($desired, $current + 2);
|
return min($desired, $current + (int)setting_value('fan.ramp_up_step'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($desired < $current) {
|
if ($desired < $current) {
|
||||||
return max($desired, $current - 2);
|
return max($desired, $current - (int)setting_value('fan.ramp_down_step'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $desired;
|
return $desired;
|
||||||
@@ -984,7 +1128,7 @@ function battery_trend_candidate(array $trendRows, float $currentPercent, int $w
|
|||||||
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
||||||
$loadFactor = 1.0;
|
$loadFactor = 1.0;
|
||||||
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
||||||
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.65, 1.75) - 1) * 0.45);
|
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.65, 1.75) - 1) * (float)setting_value('battery.load_adjust_recent'));
|
||||||
$rate *= $loadFactor;
|
$rate *= $loadFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,7 +1231,7 @@ function battery_regression_trend_candidate(array $trendRows, float $currentPerc
|
|||||||
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
||||||
$loadFactor = 1.0;
|
$loadFactor = 1.0;
|
||||||
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
||||||
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.7, 1.65) - 1) * 0.38);
|
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.7, 1.65) - 1) * (float)setting_value('battery.load_adjust_regression'));
|
||||||
$rate *= $loadFactor;
|
$rate *= $loadFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,13 +1265,13 @@ function battery_regression_trend_candidate(array $trendRows, float $currentPerc
|
|||||||
function battery_voltage_floor(float $currentVoltage): float
|
function battery_voltage_floor(float $currentVoltage): float
|
||||||
{
|
{
|
||||||
if ($currentVoltage >= 3.55) {
|
if ($currentVoltage >= 3.55) {
|
||||||
return 3.28;
|
return (float)setting_value('battery.voltage_floor_high');
|
||||||
}
|
}
|
||||||
if ($currentVoltage >= 3.35) {
|
if ($currentVoltage >= 3.35) {
|
||||||
return 3.18;
|
return (float)setting_value('battery.voltage_floor_mid');
|
||||||
}
|
}
|
||||||
|
|
||||||
return 3.05;
|
return (float)setting_value('battery.voltage_floor_low');
|
||||||
}
|
}
|
||||||
|
|
||||||
function battery_voltage_trend_candidate(array $trendRows, float $currentPercent, ?float $currentVoltage, int $windowSeconds, ?float $recentWatts): ?array
|
function battery_voltage_trend_candidate(array $trendRows, float $currentPercent, ?float $currentVoltage, int $windowSeconds, ?float $recentWatts): ?array
|
||||||
@@ -1216,7 +1360,7 @@ function battery_voltage_trend_candidate(array $trendRows, float $currentPercent
|
|||||||
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
|
||||||
$loadFactor = 1.0;
|
$loadFactor = 1.0;
|
||||||
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
|
||||||
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.75, 1.55) - 1) * 0.28);
|
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.75, 1.55) - 1) * (float)setting_value('battery.load_adjust_voltage'));
|
||||||
$seconds = (int)round($seconds / $loadFactor);
|
$seconds = (int)round($seconds / $loadFactor);
|
||||||
}
|
}
|
||||||
if ($seconds <= 0) {
|
if ($seconds <= 0) {
|
||||||
@@ -1371,7 +1515,7 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW
|
|||||||
'window_seconds' => null,
|
'window_seconds' => null,
|
||||||
'sample_count' => null,
|
'sample_count' => null,
|
||||||
'confidence' => 0.34,
|
'confidence' => 0.34,
|
||||||
'weight' => 0.18,
|
'weight' => (float)setting_value('battery.capacity_model_weight'),
|
||||||
'avg_watts' => round($recentWatts, 3),
|
'avg_watts' => round($recentWatts, 3),
|
||||||
'load_factor' => 1.0,
|
'load_factor' => 1.0,
|
||||||
'source' => 'capacity_power_model',
|
'source' => 'capacity_power_model',
|
||||||
@@ -1491,7 +1635,7 @@ function refine_battery_candidates(array $candidates): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ratio = $rate / $medianRate;
|
$ratio = $rate / $medianRate;
|
||||||
if ($ratio < 0.38 || $ratio > 2.65) {
|
if ($ratio < (float)setting_value('battery.candidate_min_ratio') || $ratio > (float)setting_value('battery.candidate_max_ratio')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,9 +2016,11 @@ function trimmed_average(array $values, float $fallback): float
|
|||||||
|
|
||||||
function notice_rolling_baseline(array $history, float $temp, float $rpm, float $pwm, array $state = []): array
|
function notice_rolling_baseline(array $history, float $temp, float $rpm, float $pwm, array $state = []): array
|
||||||
{
|
{
|
||||||
$rows = array_slice($history, -180);
|
$sampleCount = (int)setting_value('notice.baseline_samples');
|
||||||
if (count($rows) > 3) {
|
$skipRecent = (int)setting_value('notice.baseline_skip_recent');
|
||||||
$rows = array_slice($rows, 0, -3);
|
$rows = array_slice($history, -$sampleCount);
|
||||||
|
if ($skipRecent > 0 && count($rows) > $skipRecent) {
|
||||||
|
$rows = array_slice($rows, 0, -$skipRecent);
|
||||||
}
|
}
|
||||||
|
|
||||||
$temps = [];
|
$temps = [];
|
||||||
@@ -1973,8 +2119,13 @@ function fan_spike_analysis(array $history, array $fan, array $system, array $pr
|
|||||||
$rpmExpected = $mode !== 'off' && ($pwm >= 20 || $pwmAvg >= 20);
|
$rpmExpected = $mode !== 'off' && ($pwm >= 20 || $pwmAvg >= 20);
|
||||||
|
|
||||||
$reasons = [];
|
$reasons = [];
|
||||||
if ($rpmExpected && abs($rpmDelta) >= 1000) $reasons[] = 'RPM ' . signed_delta_text($rpmDelta);
|
$rpmThreshold = (float)setting_value('notice.rpm_delta_threshold');
|
||||||
if (abs($tempDelta) >= 3.0) $reasons[] = 'TEMP ' . signed_delta_text($tempDelta, 'C');
|
$tempThreshold = (float)setting_value('notice.temp_delta_threshold');
|
||||||
|
$recoverRpmThreshold = (float)setting_value('notice.recover_rpm_delta');
|
||||||
|
$recoverTempThreshold = (float)setting_value('notice.recover_temp_delta');
|
||||||
|
|
||||||
|
if ($rpmExpected && abs($rpmDelta) >= $rpmThreshold) $reasons[] = 'RPM ' . signed_delta_text($rpmDelta);
|
||||||
|
if (abs($tempDelta) >= $tempThreshold) $reasons[] = 'TEMP ' . signed_delta_text($tempDelta, 'C');
|
||||||
|
|
||||||
$spiking = false;
|
$spiking = false;
|
||||||
|
|
||||||
@@ -1996,8 +2147,8 @@ function fan_spike_analysis(array $history, array $fan, array $system, array $pr
|
|||||||
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$recovered = (abs($rpmDelta) <= 500 && abs($tempDelta) <= 1.5)
|
$recovered = (abs($rpmDelta) <= $recoverRpmThreshold && abs($tempDelta) <= $recoverTempThreshold)
|
||||||
|| (abs($rollingRpmDelta) <= 500 && abs($rollingTempDelta) <= 1.5);
|
|| (abs($rollingRpmDelta) <= $recoverRpmThreshold && abs($rollingTempDelta) <= $recoverTempThreshold);
|
||||||
if ($recovered || $reasons === []) {
|
if ($recovered || $reasons === []) {
|
||||||
$currentState = 'normal';
|
$currentState = 'normal';
|
||||||
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
save_system_notice_state('normal', $rollingBaseline['temp'], $rollingBaseline['rpm'], $rollingBaseline['pwm']);
|
||||||
@@ -2351,8 +2502,8 @@ function delta_state_text(float $value): string
|
|||||||
|
|
||||||
function notice_downward_only(float $tempDelta, float $rpmDelta): bool
|
function notice_downward_only(float $tempDelta, float $rpmDelta): bool
|
||||||
{
|
{
|
||||||
$tempTriggered = abs($tempDelta) >= 3.0;
|
$tempTriggered = abs($tempDelta) >= (float)setting_value('notice.temp_delta_threshold');
|
||||||
$rpmTriggered = abs($rpmDelta) >= 1000;
|
$rpmTriggered = abs($rpmDelta) >= (float)setting_value('notice.rpm_delta_threshold');
|
||||||
|
|
||||||
if (!$tempTriggered && !$rpmTriggered) {
|
if (!$tempTriggered && !$rpmTriggered) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2378,11 +2529,11 @@ function send_fan_spike_notify(array $spike, array $fan, array $system, array $p
|
|||||||
$pwmAvg = (float)($spike['pwm_avg'] ?? 0);
|
$pwmAvg = (float)($spike['pwm_avg'] ?? 0);
|
||||||
$reasons = [];
|
$reasons = [];
|
||||||
|
|
||||||
if (abs($tempDelta) >= 3.0) {
|
if (abs($tempDelta) >= (float)setting_value('notice.temp_delta_threshold')) {
|
||||||
$reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강');
|
$reasons[] = '온도 평균보다 ' . ($tempDelta >= 0 ? '상승' : '하강');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abs($rpmDelta) >= 1000) {
|
if (abs($rpmDelta) >= (float)setting_value('notice.rpm_delta_threshold')) {
|
||||||
$reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강');
|
$reasons[] = '팬RPM 평균보다 ' . ($rpmDelta >= 0 ? '상승' : '하강');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3217,8 +3368,8 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$history = add_battery_remaining_history(sensor_history(240));
|
$history = add_battery_remaining_history(sensor_history(240));
|
||||||
$batteryTrend = battery_trend_history(24);
|
$batteryTrend = battery_trend_history((int)setting_value('battery.trend_hours'));
|
||||||
$batteryProfile = battery_profile_history(45);
|
$batteryProfile = battery_profile_history((int)setting_value('battery.profile_days'));
|
||||||
$battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend, $batteryProfile);
|
$battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend, $batteryProfile);
|
||||||
$history = sync_current_battery_remaining_history($history, [
|
$history = sync_current_battery_remaining_history($history, [
|
||||||
'time' => date('Y-m-d H:i:s'),
|
'time' => date('Y-m-d H:i:s'),
|
||||||
@@ -3281,6 +3432,7 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
'fan_spike' => $fanSpike,
|
'fan_spike' => $fanSpike,
|
||||||
'fan_spike_history' => fan_spike_history(100),
|
'fan_spike_history' => fan_spike_history(100),
|
||||||
'ha_notify_history' => ha_notify_log_rows(20),
|
'ha_notify_history' => ha_notify_log_rows(20),
|
||||||
|
'settings' => settings_payload(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $snapshot;
|
return $snapshot;
|
||||||
@@ -3309,6 +3461,37 @@ function control_api_dispatch(): void
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'settings') {
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => settings_payload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'settings_save') {
|
||||||
|
$raw = (string)($_POST['settings'] ?? '{}');
|
||||||
|
$settings = json_decode($raw, true);
|
||||||
|
if (!is_array($settings)) {
|
||||||
|
json_out([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'bad_settings',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
save_settings($settings);
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => collect_snapshot(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'settings_reset') {
|
||||||
|
reset_settings();
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => collect_snapshot(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'fan') {
|
if ($action === 'fan') {
|
||||||
$mode = (string)($_POST['mode'] ?? 'auto');
|
$mode = (string)($_POST['mode'] ?? 'auto');
|
||||||
$pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120)));
|
$pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120)));
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
customServiceList: $('#customServiceList'),
|
customServiceList: $('#customServiceList'),
|
||||||
noticeBaseline: $('#noticeBaseline'),
|
noticeBaseline: $('#noticeBaseline'),
|
||||||
rebootBtn: $('#rebootBtn'),
|
rebootBtn: $('#rebootBtn'),
|
||||||
|
settingsBtn: $('#settingsBtn'),
|
||||||
|
settingsDialog: $('#settingsDialog'),
|
||||||
|
settingsBody: $('#settingsBody'),
|
||||||
|
settingsClose: $('#settingsClose'),
|
||||||
|
settingsSave: $('#settingsSave'),
|
||||||
|
settingsReset: $('#settingsReset'),
|
||||||
translateBtn: $('#translateBtn'),
|
translateBtn: $('#translateBtn'),
|
||||||
themeBtn: $('#themeBtn'),
|
themeBtn: $('#themeBtn'),
|
||||||
customDialog: $('#customDialog'),
|
customDialog: $('#customDialog'),
|
||||||
@@ -78,6 +84,8 @@
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
batteryTooltipOpen: false,
|
batteryTooltipOpen: false,
|
||||||
batteryTooltipText: '',
|
batteryTooltipText: '',
|
||||||
|
settingsPayload: null,
|
||||||
|
settingsOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const storageKeys = {
|
const storageKeys = {
|
||||||
@@ -165,6 +173,13 @@
|
|||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
translateButton: 'Translate',
|
translateButton: 'Translate',
|
||||||
themeButton: 'Theme',
|
themeButton: 'Theme',
|
||||||
|
settingsButton: 'Settings',
|
||||||
|
settingsTitle: 'Settings',
|
||||||
|
settingsSaved: 'Settings saved',
|
||||||
|
settingsResetDone: 'Settings reset',
|
||||||
|
settingsSaveFailed: 'settings save failed',
|
||||||
|
settingsResetConfirm: 'Reset all Control settings to defaults?',
|
||||||
|
settingsDefault: 'default',
|
||||||
rebootButton: 'Reboot',
|
rebootButton: 'Reboot',
|
||||||
dialogAlert: 'Alert',
|
dialogAlert: 'Alert',
|
||||||
dialogConfirm: 'Confirm',
|
dialogConfirm: 'Confirm',
|
||||||
@@ -299,6 +314,13 @@
|
|||||||
hide: '숨기기',
|
hide: '숨기기',
|
||||||
translateButton: '번역',
|
translateButton: '번역',
|
||||||
themeButton: '테마',
|
themeButton: '테마',
|
||||||
|
settingsButton: '설정',
|
||||||
|
settingsTitle: '설정',
|
||||||
|
settingsSaved: '설정을 저장했습니다.',
|
||||||
|
settingsResetDone: '설정을 기본값으로 되돌렸습니다.',
|
||||||
|
settingsSaveFailed: '설정 저장 실패',
|
||||||
|
settingsResetConfirm: 'Control 설정을 모두 기본값으로 되돌릴까요?',
|
||||||
|
settingsDefault: '기본값',
|
||||||
rebootButton: '재부팅',
|
rebootButton: '재부팅',
|
||||||
dialogAlert: '알림',
|
dialogAlert: '알림',
|
||||||
dialogConfirm: '확인',
|
dialogConfirm: '확인',
|
||||||
@@ -386,6 +408,7 @@
|
|||||||
});
|
});
|
||||||
els.translateBtn && (els.translateBtn.textContent = t('translateButton'));
|
els.translateBtn && (els.translateBtn.textContent = t('translateButton'));
|
||||||
els.themeBtn && (els.themeBtn.textContent = t('themeButton'));
|
els.themeBtn && (els.themeBtn.textContent = t('themeButton'));
|
||||||
|
els.settingsBtn && (els.settingsBtn.textContent = t('settingsButton'));
|
||||||
els.rebootBtn && (els.rebootBtn.textContent = t('rebootButton'));
|
els.rebootBtn && (els.rebootBtn.textContent = t('rebootButton'));
|
||||||
if (els.dmesgToggle) {
|
if (els.dmesgToggle) {
|
||||||
els.dmesgToggle.textContent = state.dmesgOpen ? t('hide') : t('show');
|
els.dmesgToggle.textContent = state.dmesgOpen ? t('hide') : t('show');
|
||||||
@@ -583,6 +606,102 @@
|
|||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSettings(payload = state.settingsPayload) {
|
||||||
|
if (!els.settingsBody || !payload) return;
|
||||||
|
state.settingsPayload = payload;
|
||||||
|
const groups = payload.groups || {};
|
||||||
|
const byGroup = {};
|
||||||
|
(payload.items || []).forEach(item => {
|
||||||
|
const group = item.group || 'etc';
|
||||||
|
if (!byGroup[group]) byGroup[group] = [];
|
||||||
|
byGroup[group].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.settingsBody.innerHTML = Object.entries(byGroup).map(([group, items]) => `
|
||||||
|
<section class="settings-group">
|
||||||
|
<h4>${escapeHtml(groups[group] || group)}</h4>
|
||||||
|
<div class="settings-grid">
|
||||||
|
${items.map(item => `
|
||||||
|
<label class="settings-field">
|
||||||
|
<span class="settings-label">
|
||||||
|
<span>${escapeHtml(item.label || item.key)}</span>
|
||||||
|
<span>${escapeHtml(item.unit || '')}</span>
|
||||||
|
</span>
|
||||||
|
<span class="settings-control">
|
||||||
|
<input
|
||||||
|
data-setting-key="${escapeHtml(item.key)}"
|
||||||
|
type="number"
|
||||||
|
min="${escapeHtml(item.min ?? '')}"
|
||||||
|
max="${escapeHtml(item.max ?? '')}"
|
||||||
|
step="${escapeHtml(item.step ?? '1')}"
|
||||||
|
value="${escapeHtml(item.value ?? item.default ?? '')}">
|
||||||
|
<span class="settings-default">${t('settingsDefault')} ${escapeHtml(item.default ?? '')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings() {
|
||||||
|
if (!els.settingsDialog) return;
|
||||||
|
try {
|
||||||
|
renderSettings(await api('settings'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
notice(e.message || t('refreshFailed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.settingsOpen = true;
|
||||||
|
els.settingsDialog.dataset.open = '1';
|
||||||
|
els.settingsDialog.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
if (!els.settingsDialog) return;
|
||||||
|
state.settingsOpen = false;
|
||||||
|
delete els.settingsDialog.dataset.open;
|
||||||
|
els.settingsDialog.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSettingsFormValues() {
|
||||||
|
const values = {};
|
||||||
|
els.settingsBody?.querySelectorAll('[data-setting-key]').forEach(input => {
|
||||||
|
values[input.dataset.settingKey] = input.value;
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
try {
|
||||||
|
const data = await api('settings_save', {
|
||||||
|
settings: JSON.stringify(currentSettingsFormValues()),
|
||||||
|
});
|
||||||
|
render(data);
|
||||||
|
renderSettings(data.settings);
|
||||||
|
notice(t('settingsSaved'), 'success');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
notice(e.message || t('settingsSaveFailed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSettings() {
|
||||||
|
if (!await window.customConfirm(t('settingsResetConfirm'), { title: t('settingsTitle') })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api('settings_reset', {});
|
||||||
|
render(data);
|
||||||
|
renderSettings(data.settings);
|
||||||
|
notice(t('settingsResetDone'), 'success');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
notice(e.message || t('settingsSaveFailed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function websocketUrl() {
|
function websocketUrl() {
|
||||||
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return `${scheme}//${location.host}/ws`;
|
return `${scheme}//${location.host}/ws`;
|
||||||
@@ -1622,6 +1741,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(data) {
|
function render(data) {
|
||||||
|
if (data.settings) {
|
||||||
|
state.settingsPayload = data.settings;
|
||||||
|
if (state.settingsOpen) {
|
||||||
|
renderSettings(data.settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
renderTop(data);
|
renderTop(data);
|
||||||
renderSystemStatus(data);
|
renderSystemStatus(data);
|
||||||
renderWifi(data);
|
renderWifi(data);
|
||||||
@@ -1866,6 +1991,15 @@
|
|||||||
els.rebootBtn?.addEventListener('click', () => {
|
els.rebootBtn?.addEventListener('click', () => {
|
||||||
requestReboot();
|
requestReboot();
|
||||||
});
|
});
|
||||||
|
els.settingsBtn?.addEventListener('click', openSettings);
|
||||||
|
els.settingsClose?.addEventListener('click', closeSettings);
|
||||||
|
els.settingsSave?.addEventListener('click', saveSettings);
|
||||||
|
els.settingsReset?.addEventListener('click', resetSettings);
|
||||||
|
els.settingsDialog?.addEventListener('click', event => {
|
||||||
|
if (event.target === els.settingsDialog) {
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
els.translateBtn?.addEventListener('click', () => {
|
els.translateBtn?.addEventListener('click', () => {
|
||||||
applyLang(state.lang === 'ko' ? 'en' : 'ko');
|
applyLang(state.lang === 'ko' ? 'en' : 'ko');
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
@@ -1884,6 +2018,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Escape' && state.settingsOpen) {
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
initPreferences();
|
initPreferences();
|
||||||
connectControlSocket();
|
connectControlSocket();
|
||||||
|
|||||||
@@ -113,8 +113,10 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
|||||||
.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)}
|
.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)}
|
||||||
.status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(520px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:hidden;padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.55;white-space:pre-line;word-break:keep-all;overflow-wrap:anywhere;pointer-events:none}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2}
|
.status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(520px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:hidden;padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.55;white-space:pre-line;word-break:keep-all;overflow-wrap:anywhere;pointer-events:none}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2}
|
||||||
.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}
|
.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}
|
||||||
|
.settings-layer{align-items:stretch}.settings-box{width:min(980px,100%);max-height:calc(100vh - 44px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;padding:0;overflow:hidden}.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line)}.settings-sub{margin:0;color:var(--sub);font-size:13px}.settings-body{overflow:auto;padding:16px 20px;display:grid;gap:14px}.settings-group{border:1px solid var(--line);border-radius:16px;background:var(--card2);padding:14px}.settings-group h4{margin:0 0 12px;font-size:15px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.settings-field{display:grid;gap:7px;min-width:0}.settings-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--sub);font-size:13px}.settings-label span:last-child{white-space:nowrap;color:var(--sub)}.settings-control{display:flex;align-items:center;gap:9px}.settings-control input{width:100%;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px;outline:none}.settings-control input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.settings-default{font-size:12px;color:var(--sub);white-space:nowrap}.settings-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 20px;border-top:1px solid var(--line)}
|
||||||
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
|
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
|
||||||
|
@media(max-width:720px){.settings-grid{grid-template-columns:1fr}.settings-head{align-items:stretch;flex-direction:column}.settings-actions{display:grid;grid-template-columns:1fr 1fr}.settings-box{max-height:calc(100vh - 24px)}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -141,6 +143,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
|
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
|
||||||
|
<button id="settingsBtn" class="btn secondary" type="button">설정</button>
|
||||||
<button id="translateBtn" class="btn secondary" type="button">Translate</button>
|
<button id="translateBtn" class="btn secondary" type="button">Translate</button>
|
||||||
<button id="themeBtn" class="btn secondary" type="button">Theme</button>
|
<button id="themeBtn" class="btn secondary" type="button">Theme</button>
|
||||||
<button id="rebootBtn" class="btn warn" type="button">Reboot</button>
|
<button id="rebootBtn" class="btn warn" type="button">Reboot</button>
|
||||||
@@ -318,6 +321,22 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
|||||||
</div>
|
</div>
|
||||||
<div id="notice" class="notice"></div>
|
<div id="notice" class="notice"></div>
|
||||||
<div id="batteryRuntimeTooltip" class="runtime-tooltip" role="tooltip" aria-hidden="true"></div>
|
<div id="batteryRuntimeTooltip" class="runtime-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
|
<div id="settingsDialog" class="dialog-layer settings-layer" aria-hidden="true">
|
||||||
|
<div class="dialog-box settings-box" role="dialog" aria-modal="true" aria-labelledby="settingsDialogTitle">
|
||||||
|
<div class="settings-head">
|
||||||
|
<div>
|
||||||
|
<h3 id="settingsDialogTitle">설정</h3>
|
||||||
|
<p class="settings-sub">Control 동작값을 즉시 저장하고 반영합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button id="settingsClose" class="btn secondary" type="button">닫기</button>
|
||||||
|
</div>
|
||||||
|
<div id="settingsBody" class="settings-body"></div>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button id="settingsReset" class="btn warn" type="button">기본값</button>
|
||||||
|
<button id="settingsSave" class="btn" type="button">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="customDialog" class="dialog-layer" aria-hidden="true">
|
<div id="customDialog" class="dialog-layer" aria-hidden="true">
|
||||||
<div class="dialog-box" role="dialog" aria-modal="true" aria-labelledby="customDialogTitle">
|
<div class="dialog-box" role="dialog" aria-modal="true" aria-labelledby="customDialogTitle">
|
||||||
<h3 id="customDialogTitle">확인</h3>
|
<h3 id="customDialogTitle">확인</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user