외출 귀가 팬 정책 추가

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
+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.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) {