Files
control/config/config.php
T

1816 lines
54 KiB
PHP

<?php
declare(strict_types=1);
const APP_NAME = 'Seoul Control Center';
const APP_HOST = 'control.chaegeon.com';
const REMEMBER_COOKIE = 'control_remember';
const REMEMBER_DAYS = 30;
$controlSecretFile = '/home/seo/secret/control.php';
if (!is_file($controlSecretFile)) {
throw new RuntimeException('Missing control secret config: ' . $controlSecretFile);
}
$controlSecretConfig = require $controlSecretFile;
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
$controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : [];
define('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? ''));
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
define('DB_PORT', (int)($controlDbConfig['port'] ?? 3306));
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
define('BATTERY_CAPACITY_WH', isset($controlBatteryConfig['capacity_wh']) ? max(0.0, (float)$controlBatteryConfig['capacity_wh']) : 0.0);
date_default_timezone_set('Asia/Seoul');
if (is_file(__DIR__ . '/vapid.php')) {
require_once __DIR__ . '/vapid.php';
}
if (is_file(__DIR__ . '/../vendor/autoload.php')) {
require_once __DIR__ . '/../vendor/autoload.php';
}
function db_server(): PDO
{
return new PDO(
'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4',
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
function db(bool $forceReconnect = false): PDO
{
static $pdo = null;
static $lastPing = 0.0;
if ($forceReconnect) {
$pdo = null;
$lastPing = 0.0;
}
if ($pdo instanceof PDO) {
if ((microtime(true) - $lastPing) < 5.0) {
return $pdo;
}
try {
$pdo->query('SELECT 1');
$lastPing = microtime(true);
return $pdo;
} catch (PDOException $e) {
if (db_connection_lost($e)) {
$pdo = null;
$lastPing = 0.0;
} else {
throw $e;
}
}
}
bootstrap_db();
$pdo = db_connect();
$lastPing = microtime(true);
return $pdo;
}
function db_connect(): PDO
{
$pdo = new PDO(
'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$pdo->exec("SET time_zone = '+09:00'");
return $pdo;
}
function db_connection_lost(PDOException $e): bool
{
$info = $e->errorInfo;
$driverCode = isset($info[1]) ? (int)$info[1] : 0;
$message = $e->getMessage();
return in_array($driverCode, [2006, 2013], true)
|| str_contains($message, 'server has gone away')
|| str_contains($message, 'Lost connection');
}
function bootstrap_db(): void
{
static $done = false;
if ($done) {
return;
}
$pdo = db_server();
$pdo->exec("
CREATE DATABASE IF NOT EXISTS `" . DB_NAME . "`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
");
$pdo->exec("USE `" . DB_NAME . "`");
$pdo->exec("
CREATE TABLE IF NOT EXISTS control_state (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
mode VARCHAR(16) NOT NULL DEFAULT 'auto',
manual_pwm INT NOT NULL DEFAULT 120,
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 fan_actions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
action_type VARCHAR(64) NOT NULL,
pwm_mode VARCHAR(16) NULL,
pwm_value INT NULL,
note TEXT NULL,
actor_ip VARCHAR(64) NULL,
success TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_created_at (created_at),
INDEX idx_action_type (action_type)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS sensor_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
recorded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
cpu_temp_c DECIMAL(6,2) NULL,
fan_rpm INT NULL,
fan_efficiency DECIMAL(10,2) NULL,
rp1_temp_c DECIMAL(6,2) NULL,
cpu_voltage DECIMAL(6,3) NULL,
cpu_watts DECIMAL(8,3) NULL,
battery_voltage DECIMAL(6,3) NULL,
battery_percent DECIMAL(6,2) NULL,
pwm_value INT NULL,
pwm_percent DECIMAL(5,2) NULL,
pwm_mode VARCHAR(16) NULL,
cpu_load_1 DECIMAL(8,2) NULL,
cpu_load_5 DECIMAL(8,2) NULL,
cpu_load_15 DECIMAL(8,2) NULL,
mem_total_mb INT NULL,
mem_used_mb INT NULL,
mem_free_mb INT NULL,
disk_total_kb BIGINT NULL,
disk_used_kb BIGINT NULL,
disk_free_kb BIGINT NULL,
uptime_seconds BIGINT NULL,
hostname VARCHAR(255) NULL,
create_ip VARCHAR(64) NULL,
INDEX idx_recorded_at (recorded_at),
INDEX idx_pwm_mode (pwm_mode)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS fan_spike_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
spike_key VARCHAR(32) NULL,
summary VARCHAR(255) NULL,
rpm_delta DECIMAL(10,2) NULL,
pwm_delta DECIMAL(10,2) NULL,
temp_delta DECIMAL(10,2) NULL,
current_rpm INT NULL,
current_pwm INT NULL,
current_temp DECIMAL(6,2) NULL,
cpu_process LONGTEXT NULL,
memory_process LONGTEXT NULL,
UNIQUE KEY uniq_spike_key (spike_key),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS remember_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
selector CHAR(24) NOT NULL,
validator_hash CHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
last_used_at DATETIME NULL,
last_ip VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
UNIQUE KEY uniq_selector (selector),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS push_subscriptions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
endpoint_hash CHAR(64) NOT NULL,
endpoint TEXT NOT NULL,
p256dh VARCHAR(255) NOT NULL,
auth VARCHAR(255) NOT NULL,
content_encoding VARCHAR(32) NOT NULL DEFAULT 'aes128gcm',
device_name VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
actor_ip VARCHAR(64) NULL,
last_send_success_at DATETIME NULL,
last_send_failed_at DATETIME NULL,
last_received_at DATETIME NULL,
last_notification_at DATETIME NULL,
last_click_at DATETIME NULL,
failure_count INT UNSIGNED NOT NULL DEFAULT 0,
last_failure_reason VARCHAR(255) NULL,
UNIQUE KEY uniq_endpoint_hash (endpoint_hash),
INDEX idx_last_seen_at (last_seen_at),
INDEX idx_last_received_at (last_received_at),
INDEX idx_last_send_success_at (last_send_success_at)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS push_event_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
event VARCHAR(32) NOT NULL,
endpoint_hash CHAR(64) NULL,
device_name VARCHAR(64) NULL,
endpoint_host VARCHAR(128) NULL,
actor_ip VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
message VARCHAR(255) NULL,
meta JSON NULL,
INDEX idx_created_at (created_at),
INDEX idx_event (event),
INDEX idx_endpoint_hash (endpoint_hash)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS system_notice_state (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
state VARCHAR(16) NOT NULL DEFAULT 'normal',
baseline_temp DECIMAL(8,2) NULL,
baseline_rpm DECIMAL(12,2) NULL,
baseline_pwm DECIMAL(8,2) NULL,
active_reason VARCHAR(255) NULL,
active_temp_delta DECIMAL(8,2) NULL,
active_rpm_delta DECIMAL(12,2) NULL,
process_signature VARCHAR(255) NULL,
alert_started_at DATETIME NULL,
last_alert_at DATETIME 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 battery_push_state (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
last_sent_at DATETIME NULL,
last_percent DECIMAL(6,2) NULL,
last_voltage DECIMAL(6,3) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
INSERT IGNORE INTO control_state
(id, mode, manual_pwm)
VALUES
(1, 'auto', 120)
");
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",
"ALTER TABLE sensor_logs ADD COLUMN disk_used_kb BIGINT NULL AFTER disk_total_kb",
"ALTER TABLE sensor_logs ADD COLUMN disk_free_kb BIGINT NULL AFTER disk_used_kb",
"ALTER TABLE sensor_logs ADD COLUMN fan_efficiency DECIMAL(10,2) NULL AFTER fan_rpm",
"ALTER TABLE sensor_logs ADD COLUMN rp1_temp_c DECIMAL(6,2) NULL AFTER fan_efficiency",
"ALTER TABLE sensor_logs ADD COLUMN cpu_voltage DECIMAL(6,3) NULL AFTER rp1_temp_c",
"ALTER TABLE sensor_logs ADD COLUMN cpu_watts DECIMAL(8,3) NULL AFTER cpu_voltage",
"ALTER TABLE sensor_logs ADD COLUMN battery_voltage DECIMAL(6,3) NULL AFTER cpu_watts",
"ALTER TABLE sensor_logs ADD COLUMN battery_percent DECIMAL(6,2) NULL AFTER battery_voltage",
"ALTER TABLE sensor_logs DROP COLUMN battery_current_amps",
"ALTER TABLE sensor_logs DROP COLUMN battery_watts",
"ALTER TABLE sensor_logs DROP COLUMN created_ip",
"ALTER TABLE fan_spike_logs ADD COLUMN spike_key VARCHAR(32) NULL AFTER created_at",
"ALTER TABLE fan_spike_logs ADD UNIQUE KEY uniq_spike_key (spike_key)",
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
"ALTER TABLE push_subscriptions ADD COLUMN device_name VARCHAR(64) NULL AFTER content_encoding",
"ALTER TABLE push_subscriptions ADD COLUMN last_send_success_at DATETIME NULL AFTER actor_ip",
"ALTER TABLE push_subscriptions ADD COLUMN last_send_failed_at DATETIME NULL AFTER last_send_success_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_received_at DATETIME NULL AFTER last_send_failed_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_notification_at DATETIME NULL AFTER last_received_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_click_at DATETIME NULL AFTER last_notification_at",
"ALTER TABLE push_subscriptions ADD COLUMN failure_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_click_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_failure_reason VARCHAR(255) NULL AFTER failure_count",
"ALTER TABLE push_subscriptions ADD INDEX idx_last_received_at (last_received_at)",
"ALTER TABLE push_subscriptions ADD INDEX idx_last_send_success_at (last_send_success_at)",
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
"ALTER TABLE sensor_logs DROP COLUMN input_voltage",
"ALTER TABLE sensor_logs DROP COLUMN cpu_core_voltage",
"ALTER TABLE sensor_logs DROP COLUMN cpu_core_amps",
"ALTER TABLE sensor_logs DROP COLUMN system_watts",
"ALTER TABLE sensor_logs DROP COLUMN cpu_freq_mhz",
"ALTER TABLE sensor_logs DROP COLUMN cpu_freq_min_mhz",
"ALTER TABLE sensor_logs DROP COLUMN cpu_freq_max_mhz",
"ALTER TABLE sensor_logs DROP COLUMN arm_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN core_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN isp_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN v3d_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN emmc_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN uart_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN hdmi_clock_mhz",
"ALTER TABLE sensor_logs DROP COLUMN sdram_c_voltage",
"ALTER TABLE sensor_logs DROP COLUMN sdram_i_voltage",
"ALTER TABLE sensor_logs DROP COLUMN sdram_p_voltage",
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in1_voltage",
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in2_voltage",
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in3_voltage",
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in4_voltage",
"ALTER TABLE sensor_logs DROP COLUMN cooling_state",
"ALTER TABLE sensor_logs DROP COLUMN cooling_max_state",
"ALTER TABLE sensor_logs DROP COLUMN regulator_3v3_voltage",
"ALTER TABLE sensor_logs DROP COLUMN regulator_5v_voltage",
"ALTER TABLE sensor_logs DROP COLUMN regulator_vdd_3v3_voltage",
"ALTER TABLE sensor_logs DROP COLUMN regulator_wl_on",
"ALTER TABLE sensor_logs DROP COLUMN regulator_sd_io_voltage",
"ALTER TABLE sensor_logs DROP COLUMN pcie_link_speed",
"ALTER TABLE sensor_logs DROP COLUMN pcie_link_width",
"ALTER TABLE sensor_logs DROP COLUMN throttled_raw",
"ALTER TABLE sensor_logs DROP COLUMN under_voltage_now",
"ALTER TABLE sensor_logs DROP COLUMN throttled_now",
"ALTER TABLE sensor_logs DROP COLUMN freq_capped_now",
"ALTER TABLE sensor_logs DROP COLUMN soft_temp_limit_now",
"ALTER TABLE sensor_logs DROP COLUMN under_voltage_seen",
"ALTER TABLE sensor_logs DROP COLUMN throttled_seen",
"ALTER TABLE sensor_logs DROP COLUMN freq_capped_seen",
"ALTER TABLE sensor_logs DROP COLUMN soft_temp_limit_seen",
"ALTER TABLE sensor_logs DROP COLUMN ac_power_ok",
"ALTER TABLE sensor_logs DROP COLUMN charging_enabled",
"ALTER TABLE fan_spike_logs DROP COLUMN io_process",
"DROP TABLE top_process_logs",
"DROP TABLE wifi_station_logs",
"DROP TABLE software_status_logs",
"DROP TABLE helper_command_logs",
"DROP TABLE service_units",
"DROP TABLE failed_units",
"DROP TABLE timer_units",
"DROP TABLE journal_warning_logs",
"DROP TABLE installed_packages",
"DROP TABLE upgradable_packages",
] as $sql) {
try {
$pdo->exec($sql);
} catch (Throwable) {
}
}
$done = true;
}
bootstrap_db();
function e(mixed $value): string
{
return htmlspecialchars(
(string)$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
function json_out(array $payload, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode(
$payload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
exit;
}
function vapid_public_key(): string
{
return defined('VAPID_PUBLIC_KEY') ? VAPID_PUBLIC_KEY : '';
}
function push_subscription_from_json(string $raw): array
{
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function push_log_event(string $event, array $context = []): void
{
try {
$endpoint = (string)($context['endpoint'] ?? '');
$endpointHash = (string)($context['endpoint_hash'] ?? '');
if ($endpointHash === '' && $endpoint !== '') {
$endpointHash = hash('sha256', $endpoint);
}
$stmt = db()->prepare("
INSERT INTO push_event_logs (
event,
endpoint_hash,
device_name,
endpoint_host,
actor_ip,
user_agent,
message,
meta
) VALUES (
:event,
:endpoint_hash,
:device_name,
:endpoint_host,
:actor_ip,
:user_agent,
:message,
:meta
)
");
unset($context['endpoint'], $context['p256dh'], $context['auth']);
$stmt->execute([
':event' => mb_substr($event, 0, 32),
':endpoint_hash' => $endpointHash !== '' ? $endpointHash : null,
':device_name' => isset($context['device_name']) ? mb_substr((string)$context['device_name'], 0, 64) : null,
':endpoint_host' => $endpoint !== '' ? mb_substr((string)(parse_url($endpoint, PHP_URL_HOST) ?: ''), 0, 128) : null,
':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64),
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null,
':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
update_push_subscription_health($event, $endpointHash, $context);
} catch (Throwable) {
}
}
function update_push_subscription_health(string $event, string $endpointHash, array $context = []): void
{
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
return;
}
$reason = mb_substr((string)($context['reason'] ?? ''), 0, 255);
try {
if (in_array($event, ['register', 'register_update'], true)) {
$stmt = db()->prepare("
UPDATE push_subscriptions
SET last_seen_at = CURRENT_TIMESTAMP,
failure_count = 0,
last_failure_reason = NULL
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
return;
}
if ($event === 'send_success') {
$stmt = db()->prepare("
UPDATE push_subscriptions
SET last_send_success_at = CURRENT_TIMESTAMP,
failure_count = 0,
last_failure_reason = NULL
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
return;
}
if ($event === 'send_failed') {
$stmt = db()->prepare("
UPDATE push_subscriptions
SET last_send_failed_at = CURRENT_TIMESTAMP,
failure_count = failure_count + 1,
last_failure_reason = :reason
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([
':endpoint_hash' => $endpointHash,
':reason' => $reason !== '' ? $reason : 'send_failed',
]);
return;
}
$columnByEvent = [
'push_received' => 'last_received_at',
'notification_shown' => 'last_notification_at',
'notification_click' => 'last_click_at',
];
if (isset($columnByEvent[$event])) {
$column = $columnByEvent[$event];
$stmt = db()->prepare("
UPDATE push_subscriptions
SET {$column} = CURRENT_TIMESTAMP,
failure_count = 0,
last_failure_reason = NULL
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
}
} catch (Throwable) {
}
}
function save_push_subscription(array $subscription): void
{
$endpoint = (string)($subscription['endpoint'] ?? '');
$keys = is_array($subscription['keys'] ?? null) ? $subscription['keys'] : [];
$p256dh = (string)($keys['p256dh'] ?? '');
$auth = (string)($keys['auth'] ?? '');
$deviceName = trim((string)($subscription['device_name'] ?? ''));
preg_match_all('/[\x{AC00}-\x{D7A3}\x{3131}-\x{318E}]/u', $deviceName, $hangulMatches);
$hangulCount = count($hangulMatches[0] ?? []);
if ($endpoint === '' || $p256dh === '' || $auth === '') {
json_out([
'ok' => false,
'error' => 'invalid_subscription',
], 422);
}
if ($deviceName === '' || $hangulCount < 2) {
json_out([
'ok' => false,
'error' => 'invalid_device_name',
'message' => '기기 이름은 한글 2글자 이상이어야 합니다.',
], 422);
}
$endpointHash = hash('sha256', $endpoint);
$exists = false;
try {
$check = db()->prepare("SELECT 1 FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash LIMIT 1");
$check->execute([':endpoint_hash' => $endpointHash]);
$exists = (bool)$check->fetchColumn();
} catch (Throwable) {
}
$stmt = db()->prepare("
INSERT INTO push_subscriptions (
endpoint_hash,
endpoint,
p256dh,
auth,
content_encoding,
device_name,
user_agent,
actor_ip
) VALUES (
:endpoint_hash,
:endpoint,
:p256dh,
:auth,
:content_encoding,
:device_name,
:user_agent,
:actor_ip
)
ON DUPLICATE KEY UPDATE
endpoint = VALUES(endpoint),
p256dh = VALUES(p256dh),
auth = VALUES(auth),
content_encoding = VALUES(content_encoding),
device_name = VALUES(device_name),
user_agent = VALUES(user_agent),
actor_ip = VALUES(actor_ip),
last_seen_at = CURRENT_TIMESTAMP
");
$stmt->execute([
':endpoint_hash' => $endpointHash,
':endpoint' => $endpoint,
':p256dh' => $p256dh,
':auth' => $auth,
':content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
':device_name' => mb_substr($deviceName, 0, 64),
':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
push_log_event($exists ? 'register_update' : 'register', [
'endpoint' => $endpoint,
'device_name' => $deviceName,
'content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
]);
}
function push_device_rows(): array
{
$stmt = db()->query("
SELECT
endpoint_hash,
endpoint,
device_name,
content_encoding,
user_agent,
actor_ip,
created_at,
last_seen_at,
last_send_success_at,
last_send_failed_at,
last_received_at,
last_notification_at,
last_click_at,
failure_count,
last_failure_reason,
TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS last_seen_seconds,
TIMESTAMPDIFF(SECOND, last_send_success_at, NOW()) AS last_send_success_seconds,
TIMESTAMPDIFF(SECOND, last_received_at, NOW()) AS last_received_seconds,
TIMESTAMPDIFF(SECOND, last_notification_at, NOW()) AS last_notification_seconds
FROM push_subscriptions
ORDER BY last_seen_at DESC
LIMIT 200
");
$rows = [];
foreach ($stmt->fetchAll() as $row) {
$endpoint = (string)($row['endpoint'] ?? '');
$failureCount = (int)($row['failure_count'] ?? 0);
$lastReceivedSeconds = isset($row['last_received_seconds']) ? (int)$row['last_received_seconds'] : null;
$lastSendSuccessSeconds = isset($row['last_send_success_seconds']) ? (int)$row['last_send_success_seconds'] : null;
$status = 'pending';
$statusText = '수신 대기';
if ($failureCount >= 3) {
$status = 'failed';
$statusText = '발송 실패 누적';
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 86400) {
$status = 'healthy';
$statusText = '정상';
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 604800) {
$status = 'watch';
$statusText = '수신 지연';
} elseif ($lastSendSuccessSeconds !== null) {
$status = 'stale';
$statusText = '장기 미수신';
}
$rows[] = [
'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)),
'endpoint' => $endpoint,
'host' => parse_url($endpoint, PHP_URL_HOST) ?: 'unknown',
'device_name' => (string)($row['device_name'] ?? ''),
'content_encoding' => (string)($row['content_encoding'] ?? ''),
'user_agent' => (string)($row['user_agent'] ?? ''),
'actor_ip' => (string)($row['actor_ip'] ?? ''),
'created_at' => (string)($row['created_at'] ?? ''),
'last_seen_at' => (string)($row['last_seen_at'] ?? ''),
'last_send_success_at' => (string)($row['last_send_success_at'] ?? ''),
'last_send_failed_at' => (string)($row['last_send_failed_at'] ?? ''),
'last_received_at' => (string)($row['last_received_at'] ?? ''),
'last_notification_at' => (string)($row['last_notification_at'] ?? ''),
'last_click_at' => (string)($row['last_click_at'] ?? ''),
'last_seen_seconds' => $row['last_seen_seconds'] !== null ? (int)$row['last_seen_seconds'] : null,
'last_send_success_seconds' => $lastSendSuccessSeconds,
'last_received_seconds' => $lastReceivedSeconds,
'last_notification_seconds' => $row['last_notification_seconds'] !== null ? (int)$row['last_notification_seconds'] : null,
'failure_count' => $failureCount,
'last_failure_reason' => (string)($row['last_failure_reason'] ?? ''),
'health_status' => $status,
'health_text' => $statusText,
];
}
return $rows;
}
function push_health_summary(): array
{
$devices = push_device_rows();
$summary = [
'total' => count($devices),
'healthy' => 0,
'watch' => 0,
'stale' => 0,
'failed' => 0,
'pending' => 0,
];
foreach ($devices as $device) {
$status = (string)($device['health_status'] ?? 'pending');
if (!array_key_exists($status, $summary)) {
$status = 'pending';
}
$summary[$status]++;
}
return $summary;
}
function push_subscription_status(string $endpoint): array
{
$endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : '';
$matched = null;
if ($endpointHash !== '') {
$stmt = db()->prepare("
SELECT device_name
FROM push_subscriptions
WHERE endpoint_hash = :endpoint_hash
LIMIT 1
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
$row = $stmt->fetch();
if ($row) {
$matched = $row;
}
}
$count = (int)db()->query("SELECT COUNT(*) FROM push_subscriptions")->fetchColumn();
return [
'subscriber_count' => $count,
'subscribed' => $matched !== null,
'device_name' => $matched['device_name'] ?? null,
];
}
function delete_push_device(string $endpointHash): void
{
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
json_out([
'ok' => false,
'error' => 'invalid_push_device',
], 422);
}
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
$stmt->execute([':endpoint_hash' => $endpointHash]);
push_log_event('delete_device', [
'endpoint_hash' => $endpointHash,
'deleted' => $stmt->rowCount(),
]);
}
function delete_push_endpoint(string $endpoint): void
{
if ($endpoint === '') {
json_out([
'ok' => false,
'error' => 'invalid_push_endpoint',
], 422);
}
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
$endpointHash = hash('sha256', $endpoint);
$stmt->execute([':endpoint_hash' => $endpointHash]);
push_log_event('unregister', [
'endpoint' => $endpoint,
'endpoint_hash' => $endpointHash,
'deleted' => $stmt->rowCount(),
]);
}
function send_push_payload(array $payload): array
{
$pushId = (string)($payload['push_id'] ?? '');
if ($pushId === '') {
$pushId = bin2hex(random_bytes(12));
$payload['push_id'] = $pushId;
}
$tag = (string)($payload['tag'] ?? '');
if (
!class_exists(\Minishlink\WebPush\WebPush::class)
|| !defined('VAPID_PUBLIC_KEY')
|| !defined('VAPID_PRIVATE_KEY')
) {
push_log_event('send_config_missing', [
'message' => 'web_push_not_configured',
'push_id' => $pushId,
'tag' => $tag,
]);
return [
'sent' => 0,
'failed' => 0,
'error' => 'web_push_not_configured',
];
}
$stmt = db()->query("
SELECT endpoint, p256dh, auth, content_encoding
FROM push_subscriptions
ORDER BY last_seen_at DESC
LIMIT 200
");
$rows = $stmt->fetchAll();
if ($rows === []) {
push_log_event('send_no_subscribers', [
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
]);
return [
'sent' => 0,
'failed' => 0,
'error' => null,
];
}
$webPush = new \Minishlink\WebPush\WebPush([
'VAPID' => [
'subject' => 'mailto:admin@' . APP_HOST,
'publicKey' => VAPID_PUBLIC_KEY,
'privateKey' => VAPID_PRIVATE_KEY,
],
]);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sent = 0;
$failed = 0;
push_log_event('send_request', [
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
'subscriber_count' => count($rows),
]);
foreach ($rows as $row) {
$subscription = \Minishlink\WebPush\Subscription::create([
'endpoint' => (string)$row['endpoint'],
'publicKey' => (string)$row['p256dh'],
'authToken' => (string)$row['auth'],
'contentEncoding' => (string)($row['content_encoding'] ?: 'aes128gcm'),
]);
$webPush->queueNotification($subscription, $json);
}
foreach ($webPush->flush() as $report) {
$endpoint = $report->getEndpoint();
if ($report->isSuccess()) {
$sent++;
push_log_event('send_success', [
'endpoint' => $endpoint,
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
]);
continue;
}
$failed++;
$reason = method_exists($report, 'getReason') ? (string)$report->getReason() : 'send_failed';
if ($report->isSubscriptionExpired()) {
$delete = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
$delete->execute([':endpoint_hash' => hash('sha256', $endpoint)]);
push_log_event('send_expired', [
'endpoint' => $endpoint,
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
'reason' => $reason,
]);
continue;
}
push_log_event('send_failed', [
'endpoint' => $endpoint,
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
'reason' => $reason,
]);
}
return [
'sent' => $sent,
'failed' => $failed,
'error' => null,
];
}
function latest_push_send_epoch_by_tag(string $tagPrefix): int
{
$stmt = db()->prepare("
SELECT UNIX_TIMESTAMP(MAX(created_at))
FROM push_event_logs
WHERE event = 'send_success'
AND meta LIKE :tag
");
$stmt->execute([
':tag' => '%\"tag\":\"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagPrefix) . '%',
]);
return (int)($stmt->fetchColumn() ?: 0);
}
function send_push_healthcheck_if_due(int $minHours = 24, bool $force = false): array
{
$minHours = max(1, min(168, $minHours));
$latest = latest_push_send_epoch_by_tag('control-healthcheck-');
if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) {
return [
'sent' => 0,
'failed' => 0,
'error' => null,
'skipped' => true,
'message' => 'healthcheck_cooldown',
'next_after_seconds' => ($minHours * 3600) - (time() - $latest),
];
}
return send_push_payload([
'title' => 'Seoul Control Center',
'body' => '푸시 기기 상태 확인 알림입니다.',
'url' => '/',
'tag' => 'control-healthcheck-' . date('YmdHi'),
'created_at' => date('Y-m-d H:i:s'),
'silent' => true,
'data' => [
'kind' => 'push_healthcheck',
],
]);
}
function reset_battery_low_push_state(): void
{
$stmt = db()->prepare("
INSERT INTO battery_push_state
(id, last_sent_at, last_percent, last_voltage)
VALUES
(1, NULL, NULL, NULL)
ON DUPLICATE KEY UPDATE
last_sent_at = NULL,
last_percent = NULL,
last_voltage = NULL
");
$stmt->execute();
}
function battery_low_push_due(float $percent, ?float $voltage): bool
{
$stmt = db()->query("
SELECT last_sent_at
FROM battery_push_state
WHERE id = 1
LIMIT 1
");
$row = $stmt->fetch() ?: [];
$lastSent = strtotime((string)($row['last_sent_at'] ?? '')) ?: 0;
if ($lastSent > 0 && time() - $lastSent < 10) {
return false;
}
$save = db()->prepare("
INSERT INTO battery_push_state
(id, last_sent_at, last_percent, last_voltage)
VALUES
(1, CURRENT_TIMESTAMP, :last_percent, :last_voltage)
ON DUPLICATE KEY UPDATE
last_sent_at = CURRENT_TIMESTAMP,
last_percent = VALUES(last_percent),
last_voltage = VALUES(last_voltage)
");
$save->execute([
':last_percent' => round($percent, 2),
':last_voltage' => $voltage === null ? null : round($voltage, 3),
]);
return true;
}
function battery_soc_rising_from_history(float $percent): bool
{
$stmt = db()->query("
SELECT battery_percent
FROM sensor_logs
WHERE battery_percent IS NOT NULL
ORDER BY id DESC
LIMIT 20
");
$rows = array_reverse($stmt->fetchAll());
if (count($rows) < 8) {
return false;
}
$values = [];
foreach ($rows as $row) {
$value = $row['battery_percent'] ?? null;
if ($value === null || $value === '' || !is_numeric($value)) {
continue;
}
$values[] = (float)$value;
}
if (count($values) < 8) {
return false;
}
$older = array_slice($values, 0, 5);
$recent = array_slice($values, -5);
$olderAvg = array_sum($older) / count($older);
$recentAvg = array_sum($recent) / count($recent);
return ($recentAvg - $olderAvg) >= 0.20
&& ($percent - $olderAvg) >= 0.15;
}
function send_battery_low_push_if_needed(array $battery): array
{
$percent = $battery['percent'] ?? null;
if ($percent === null || $percent === '' || !is_numeric($percent)) {
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_soc_missing',
];
}
$percent = (float)$percent;
$voltage = isset($battery['voltage']) && is_numeric($battery['voltage'])
? (float)$battery['voltage']
: null;
if ($percent > 20.0) {
reset_battery_low_push_state();
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_soc_ok',
];
}
if (battery_soc_rising_from_history($percent)) {
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_soc_rising',
];
}
if (!battery_low_push_due($percent, $voltage)) {
return [
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_low_cooldown',
];
}
$body = 'Battery SOC '
. number_format($percent, 2)
. '%'
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
. "\n20% 이하 상태입니다.";
return send_push_payload([
'title' => '배터리 SOC 경고',
'body' => $body,
'url' => '/',
'tag' => 'control-battery-low',
'renotify' => false,
'require_interaction' => true,
'silent' => false,
'vibrate' => [900, 250, 900, 250, 1400],
'created_at' => date('Y-m-d H:i:s'),
'data' => [
'battery_percent' => round($percent, 2),
'battery_voltage' => $voltage === null ? null : round($voltage, 3),
],
]);
}
function signed_in(): bool
{
if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) {
return true;
}
return auto_login_from_cookie();
}
function require_login(): void
{
if (!signed_in()) {
header('Location: /');
exit;
}
}
function remember_cookie_options(int $expires): array
{
return [
'expires' => $expires,
'path' => '/',
'domain' => '',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
];
}
function issue_remember_token(): void
{
$selector = bin2hex(random_bytes(12));
$validator = bin2hex(random_bytes(32));
$expires = time() + REMEMBER_DAYS * 86400;
$stmt = db()->prepare("
INSERT INTO remember_tokens
(
selector,
validator_hash,
expires_at,
last_ip,
user_agent
)
VALUES
(
:selector,
:validator_hash,
FROM_UNIXTIME(:expires_at),
:last_ip,
:user_agent
)
");
$stmt->execute([
':selector' => $selector,
':validator_hash' => hash('sha256', $validator),
':expires_at' => $expires,
':last_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
setcookie(
REMEMBER_COOKIE,
$selector . ':' . $validator,
remember_cookie_options($expires)
);
db()->exec("
DELETE FROM remember_tokens
WHERE expires_at < NOW()
");
}
function clear_remember_token(): void
{
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
$parts = explode(':', $raw, 2);
if (count($parts) === 2 && preg_match('/^[a-f0-9]{24}$/', $parts[0])) {
$stmt = db()->prepare("
DELETE FROM remember_tokens
WHERE selector = :selector
");
$stmt->execute([
':selector' => $parts[0],
]);
}
setcookie(
REMEMBER_COOKIE,
'',
remember_cookie_options(time() - 3600)
);
unset($_COOKIE[REMEMBER_COOKIE]);
}
function auto_login_from_cookie(): bool
{
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
if ($raw === '') {
return false;
}
$parts = explode(':', $raw, 2);
if (
count($parts) !== 2
|| !preg_match('/^[a-f0-9]{24}$/', $parts[0])
|| !preg_match('/^[a-f0-9]{64}$/', $parts[1])
) {
clear_remember_token();
return false;
}
[$selector, $validator] = $parts;
$stmt = db()->prepare("
SELECT
id,
validator_hash,
UNIX_TIMESTAMP(expires_at) AS expires_ts
FROM remember_tokens
WHERE selector = :selector
LIMIT 1
");
$stmt->execute([
':selector' => $selector,
]);
$row = $stmt->fetch();
if (!$row || (int)$row['expires_ts'] < time()) {
clear_remember_token();
return false;
}
if (!hash_equals((string)$row['validator_hash'], hash('sha256', $validator))) {
clear_remember_token();
return false;
}
$_SESSION['control_login'] = true;
$stmt = db()->prepare("
UPDATE remember_tokens
SET
last_used_at = NOW(),
last_ip = :last_ip,
user_agent = :user_agent
WHERE id = :id
");
$stmt->execute([
':id' => $row['id'],
':last_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
return true;
}
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(24));
}
return (string)$_SESSION['csrf'];
}
function require_csrf(): void
{
$token = $_POST['csrf']
?? $_SERVER['HTTP_X_CSRF_TOKEN']
?? '';
if (!hash_equals(csrf_token(), (string)$token)) {
json_out([
'ok' => false,
'error' => 'bad_csrf',
], 403);
}
}
function sh(array $cmd, bool $root = false, int $timeout = 8): array
{
$full = $cmd;
if (
$root
&& function_exists('posix_geteuid')
&& posix_geteuid() !== 0
) {
$full = array_merge(
['/usr/bin/sudo', '-n'],
$cmd
);
}
$escaped = array_map('escapeshellarg', $full);
$command =
'/usr/bin/timeout '
. (int)$timeout
. 's '
. implode(' ', $escaped)
. ' 2>&1';
$output = [];
$code = 0;
exec($command, $output, $code);
return [
'code' => $code,
'out' => implode("\n", $output),
];
}
function first_readable(array $files): string
{
foreach ($files as $file) {
if (!is_readable($file)) {
continue;
}
$value = trim((string)@file_get_contents($file));
if ($value !== '') {
return $value;
}
}
return '';
}
function get_control_state(): array
{
$stmt = db()->query("
SELECT *
FROM control_state
WHERE id = 1
LIMIT 1
");
$row = $stmt->fetch() ?: [];
return [
'mode' => $row['mode'] ?? 'auto',
'manual_pwm' => isset($row['manual_pwm']) ? (int)$row['manual_pwm'] : 120,
'updated_at' => $row['updated_at'] ?? null,
];
}
function set_control_state(string $mode, int $manualPwm): void
{
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
$mode = 'auto';
}
$manualPwm = max(0, min(255, $manualPwm));
$stmt = db()->prepare("
INSERT INTO control_state
(id, mode, manual_pwm)
VALUES
(1, :mode, :manual_pwm)
ON DUPLICATE KEY UPDATE
mode = VALUES(mode),
manual_pwm = VALUES(manual_pwm),
updated_at = CURRENT_TIMESTAMP
");
$stmt->execute([
':mode' => $mode,
':manual_pwm' => $manualPwm,
]);
}
function rp1_temp_c(): ?float
{
foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) {
if (trim(first_readable([$dir . '/name'])) !== 'rp1_adc') {
continue;
}
$raw = first_readable([$dir . '/temp1_input']);
if ($raw !== '' && is_numeric($raw)) {
return round(((float)$raw) / 1000, 2);
}
}
return null;
}
function cpu_power_status(): array
{
$out = sh(['/usr/bin/vcgencmd', 'pmic_read_adc'], false, 3)['out'];
$voltage = null;
$amps = null;
foreach (preg_split('/\R/', trim($out)) ?: [] as $line) {
if (preg_match('/VDD_CORE_V\\s+[^=]+=([-+]?[0-9]*\\.?[0-9]+)V/', $line, $m)) {
$voltage = (float)$m[1];
} elseif (preg_match('/VDD_CORE_A\\s+[^=]+=([-+]?[0-9]*\\.?[0-9]+)A/', $line, $m)) {
$amps = (float)$m[1];
}
}
return [
'voltage' => $voltage === null ? null : round($voltage, 3),
'amps' => $amps === null ? null : round($amps, 3),
'watts' => ($voltage === null || $amps === null) ? null : round($voltage * $amps, 3),
];
}
function i2cget_byte(int $bus, int $address, int $register): ?int
{
$bin = null;
foreach (['/usr/sbin/i2cget', '/usr/bin/i2cget'] as $candidate) {
if (is_executable($candidate)) {
$bin = $candidate;
break;
}
}
if ($bin === null) {
return null;
}
$result = sh([
$bin,
'-y',
(string)$bus,
sprintf('0x%02x', $address),
sprintf('0x%02x', $register),
], true, 2);
if ((int)$result['code'] !== 0) {
return null;
}
$raw = trim((string)$result['out']);
if (!preg_match('/0x([0-9a-f]{1,2})/i', $raw, $m)) {
return null;
}
return hexdec($m[1]);
}
function i2cget_word_bytes(int $bus, int $address, int $register): ?array
{
$msb = i2cget_byte($bus, $address, $register);
$lsb = i2cget_byte($bus, $address, $register + 1);
if ($msb === null || $lsb === null) {
return null;
}
return [$msb, $lsb];
}
function battery_status(): array
{
$voltage = null;
$percent = null;
$vcell = i2cget_word_bytes(1, 0x36, 0x02);
if ($vcell !== null) {
[$msb, $lsb] = $vcell;
$raw = ($msb << 4) | ($lsb >> 4);
$voltage = round($raw * 0.00125, 3);
}
$soc = i2cget_word_bytes(1, 0x36, 0x04);
if ($soc !== null) {
[$msb, $lsb] = $soc;
$percent = round((($msb << 8) | $lsb) / 256, 2);
}
return [
'voltage' => $voltage,
'percent' => $percent,
];
}
function fan_efficiency(?int $rpm, ?float $cpuTemp, ?float $cpuWatts): ?int
{
if (
$rpm === null
|| $cpuTemp === null
|| $cpuWatts === null
|| $cpuWatts <= 0
) {
return null;
}
if ($rpm <= 0) {
return 0;
}
$ambientTemp = 25.0;
$maxRpm = 10000.0;
$fanRatio = max(0.0, min(1.0, $rpm / $maxRpm));
$deltaTemp = max(0.1, $cpuTemp - $ambientTemp);
$thermalResistance = $deltaTemp / max($cpuWatts, 0.1);
// 핵심: °C/W 낮을수록 좋음
if ($thermalResistance <= 6.0) {
$thermalScore = 100;
} elseif ($thermalResistance <= 7.0) {
$thermalScore = 95;
} elseif ($thermalResistance <= 8.0) {
$thermalScore = 88;
} elseif ($thermalResistance <= 9.0) {
$thermalScore = 80;
} elseif ($thermalResistance <= 10.0) {
$thermalScore = 72;
} elseif ($thermalResistance <= 11.0) {
$thermalScore = 64;
} elseif ($thermalResistance <= 12.0) {
$thermalScore = 56;
} elseif ($thermalResistance <= 13.0) {
$thermalScore = 48;
} elseif ($thermalResistance <= 14.0) {
$thermalScore = 40;
} elseif ($thermalResistance <= 16.0) {
$thermalScore = 30;
} elseif ($thermalResistance <= 18.0) {
$thermalScore = 20;
} else {
$thermalScore = 10;
}
// 목표 온도: 58~65°C
if ($cpuTemp < 45.0) {
$tempScore = 70;
} elseif ($cpuTemp < 50.0) {
$tempScore = 80;
} elseif ($cpuTemp < 55.0) {
$tempScore = 90;
} elseif ($cpuTemp < 58.0) {
$tempScore = 96;
} elseif ($cpuTemp < 65.0) {
$tempScore = 100;
} elseif ($cpuTemp < 70.0) {
$tempScore = 88;
} elseif ($cpuTemp < 75.0) {
$tempScore = 72;
} elseif ($cpuTemp < 80.0) {
$tempScore = 52;
} elseif ($cpuTemp < 85.0) {
$tempScore = 30;
} else {
$tempScore = 10;
}
// RPM은 비용. 단, 고온에서 고RPM은 정상이라 과하게 깎지 않음.
if ($fanRatio < 0.20) {
$fanScore = 85;
} elseif ($fanRatio < 0.40) {
$fanScore = 100;
} elseif ($fanRatio < 0.60) {
$fanScore = 92;
} elseif ($fanRatio < 0.75) {
$fanScore = 82;
} elseif ($fanRatio < 0.90) {
$fanScore = 70;
} else {
$fanScore = 58;
}
$penalty = 0.0;
$bonus = 0.0;
// 고RPM인데도 온도가 높으면 비효율
if ($fanRatio >= 0.85 && $cpuTemp >= 80.0) {
$penalty += 18;
} elseif ($fanRatio >= 0.75 && $cpuTemp >= 75.0) {
$penalty += 10;
}
// 저부하인데 팬이 너무 높으면 낭비
if ($cpuWatts < 2.0 && $fanRatio >= 0.70) {
$penalty += 12;
} elseif ($cpuWatts < 3.0 && $fanRatio >= 0.85) {
$penalty += 8;
}
// 차가운데 팬이 높으면 과냉각
if ($cpuTemp < 55.0 && $fanRatio >= 0.70) {
$penalty += 12;
}
// 고부하인데 온도 잘 잡으면 보너스
if ($cpuWatts >= 5.0 && $cpuTemp <= 68.0 && $fanRatio <= 0.75) {
$bonus += 8;
} elseif ($cpuWatts >= 4.0 && $cpuTemp <= 65.0 && $fanRatio <= 0.65) {
$bonus += 5;
}
$score =
($thermalScore * 0.65)
+ ($tempScore * 0.25)
+ ($fanScore * 0.10)
- $penalty
+ $bonus;
return max(0, min(100, (int)round($score)));
}
function add_fan_action(
string $type,
?string $mode,
?int $pwm,
?string $note,
bool $success = true
): void {
$stmt = db()->prepare("
INSERT INTO fan_actions
(
action_type,
pwm_mode,
pwm_value,
note,
actor_ip,
success
)
VALUES
(
:action_type,
:pwm_mode,
:pwm_value,
:note,
:actor_ip,
:success
)
");
$stmt->execute([
':action_type' => $type,
':pwm_mode' => $mode,
':pwm_value' => $pwm,
':note' => $note,
':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
':success' => $success ? 1 : 0,
]);
}
function add_sensor_log(array $r): void
{
$stmt = db()->prepare("
INSERT INTO sensor_logs
(
cpu_temp_c,
fan_rpm,
fan_efficiency,
rp1_temp_c,
cpu_voltage,
cpu_watts,
battery_voltage,
battery_percent,
pwm_value,
pwm_percent,
pwm_mode,
cpu_load_1,
cpu_load_5,
cpu_load_15,
mem_total_mb,
mem_used_mb,
mem_free_mb,
disk_total_kb,
disk_used_kb,
disk_free_kb,
uptime_seconds,
hostname,
create_ip
)
VALUES
(
:cpu_temp_c,
:fan_rpm,
:fan_efficiency,
:rp1_temp_c,
:cpu_voltage,
:cpu_watts,
:battery_voltage,
:battery_percent,
:pwm_value,
:pwm_percent,
:pwm_mode,
:cpu_load_1,
:cpu_load_5,
:cpu_load_15,
:mem_total_mb,
:mem_used_mb,
:mem_free_mb,
:disk_total_kb,
:disk_used_kb,
:disk_free_kb,
:uptime_seconds,
:hostname,
:create_ip
)
");
$stmt->execute([
':cpu_temp_c' => $r['cpu_temp_c'] ?? null,
':fan_rpm' => $r['fan_rpm'] ?? null,
':fan_efficiency' => $r['fan_efficiency'] ?? null,
':rp1_temp_c' => $r['rp1_temp_c'] ?? null,
':cpu_voltage' => $r['cpu_voltage'] ?? null,
':cpu_watts' => $r['cpu_watts'] ?? null,
':battery_voltage' => $r['battery_voltage'] ?? null,
':battery_percent' => $r['battery_percent'] ?? null,
':pwm_value' => $r['pwm_value'] ?? null,
':pwm_percent' => $r['pwm_percent'] ?? null,
':pwm_mode' => $r['pwm_mode'] ?? null,
':cpu_load_1' => $r['cpu_load_1'] ?? null,
':cpu_load_5' => $r['cpu_load_5'] ?? null,
':cpu_load_15' => $r['cpu_load_15'] ?? null,
':mem_total_mb' => $r['mem_total_mb'] ?? null,
':mem_used_mb' => $r['mem_used_mb'] ?? null,
':mem_free_mb' => $r['mem_free_mb'] ?? null,
':disk_total_kb' => $r['disk_total_kb'] ?? null,
':disk_used_kb' => $r['disk_used_kb'] ?? null,
':disk_free_kb' => $r['disk_free_kb'] ?? null,
':uptime_seconds' => $r['uptime_seconds'] ?? null,
':hostname' => $r['hostname'] ?? null,
':create_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
]);
}