외출 귀가 팬 정책 추가
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user