commit 36588b93f17042f0adfe214ef4fd07825b27c7f1 Author: seo Date: Sun Jun 7 00:33:58 2026 +0900 Initial car project import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f010452 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +.agents/ +.codex/ +*.log +*.db +*.sqlite +*.sql +cache/ +tmp/ +secrets/ +secret/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc26b0a --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Car + +PHP based vehicle service for state collection, TCP command dispatch, monitoring, and mobile data usage display. + +## Main Features + +- Vehicle status collection and storage. +- TCP command dispatch for allowed vehicle commands. +- Monitoring UI with separated status and usage AJAX endpoints. +- Data usage and billing display with adjustment metadata. +- TCP failure reason and receive freshness metadata. + +## Main APIs + +- `api.php?action=status` +- `api.php?action=command` +- `monitor.php?mode=ajax` +- `monitor.php?mode=usage` + +## Structure + +- `api.php`: vehicle status and control API. +- `monitor.php`: monitoring UI and AJAX responses. +- `common.php`: external secret loading and shared DB/API helpers. +- `collector_se.php`: CLI/cron state collector. +- `sw.js`: service worker. +- `assets/`: icons and static assets. + +## Secrets + +Runtime settings are loaded from `/home/seo/secret/car.php`. Do not commit that file. + +Expected values include TCP settings, DB credentials, API token, and allowed IP policy. + +## Security + +- Vehicle API uses API token or allowed IP policy. +- Control commands are limited to known command codes. +- Secret files must remain outside the repository with restricted permissions. + diff --git a/api.php b/api.php new file mode 100644 index 0000000..07e18a0 --- /dev/null +++ b/api.php @@ -0,0 +1,189 @@ + 'FORBIDDEN_IP', + 'request_id' => $requestId, + 'client_ip' => $clientIp + ], 403); +} + +$params = ($requestMethod === 'POST') ? $_POST : $_GET; +$token = $params['token'] ?? ''; + +if (!hash_equals(AUTH_TOKEN, $token)) { + json_exit([ + 'error' => 'FORBIDDEN_TOKEN', + 'request_id' => $requestId + ], 403); +} + +$pdo = db(); + +if (($params['log'] ?? '') == '1') { + $limit = isset($params['limit']) ? (int)$params['limit'] : 50; + $logs = db_logs($pdo, $limit); + + json_exit([ + 'request_id' => $requestId, + 'limit' => $limit, + 'count' => count($logs), + 'logs' => $logs + ]); +} + +$cmdRequested = $params['cmd'] ?? 'se'; +$cmd = normalize_cmd($cmdRequested); + +if ($cmd === 'se') { + $latest = db_latest($pdo); + + if (!$latest) { + json_exit([ + 'request_id' => $requestId, + 'error' => 'no_latest_data' + ], 500); + } + + $ageSeconds = max(0, time() - strtotime((string)$latest['ts'])); + $latestUsage = db_latest_usage($pdo); + $staleReason = null; + if ($ageSeconds > 30 && $latestUsage && (int)$latestUsage['tcp_ok'] === 0) { + $staleReason = (string)($latestUsage['tcp_error'] ?? 'tcp_failed'); + } + + json_exit([ + 'request_id' => $requestId, + 'cmd_requested' => 'se', + 'cmd' => $latest['cmd'], + 'ts' => $latest['ts'], + 'meta' => [ + 'age_seconds' => $ageSeconds, + 'stale' => $ageSeconds > 30, + 'stale_reason' => $staleReason, + 'latest_tcp' => $latestUsage, + ], + + 'raw_full' => $latest['raw_full'], + 'raw_trim' => $latest['raw_trim'], + + 'data' => [ + 'boundary' => (int)$latest['boundary'], + 'engine' => (int)$latest['engine'], + 'driving' => (int)$latest['driving'], + 'battery_voltage' => (float)$latest['battery_voltage'], + 'door_fl' => (int)$latest['door_fl'], + 'door_fr' => (int)$latest['door_fr'], + 'door_rl' => (int)$latest['door_rl'], + 'door_rr' => (int)$latest['door_rr'], + 'door_trunk' => (int)$latest['door_trunk'], + 'remote_start_preparing' => (int)$latest['remote_start_preparing'], + 'remote_start_running' => (int)$latest['remote_start_running'], + 'remote_start_remaining' => $latest['remote_start_remaining'], + 'hazard' => (int)$latest['hazard'], + ] + ]); +} + +if (!in_array($cmd, CONTROL_CMD, true)) { + json_exit([ + 'error' => 'INVALID_CMD', + 'request_id' => $requestId, + 'cmd' => $cmd + ], 400); +} + +if ($requestMethod !== 'POST') { + json_exit([ + 'error' => 'METHOD_NOT_ALLOWED_USE_POST', + 'request_id' => $requestId, + 'cmd' => $cmd + ], 405); +} + +$tcpMs = 0; +$connectMs = 0; +$readMs = 0; +$tcpError = ''; +$trimError = ''; +$sentBytes = 0; +$receivedBytes = 0; + +$rawFull = tcp_request( + $cmd, + $tcpMs, + $connectMs, + $readMs, + $tcpError, + 'api_control', + $requestId, + $sentBytes, + $receivedBytes +); +$execMs = (int)round((microtime(true) - $tsStart) * 1000); + +if ($rawFull === '') { + json_exit([ + 'request_id' => $requestId, + 'cmd_requested' => $cmdRequested, + 'cmd' => $cmd, + 'ts' => date('Y-m-d H:i:s'), + 'exec_ms' => $execMs, + 'client_ip' => $clientIp, + 'ua' => $userAgent, + 'tcp_ok' => 0, + 'tcp_ms' => $tcpMs, + 'connect_ms' => $connectMs, + 'read_ms' => $readMs, + 'sent_bytes' => $sentBytes, + 'received_bytes' => $receivedBytes, + 'total_bytes' => $sentBytes + $receivedBytes, + 'tcp_error' => ($tcpError !== '' ? $tcpError : 'timeout_or_empty'), + 'error' => 'CONTROL_CMD_SEND_FAILED' + ], 502); +} + +$rawTrim = make_trim($rawFull, $trimError); +$data = ($rawTrim !== '') ? parse_trim($rawTrim) : null; + +if ($rawTrim !== '' && $data !== null && is_valid_status_data($data)) { + db_insert_status( + $pdo, + $cmd, + $rawFull, + $rawTrim, + $data + ); +} + +json_exit([ + 'request_id' => $requestId, + 'cmd_requested' => $cmdRequested, + 'cmd' => $cmd, + 'ts' => date('Y-m-d H:i:s'), + 'exec_ms' => $execMs, + 'client_ip' => $clientIp, + 'ua' => $userAgent, + 'tcp_ok' => 1, + 'tcp_ms' => $tcpMs, + 'connect_ms' => $connectMs, + 'read_ms' => $readMs, + 'sent_bytes' => $sentBytes, + 'received_bytes' => $receivedBytes, + 'total_bytes' => $sentBytes + $receivedBytes, + 'tcp_error' => $tcpError, + 'accepted' => true, + 'ack_only' => ($rawTrim === ''), + 'raw_full' => $rawFull, + 'raw_trim' => $rawTrim, + 'data' => $data +]); diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png new file mode 100644 index 0000000..63b1685 Binary files /dev/null and b/assets/apple-touch-icon.png differ diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..7f62cf1 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icon-192.png b/assets/icon-192.png new file mode 100644 index 0000000..f336cbb Binary files /dev/null and b/assets/icon-192.png differ diff --git a/assets/icon-32.png b/assets/icon-32.png new file mode 100644 index 0000000..4a11826 Binary files /dev/null and b/assets/icon-32.png differ diff --git a/assets/icon-512.png b/assets/icon-512.png new file mode 100644 index 0000000..4014913 Binary files /dev/null and b/assets/icon-512.png differ diff --git a/assets/site.webmanifest b/assets/site.webmanifest new file mode 100644 index 0000000..40e336b --- /dev/null +++ b/assets/site.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "Car Monitor", + "short_name": "Car Monitor", + "description": "Vehicle status monitor", + "start_url": "/car/monitor.php", + "scope": "/car/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0f172a", + "theme_color": "#0f172a", + "icons": [ + { + "src": "/car/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/car/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/collector_se.php b/collector_se.php new file mode 100644 index 0000000..a867a49 --- /dev/null +++ b/collector_se.php @@ -0,0 +1,108 @@ + is_string($ip) && $ip !== '' +))); + +const ALLOWED_CMD = ['se', 'ef', 'en', 'hn', 'hf', 'dl', 'du', 'tu']; +const CONTROL_CMD = ['ef', 'en', 'hn', 'hf', 'dl', 'du', 'tu']; + +const RAW_FULL_REGEX = '/(?\d{11})\/R:(?[a-z])\/E:(?[io]{5}\d{3}[io])\/D:(?[oi]{7})\/L:(?o{5})\/F:(?[ots][oi]\d{4}[oi]{4})\/S:(?[^\/]+)/'; + +function db(): PDO +{ + static $pdo = null; + + if ($pdo instanceof PDO) { + return $pdo; + } + + $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET; + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + return $pdo; +} + +function b(string $ch): int +{ + return ($ch === 'i') ? 1 : 0; +} + +function at(string $s, int $i): string +{ + return $s[$i] ?? ''; +} + +function get_client_ip(): string +{ + $ip = + $_SERVER['HTTP_CF_CONNECTING_IP'] ?? + $_SERVER['HTTP_X_FORWARDED_FOR'] ?? + $_SERVER['REMOTE_ADDR'] ?? + ''; + + if (strpos($ip, ',') !== false) { + $ip = trim(explode(',', $ip)[0]); + } + + return trim($ip); +} + +function normalize_cmd(?string $cmd): string +{ + $cmd = strtolower(trim((string)$cmd)); + return in_array($cmd, ALLOWED_CMD, true) ? $cmd : 'se'; +} + +function json_exit(array $payload, int $status = 200): void +{ + 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 db_insert_data_usage( + PDO $pdo, + string $source, + string $cmd, + int $sentBytes, + int $receivedBytes, + bool $tcpOk, + string $tcpError = '', + ?string $requestId = null +): void { + try { + $stmt = $pdo->prepare("INSERT INTO car_data_usage ( + id, ts, source, cmd, sent_bytes, received_bytes, total_bytes, + tcp_ok, tcp_error, request_id + ) VALUES ( + 'car', :ts, :source, :cmd, :sent_bytes, :received_bytes, :total_bytes, + :tcp_ok, :tcp_error, :request_id + )"); + + $stmt->execute([ + ':ts' => date('Y-m-d H:i:s'), + ':source' => substr($source, 0, 20), + ':cmd' => substr($cmd, 0, 10), + ':sent_bytes' => max(0, $sentBytes), + ':received_bytes' => max(0, $receivedBytes), + ':total_bytes' => max(0, $sentBytes + $receivedBytes), + ':tcp_ok' => $tcpOk ? 1 : 0, + ':tcp_error' => $tcpError !== '' ? substr($tcpError, 0, 100) : null, + ':request_id' => $requestId, + ]); + } catch (Throwable $e) { + // 사용량 기록 실패가 차량 제어/조회 자체를 막으면 안 된다. + } +} + +function record_tcp_usage( + string $source, + string $cmd, + int $sentBytes, + int $receivedBytes, + bool $tcpOk, + string $tcpError = '', + ?string $requestId = null +): void { + db_insert_data_usage( + db(), + $source, + $cmd, + $sentBytes, + $receivedBytes, + $tcpOk, + $tcpError, + $requestId + ); +} + +function tcp_request( + string $cmd, + int &$tcpMs, + int &$connectMs, + int &$readMs, + string &$tcpError, + string $usageSource = 'api_control', + ?string $requestId = null, + int &$sentBytes = 0, + int &$receivedBytes = 0 +): string +{ + $tcpMs = 0; + $connectMs = 0; + $readMs = 0; + $tcpError = ''; + $sentBytes = 0; + $receivedBytes = 0; + + $request = json_encode([ + 'type' => 'R', + 'type_sub' => 'car_controll', + 'data' => [ + 'command' => '+SCMD=' . MODEM . '/C:' . $cmd, + 'modem' => MODEM, + 'user' => USER, + 'uid' => UID, + 'type' => TYPE, + ], + ], JSON_UNESCAPED_SLASHES); + + $t0 = microtime(true); + + $deadline = $t0 + TCP_TOTAL_TIMEOUT; + + $remain = $deadline - microtime(true); + if ($remain <= 0) { + record_tcp_usage($usageSource, $cmd, 0, 0, false, 'deadline_expired_before_connect', $requestId); + return ''; + } + + $fp = @stream_socket_client( + "tcp://" . TCP_HOST . ":" . TCP_PORT, + $errno, + $errstr, + $remain + ); + $connectMs = (int)round((microtime(true) - $t0) * 1000); + + if (!$fp) { + $tcpError = "connect_error: $errno $errstr"; + $tcpMs = $connectMs; + record_tcp_usage($usageSource, $cmd, 0, 0, false, $tcpError, $requestId); + return ''; + } + + stream_set_blocking($fp, false); + + $written = @fwrite($fp, $request); + if ($written === false || $written < strlen($request)) { + $sentBytes = ($written === false) ? 0 : (int)$written; + fclose($fp); + $tcpError = 'write_failed'; + record_tcp_usage($usageSource, $cmd, $sentBytes, 0, false, $tcpError, $requestId); + return ''; + } + $sentBytes = (int)$written; + + $r0 = microtime(true); + $response = ''; + while (microtime(true) < $deadline) { + $read = [$fp]; + $w = null; + $e = null; + + $remain = $deadline - microtime(true); + if ($remain <= 0) break; + + $sec = (int)$remain; + $usec = (int)(($remain - $sec) * 1000000); + + $sel = @stream_select($read, $w, $e, $sec, $usec); + if ($sel === false) { + $tcpError = 'stream_select_failed'; + break; + } + + if ($sel > 0) { + $chunk = @fread($fp, 4096); + if ($chunk === false) { + $tcpError = 'read_failed'; + break; + } + + if ($chunk !== '') { + $response .= $chunk; + + if (preg_match(RAW_FULL_REGEX, trim($response))) { + break; + } + } + } + + if (feof($fp)) { + break; + } + } + + $readMs = (int)round((microtime(true) - $r0) * 1000); + fclose($fp); + + $tcpMs = $connectMs + $readMs; + $receivedBytes = strlen($response); + + if (trim($response) === '') { + if ($tcpError === '') { + $tcpError = 'total_timeout_4_95s'; + } + record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId); + return ''; + } + + if (!preg_match(RAW_FULL_REGEX, trim($response))) { + $tcpError = 'invalid_or_partial_response'; + record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId); + return ''; + } + + record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, true, $tcpError, $requestId); + + return $response; +} + +function make_trim(string $rawFull, string &$trimError = ''): string +{ + $trimError = ''; + $rawFull = trim($rawFull); + + if (!preg_match(RAW_FULL_REGEX, $rawFull, $m)) { + $trimError = 'invalid_format_regex'; + return ''; + } + + if (($m['modem'] ?? '') !== MODEM) { + $trimError = 'invalid_modem'; + return ''; + } + + $E0 = $m['E0'] ?? ''; + $D0 = $m['D0'] ?? ''; + $F0 = $m['F0'] ?? ''; + + $E = substr($E0, 0, -1); + $D = substr($D0, 0, -2); + $F = substr($F0, 0, -2); + + if ($E === '' || $D === '' || $F === '') { + $trimError = 'trim_empty_after_cut'; + return ''; + } + + if (strlen("E:$E") < 10) { $trimError = 'E_too_short'; return ''; } + if (strlen("D:$D") < 7) { $trimError = 'D_too_short'; return ''; } + if (strlen("F:$F") < 10) { $trimError = 'F_too_short'; return ''; } + + return "E:$E/D:$D/F:$F"; +} + +function parse_trim(string $rawTrim): array +{ + $p = explode('/', $rawTrim); + + $E = $p[0] ?? ''; + $D = $p[1] ?? ''; + $F = $p[2] ?? ''; + + $boundary = b(at($E, 2)); + $engine = b(at($E, 3)); + + $driving = ( + $boundary === 0 && + b(at($E, 3)) === 1 && + b(at($E, 4)) === 1 && + b(at($E, 5)) === 1 && + b(at($E, 6)) === 1 + ) ? 1 : 0; + + $volt10 = (int)substr($E, 7, 3); + $batteryVoltage = (float)number_format($volt10 / 10, 1, '.', ''); + + $doorFl = b(at($D, 2)); + $doorFr = b(at($D, 3)); + $doorRl = b(at($D, 4)); + $doorRr = b(at($D, 5)); + $doorTrunk = b(at($D, 6)); + + $rsCode = substr($F, 2, 2); + $remoteStartPreparing = ($rsCode === 'to') ? 1 : 0; + $remoteStartRunning = ($rsCode === 'si') ? 1 : 0; + + $mmss = substr($F, 4, 4); + $remoteStartRemaining = substr($mmss, 0, 2) . ":" . substr($mmss, 2, 2); + + $hazard = b(at($F, 9)); + + return [ + 'boundary' => $boundary, + 'engine' => $engine, + 'driving' => $driving, + 'battery_voltage' => $batteryVoltage, + + 'door_fl' => $doorFl, + 'door_fr' => $doorFr, + 'door_rl' => $doorRl, + 'door_rr' => $doorRr, + 'door_trunk' => $doorTrunk, + + 'remote_start_preparing' => $remoteStartPreparing, + 'remote_start_running' => $remoteStartRunning, + 'remote_start_remaining' => $remoteStartRemaining, + + 'hazard' => $hazard, + ]; +} + +function is_valid_status_data(array $data): bool +{ + $v = (float)($data['battery_voltage'] ?? 0); + + // 1V는 실제 배터리값이 아니라 통신/파싱 이상값으로 보고 저장 차단 + if ($v <= 1.0) { + return false; + } + + // 완전히 말이 안 되는 값만 차단 + // 시동 ON 중 15V대는 정상일 수 있으므로 여기서 15.0으로 자르면 안 됨 + if ($v < 9.0 || $v > 16.5) { + return false; + } + + return true; +} + +function db_insert_status( + PDO $pdo, + string $cmd, + string $rawFull, + string $rawTrim, + array $data +): void { + $v = (float)($data['battery_voltage'] ?? 0); + + // 1V는 저장 금지 + if ($v <= 1.0) { + return; + } + + // 시동 OFF 상태에서만 말도 안 되는 고전압 튐 차단 + // 평소 OFF 전압이 12.5~13.4V라 했으므로 14.2V 이상은 튐으로 판단 + $engineOn = + (int)$data['engine'] === 1 || + (int)$data['driving'] === 1 || + (int)$data['remote_start_preparing'] === 1 || + (int)$data['remote_start_running'] === 1; + + if (!$engineOn && $v >= 14.2) { + return; + } + + $sql = "INSERT INTO car_status ( + id, ts, cmd, + boundary, engine, driving, battery_voltage, + door_fl, door_fr, door_rl, door_rr, door_trunk, + remote_start_preparing, remote_start_running, remote_start_remaining, + hazard, raw_full, raw_trim + ) VALUES ( + 'car', :ts, :cmd, + :boundary, :engine, :driving, :battery_voltage, + :door_fl, :door_fr, :door_rl, :door_rr, :door_trunk, + :remote_start_preparing, :remote_start_running, :remote_start_remaining, + :hazard, :raw_full, :raw_trim + )"; + + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':ts' => date('Y-m-d H:i:s'), + ':cmd' => $cmd, + + ':boundary' => $data['boundary'], + ':engine' => $data['engine'], + ':driving' => $data['driving'], + ':battery_voltage' => $data['battery_voltage'], + + ':door_fl' => $data['door_fl'], + ':door_fr' => $data['door_fr'], + ':door_rl' => $data['door_rl'], + ':door_rr' => $data['door_rr'], + ':door_trunk' => $data['door_trunk'], + + ':remote_start_preparing' => $data['remote_start_preparing'], + ':remote_start_running' => $data['remote_start_running'], + ':remote_start_remaining' => $data['remote_start_remaining'], + + ':hazard' => $data['hazard'], + + ':raw_full' => $rawFull, + ':raw_trim' => $rawTrim, + ]); +} + +function db_latest(PDO $pdo): ?array +{ + $stmt = $pdo->query("SELECT * FROM car_status WHERE id='car' ORDER BY ts DESC LIMIT 1"); + $row = $stmt->fetch(); + return $row ?: null; +} + +function db_latest_usage(PDO $pdo): ?array +{ + $stmt = $pdo->query(" + SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error + FROM car_data_usage + WHERE id='car' + ORDER BY ts DESC + LIMIT 1 + "); + $row = $stmt->fetch(); + return $row ?: null; +} + +function db_logs(PDO $pdo, int $limit): array +{ + $limit = max(1, min(500, $limit)); + $stmt = $pdo->prepare("SELECT ts, cmd, raw_full, raw_trim FROM car_status WHERE id='car' ORDER BY ts DESC LIMIT $limit"); + $stmt->execute(); + return $stmt->fetchAll(); +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..bda0146 Binary files /dev/null and b/favicon.ico differ diff --git a/monitor.php b/monitor.php new file mode 100644 index 0000000..ead5f9a --- /dev/null +++ b/monitor.php @@ -0,0 +1,1964 @@ + ['total_bytes' => 26937856, 'meter_adjusted_bytes' => 13240579, 'included_bytes' => 104857600, 'coupon_registered_at' => null], +]; + +function car_db(): PDO +{ + static $pdo = null; + + if ($pdo instanceof PDO) { + return $pdo; + } + + $dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET; + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + return $pdo; +} + +function seconds_from_ts(?string $ts): ?int +{ + if (!$ts) { + return null; + } + + $time = strtotime($ts); + if ($time === false) { + return null; + } + + return max(0, time() - $time); +} + +function current_state_duration(PDO $pdo, ?array $latest): int +{ + if (!$latest || empty($latest['ts'])) { + return 0; + } + + $driving = (int)($latest['driving'] ?? 0); + $engine = (int)($latest['engine'] ?? 0); + $remoteRunning = (int)($latest['remote_start_running'] ?? 0); + $remotePreparing = (int)($latest['remote_start_preparing'] ?? 0); + $latestTs = strtotime((string)$latest['ts']); + + if ($latestTs === false) { + return 0; + } + + $stmt = $pdo->prepare("" + . "SELECT ts, driving, engine, remote_start_running, remote_start_preparing " + . "FROM car_status " + . "WHERE id='car' " + . "AND ts <= :latest_ts " + . "ORDER BY ts DESC LIMIT 50000" + ); + $stmt->execute([ + ':latest_ts' => date('Y-m-d H:i:s', $latestTs), + ]); + + $startTs = $latestTs; + $newerTs = $latestTs; + + while ($row = $stmt->fetch()) { + $rowTs = strtotime((string)($row['ts'] ?? '')); + if ($rowTs === false) { + continue; + } + + if (($newerTs - $rowTs) > RECEIVE_GAP_LIMIT_SEC) { + break; + } + + $sameState = + (int)($row['driving'] ?? 0) === $driving && + (int)($row['engine'] ?? 0) === $engine && + (int)($row['remote_start_running'] ?? 0) === $remoteRunning && + (int)($row['remote_start_preparing'] ?? 0) === $remotePreparing; + + if (!$sameState) { + break; + } + + $startTs = $rowTs; + $newerTs = $rowTs; + } + + $now = time(); + $endTs = ($now - $latestTs) > RECEIVE_GAP_LIMIT_SEC ? $latestTs : $now; + + return max(0, $endTs - $startTs); +} + +function current_engine_state_duration(PDO $pdo, ?array $latest): int +{ + if (!$latest || empty($latest['ts'])) { + return 0; + } + + $engine = (int)($latest['engine'] ?? 0); + $latestTs = strtotime((string)$latest['ts']); + + if ($latestTs === false) { + return 0; + } + + $latestTsText = date('Y-m-d H:i:s', $latestTs); + $oppositeEngine = $engine === 1 ? 0 : 1; + + $stmt = $pdo->prepare("" + . "SELECT ts " + . "FROM car_status " + . "WHERE id='car' AND engine = :engine AND ts <= :latest_ts " + . "ORDER BY ts DESC LIMIT 1" + ); + $stmt->execute([ + ':engine' => $oppositeEngine, + ':latest_ts' => $latestTsText, + ]); + $oppositeTs = $stmt->fetchColumn(); + + if ($oppositeTs) { + $stmt = $pdo->prepare("" + . "SELECT ts " + . "FROM car_status " + . "WHERE id='car' AND engine = :engine AND ts > :opposite_ts AND ts <= :latest_ts " + . "ORDER BY ts ASC LIMIT 1" + ); + $stmt->execute([ + ':engine' => $engine, + ':opposite_ts' => (string)$oppositeTs, + ':latest_ts' => $latestTsText, + ]); + $startTsText = $stmt->fetchColumn() ?: $latestTsText; + } else { + $stmt = $pdo->prepare("" + . "SELECT ts " + . "FROM car_status " + . "WHERE id='car' AND engine = :engine AND ts <= :latest_ts " + . "ORDER BY ts ASC LIMIT 1" + ); + $stmt->execute([ + ':engine' => $engine, + ':latest_ts' => $latestTsText, + ]); + $startTsText = $stmt->fetchColumn() ?: $latestTsText; + } + + $startTs = strtotime((string)$startTsText); + if ($startTs === false) { + return 0; + } + + return max(0, time() - $startTs); +} + +function latest_tcp_usage(PDO $pdo): ?array +{ + $stmt = $pdo->query("" + . "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error " + . "FROM car_data_usage " + . "WHERE id='car' " + . "ORDER BY ts DESC LIMIT 1" + ); + + $row = $stmt->fetch(); + return $row ?: null; +} + +function monthly_data_usage(PDO $pdo): array +{ + $now = new DateTimeImmutable('now'); + $monthStart = $now->modify('first day of this month')->setTime(0, 0, 0); + $nextMonthStart = $monthStart->modify('first day of next month'); + + $stmt = $pdo->prepare("" + . "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error " + . "FROM car_data_usage " + . "WHERE id='car' AND ts >= :month_start AND ts < :next_month_start " + . "ORDER BY ts ASC" + ); + $stmt->execute([ + ':month_start' => $monthStart->format('Y-m-d H:i:s'), + ':next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'), + ]); + $usageRows = $stmt->fetchAll(); + $usage = calibrated_usage_rows($usageRows); + $todayStart = $now->setTime(0, 0, 0); + $tomorrowStart = $todayStart->modify('+1 day'); + $todayUsage = calibrated_usage_rows(array_values(array_filter($usageRows, function (array $row) use ($todayStart, $tomorrowStart): bool { + $ts = strtotime((string)($row['ts'] ?? '')); + return $ts !== false && $ts >= $todayStart->getTimestamp() && $ts < $tomorrowStart->getTimestamp(); + }))); + + $sentBytes = $usage['sent_bytes']; + $receivedBytes = $usage['received_bytes']; + $totalBytes = $usage['total_bytes']; + $adjustedTotalBytes = $usage['adjusted_total_bytes']; + $remainingBytes = max(DATA_MONTHLY_INCLUDED_BYTES - $adjustedTotalBytes, 0); + $overBytes = max($adjustedTotalBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); + $overUnits = $overBytes > 0 ? (int)ceil($overBytes / DATA_BILLING_UNIT_BYTES) : 0; + $overFeeKrw = $overUnits * DATA_BILLING_UNIT_KRW; + + $monthSeconds = max(1, $nextMonthStart->getTimestamp() - $monthStart->getTimestamp()); + $remainingSeconds = max(0, $nextMonthStart->getTimestamp() - $now->getTimestamp()); + $daysInMonth = (int)$monthStart->format('t'); + $firstUsageTs = $usage['first_usage_ts']; + $projectionStart = $firstUsageTs ? strtotime((string)$firstUsageTs) : $monthStart->getTimestamp(); + if ($projectionStart === false || $projectionStart < $monthStart->getTimestamp()) { + $projectionStart = $monthStart->getTimestamp(); + } + $measuredSeconds = max(1, $now->getTimestamp() - $projectionStart); + $currentBilling = current_billing_usage($monthStart->format('Y-m')); + $billingScale = billing_meter_scale($currentBilling); + $billingCurrentBytes = (int)round($adjustedTotalBytes * $billingScale); + $projectedBytes = (int)round($adjustedTotalBytes / $measuredSeconds * $monthSeconds); + $billingProjectedBytes = $adjustedTotalBytes > 0 + ? max((int)round($projectedBytes * $billingScale), $billingCurrentBytes) + : 0; + $calibration = billing_calibration($currentBilling, $billingScale); + if ($billingProjectedBytes <= 0) { + $billingProjectedBytes = $calibration['projected_total_bytes']; + } + $projectedOverBytes = max($projectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); + $projectedOverUnits = $projectedOverBytes > 0 ? (int)ceil($projectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0; + $projectedOverFeeKrw = $projectedOverUnits * DATA_BILLING_UNIT_KRW; + $billingCurrentOverBytes = max($billingCurrentBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); + $billingCurrentOverUnits = $billingCurrentOverBytes > 0 ? (int)ceil($billingCurrentOverBytes / DATA_BILLING_UNIT_BYTES) : 0; + $billingCurrentOverFeeKrw = $billingCurrentOverUnits * DATA_BILLING_UNIT_KRW; + $billingProjectedOverBytes = max($billingProjectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); + $billingProjectedOverUnits = $billingProjectedOverBytes > 0 ? (int)ceil($billingProjectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0; + $billingProjectedOverFeeKrw = $billingProjectedOverUnits * DATA_BILLING_UNIT_KRW; + $fixedFeeKrw = DATA_MONTHLY_BASE_FEE_KRW + DATA_MONTHLY_ADDON_FEE_KRW; + $dailyRecommendedBytes = (int)floor(DATA_MONTHLY_INCLUDED_BYTES / max(1, $daysInMonth)); + $estimatedTodayBytes = (int)round($todayUsage['adjusted_total_bytes'] * $billingScale); + + return [ + 'month' => $monthStart->format('Y-m'), + 'month_start' => $monthStart->format('Y-m-d H:i:s'), + 'next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'), + 'days_remaining' => (int)floor($remainingSeconds / 86400), + 'days_in_month' => $daysInMonth, + 'included_bytes' => DATA_MONTHLY_INCLUDED_BYTES, + 'daily_recommended_bytes' => $dailyRecommendedBytes, + 'estimated_today_bytes' => $estimatedTodayBytes, + 'meter_estimated_today_bytes' => $todayUsage['adjusted_total_bytes'], + 'sent_bytes' => $sentBytes, + 'received_bytes' => $receivedBytes, + 'total_bytes' => $totalBytes, + 'adjusted_total_bytes' => $adjustedTotalBytes, + 'billing_sent_bytes' => (int)round($sentBytes * $billingScale), + 'billing_received_bytes' => (int)round($receivedBytes * $billingScale), + 'billing_adjusted_failure_bytes' => (int)round($usage['adjusted_failure_bytes'] * $billingScale), + 'connected_failure_count' => $usage['connected_failure_count'], + 'connect_failure_count' => $usage['connect_failure_count'], + 'receive_gap_excluded_count' => $usage['receive_gap_excluded_count'], + 'adjusted_failure_bytes' => $usage['adjusted_failure_bytes'], + 'today_total_bytes' => $todayUsage['total_bytes'], + 'today_adjusted_total_bytes' => $todayUsage['adjusted_total_bytes'], + 'today_sample_count' => $todayUsage['sample_count'], + 'today_connected_failure_count' => $todayUsage['connected_failure_count'], + 'today_connect_failure_count' => $todayUsage['connect_failure_count'], + 'today_receive_gap_excluded_count' => $todayUsage['receive_gap_excluded_count'], + 'remaining_bytes' => $remainingBytes, + 'over_bytes' => $overBytes, + 'over_fee_raw_krw' => round($overFeeKrw, 2), + 'over_fee_krw' => floor_krw_10($overFeeKrw), + 'base_fee_krw' => DATA_MONTHLY_BASE_FEE_KRW, + 'addon_fee_krw' => DATA_MONTHLY_ADDON_FEE_KRW, + 'fixed_fee_krw' => $fixedFeeKrw, + 'estimated_service_fee_raw_krw' => round($fixedFeeKrw + $overFeeKrw, 2), + 'estimated_service_fee_krw' => floor_krw_10($fixedFeeKrw + $overFeeKrw), + 'projected_total_bytes' => $projectedBytes, + 'projected_over_bytes' => $projectedOverBytes, + 'projected_over_fee_raw_krw' => round($projectedOverFeeKrw, 2), + 'projected_over_fee_krw' => floor_krw_10($projectedOverFeeKrw), + 'projected_service_fee_raw_krw' => round($fixedFeeKrw + $projectedOverFeeKrw, 2), + 'projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $projectedOverFeeKrw), + 'billing_estimated_current_bytes' => $billingCurrentBytes, + 'billing_meter_scale' => round($billingScale, 6), + 'billing_remaining_bytes' => max(DATA_MONTHLY_INCLUDED_BYTES - $billingCurrentBytes, 0), + 'billing_over_bytes' => $billingCurrentOverBytes, + 'billing_over_fee_raw_krw' => round($billingCurrentOverFeeKrw, 2), + 'billing_over_fee_krw' => floor_krw_10($billingCurrentOverFeeKrw), + 'billing_projected_total_bytes' => $billingProjectedBytes, + 'billing_projected_over_bytes' => $billingProjectedOverBytes, + 'billing_projected_over_fee_raw_krw' => round($billingProjectedOverFeeKrw, 2), + 'billing_projected_over_fee_krw' => floor_krw_10($billingProjectedOverFeeKrw), + 'billing_projected_service_fee_raw_krw' => round($fixedFeeKrw + $billingProjectedOverFeeKrw, 2), + 'billing_projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $billingProjectedOverFeeKrw), + 'calibration' => $calibration, + 'sample_count' => $usage['sample_count'], + 'first_usage_ts' => $usage['first_usage_ts'], + 'last_usage_ts' => $usage['last_usage_ts'], + 'measured_seconds' => $measuredSeconds, + 'billing_unit_bytes' => DATA_BILLING_UNIT_BYTES, + 'billing_unit_krw' => DATA_BILLING_UNIT_KRW, + ]; +} + +function bytes_to_mb(float $bytes): float +{ + return $bytes / 1024 / 1024; +} + +function floor_krw_10(float $value): int +{ + return (int)floor(max(0.0, $value) / 10) * 10; +} + +function calibrated_usage_rows(array $rows): array +{ + $sentBytes = 0; + $receivedBytes = 0; + $totalBytes = 0; + $successfulTotalBytes = 0; + $successfulCount = 0; + $successByCmd = []; + $firstUsageTs = null; + $lastUsageTs = null; + + foreach ($rows as $row) { + $cmd = (string)($row['cmd'] ?? ''); + $sent = (int)($row['sent_bytes'] ?? 0); + $received = (int)($row['received_bytes'] ?? 0); + $total = (int)($row['total_bytes'] ?? 0); + $tcpOk = (int)($row['tcp_ok'] ?? 0) === 1; + + $sentBytes += $sent; + $receivedBytes += $received; + $totalBytes += $total; + + if ($firstUsageTs === null && !empty($row['ts'])) { + $firstUsageTs = (string)$row['ts']; + } + if (!empty($row['ts'])) { + $lastUsageTs = (string)$row['ts']; + } + + if ($tcpOk && $total > 0) { + $successfulTotalBytes += $total; + $successfulCount++; + if (!isset($successByCmd[$cmd])) { + $successByCmd[$cmd] = ['total' => 0, 'count' => 0]; + } + $successByCmd[$cmd]['total'] += $total; + $successByCmd[$cmd]['count']++; + } + } + + $globalAverage = $successfulCount > 0 ? (int)round($successfulTotalBytes / $successfulCount) : 0; + $avgByCmd = []; + foreach ($successByCmd as $cmd => $stat) { + $avgByCmd[$cmd] = $stat['count'] > 0 ? (int)round($stat['total'] / $stat['count']) : $globalAverage; + } + + $adjustedTotalBytes = 0; + $adjustedFailureBytes = 0; + $connectedFailureCount = 0; + $connectFailureCount = 0; + $receiveGapExcludedCount = 0; + $lastSuccessfulTs = null; + + foreach ($rows as $row) { + $cmd = (string)($row['cmd'] ?? ''); + $sent = (int)($row['sent_bytes'] ?? 0); + $total = (int)($row['total_bytes'] ?? 0); + $tcpOk = (int)($row['tcp_ok'] ?? 0) === 1; + $rowTs = strtotime((string)($row['ts'] ?? '')); + + if ($tcpOk) { + $adjustedTotalBytes += $total; + if ($rowTs !== false) { + $lastSuccessfulTs = $rowTs; + } + continue; + } + + if ($sent <= 0) { + // TCP 연결 자체가 실패한 건은 모뎀까지 명령이 전달되지 않은 것으로 보고 과금 추정에서 제외한다. + $connectFailureCount++; + continue; + } + + $connectedFailureCount++; + + if ( + $lastSuccessfulTs === null || + $rowTs === false || + ($rowTs - $lastSuccessfulTs) > RECEIVE_GAP_LIMIT_SEC + ) { + $receiveGapExcludedCount++; + continue; + } + + $estimatedBytes = $avgByCmd[$cmd] ?? $globalAverage; + + if ($estimatedBytes <= 0) { + $estimatedBytes = max($sent, $total); + } + + $adjustedTotalBytes += $estimatedBytes; + $adjustedFailureBytes += $estimatedBytes; + } + + return [ + 'sent_bytes' => $sentBytes, + 'received_bytes' => $receivedBytes, + 'total_bytes' => $totalBytes, + 'adjusted_total_bytes' => $adjustedTotalBytes, + 'adjusted_failure_bytes' => $adjustedFailureBytes, + 'connected_failure_count' => $connectedFailureCount, + 'connect_failure_count' => $connectFailureCount, + 'receive_gap_excluded_count' => $receiveGapExcludedCount, + 'sample_count' => count($rows), + 'success_count' => $successfulCount, + 'success_average_bytes' => $globalAverage, + 'first_usage_ts' => $firstUsageTs, + 'last_usage_ts' => $lastUsageTs, + ]; +} + +function current_billing_usage(string $month): ?array +{ + if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) { + return null; + } + + $row = DATA_CURRENT_BILLING_USAGE_BYTES[$month]; + return [ + 'month' => $month, + 'total_bytes' => (int)($row['total_bytes'] ?? 0), + 'total_mb' => round(bytes_to_mb((int)($row['total_bytes'] ?? 0)), 6), + 'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0), + 'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES), + 'included_mb' => round(bytes_to_mb((int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES)), 6), + 'coupon_registered_at' => $row['coupon_registered_at'] ?? null, + ]; +} + +function billing_meter_scale(?array $currentBilling): float +{ + if (!$currentBilling) { + return 1.0; + } + + $meterAdjustedBytes = (int)($currentBilling['meter_adjusted_bytes'] ?? 0); + if ($meterAdjustedBytes <= 0) { + return 1.0; + } + + return (int)($currentBilling['total_bytes'] ?? 0) / $meterAdjustedBytes; +} + +function billing_calibration(?array $currentBilling, float $billingScale): array +{ + $estimatedCurrentBytes = $currentBilling ? (int)$currentBilling['total_bytes'] : 0; + $source = $currentBilling ? 'carrier_scaled_meter_usage' : 'meter_adjusted_usage'; + + return [ + 'mode' => $source, + 'note' => $currentBilling + ? 'Carrier portal current usage is used to reverse-calculate a billing scale for metered local sent/received bytes. Metered local usage remains available separately.' + : 'Metered usage includes successful requests and connected timeouts within 60 seconds of the last successful receive. Pure connect failures and longer receive gaps are excluded.', + 'current_billing' => $currentBilling, + 'billing_meter_scale' => round($billingScale, 6), + 'projected_total_bytes' => 0, + 'projected_total_mb' => 0.0, + 'estimated_current_bytes' => $estimatedCurrentBytes, + 'estimated_current_mb' => round(bytes_to_mb($estimatedCurrentBytes), 2), + 'formula_fee_per_mb_krw' => round(1024 * 1024 / DATA_BILLING_UNIT_BYTES * DATA_BILLING_UNIT_KRW, 3), + ]; +} + +function json_response(array $payload, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') { + try { + $pdo = car_db(); + + $latest = $pdo->query("" + . "SELECT * FROM car_status " + . "WHERE id='car' " + . "ORDER BY ts DESC LIMIT 1" + )->fetch() ?: null; + + $stmtLogs = $pdo->prepare("" + . "SELECT ts, cmd, boundary, engine, driving, battery_voltage, " + . "door_fl, door_fr, door_rl, door_rr, door_trunk, " + . "remote_start_preparing, remote_start_running, remote_start_remaining, " + . "hazard, raw_full, raw_trim " + . "FROM car_status " + . "WHERE id='car' " + . "ORDER BY ts DESC LIMIT :limit" + ); + $stmtLogs->bindValue(':limit', LOG_LIMIT, PDO::PARAM_INT); + $stmtLogs->execute(); + $logs = $stmtLogs->fetchAll(); + + $stmtChart = $pdo->prepare("" + . "SELECT ts, battery_voltage FROM (" + . " SELECT ts, battery_voltage " + . " FROM car_status " + . " WHERE id='car' AND battery_voltage > 0 " + . " ORDER BY ts DESC LIMIT :limit" + . ") AS recent ORDER BY ts ASC" + ); + $stmtChart->bindValue(':limit', CHART_LIMIT, PDO::PARAM_INT); + $stmtChart->execute(); + $chart = $stmtChart->fetchAll(); + + $ageSeconds = seconds_from_ts($latest['ts'] ?? null); + $latestTcp = latest_tcp_usage($pdo); + + $isFastPollState = $latest ? ( + (int)($latest['driving'] ?? 0) === 1 || + (int)($latest['engine'] ?? 0) === 1 || + (int)($latest['remote_start_preparing'] ?? 0) === 1 || + (int)($latest['remote_start_running'] ?? 0) === 1 + ) : false; + $isStale = $ageSeconds !== null && $ageSeconds > 30; + $staleReason = null; + if ($isStale && $latestTcp && (int)$latestTcp['tcp_ok'] === 0) { + $staleReason = (string)($latestTcp['tcp_error'] ?? 'tcp_failed'); + } + + json_response([ + 'status' => 'success', + 'data' => $latest, + 'logs' => $logs, + 'chart' => $chart, + 'meta' => [ + 'age_seconds' => $ageSeconds, + 'stale' => $isStale, + 'stale_reason' => $staleReason, + 'latest_tcp' => $latestTcp, + 'state_duration' => current_engine_state_duration($pdo, $latest), + 'poll_interval' => $isFastPollState ? 5 : 10, + 'log_count' => count($logs), + 'chart_count' => count($chart), + ], + ]); + } catch (Throwable $e) { + json_response([ + 'status' => 'error', + 'message' => 'DB 조회 실패', + ], 500); + } +} + +if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { + try { + json_response([ + 'status' => 'success', + 'usage' => monthly_data_usage(car_db()), + ]); + } catch (Throwable $e) { + json_response([ + 'status' => 'error', + 'message' => '데이터 사용량 조회 실패', + ], 500); + } +} +?> + + + + + + + + + + + + Car Monitor + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
Car Monitor
+ WAIT +
+
Vehicle Status Dashboard
+
+
+ +
+
+
LAST DATA
+
--:--:--
+
+ + +
+
+ +
+
+
+
+
Battery
+
+ -V +
+
-
+
+ +
+
+
+ +
+
+
+
Engine
+
+ OFF +
+
-
+
+
+ +
+
+
+
Remote Start
+
+ Standby +
+
+ --:-- +
+
+
+ +
+
+
+
+
+
AGE
+
-
+
+
+
Poll Target
+
+ -s +
+
-
+
+
+
+ +
+
+
+
+
+
데이터 사용량
+
+ - + / 100MB +
+
+
+ +
+
+
+
+
+
+
하루 권장
+
-
+
+
+
오늘 사용
+
-
+
+
+
잔여 사용량
+
-
+
+
+
예상 사용량
+
-
+
+
+
예상 초과
+
-
+
+
+
예상 요금
+
-
+
+
+
+
+
+
+ +
+
+
+
+
Real-time Visual
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ +
+
+
+
Status Log
+
+
+
+ + + + + + + + + + + + + + + +
TIMECMDENGBATDOORREMOTEHAZAGETRIM
+
+
+
+
+
+
+ + + + + + + + + diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..c24c2ea --- /dev/null +++ b/sw.js @@ -0,0 +1,45 @@ +const CACHE_NAME = 'car-monitor-v1'; +const CORE_ASSETS = [ + '/car/monitor.php', + '/car/assets/favicon.svg', + '/car/assets/icon-192.png', + '/car/assets/icon-512.png', + '/car/assets/apple-touch-icon.png', + '/car/assets/site.webmanifest' +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(CORE_ASSETS)) + .catch(() => undefined) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => Promise.all( + keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)) + )) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + + if (url.origin !== location.origin || url.searchParams.get('mode') === 'ajax') { + return; + } + + event.respondWith( + fetch(event.request) + .then(response => { + const copy = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy)); + return response; + }) + .catch(() => caches.match(event.request)) + ); +});