|
|
|
@@ -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) {
|
|
|
|
|