외출 귀가 팬 정책 추가

This commit is contained in:
seo
2026-07-23 03:00:24 +09:00
parent f6ed67b75a
commit c2099c21f0
4 changed files with 265 additions and 1 deletions
+4 -1
View File
@@ -12,6 +12,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- 팬 모드 `auto`, `manual`, `off` 제어 - 팬 모드 `auto`, `manual`, `off` 제어
- PWM slider 기반 수동 팬 제어 - PWM slider 기반 수동 팬 제어
- 서울 HA의 휴대폰 device tracker로 외출/귀가를 감지해 상태 변화 시 1회만 팬 모드 자동 전환
- 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지 파라미터 조정 - 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지 파라미터 조정
- 설정 모달에서 라즈베리파이 정품 7인치 DSI 터치 디스플레이 상태 확인, 밝기/백라이트 즉시 적용, 콘솔 회전, 터치 좌표 보정, LCD/터치 비활성화 부팅 설정 조정 - 설정 모달에서 라즈베리파이 정품 7인치 DSI 터치 디스플레이 상태 확인, 밝기/백라이트 즉시 적용, 콘솔 회전, 터치 좌표 보정, LCD/터치 비활성화 부팅 설정 조정
- CPU 온도, 팬 RPM 핵심 차트 - CPU 온도, 팬 RPM 핵심 차트
@@ -71,6 +72,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
## 데이터/저장소 ## 데이터/저장소
- `control_state`: 팬 모드와 PWM 상태 - `control_state`: 팬 모드와 PWM 상태
- `presence_fan_policy_state`: 외출/귀가 기반 팬 정책의 마지막 확인/적용 상태
- `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다. - `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다.
- `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력 - `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력
- `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시 - `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 조작 허용 여부 - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부
- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수 - 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수
- 터치 디스플레이: 현재 DSI 연결 상태, `800x480` 모드, framebuffer, 백라이트 밝기, 자동감지/수동 overlay 상태 표시와 밝기 즉시 적용, 콘솔 회전, LCD 출력, 터치 입력, `invx`/`invy`/`swapxy`, 터치 좌표 크기 저장 - 터치 디스플레이: 현재 DSI 연결 상태, `800x480` 모드, framebuffer, 백라이트 밝기, 자동감지/수동 overlay 상태 표시와 밝기 즉시 적용, 콘솔 회전, LCD 출력, 터치 입력, `invx`/`invy`/`swapxy`, 터치 좌표 크기 저장
@@ -118,6 +120,7 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가
- UPS/배터리 잔여 시간: 현재 운영 구성에서는 표시하지 않음 - UPS/배터리 잔여 시간: 현재 운영 구성에서는 표시하지 않음
- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 - dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지
- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 - WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행
- 외출/귀가 팬 정책: 팬 정책 적용 루프에서 기본 30초마다 서울 HA의 휴대폰 device tracker를 확인
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
- 팬 슬라이더 자동 적용 debounce: 약 450ms - 팬 슬라이더 자동 적용 debounce: 약 450ms
+2
View File
@@ -170,6 +170,8 @@ function dmesg_log_throttled(string $key, string $message, int $seconds = 30): v
} }
try { try {
sync_presence_fan_policy();
$state = get_control_state(); $state = get_control_state();
$mode = (string)($state['mode'] ?? 'auto'); $mode = (string)($state['mode'] ?? 'auto');
+257
View File
@@ -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.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_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.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.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.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' => '회'], '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 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(" $pdo->exec("
CREATE TABLE IF NOT EXISTS power_event_states ( CREATE TABLE IF NOT EXISTS power_event_states (
event_key VARCHAR(32) NOT NULL PRIMARY KEY, event_key VARCHAR(32) NOT NULL PRIMARY KEY,
@@ -617,6 +636,13 @@ function bootstrap_db(): void
(1, 'auto', 120) (1, 'auto', 120)
"); ");
$pdo->exec("
INSERT IGNORE INTO presence_fan_policy_state
(id)
VALUES
(1)
");
foreach ([ foreach ([
"ALTER TABLE sensor_logs ADD COLUMN create_ip VARCHAR(64) NULL", "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", "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 function rp1_temp_c(): ?float
{ {
foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) { foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) {
+2
View File
@@ -519,6 +519,8 @@ function write_sys_value(string $path, int $value): bool
function apply_fan_policy(): array function apply_fan_policy(): array
{ {
sync_presence_fan_policy();
$state = get_control_state(); $state = get_control_state();
$paths = fan_paths(); $paths = fan_paths();