diff --git a/README.md b/README.md index 6654dca..f3beb1e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 팬 모드 `auto`, `manual`, `off` 제어 - PWM slider 기반 수동 팬 제어 +- 서울 HA의 휴대폰 device tracker로 외출/귀가를 감지해 상태 변화 시 1회만 팬 모드 자동 전환 - 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지 파라미터 조정 - 설정 모달에서 라즈베리파이 정품 7인치 DSI 터치 디스플레이 상태 확인, 밝기/백라이트 즉시 적용, 콘솔 회전, 터치 좌표 보정, LCD/터치 비활성화 부팅 설정 조정 - CPU 온도, 팬 RPM 핵심 차트 @@ -71,6 +72,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 ## 데이터/저장소 - `control_state`: 팬 모드와 PWM 상태 +- `presence_fan_policy_state`: 외출/귀가 기반 팬 정책의 마지막 확인/적용 상태 - `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다. - `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력 - `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시 @@ -97,7 +99,7 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 설정 가능한 항목은 다음 범위입니다. -- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, RP1 온도 반영 비율, 팬 판단 온도 여유, 자동 상승/하강 PWM step +- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, RP1 온도 반영 비율, 팬 판단 온도 여유, 자동 상승/하강 PWM step, 외출/귀가 팬 모드 자동 전환, 외출 시 PWM - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 - 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수 - 터치 디스플레이: 현재 DSI 연결 상태, `800x480` 모드, framebuffer, 백라이트 밝기, 자동감지/수동 overlay 상태 표시와 밝기 즉시 적용, 콘솔 회전, LCD 출력, 터치 입력, `invx`/`invy`/`swapxy`, 터치 좌표 크기 저장 @@ -118,6 +120,7 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 - UPS/배터리 잔여 시간: 현재 운영 구성에서는 표시하지 않음 - dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 - WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 +- 외출/귀가 팬 정책: 팬 정책 적용 루프에서 기본 30초마다 서울 HA의 휴대폰 device tracker를 확인 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 - 팬 슬라이더 자동 적용 debounce: 약 450ms diff --git a/apply_policy.php b/apply_policy.php index f0fceea..7a3a75b 100644 --- a/apply_policy.php +++ b/apply_policy.php @@ -170,6 +170,8 @@ function dmesg_log_throttled(string $key, string $message, int $seconds = 30): v } try { + sync_presence_fan_policy(); + $state = get_control_state(); $mode = (string)($state['mode'] ?? 'auto'); diff --git a/config/config.php b/config/config.php index 7ad947b..b34ef45 100644 --- a/config/config.php +++ b/config/config.php @@ -156,6 +156,9 @@ function setting_definitions(): array 'fan.control_temp_headroom' => ['group' => 'fan', 'label' => '팬 판단 온도 여유', 'type' => 'number', 'default' => 1.5, 'min' => 0, 'max' => 8, 'step' => 0.1, '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'], + 'fan.presence_policy_enabled' => ['group' => 'fan', 'label' => '외출/귀가 팬 모드 자동 전환', 'type' => 'boolean', 'default' => 1], + 'fan.presence_check_seconds' => ['group' => 'fan', 'label' => '외출/귀가 확인 주기', 'type' => 'number', 'default' => 30, 'min' => 10, 'max' => 300, 'step' => 5, 'unit' => '초'], + 'fan.away_manual_pwm' => ['group' => 'fan', 'label' => '외출 시 수동 PWM', 'type' => 'number', 'default' => 255, 'min' => 0, 'max' => 255, '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' => '회'], @@ -426,6 +429,22 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS presence_fan_policy_state ( + id TINYINT UNSIGNED NOT NULL PRIMARY KEY, + last_presence VARCHAR(16) NULL, + last_checked_at DATETIME NULL, + last_seen_at DATETIME NULL, + last_applied_presence VARCHAR(16) NULL, + last_applied_at DATETIME NULL, + last_error VARCHAR(255) NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + $pdo->exec(" CREATE TABLE IF NOT EXISTS power_event_states ( event_key VARCHAR(32) NOT NULL PRIMARY KEY, @@ -617,6 +636,13 @@ function bootstrap_db(): void (1, 'auto', 120) "); + $pdo->exec(" + INSERT IGNORE INTO presence_fan_policy_state + (id) + VALUES + (1) + "); + foreach ([ "ALTER TABLE sensor_logs ADD COLUMN create_ip VARCHAR(64) NULL", "ALTER TABLE sensor_logs ADD COLUMN disk_total_kb BIGINT NULL AFTER mem_free_mb", @@ -1114,6 +1140,237 @@ function set_control_state(string $mode, int $manualPwm): void ]); } +function presence_fan_policy_state(): array +{ + $stmt = db()->query(" + SELECT * + FROM presence_fan_policy_state + WHERE id = 1 + LIMIT 1 + "); + + return $stmt->fetch() ?: []; +} + +function update_presence_fan_policy_state(array $values): void +{ + $allowed = [ + 'last_presence', + 'last_checked_at', + 'last_seen_at', + 'last_applied_presence', + 'last_applied_at', + 'last_error', + ]; + + $sets = []; + $params = []; + foreach ($allowed as $key) { + if (!array_key_exists($key, $values)) { + continue; + } + $sets[] = $key . ' = :' . $key; + $params[':' . $key] = $values[$key]; + } + + if ($sets === []) { + return; + } + + $stmt = db()->prepare(" + UPDATE presence_fan_policy_state + SET " . implode(', ', $sets) . " + WHERE id = 1 + "); + $stmt->execute($params); +} + +function control_common_config(): array +{ + static $config = null; + if ($config !== null) { + return $config; + } + + $file = '/mnt/synology-web/custom/common/config.php'; + if (!is_file($file)) { + return $config = []; + } + + $loaded = require $file; + return $config = is_array($loaded) ? $loaded : []; +} + +function control_ha_state(string $base, string $token, string $entity): array +{ + $ch = curl_init(rtrim($base, '/') . '/api/states/' . rawurlencode($entity)); + if (!$ch) { + throw new RuntimeException('HA curl init failed.'); + } + + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + ]); + + $response = curl_exec($ch); + $errno = curl_errno($ch); + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errno !== 0 || $status < 200 || $status >= 300) { + throw new RuntimeException('HA state read failed: status=' . $status . ', errno=' . $errno); + } + + $json = json_decode((string)$response, true); + if (!is_array($json)) { + throw new RuntimeException('HA state JSON parse failed.'); + } + + return $json; +} + +function control_distance_m(float $lat1, float $lon1, float $lat2, float $lon2): float +{ + $earth = 6371000.0; + $dLat = deg2rad($lat2 - $lat1); + $dLon = deg2rad($lon2 - $lon1); + $a = sin($dLat / 2) ** 2 + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; + + return $earth * 2 * atan2(sqrt($a), sqrt(1 - $a)); +} + +function current_control_presence(): array +{ + $config = control_common_config(); + $ha = $config['ha']['seoul'] ?? []; + $entity = (string)($config['entities']['device'] ?? ''); + $home = $config['findmydevice_home'] ?? []; + + $base = (string)($ha['base'] ?? ''); + $token = (string)($ha['token'] ?? ''); + if ($base === '' || $token === '' || $entity === '') { + throw new RuntimeException('Seoul HA presence config is missing.'); + } + + $state = control_ha_state($base, $token, $entity); + $rawState = strtolower(trim((string)($state['state'] ?? ''))); + $attrs = is_array($state['attributes'] ?? null) ? $state['attributes'] : []; + + if (in_array($rawState, ['home'], true)) { + return ['presence' => 'home', 'source' => 'device_tracker', 'raw_state' => $rawState]; + } + + if (in_array($rawState, ['not_home', 'away'], true)) { + return ['presence' => 'away', 'source' => 'device_tracker', 'raw_state' => $rawState]; + } + + $lat = $attrs['latitude'] ?? null; + $lon = $attrs['longitude'] ?? null; + if (is_numeric($lat) && is_numeric($lon) && is_numeric($home['latitude'] ?? null) && is_numeric($home['longitude'] ?? null)) { + $radius = max(1.0, (float)($home['radius_m'] ?? 150)); + $distance = control_distance_m((float)$home['latitude'], (float)$home['longitude'], (float)$lat, (float)$lon); + return [ + 'presence' => $distance <= $radius ? 'home' : 'away', + 'source' => 'gps_distance', + 'raw_state' => $rawState, + 'distance_m' => round($distance, 1), + ]; + } + + throw new RuntimeException('Presence state is unavailable: ' . ($rawState ?: 'empty')); +} + +function sync_presence_fan_policy(bool $force = false): array +{ + if (!(bool)setting_value('fan.presence_policy_enabled')) { + return ['ok' => true, 'skipped' => 'disabled']; + } + + $state = presence_fan_policy_state(); + $lastChecked = strtotime((string)($state['last_checked_at'] ?? '')) ?: 0; + $interval = max(10, (int)setting_value('fan.presence_check_seconds')); + if (!$force && $lastChecked > 0 && time() - $lastChecked < $interval) { + return ['ok' => true, 'skipped' => 'throttled']; + } + + $now = date('Y-m-d H:i:s'); + + try { + $presence = current_control_presence(); + } catch (Throwable $e) { + update_presence_fan_policy_state([ + 'last_checked_at' => $now, + 'last_error' => mb_substr($e->getMessage(), 0, 255, 'UTF-8'), + ]); + return ['ok' => false, 'error' => $e->getMessage()]; + } + + $currentPresence = (string)$presence['presence']; + $previousPresence = (string)($state['last_presence'] ?? ''); + $changed = $previousPresence === '' || $previousPresence !== $currentPresence; + $update = [ + 'last_presence' => $currentPresence, + 'last_checked_at' => $now, + 'last_seen_at' => $now, + 'last_error' => null, + ]; + + $applied = false; + if ($changed) { + $fan = get_control_state(); + $mode = (string)($fan['mode'] ?? 'auto'); + $manualPwm = max(0, min(255, (int)($fan['manual_pwm'] ?? 120))); + $targetPwm = max(0, min(255, (int)setting_value('fan.away_manual_pwm'))); + + if ($currentPresence === 'away' && ($mode !== 'manual' || $manualPwm !== $targetPwm)) { + set_control_state('manual', $targetPwm); + add_fan_action( + 'presence_fan_policy', + 'manual', + $targetPwm, + 'presence=away, previous_presence=' . ($previousPresence ?: 'unknown') + . ', previous_fan=' . $mode . ':' . $manualPwm + . ', source=' . ($presence['source'] ?? 'unknown'), + true + ); + $applied = true; + } elseif ($currentPresence === 'home' && $mode !== 'auto') { + set_control_state('auto', $manualPwm); + add_fan_action( + 'presence_fan_policy', + 'auto', + $manualPwm, + 'presence=home, previous_presence=' . ($previousPresence ?: 'unknown') + . ', previous_fan=' . $mode . ':' . $manualPwm + . ', source=' . ($presence['source'] ?? 'unknown'), + true + ); + $applied = true; + } + + if ($applied) { + $update['last_applied_presence'] = $currentPresence; + $update['last_applied_at'] = $now; + } + } + + update_presence_fan_policy_state($update); + + return [ + 'ok' => true, + 'presence' => $currentPresence, + 'changed' => $changed, + 'applied' => $applied, + 'source' => $presence['source'] ?? 'unknown', + ]; +} + function rp1_temp_c(): ?float { foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) { diff --git a/public/api.php b/public/api.php index 1ece5d5..5faf0c2 100644 --- a/public/api.php +++ b/public/api.php @@ -519,6 +519,8 @@ function write_sys_value(string $path, int $value): bool function apply_fan_policy(): array { + sync_presence_fan_policy(); + $state = get_control_state(); $paths = fan_paths();