From 36588b93f17042f0adfe214ef4fd07825b27c7f1 Mon Sep 17 00:00:00 2001 From: seo Date: Sun, 7 Jun 2026 00:33:58 +0900 Subject: [PATCH] Initial car project import --- .gitignore | 11 + README.md | 40 + api.php | 189 ++++ assets/apple-touch-icon.png | Bin 0 -> 1468 bytes assets/favicon.svg | 10 + assets/icon-192.png | Bin 0 -> 1562 bytes assets/icon-32.png | Bin 0 -> 362 bytes assets/icon-512.png | Bin 0 -> 4494 bytes assets/site.webmanifest | 25 + collector_se.php | 108 ++ common.php | 491 +++++++++ favicon.ico | Bin 0 -> 384 bytes monitor.php | 1964 +++++++++++++++++++++++++++++++++++ sw.js | 45 + 14 files changed, 2883 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api.php create mode 100644 assets/apple-touch-icon.png create mode 100644 assets/favicon.svg create mode 100644 assets/icon-192.png create mode 100644 assets/icon-32.png create mode 100644 assets/icon-512.png create mode 100644 assets/site.webmanifest create mode 100644 collector_se.php create mode 100644 common.php create mode 100644 favicon.ico create mode 100644 monitor.php create mode 100644 sw.js 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 0000000000000000000000000000000000000000..63b1685cb08c157db0339cd9084a0e6e84a02ae7 GIT binary patch literal 1468 zcmaJ>c`zGz6i&B@RaH^PqP4;}(!^b*h&G2at!wItHnOycyDHQMtLunlUENls+S1Ub zbc!&f2SS%nRYzzhN*&8q8zGi$P(f|%%$okOvoqg&@0<62-~8syoA;MiYx`(W6o<`pbpO0gs59yYO5$Ow@Hf_ z%xvGuI7Sh#_E(JFI}xymKsb@4i)R;NUZh-oYKK~gx&FXd3(Ud~inkBNwHaZtKhoETN@+ndgP(OlCjN{)qb?bU# zbzNwJR6nc-^aPFD+Q%(0q`BfGgWLj5&#I8^B5pycz?CB^r)Yy}snT*ob2x67(a`Ddy23sII=)xmp{N<eEk9dSA=h-bud^q^|z)SU?AOc(cdAZIksK-nby zU3_+ev*TLS=Gw=Op?2{`K0RGGAuHFMNbIk3j%1Ah#miT^X7r*xz9&*kF|RyXHywmi zrEw6^)Ka*`qiplvkZa&L9>-h<%7Ml4jH>FAnir@!jDbt|`4R;E2#If7K*H5AmB(=( zXkJ>)T@J-nc>IC+tdt~kQ7!Z=FS6mM3{Q>bh3LkS1f~+HPH*)2%a|$DZH7%ft>n-4 zH*%bSAL z>rhob!|>G4SQq%sWrSPCtm>!OPT;{3#iK;7{ELj?K~o~{G~}xx?@fD;(9U%@is(b9 zQkNs|eUZ3jL&xwB)5<+Tl8Kp$_JO$uo5_VE3k229kGF2QSys^K2CDAOr zWfX%1a#l?WSn!$(m6lbG>n3EGhuF{T1mao%Yymru7N5Zy7GOpC1jvWObR6Gi-hdD_ zc{nJ|-;hOoomQyR#P{X-Et**83nO`x>yf-D@H6c6Y(@+?uj2J16BdGw>g;m=o4vVDlK-`*df123y@;v&2H zvvW&>q9ilKawAQhhzPCVFed2TgaZb4y#FqaK&82!`6$4)s5YbdlyFEylnYPI*N}{{ z!nBdeW&Q`u8b=5*0}dE@1O{DrLeK-#P%s187Wu0+HG>J%n#6*UTg<}UT1Ffd+hQOD zh2DQy2QIEx2{wr4C&u;rF&RcxJ;~8rn7=X!{npeM`rdby;g(Zq#760fH6@e=D|TS- z{mE=SP)dASo|6Mi2$>z6si9QgmV@W@ZVC*hPVNADG}CidlERl|=NRDO;)Sk9{*d)I DULC)k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f336cbbc6782698fb004f8db1516a0c69b1e087d GIT binary patch literal 1562 zcmb7^X-v~e6u{>Xg%&6XSfD_qrKr0rN2!PsSfBzzkwm4igws|~G@`bO6kVkJIjo{U zfhdqru86B3LPZV{wMCFyVI|z4MM3104Yb^4+uiJkT|aH|d-F1H=FNMVnfH>99r4gq zH&O=xXnIoJXmTx52uf8x(nii95if4wve=+(n*R@per`-Etx%^33j_iw2a*8+No36x$h< z$jg#)1v9q|IZKSAh+JX%n8(o?cfr(cENAJA7h;euTDW(*PUmFo-bNnk5|Ju%KaSEyUN}UKjsOox$o5h6HcONnuzjU5IDQg&LO0uxfDm!GrkQ@LT{pZ2IpPu`m7ur@)5Vs%>D{$I+)R2MeS{*q$s3N#LM%r9IqdazP zKHGm(Da06lS@6+jGLjy)UDT&x|C(S|ux_gl&Av>}KsKz7_#~+@W@6snc@FC!i?SoX zt;Qz2MhFf~6{TY07waZbdXz46wJZtet&0jlvX$`ZF{*!0FrW=-1VwbDvCO+0>pZZW zpUwap-(bMIIAZ(Sfrdqm(Z%(5cFFrzo^~^Nuf7xr>3%V@%OB!`=bm`6oR))6Fymoe zu4ZB1VZ9S7m*s{=RZvU-{>Jw3`yh645SWbYB3}lyIt_!1fZyfVy8uzwNsMtw?u^lg zsKcajl(P8ReE$G~FhTFmP!+9?^rP2v#TYLkIjU~q-6W^o9xyU@65)IDX3{nt1H?GR z?@}TQ_GyB5d1!8y?1uP6Ri-6Z=DP^8wBO_eZd=dX0{UN4jGKA!CGutu NJP#dlD|cZe{0U+Epkx35 literal 0 HcmV?d00001 diff --git a/assets/icon-32.png b/assets/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..4a1182646e9bc8d5708f4d55920ea2ee5d69cf85 GIT binary patch literal 362 zcmV-w0hRuVP)k>e0hx#T?|8jR+i&(1PX`NmSb}S zoIx+k(H+4!5SAl5f{|Fuh1Z#oY{A>0GX&Kzk!&iJ0ns5qE5L$J^7Yo&RI^CbTNJDK zXcP>12;fZv*g#<41%hhU7a7pb0WeVQid}2#?mrBHeHVy!1W8c<^F1!h3CU6H08#)k zQALsii19rPkP-?s%>~4S0#W6F=CX4XIplhj5l%URAwa3+gv`L}0BTrH$gBY`2}yFm zDNlK-sw3zCOV07*qo IM6N<$g6!{(p8x;= literal 0 HcmV?d00001 diff --git a/assets/icon-512.png b/assets/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..40149130cd2931b0ac8d19f5290d02edd34ceb6a GIT binary patch literal 4494 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 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 0000000000000000000000000000000000000000..bda0146c3126315e40c4215893ff49a926d502a7 GIT binary patch literal 384 zcmZQzU<5)11qKkwkj2QrAjZJJ&>7(8&dVjm1!VGidbk7uX`l)Q1r9bKSzJ~11xRrg zctjR6Fz_7#VaBQ2e9}NOWISCQLn2y}Qw}iwIJ)&;vhZ?|nwrX9!B`&WhOlXmi&(hY zuT4uXW|`xtfBK^AK?{Lt%GOO2BTlzopE)7t;Ozy*ELROC=4ef2yr}4)eW1zz)7NX) zLpFO{i56M?BSVCzg5!ZN^9_fD`>ah_*Tf|+oMSWyc-s}5{ra6fPh+ikM;VJ}Aj4-{ zz0;g01K%(NF&GBAPGau(Xv;G}h|AyZ|wz^4t*HWiClKB<}_=x5oa=&%b5eO z8L}e$POM?n=kzQ(py^+^YL)T}h9^guji*d_xZ}!0uMU@|P3Pa=PwJSNTi9H>EOSiqLLcW|*~YEeZu;&I-p;vnOYp|7 a=L~j>bJ+Lq;FJdj9)qW=pUXO@geCwujE{o= literal 0 HcmV?d00001 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)) + ); +});