feat: publish NIRO Gen5W research overview and reviewed source examples

This commit is contained in:
seo committed 2026-11-02 09:59:38 +09:00
commit ccad0ed9f3
23 files changed
+1279

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.md text eol=lf
*.java text eol=lf
*.php text eol=lf
*.py text eol=lf
+40
View File
@@ -0,0 +1,40 @@
# This repository publishes source and synthetic tests, not vehicle payloads.
.env
.env.*
*.key
*.pem
*.p12
*.pfx
*.jks
*.pass
*.properties.local
*.local.php
__pycache__/
*.py[cod]
*.class
*.jar
*.apk
*.odex
*.dex
*.so
*.img
*.bin
*.tar
*.zip
*.db
*.sqlite
*.sqlite3
*.log
*.xlsx
.venv/
build/
vendor/
node_modules/
captures/
data/
analysis-private/
staging/
.idea/
.vscode/
Thumbs.db
.DS_Store
+74
View File
@@ -0,0 +1,74 @@
# NIRO Gen5W — 순정 화면을 유지하는 차량 소프트웨어 연구
기아 니로 한국형 Gen5W 헤드유닛을 분석하고, 순정 UI와 기존 차량 기능을 유지하면서 필요한 기능을 복원·연동한 개인 프로젝트입니다. 작성자는 [seo](https://git.chaegeon.com/seo)입니다.
**이 저장소는 실차 작업 성과와 선별한 소스를 공개하는 곳입니다. 누구나 적용할 수 있는 완성형 설치 패키지나 공식 펌웨어가 아닙니다.** 제조사 펌웨어·지도·APK/ODEX 원본·실차 로그·인증정보는 제공하지 않습니다.
공개본 작성: 2026-11-02 · 기능 상태 기준: 2026-11-01의 비공개 운영 기록
## 어떤 일을 했나
단순히 숨겨진 메뉴를 켜는 데 그치지 않고, Android 앱·ODEX·ARM/Thumb 네이티브 코드·Binder·차량 신호·서버 응답 계약을 연결해 분석했습니다. 실제로 바뀐 부분과 확인하지 못한 부분을 구분해 기록합니다.
| 분야 | 작업 성과 | 현재 구분 |
|---|---|---|
| 온라인 날씨 | 순정 홈·상세 날씨·대기질·예보를 로컬 백엔드에 연결 | 실차 적용·갱신 확인 |
| 시동 브리핑 | 순정 날씨 TTS 경로의 대기 원인을 분석하고 실행 시간대를 확장 | 냉간 부팅 음성 출력 확인 |
| 캘린더·스포츠 | HA 일정과 외부 스포츠 데이터를 순정 요청·응답 모델로 변환 | 순정 화면 확인, 공급자별 범위 제한 |
| 내 차 위치 공유 | 수신자별 독립 링크, 기존 링크 갱신, SMS 중복 방지·부분 실패 격리·개별 해지 | 혼합 수신자와 링크 해지 실차 확인 |
| Send to Car | 주소 검색부터 차량의 순정 POI 저장·ACK까지 연결 | 실차 수신·재부팅 자동 복구 확인 |
| HA·MQTT | 유효한 차량 값을 변화 이벤트에 연결하고 초기값·미확인값을 분리 | 운영 중, 센서별 신뢰도 구분 |
| 지도 RPM·차속 | 수신된 RPM 표시, 내비 내부 차속 보정, 주·야간 가독성 보완 | 실차 적용, ECU 원시값은 변경하지 않음 |
| 지도 축척 | 5단계 자동 축척 사이에 75·150·300·600 m를 추가 | 9단계 적용, 일부 구간·표시 실차 확인 |
| 순정 메뉴 | Wi-Fi 설정·경치 좋은 길·탐색 회피 옵션을 제한된 분기에서 복원 | 적용, 세부 비교 시험 범위는 문서에 명시 |
| DMB/TPEG | 수신 경합·재연결·보존 정책을 분석하고 유효한 교통값 갱신 경계를 보정 | 운영 중, 방송 지연·미지원 도로는 해결하지 못함 |
| 후면 단속 안내 | 검증된 기준 DB와 일치하는 기존 후면 속성 보존 | 알려진 기존 카메라 대상, 신규 후면 자동 분류는 미지원 |
| 지도·엔진 호환 | 신지도와 구형 엔진 조합의 경로 계산 crash를 분석하고 대응 엔진에 기존 변경 이식 | 동일 실패 목적지 재계산 성공 |
| 읽기 진단·원격제어 | 순정 진단·제어 경로와 최종 응답을 서버 상태기계에 연결 | 확인된 기능만, 완전 전원 차단 뒤 수신은 불가 |
**미완료도 숨기지 않습니다.** Naver 경로를 순정 지도에 완전히 이식하는 작업과 IP 교통정보는 보류, 무선 AA/CarPlay는 이식 검토 단계입니다. 연료 잔량 %는 신뢰할 수 있는 원천을 찾지 못해 측정값처럼 발행하지 않습니다. ECU 정비 쓰기는 제공하지 않습니다.
## 먼저 읽기
- [전체 기능과 검증 범위](docs/FEATURES_KO.md)
- [차량–서버–HA 구조](docs/ARCHITECTURE_KO.md)
- [어려웠던 문제와 확인된 한계](docs/RESEARCH_KO.md)
- [공개 범위·출처·검증 원칙](docs/PUBLICATION_KO.md)
- [보안·시험 주의사항](SECURITY.md)
## 이번 공개본에 포함한 코드
| 위치 | 내용 | 차량 없이 시험 |
|---|---|---|
| `telemetry/java/` | 조각난 HSCAN 프레임을 재조립·검증하는 수신 전용 RPM decoder | 가능 |
| `weather/java/` | 날씨 요청 중복 병합·캐시 재사용·실패 backoff와 요청/완료 계수 | 가능 |
| `backend/php/` | 수신 트래픽과 독립적인 MQTT keepalive, raw Snappy literal encoder | 가능 |
| `navigation/archived/v038/` | 고정 입력 해시·허용 변경 창을 검사하는 과거 차속 보정 생성기 | 합성 시험 가능, 적용에는 별도 원본 필요 |
| `tests/`, `tools/` | 합성 입력 시험과 공개 파일 검사 | 가능 |
위 코드는 전체 운영 프로젝트의 일부입니다. 서버 전체, 실제 차량 접속 도구, 자동 설치·재부팅 기능은 포함하지 않았습니다. 과거 네이티브 예제를 현재 운영 엔진용 패치로 사용하면 안 됩니다.
## 로컬 검증
Linux 또는 WSL에서 Python 3.10+, JDK 8+, PHP 8.1+가 필요합니다. Python 외부 패키지, Android SDK, 제조사 파일, 차량 연결, API 키는 필요하지 않습니다.
```sh
python3 tools/check_publication.py
python3 tools/run_tests.py
```
시험은 임시 디렉터리에서 Java를 컴파일하고 PHP·Python 시험을 실행합니다. 차량·네트워크·ECU에 연결하지 않습니다. 단위시험 통과는 다른 차종·펌웨어에서의 실차 성공을 보장하지 않습니다.
## 개발 원칙
1. 앱에 보이는 메뉴, 통신 계약, 실제 원천 데이터, 화면 결과를 따로 검증합니다.
2. 미확인 초기값을 0·꺼짐·성공으로 위장하지 않습니다.
3. 네이티브 변경은 정확한 입력 버전·해시와 허용 범위를 검사합니다.
4. 전역 개통·모뎀 상태를 속이거나 안전·긴급 기능을 일괄 우회하지 않습니다.
5. 연구 결과, 합성 시험, 실차 관측, 운영 적용을 같은 의미로 쓰지 않습니다.
## 관련 프로젝트와 이용 조건
[hmg_nemesis](https://github.com/xwtk/hmg_nemesis), [HMG-Leafia-Plus](https://github.com/VysseCodes/HMG-Leafia-Plus), [gen5w-utils](https://github.com/Helloyunho/gen5w-utils)는 관련 생태계의 별도 프로젝트입니다. 이 저장소가 이들의 공식 포크나 니로 지원판이라는 뜻은 아니며, 해당 소스·배포물은 이 공개본에 복사하지 않았습니다.
현재 이용 조건은 [RIGHTS.md](RIGHTS.md)를 확인하세요. Hyundai·Kia·현대모비스·Apple·Google 및 데이터 제공 사업자와 무관한 개인 연구입니다.
+9
View File
@@ -0,0 +1,9 @@
# 이용 조건
Copyright (c) 2026 seo. All rights reserved.
이 저장소는 작업 성과와 소스의 공개 열람을 위해 만들었습니다. 현재 별도의 오픈소스 라이선스를 부여하지 않았습니다. 공개됐다는 사실만으로 사용·수정·재배포·재라이선스가 포괄적으로 허용된다고 해석하지 마세요. 별도 이용 허락은 작성자에게 문의하세요.
이 안내는 적용 법률이 허용하는 권리나 제3자 자료에 별도로 적용되는 이용 조건을 제한하지 않습니다. 제조사·관련 프로젝트·데이터 제공자의 상표와 권리는 각 소유자에게 있습니다.
라이선스가 확정되면 이 파일과 README를 함께 갱신합니다.
+11
View File
@@ -0,0 +1,11 @@
# 보안 및 시험 경계
- 차량·서버 접속 정보, 전화번호, GPS, 원문 로그, token, firmware 키를 Issue에 올리지 마세요.
- 민감한 보고는 작성자 프로필의 비공개 연락 수단을 이용하고, 공개 Issue에는 비밀값 없는 최소 재현만 남겨 주세요. 별도 응답 시간 보장은 없습니다.
- 단위시험은 합성 입력과 임시 디렉터리만 사용합니다. 차량 자동 연결·명령 송신·재부팅 기능은 없습니다.
- 고정 해시 검사를 제거하거나 다른 펌웨어에 명령 offset을 그대로 적용하지 마세요.
- root 권한, 서비스 가입 상태, 하드웨어·ECU 지원 여부는 서로 다른 문제입니다.
- 운전자 조작 방지, 전·후방 카메라, 긴급 서비스, 진단 장비 충돌 방지 조건을 일괄 우회하지 마세요. 실제 적용은 안전한 정차 환경과 적절한 복구 수단이 있는 조건에서 별도로 검증해야 합니다.
- 제조사 원본·수정 전체 바이너리·실차 capture를 기여물로 제출하지 마세요.
보안 개선·시험 제안은 환영하지만, 이 저장소를 범용 차량 설치·정비·제어 제품으로 취급하지 않습니다.
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* Tracks the MQTT client keepalive independently from inbound broker traffic.
*
* MQTT keepalive is a client-to-broker obligation. Receiving a continuous
* stream of PUBLISH packets must therefore never postpone the next PINGREQ.
*/
final class NiroMqttKeepalive
{
private int $lastPingAt;
public function __construct(int $connectedAt, private readonly int $intervalSeconds = 15)
{
if ($intervalSeconds < 1) {
throw new InvalidArgumentException('MQTT keepalive interval must be positive');
}
$this->lastPingAt = $connectedAt;
}
public function isDue(int $now): bool
{
return $now - $this->lastPingAt >= $this->intervalSeconds;
}
public function markSent(int $now): void
{
$this->lastPingAt = $now;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* Minimal raw Snappy encoder for the V038 native TFS decoder.
*
* The head unit advertises application/x-snappy-framed, but the native
* response path calls snappy::Uncompress on a raw Snappy block. Literal-only
* blocks are valid raw Snappy and avoid a platform extension dependency.
*/
final class NiroRawSnappy
{
public static function encodeLiteral(string $plain): string
{
$length = strlen($plain);
if ($length > 8 * 1024 * 1024) {
throw new InvalidArgumentException('Snappy input exceeds limit');
}
$encoded = self::varint($length);
$offset = 0;
while ($offset < $length) {
$chunkLength = min(65536, $length - $offset);
$encoded .= self::literalTag($chunkLength);
$encoded .= substr($plain, $offset, $chunkLength);
$offset += $chunkLength;
}
return $encoded;
}
private static function varint(int $value): string
{
$encoded = '';
do {
$byte = $value & 0x7f;
$value >>= 7;
if ($value !== 0) {
$byte |= 0x80;
}
$encoded .= chr($byte);
} while ($value !== 0);
return $encoded;
}
private static function literalTag(int $length): string
{
if ($length < 1 || $length > 65536) {
throw new InvalidArgumentException('Invalid Snappy literal length');
}
if ($length <= 60) {
return chr(($length - 1) << 2);
}
$value = $length - 1;
$bytes = '';
while ($value !== 0) {
$bytes .= chr($value & 0xff);
$value >>= 8;
}
return chr((59 + strlen($bytes)) << 2) . $bytes;
}
}
+39
View File
@@ -0,0 +1,39 @@
# 구조와 경계
이 문서는 개인 접속 주소나 운영 비밀값 없이 전체 설계를 설명합니다. 본 공개본에 모든 구성 요소가 구현·포함돼 있다는 뜻은 아닙니다.
## 데이터가 흐르는 경로
```text
차량 ECU / MICOM / HSCAN
↓ 원래 헤드유닛이 수신하는 정보
Android 4.4 계열 서비스·Binder·순정 앱·내비 native
├─ 순정 화면: 날씨 / 캘린더 / 스포츠 / RPM / 위치공유
├─ 제한된 HTTPS 연동 → 개인 백엔드 → 외부 데이터 공급자
└─ 검증된 변화값 → MQTT → Home Assistant / 모니터 / 차계부
```
핵심은 Android의 root 권한과 차량 ECU의 권한을 구분하는 것입니다. 헤드유닛에서 root를 얻어도 모든 차량 신호가 수신되는 것은 아니며, 모든 ECU 제어가 가능한 것도 아닙니다.
## 세 가지 코드 계층
| 계층 | 역할 | 공개본의 범위 |
|---|---|---|
| 순정 앱과 ODEX | 화면·요청·callback·서비스 진입 | 설명만, 제조사 원본·수정 전체 앱 제외 |
| 내비 네이티브 | 지도·경로 계산·교통·카메라·렌더링 | 설명과 과거 단일 변경 생성기 예제 |
| 독립 수집·변환 코드 | 수신 검증·요청 제어·MQTT·서버 계약 변환 | 검토한 일부 소스와 합성 시험 |
운영 배포는 동일 버전 확인과 해시 검증을 거친 작은 변경을 우선했습니다. 일부 앱은 원본 APK를 유지하고 ODEX payload를 bind하는 방식, 내비는 해당 엔진에 맞는 제한된 native 변경을 사용했습니다. 이 방법도 버전 의존성이 있으므로 범용 설치법이 아닙니다.
## 원천값과 표시값
- RPM decoder는 도착한 프레임을 검증한 뒤 callback을 호출합니다. 이벤트 반영은 화면 렌더링 시간이 0이라는 뜻이 아닙니다.
- 내비 차속 보정은 내비의 내부 소비값을 바꾸는 것입니다. 차량 ECU·계기판·OBD의 원시 속도를 변경하지 않습니다.
- MQTT는 이미 확인한 값을 운반하는 수단입니다. 초기값이나 잘못된 해석을 MQTT가 정답으로 만들어 주지 않습니다.
- 연결이 끊긴 실시간 센서와 마지막 관측값은 서로 구분해야 합니다. 오래된 속도·기어·시동값을 현재 제어 판단에 쓰지 않습니다.
## 서버 인증과 저장
운영 서버는 차량 인증, 관리자 세션, CSRF, 명령 상태기계, 수신자별 공유 링크와 만료·폐기를 따로 관리합니다. 실제 키·개인정보·DB·원문 요청 로그는 공개하지 않습니다. 백엔드 변환기는 정식 서비스 가입·결제·차량 인증을 대신하지 않으며, SOS·결제의 가짜 성공을 제공하지 않습니다.
헤드유닛의 전원이 완전히 꺼져 Android와 네트워크가 정지하면 이 경로로 원격 명령을 받을 수 없습니다. 별도 저전력 수신기와 검증된 wake 연동은 독립적인 하드웨어 과제입니다.
+42
View File
@@ -0,0 +1,42 @@
# 기능별 구현과 확인 범위
기준: 2026-11-01 운영 기록. 이 표는 전체 개인 프로젝트의 성과이며, 해당 기능의 완성 소스·배포물이 모두 공개돼 있다는 뜻은 아닙니다. `실차 확인`도 표에 적힌 조건과 대상에 한정됩니다.
| 기능 | 구현·관측한 내용 | 남은 한계 |
|---|---|---|
| Wi-Fi·관리 경로 | 순정 STA 연결·설정 저장·재부팅 자동 연결, SSH·Tailscale 운영 | 다른 차종·공유기 조합은 미검증 |
| 순정 Wi-Fi 메뉴 | 순정 설정 타일·Wi-Fi 탭 관련 분기만 제한적으로 복원 | 전역 feature 판정 위장 안 함 |
| 날씨·대기질 | 홈·상세·예보·갱신, 관측과 모델 fallback 구분 | 공급자 coverage·갱신 주기 의존 |
| 웰컴 날씨 TTS | 날씨 전용 대기 경로와 실행 시간대 보정, 야간 냉간 부팅 출력 | 뉴스 브리핑 구현 아님 |
| HA 캘린더 | 읽기 전용 일정 변환·순정 목록·수동 갱신 | 일정 필드별 모든 현장 조합은 미검증 |
| 스포츠 | 지원 6종목의 팀 데이터·순정 parser·목록 | 현재 시즌·경기·순위는 공급자 플랜 의존, 골프·크리켓 미공급 |
| 위치공유 | 번호 정규화·수신자별 링크·기존 링크 재사용·신규 SMS·부분 실패 격리·개별 해지 | 최신 GPS 대기·Binder 재연결 등 지속 개선 |
| 공유 웹 화면 | 같은 페이지의 위치·허용 차량정보·지도·이동 궤적 갱신 | 링크 수명·권한을 넘는 정보는 미공개 |
| 목적지 전송 | 주소 검색→대기열→순정 POI→최종 ACK | Android·네트워크가 꺼지면 수신 불가 |
| 순정 진단 | 12개 고유 ECU와 읽기 보고 경로 확인 | 외부 장비 충돌 방지 유지, 정비 쓰기 제외 |
| 원격제어 | 확인된 시동·도어 명령의 순정 실행과 최종 응답 연계 | 모든 명령 검증 아님, 완전 전원 차단 뒤 제어 불가 |
| HA·MQTT | 의미를 확인한 센서·변화값 전달, 잘못된 초기값 발행 제한 | 공용 getter가 모두 해당 차량의 유효 센서는 아님 |
| 디지털 액자 | 순정 표시 경로·이미지 리사이즈·서로 다른 화면 비율 대응 | 두 화면에 같은 픽셀 배열을 강제로 맞추지 않음 |
| 지도 RPM | 실제 수신 RPM/1,000과 소수 한 자리, 주·야간 색상 | 연료 %나 ECU 제어와 별개 |
| 내비 차속 | 내비 내부 한 입력 경계에서 1 이상일 때 -1, 0 유지 | ECU 원시 속도 변경 아님 |
| 구간 평균속도 | 화면 표시 보정, 계산·초과 판단 경로 보존 | 실제 구간 진입의 모든 화면 회귀는 미완료 |
| 자동 축척 | 50·75·100·150·200·300·400·600·800 m | 일부 단계의 실제 렌더링·라벨 확인, 전 화면 검증 아님 |
| 경치 좋은 길 | 설치 지도 DB·사진·기존 scene을 이용해 메뉴·목록·미리보기 복원 | 별도 온라인 추천 경로가 아님 |
| 회피 옵션 | 고속도로·페리·유료도로·터널·비포장 회피 메뉴와 OEM 비용 경로 연결 | 각 도로 유형의 비교 주행 시험은 미완료 |
| 지도·엔진 | 신지도와 맞는 엔진으로 HAFP 경로 계산 crash 대응 | 지도만 바꿔도 항상 호환된다는 뜻 아님 |
| DMB/TPEG | PCBER/FIC·서비스 재연결·개별 값 보존 경계 보정 | 방송 지연·수신 음영·미지원 도로는 남음 |
| 후면 카메라 | 일치 조건을 만족하는 기존 후면 DB 속성 보존 | 방송에 없는 신규 rear 속성은 추정하지 않음 |
## 보류·미구현·미지원
| 항목 | 상태와 이유 |
|---|---|
| Naver 경로 완전 이식 | 무기한 보류. 단일 geometry의 지도 link 변환과 parser 시험은 있었지만, 순정 지도 안내·옵션·재탐색 전체는 미완료 |
| IP 교통정보 | 무기한 보류. 계약과 설치 지도 식별자 변환을 연구했으나 전체 coverage·실차 표시 완료 아님 |
| 무선 AA/CarPlay | 순정 비활성. 외부 구현과 니로 원본의 호환성 분석 단계, 실연결 성공 아님 |
| 인터넷 STA+projection AP 동시 사용 | 미구현·실차 검증 필요 |
| ECO 주 경로 | 미적용. 한국형 비용 factory·연료 표 경로가 연결되지 않아 메뉴 노출만으로 해결 불가 |
| 실제 연료 잔량 % | 미확인. 고정 getter·미수신값을 잔량으로 환산하지 않음 |
| DTC 삭제·강제 액추에이터·ECU 코딩 | 운영 기능으로 제공하지 않음 |
| 실제 CarPay 결제 | 구현하지 않음. 가맹·차량·결제 인증을 로컬 성공 응답으로 대체하지 않음 |
| MCU 내부 전체 역분석 | 완료 아님. Android/native 경계와 암호화된 MCU 실행 코드를 구분 |
+37
View File
@@ -0,0 +1,37 @@
# 공개 범위와 출처
## 별도 공개 저장소
기존 비공개 운영 저장소의 `.git`·이력·설정·리모트를 복사하지 않고 새 이력으로 시작합니다. 이 저장소를 운영 배포의 기준선으로 사용하지 않습니다.
## 초기 공개 소스의 출처
| 공개 모듈 | 성격 | 시험 입력 |
|---|---|---|
| `HscanRpmDecoder` | 개인 프로젝트에서 작성한 수신 프레임 검증·RPM 해석 코드 | 프로그램으로 생성한 합성 프레임 |
| `WeatherRefreshGate` | 직접 작성한 요청 admission·재사용·실패 backoff 상태기계 | 합성 monotonic 시각 |
| `WeatherRefreshCounters` | 직접 작성한 요청/완료 계수와 파일 교체 로직 | 시험 임시 디렉터리 |
| `NiroMqttKeepalive` | 직접 작성한 MQTT keepalive 시간 추적 | 합성 시각 |
| `NiroRawSnappy` | 직접 작성한 raw Snappy literal encoder | 합성 바이트열 |
| 과거 V038 차속 생성기 | 입력 해시·명령 창·변경 범위를 검사하는 직접 작성한 도구 | 합성 이미지, 제조사 원본 없음 |
Java namespace는 원래 코드의 `com.chaegeon.niro`를 유지합니다. 이는 운영 서버 주소나 차량 식별값이 아닙니다. 시험의 CAN envelope·메시지 번호·단위는 설명을 위한 계약 정보이며 실차 capture 전체를 포함하지 않습니다.
내비 예제에는 호환성 검사용 짧은 명령 값과 직접 작성한 wrapper가 있습니다. 제조사 라이브러리 자체·디컴파일 전체·지도·완성 패치 바이너리는 제공하지 않습니다. 대상 파일을 소유한다는 사실만으로 모든 변경·배포 권한이 생기는 것은 아니므로 적용 가능성과 이용 조건은 각자가 확인해야 합니다.
외부 프로젝트는 관련 링크로 소개할 뿐, NEMESIS·Leafia·gen5w-utils의 소스·바이너리를 초기 공개본에 가져오지 않았습니다. 향후 외부 코드를 추가하면 원래 라이선스와 변경·출처를 별도로 기록해야 합니다.
## 제외한 것
- 제조사 펌웨어·지도·APK·ODEX·라이브러리 원본 및 수정 전체 배포물
- 차량의 실제 GPS·주행 기록·전화번호·VIN·차량 고유 식별자
- API 키·비밀번호·SSH 개인키·MQTT 인증정보·공유 원문 token
- 실차·운영 서버의 원문 로그·DB·백업·내부 주소·배포 설정
- 차량 자동 접속·재부팅·실제 명령 실행·ECU 쓰기 기능
- 이전 비공개 Git 이력과 운영 배포 파일
## 공개 검사
`tools/check_publication.py`는 공개 허용 파일 목록과 기본 비밀값·금지 산출물을 검사합니다. Git이 초기화돼 있으면 추적 파일과 이력의 정체성도 확인합니다. 이 검사는 모든 개인정보·저작권·보안 문제를 자동 판별하는 감사가 아닙니다.
새 파일을 추가하려면 내용을 검토한 뒤 허용 목록을 명시적으로 갱신해야 합니다. `.gitignore`만 바꿔 이미 추적된 파일이나 과거 이력이 사라진다고 가정하지 않습니다.
+35
View File
@@ -0,0 +1,35 @@
# 문제를 어떻게 좁혔나
## 1. 센서 이름이 있다고 실제 센서값은 아니다
공용 automotive framework의 수많은 getter 중에는 현재 차종에서 한 번도 갱신되지 않는 초기값과 다른 variant의 값이 있습니다. 전조등·미등의 `0`을 곧바로 꺼짐으로, 후측방의 `-1,-1`을 경고 없음으로 발행하면 그럴듯하지만 틀린 화면이 됩니다.
원천 callback·유효성·단위를 확인하고, 미확인은 미확인으로 남기는 것이 목표입니다. 특히 연료 게이지가 변해도 같은 숫자를 반환하는 getter는 잔량 측정값으로 채택하지 않았습니다. RPM 수신을 성공했다고 연료까지 찾았다고 기록하지 않습니다.
공개한 `HscanRpmDecoder`는 정해진 envelope·길이·XOR·메시지 구조·패딩을 검증한 뒤 RPM을 읽습니다. 이미 받은 바이트를 해석할 뿐, ECU 요청·socket 접속·CAN 송신 기능은 없습니다.
## 2. 교통 packet 도착과 도로별 정보 갱신은 다르다
TPEG packet이 몇 초마다 들어온다는 사실은 모든 도로의 최신 값이 그 간격으로 도착한다는 뜻이 아닙니다. RF lock, PCBER/FIC 확정, 서비스 연결, 내비 callback, 개별 도로의 관측·만료는 따로 봐야 합니다.
운영 보정은 새 유효값으로 갱신하고 누락·무효값으로 유효한 이전 값을 즉시 지우지 않는 것입니다. 보존 TTL은 60분이며, 오래된 값이 실제 현재 교통이라는 뜻은 아닙니다. 미지원 도로·만료 구간은 회색으로 남을 수 있습니다.
후면 카메라도 decoder가 전달하지 않는 rear 속성을 class·개수·거리만으로 추정하지 않습니다. 현재 보존은 검증된 기준 DB와 일치하는 기존 항목에 한정됩니다.
## 3. 지도 파일과 엔진은 독립적으로 바꿔도 되는가
신지도와 구형 엔진의 조합은 부팅·화면 표시에 성공했지만 특정 목적지 계산에서 HAFP node parser crash를 냈습니다. 이후 지도와 맞는 엔진에 기존 변경을 다시 이식하고 같은 실패 요청으로 확인했습니다.
교훈은 `부팅 성공 = 모든 경로 계산 호환`이 아니라는 점입니다. 공개한 과거 V038 차속 생성기는 이 현재 엔진용 설치 파일이 아닙니다. 정확한 입력 해시가 아니면 거부하는 과거 예제로만 제공합니다.
## 4. 외부 경로 geometry와 순정 길안내는 다른 계약이다
Naver 좌표를 목적지·경유점으로 보내면 순정 내비가 자체 경로를 계산할 수는 있지만, 외부 경로를 그대로 안내하는 것은 아닙니다. 설치 지도 link·방향·parcel 경계·연결성·안내 모델이 맞아야 합니다.
과거 한 시험에서 geometry 408점을 241개 RealLink·5,735바이트 순정 응답으로 변환하고 parser 소비를 확인했습니다. 408은 고정 규격이 아니라 그 입력의 표본 수입니다. 이 성과를 임의 경로·실제 안내·주기/이탈 재탐색 완료로 확대하지 않습니다. 관련 실차 개발은 보류돼 있습니다.
## 5. 실패를 숨기는 성공 응답은 해결이 아니다
위치공유는 수신자별 성공·실패를 격리하고, 한 명의 실패로 다른 사람의 정상 링크를 폐기하지 않아야 합니다. 원격 명령은 최종 차량 응답 전까지 성공이 아닙니다. 새 메뉴가 보이는 것과 해당 ECU·경로 엔진이 기능을 지원하는 것도 다릅니다.
이 공개본은 그러한 원칙을 설명하고 일부 순수 로직을 제공합니다. 전체 운영 서버·차량 제어 기능을 배포하는 저장소는 아닙니다.
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Correct the navigation display-speed source once, before it is shared."""
from __future__ import annotations
import argparse
import hashlib
import struct
from pathlib import Path
SOURCE_SHA256 = "f284d61e18f58f10db032e64799f7c3c348d8533c5b48e2ef390b69606a81648"
SETTER_OFFSET = 0x018B302C
SETTER_CONTINUE = SETTER_OFFSET + 4
SETTER_ORIGINAL = bytes.fromhex("00 f5 ad 30")
CAVE_OFFSET = 0x0189DAB4
CAVE_SIZE = 60
DISPLAY_CALL_SITES = (
0x019F5376,
0x019F9F70,
0x019FA034,
0x019FA0F8,
0x01A23E6E,
)
CONVERT_SPEED = 0x018EC9E8
SAFETY_CALL_SITE = 0x020C411A
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def encode_thumb_branch(source: int, destination: int, link: bool = False) -> bytes:
displacement = destination - (source + 4)
if displacement & 1:
raise ValueError("Thumb branch destination is not halfword aligned")
if not -(1 << 24) <= displacement < (1 << 24):
raise ValueError("Thumb branch destination is out of range")
immediate = displacement & 0x01FFFFFF
sign = (immediate >> 24) & 1
i1 = (immediate >> 23) & 1
i2 = (immediate >> 22) & 1
j1 = (~(i1 ^ sign)) & 1
j2 = (~(i2 ^ sign)) & 1
first = 0xF000 | (sign << 10) | ((immediate >> 12) & 0x03FF)
second_base = 0xD000 if link else 0x9000
second = second_base | (j1 << 13) | (j2 << 11) | ((immediate >> 1) & 0x07FF)
return struct.pack("<HH", first, second)
def source_wrapper() -> bytes:
# r1 contains the incoming IEEE-754 display-speed float. Keep values below
# 1.0 unchanged; otherwise subtract exactly 1.0, then execute the displaced
# stock object-offset instruction and return to the unmodified setter body.
body = bytes.fromhex(
"07 ee 90 1a" # vmov s15, r1
" b7 ee 00 7a" # vmov.f32 s14, #1.0
" f4 ee c7 7a" # vcmpe.f32 s15, s14
" f1 ee 10 fa" # vmrs APSR_nzcv, fpscr
" 03 db" # blt skip_subtract
" 77 ee c7 7a" # vsub.f32 s15, s15, s14
" 17 ee 90 1a" # vmov r1, s15
" 00 f5 ad 30" # add.w r0, r0, #0x15a00 (displaced stock instruction)
)
return body + encode_thumb_branch(CAVE_OFFSET + len(body), SETTER_CONTINUE)
def patch_image(source: bytes) -> bytes:
if digest(source) != SOURCE_SHA256:
raise ValueError(f"unknown source sha256={digest(source)}")
if source[SETTER_OFFSET:SETTER_CONTINUE] != SETTER_ORIGINAL:
raise ValueError("display-speed source setter differs")
if source[CAVE_OFFSET:CAVE_OFFSET + CAVE_SIZE] != bytes(CAVE_SIZE):
raise ValueError("speed source code cave differs")
for site in DISPLAY_CALL_SITES:
expected = encode_thumb_branch(site, CONVERT_SPEED, link=True)
if source[site:site + 4] != expected:
raise ValueError(f"display call site is not stock: 0x{site:x}")
safety_call = encode_thumb_branch(SAFETY_CALL_SITE, CONVERT_SPEED, link=True)
if source[SAFETY_CALL_SITE:SAFETY_CALL_SITE + 4] != safety_call:
raise ValueError("overspeed call site differs")
result = bytearray(source)
wrapper = source_wrapper()
if len(wrapper) > CAVE_SIZE:
raise AssertionError("speed source wrapper exceeds the approved cave")
result[CAVE_OFFSET:CAVE_OFFSET + len(wrapper)] = wrapper
result[SETTER_OFFSET:SETTER_CONTINUE] = encode_thumb_branch(
SETTER_OFFSET, CAVE_OFFSET
)
changed = {
index for index, (before, after) in enumerate(zip(source, result))
if before != after
}
allowed = set(range(SETTER_OFFSET, SETTER_CONTINUE))
allowed.update(range(CAVE_OFFSET, CAVE_OFFSET + len(wrapper)))
if not changed or not changed.issubset(allowed):
raise AssertionError("change outside the central speed-source windows")
for site in DISPLAY_CALL_SITES:
expected = encode_thumb_branch(site, CONVERT_SPEED, link=True)
if result[site:site + 4] != expected:
raise AssertionError(f"display call site changed: 0x{site:x}")
if result[SAFETY_CALL_SITE:SAFETY_CALL_SITE + 4] != safety_call:
raise AssertionError("overspeed call site changed")
if len(result) != len(source):
raise AssertionError("native library size changed")
return bytes(result)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
if args.destination.exists():
raise SystemExit("destination already exists")
source = args.source.read_bytes()
result = patch_image(source)
temporary = args.destination.with_name(args.destination.name + ".new")
temporary.write_bytes(result)
temporary.replace(args.destination)
print(f"source_sha256={digest(source)}")
print(f"output_sha256={digest(result)}")
print(f"setter_branch=0x{SETTER_OFFSET:x}:{result[SETTER_OFFSET:SETTER_CONTINUE].hex()}")
print(f"wrapper=0x{CAVE_OFFSET:x}:{source_wrapper().hex()}")
print("display_calls=STOCK:" + ",".join(f"0x{site:x}" for site in DISPLAY_CALL_SITES))
print(f"overspeed_call=STOCK:0x{SAFETY_CALL_SITE:x}")
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
package com.chaegeon.niro;
/** Receive-only V038 Can2A20HEV ALL_C / EMS1 decoder. No Android or transport I/O. */
final class HscanRpmDecoder {
interface Listener { void onRpm(int rpm); }
// Observed hscand envelope: FF 0B 8E 01 8B C7 01 00, 255 data bytes + XOR.
private static final int FRAME_SIZE = 264;
private static final int[] HEADER = {255, 11, 142, 1, 139, 199, 1, 0};
private final byte[] frame = new byte[FRAME_SIZE];
private final Listener listener;
private int used;
long validFrames;
long rejectedFrames;
long samples;
HscanRpmDecoder(Listener listener) { this.listener = listener; }
void accept(byte[] bytes, int offset, int length) {
if (offset < 0 || length < 0 || offset > bytes.length - length)
throw new IndexOutOfBoundsException();
for (int i = offset; i < offset + length; i++) {
frame[used++] = bytes[i];
// Find the exact bounded header again after garbage or corrupt framing.
while (used > 0 && !headerMatches()) discardFirst();
if (used == FRAME_SIZE) {
if (validate()) {
validFrames++;
emit();
used = 0;
} else {
rejectedFrames++;
discardFirst();
while (used > 0 && !headerMatches()) discardFirst();
}
}
}
}
private boolean headerMatches() {
for (int i = 0; i < Math.min(used, HEADER.length); i++)
if ((frame[i] & 255) != HEADER[i]) return false;
return true;
}
private void discardFirst() {
System.arraycopy(frame, 1, frame, 0, --used);
}
private boolean validate() {
int xor = 0;
for (byte value : frame) xor ^= value & 255;
if (xor != 0 || (frame[8] & 255) != 0xc7) return false;
int count = (frame[9] & 255) | ((frame[10] & 255) << 8);
int end = FRAME_SIZE - 1, p = 11;
if (count > (end - p) / 3) return false;
for (int i = 0; i < count; i++) {
if (p + 2 > end) return false;
int fn = frame[p++] & 255, size = frame[p++] & 255;
if (size < 1 || size > 8 || p + size > end) return false;
// A different profile must never reinterpret a short record as EMS1.
if (fn == 0x16 && size != 8) return false;
p += size;
}
while (p < end) if (frame[p++] != 0) return false;
return true;
}
private void emit() {
int count = (frame[9] & 255) | ((frame[10] & 255) << 8), p = 11;
for (int i = 0; i < count; i++) {
int fn = frame[p++] & 255, size = frame[p++] & 255;
if (fn == 0x16) {
// CAN 0x316 EMS1: same LE16 * 0.25 and int truncation as stock HEV.
int raw = (frame[p + 2] & 255) | ((frame[p + 3] & 255) << 8);
if (raw < 0xfffe) { samples++; listener.onRpm(raw / 4); }
}
p += size;
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package com.chaegeon.niro;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class HscanRpmDecoderTest {
private static int assertions;
private static final class Sink implements HscanRpmDecoder.Listener {
final List<Integer> values = new ArrayList<Integer>();
public void onRpm(int rpm) { values.add(rpm); }
}
public static void main(String[] args) throws Exception {
Sink sink = new Sink();
HscanRpmDecoder decoder = new HscanRpmDecoder(sink);
byte[] zero = packet(0), rev = packet(8403);
decoder.accept(zero, 0, zero.length);
check(sink.values.equals(Arrays.asList(0)), "valid stopped engine");
for (byte b : rev) decoder.accept(new byte[] {b}, 0, 1);
check(sink.values.equals(Arrays.asList(0, 2100)), "single-byte fragmentation + stock truncation");
byte[] burst = new byte[528];
System.arraycopy(packet(9200), 0, burst, 0, 264);
System.arraycopy(packet(6400), 0, burst, 264, 264);
decoder.accept(burst, 0, burst.length);
check(sink.values.equals(Arrays.asList(0, 2100, 2300, 1600)), "coalesced changes emitted before accept returns");
int before = sink.values.size();
byte[] bad = packet(4000); bad[30] ^= 1;
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "bad XOR never emitted");
bad = packet(4000); bad[9] = 2; bad[22] = 9; checksum(bad);
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "validate entire frame before any callback");
bad = packet(4000); bad[12] = 7; checksum(bad);
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "wrong EMS1 length");
bad = packet(4000); bad[200] = 1; checksum(bad);
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "nonzero tail rejected");
bad = packet(4000); bad[9] = (byte) 255; checksum(bad);
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "excess count bounded");
bad = packet(4000); bad[11] = 0x17; checksum(bad);
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "unrelated CAN not RPM");
bad = packet(4000); bad[2] = 0; checksum(bad);
decoder.accept(bad, 0, bad.length);
check(sink.values.size() == before, "other envelope not accepted");
for (int sentinel : new int[] {65534, 65535}) {
byte[] bytes = packet(sentinel); decoder.accept(bytes, 0, bytes.length);
}
check(sink.values.size() == before, "reserved raw values ignored");
decoder.accept(new byte[] {1, 2, (byte) 255, 4, 5}, 0, 5);
decoder.accept(rev, 0, rev.length);
check(sink.values.size() == before + 1, "recovery after corrupt frames and garbage");
for (int split = 1; split < 264; split++) {
Sink s = new Sink(); HscanRpmDecoder d = new HscanRpmDecoder(s);
d.accept(rev, 0, split);
check(s.values.isEmpty(), "partial frame held");
d.accept(rev, split, rev.length - split);
check(s.values.equals(Arrays.asList(2100)), "every split decodes once");
}
if (args.length > 0) {
Sink live = new Sink(); HscanRpmDecoder d = new HscanRpmDecoder(live);
long bytes = 0;
for (String file : args) {
FileInputStream in = new FileInputStream(file);
try {
byte[] buffer = new byte[137]; int n;
while ((n = in.read(buffer)) >= 0) { d.accept(buffer, 0, n); bytes += n; }
} finally { in.close(); }
}
check(d.validFrames * 264 == bytes, "all private capture frames validated");
check(d.rejectedFrames == 0 && d.samples > 500, "real EMS1 samples recovered");
System.out.println("private_fixture_frames=" + d.validFrames + " rpm_samples=" + d.samples);
}
System.out.println("HscanRpmDecoderTest OK assertions=" + assertions);
}
private static byte[] packet(int raw) {
byte[] b = new byte[264];
int[] header = {255, 11, 142, 1, 139, 199, 1, 0, 199, 1, 0, 22, 8};
for (int i = 0; i < header.length; i++) b[i] = (byte) header[i];
b[15] = (byte) raw; b[16] = (byte) (raw >> 8);
checksum(b); return b;
}
private static void checksum(byte[] b) {
b[263] = 0;
for (int i = 0; i < 263; i++) b[263] ^= b[i];
}
private static void check(boolean good, String message) {
assertions++; if (!good) throw new AssertionError(message);
}
}
@@ -0,0 +1,65 @@
package com.chaegeon.niro;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public final class WeatherRefreshCountersTest {
private static int checks;
private static void ok(boolean actual) {
++checks;
if (!actual) { throw new AssertionError("check " + checks); }
}
private static String read(File file) throws Exception {
FileInputStream input = new FileInputStream(file);
try {
byte[] data = new byte[32];
int length = input.read(data);
ok(length > 0 && input.read() == -1);
return new String(data, 0, length, "US-ASCII");
} finally {
input.close();
}
}
public static void main(String[] args) throws Exception {
File directory = File.createTempFile("weather-refresh-counters-", ".dir");
if (!directory.delete() || !directory.mkdir()) {
throw new IOException("test directory setup failed");
}
File signal = new File(directory, "signal.counter");
File callback = new File(directory, "callback.counter");
try {
WeatherRefreshCounters counters = new WeatherRefreshCounters(directory);
ok("0\n".equals(read(signal)));
ok("0\n".equals(read(callback)));
counters.signalAccepted();
counters.signalAccepted();
ok("2\n".equals(read(signal)));
ok("0\n".equals(read(callback)));
counters.freshCallbackCompleted();
ok("2\n".equals(read(signal)));
ok("1\n".equals(read(callback)));
ok(!new File(directory, "signal.counter.new").exists());
ok(!new File(directory, "callback.counter.new").exists());
File stale = new File(directory, "signal.counter.new");
ok(stale.createNewFile());
boolean failedClosed = false;
try {
counters.signalAccepted();
} catch (IOException expected) {
failedClosed = true;
}
ok(failedClosed);
ok("2\n".equals(read(signal)));
ok(stale.delete());
} finally {
if (signal.exists() && !signal.delete()) { throw new IOException("signal cleanup"); }
if (callback.exists() && !callback.delete()) { throw new IOException("callback cleanup"); }
if (!directory.delete()) { throw new IOException("directory cleanup"); }
}
System.out.println("result=PASS_WEATHER_REFRESH_COUNTERS checks=" + checks);
}
}
+59
View File
@@ -0,0 +1,59 @@
import com.chaegeon.niro.WeatherRefreshGate;
public final class WeatherRefreshGateTest {
private static void check(boolean condition, String name) {
if (!condition) {
throw new AssertionError(name);
}
}
public static void main(String[] args) {
WeatherRefreshGate gate = new WeatherRefreshGate(60000L, 10000L, 2000L);
check(gate.admit(100L) == WeatherRefreshGate.Admission.FETCH,
"first fetch accepted");
check(gate.isRunning(), "running");
check(gate.admit(101L) == WeatherRefreshGate.Admission.COALESCE,
"in-flight request coalesced");
gate.finish(200L, WeatherRefreshGate.Admission.FETCH, true);
check(!gate.isRunning(), "finished");
check(gate.admit(300L) == WeatherRefreshGate.Admission.REPLAY,
"recent successful response replayed");
check(gate.replayDelayMillis(300L) == 1900L,
"first replay waits until two seconds after fetch callback");
check(gate.admit(301L) == WeatherRefreshGate.Admission.COALESCE,
"in-flight replay coalesced");
gate.finish(400L, WeatherRefreshGate.Admission.REPLAY, true);
check(gate.admit(500L) == WeatherRefreshGate.Admission.REPLAY,
"second replay admitted without new fetch");
check(gate.replayDelayMillis(500L) == 1900L,
"subsequent replay is paced from previous callback");
gate.finish(501L, WeatherRefreshGate.Admission.REPLAY, true);
check(gate.admit(60199L) == WeatherRefreshGate.Admission.REPLAY,
"replay does not extend fetch TTL");
check(gate.replayDelayMillis(60199L) == 0L,
"replay runs immediately after spacing elapsed");
gate.finish(60200L, WeatherRefreshGate.Admission.REPLAY, true);
check(gate.admit(60200L) == WeatherRefreshGate.Admission.FETCH,
"new fetch accepted at original TTL boundary");
gate.finish(60300L, WeatherRefreshGate.Admission.FETCH, false);
check(gate.admit(60301L) == WeatherRefreshGate.Admission.BACKOFF,
"fast failed-fetch retry is throttled");
check(!gate.isRunning(), "backoff does not start worker");
check(gate.admit(70299L) == WeatherRefreshGate.Admission.BACKOFF,
"retry stays throttled until boundary");
check(gate.admit(70300L) == WeatherRefreshGate.Admission.FETCH,
"failed fetch may retry after ten seconds");
gate.finish(70302L, WeatherRefreshGate.Admission.FETCH, true);
check(gate.admit(70303L) == WeatherRefreshGate.Admission.REPLAY,
"successful response becomes replayable");
gate.finish(70304L, WeatherRefreshGate.Admission.REPLAY, false);
check(gate.admit(70305L) == WeatherRefreshGate.Admission.BACKOFF,
"failed replay invalidates cache and throttles retry");
check(gate.admit(80303L) == WeatherRefreshGate.Admission.BACKOFF,
"failed replay still throttled before retry boundary");
check(gate.admit(80304L) == WeatherRefreshGate.Admission.FETCH,
"failed replay retries with fresh fetch after ten seconds");
gate.finish(80306L, WeatherRefreshGate.Admission.FETCH, false);
System.out.println("result=PASS_WEATHER_REFRESH_GATE");
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../backend/php/NiroMqttKeepalive.php';
function assertMqttKeepalive(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "NiroMqttKeepaliveTest failed: {$message}\n");
exit(1);
}
}
$keepalive = new NiroMqttKeepalive(100, 15);
for ($now = 101; $now < 115; $now++) {
assertMqttKeepalive(!$keepalive->isDue($now), 'PINGREQ became due too early');
}
assertMqttKeepalive($keepalive->isDue(115),
'continuous inbound packets postponed the client PINGREQ');
$keepalive->markSent(115);
assertMqttKeepalive(!$keepalive->isDue(129), 'PINGREQ repeated before its interval');
assertMqttKeepalive($keepalive->isDue(130), 'next PINGREQ was not scheduled');
$invalidIntervalRejected = false;
try {
new NiroMqttKeepalive(100, 0);
} catch (InvalidArgumentException) {
$invalidIntervalRejected = true;
}
assertMqttKeepalive($invalidIntervalRejected, 'invalid keepalive interval was accepted');
echo "NiroMqttKeepaliveTest passed\n";
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../backend/php/NiroRawSnappy.php';
function checkSnappy(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
/** Independent literal-only reader for synthetic encoder tests. */
function readLiteralSnappy(string $encoded): string
{
$position = 0;
$length = 0;
$shift = 0;
do {
checkSnappy($position < strlen($encoded) && $shift <= 28, 'invalid varint');
$byte = ord($encoded[$position++]);
$length |= ($byte & 0x7f) << $shift;
$shift += 7;
} while (($byte & 0x80) !== 0);
$output = '';
while ($position < strlen($encoded)) {
$tag = ord($encoded[$position++]);
checkSnappy(($tag & 3) === 0, 'non-literal tag');
$size = $tag >> 2;
if ($size >= 60) {
$count = $size - 59;
checkSnappy($position + $count <= strlen($encoded), 'truncated size');
$size = 0;
for ($i = 0; $i < $count; ++$i) {
$size |= ord($encoded[$position++]) << ($i * 8);
}
}
++$size;
checkSnappy($position + $size <= strlen($encoded), 'truncated literal');
$output .= substr($encoded, $position, $size);
$position += $size;
}
checkSnappy(strlen($output) === $length, 'uncompressed length mismatch');
return $output;
}
$cases = ['', 'a', "synthetic\0payload"];
foreach ([60, 61, 255, 256, 65535, 65536, 65537, 131073] as $length) {
$cases[] = str_repeat('x', $length);
}
foreach ($cases as $plain) {
$encoded = NiroRawSnappy::encodeLiteral($plain);
checkSnappy(readLiteralSnappy($encoded) === $plain, 'round trip failed');
checkSnappy($encoded === NiroRawSnappy::encodeLiteral($plain), 'not deterministic');
}
$rejected = false;
try {
NiroRawSnappy::encodeLiteral(str_repeat('x', 8 * 1024 * 1024 + 1));
} catch (InvalidArgumentException $expected) {
$rejected = true;
}
checkSnappy($rejected, 'oversized input was accepted');
echo 'NiroRawSnappyTest passed cases=' . count($cases) . PHP_EOL;
+78
View File
@@ -0,0 +1,78 @@
"""Synthetic structural tests only; no OEM image or ARM execution is used."""
import hashlib
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
PATH = ROOT / "navigation/archived/v038/patch_nav_vehicle_speed_source.py"
SPEC = importlib.util.spec_from_file_location("archived_speed_patch", PATH)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class ArchivedSpeedPatchTests(unittest.TestCase):
def fixture(self):
settings = dict(
SETTER_OFFSET=16,
SETTER_CONTINUE=20,
CAVE_OFFSET=64,
CAVE_SIZE=60,
DISPLAY_CALL_SITES=(160, 168, 176, 184, 192),
CONVERT_SPEED=256,
SAFETY_CALL_SITE=200,
)
image = bytearray(320)
image[16:20] = MODULE.SETTER_ORIGINAL
for site in settings["DISPLAY_CALL_SITES"] + (settings["SAFETY_CALL_SITE"],):
image[site:site + 4] = MODULE.encode_thumb_branch(site, 256, link=True)
settings["SOURCE_SHA256"] = hashlib.sha256(image).hexdigest()
return bytes(image), settings
def test_unknown_original_is_rejected(self):
with self.assertRaisesRegex(ValueError, "unknown source"):
MODULE.patch_image(b"synthetic-not-a-library")
def test_thumb_alignment_and_range_are_checked(self):
self.assertEqual(MODULE.encode_thumb_branch(0x1000, 0x1004), b"\x00\xf0\x00\xb8")
with self.assertRaisesRegex(ValueError, "halfword"):
MODULE.encode_thumb_branch(0x1000, 0x1005)
with self.assertRaisesRegex(ValueError, "out of range"):
MODULE.encode_thumb_branch(0x1000, 0x2001004)
def test_only_declared_windows_change(self):
original, settings = self.fixture()
with patch.multiple(MODULE, **settings):
result = MODULE.patch_image(original)
allowed = set(range(16, 20)) | set(range(64, 124))
changed = {i for i, (a, b) in enumerate(zip(original, result)) if a != b}
self.assertTrue(changed)
self.assertLessEqual(changed, allowed)
self.assertEqual(len(result), len(original))
for site in settings["DISPLAY_CALL_SITES"] + (settings["SAFETY_CALL_SITE"],):
self.assertEqual(result[site:site + 4], original[site:site + 4])
def test_occupied_cave_is_rejected_even_with_matching_hash(self):
original, settings = self.fixture()
bad = bytearray(original)
bad[64] = 1
settings["SOURCE_SHA256"] = hashlib.sha256(bad).hexdigest()
with patch.multiple(MODULE, **settings):
with self.assertRaisesRegex(ValueError, "code cave"):
MODULE.patch_image(bytes(bad))
def test_changed_safety_call_is_rejected(self):
original, settings = self.fixture()
bad = bytearray(original)
bad[200] ^= 1
settings["SOURCE_SHA256"] = hashlib.sha256(bad).hexdigest()
with patch.multiple(MODULE, **settings):
with self.assertRaisesRegex(ValueError, "overspeed"):
MODULE.patch_image(bytes(bad))
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Small publication gate, not a comprehensive security or licensing audit."""
import os
from pathlib import Path
import re
import subprocess
ROOT = Path(__file__).resolve().parents[1]
ALLOWED = {
".gitattributes", ".gitignore", "README.md", "RIGHTS.md", "LICENSE",
"SECURITY.md", "docs/ARCHITECTURE_KO.md", "docs/FEATURES_KO.md",
"docs/RESEARCH_KO.md", "docs/PUBLICATION_KO.md",
"telemetry/java/com/chaegeon/niro/HscanRpmDecoder.java",
"weather/java/com/chaegeon/niro/WeatherRefreshGate.java",
"weather/java/com/chaegeon/niro/WeatherRefreshCounters.java",
"backend/php/NiroMqttKeepalive.php", "backend/php/NiroRawSnappy.php",
"navigation/archived/v038/patch_nav_vehicle_speed_source.py",
"tests/java/HscanRpmDecoderTest.java", "tests/java/WeatherRefreshGateTest.java",
"tests/java/WeatherRefreshCountersTest.java", "tests/php/NiroMqttKeepaliveTest.php",
"tests/php/NiroRawSnappyTest.php", "tests/python/test_archived_speed_patch.py",
"tools/run_tests.py", "tools/check_publication.py",
}
PATTERNS = {
"private-key material": re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
"GitHub credential candidate": re.compile(rb"\b(?:ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,})\b"),
"literal bearer candidate": re.compile(rb"Bearer [A-Za-z0-9_./+~-]{24,}"),
"phone-number candidate": re.compile(rb"(?<![A-Za-z0-9])01[016789](?:[ -]?[0-9]){7,8}(?![A-Za-z0-9])"),
"private operational hostname": re.compile(rb"niro-gen5w\.tail[0-9a-z]+\.ts\.net|car\.chaegeon\.com"),
}
def git(*arguments):
# Use the executable, not a shell function or a machine-specific wrapper.
return subprocess.run(
["git", *arguments], cwd=ROOT, check=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, encoding="utf-8",
).stdout
def main():
errors = []
files = []
for directory, directories, names in os.walk(ROOT, followlinks=False):
base = Path(directory)
for name in list(directories):
target = base / name
relative = target.relative_to(ROOT).as_posix()
if relative == ".git":
directories.remove(name)
elif target.is_symlink():
errors.append("symlink directory: " + relative)
directories.remove(name)
for name in names:
path = base / name
relative = path.relative_to(ROOT).as_posix()
files.append(relative)
if path.is_symlink() or relative not in ALLOWED:
errors.append("file is not approved: " + relative)
continue
content = path.read_bytes()
if len(content) > 128 * 1024 or b"\0" in content:
errors.append("binary or unexpectedly large file: " + relative)
continue
try:
content.decode("utf-8")
except UnicodeDecodeError:
errors.append("non-UTF-8 file: " + relative)
for description, pattern in PATTERNS.items():
if pattern.search(content):
# Do not reveal a matched secret or personal value in output.
errors.append(description + ": " + relative)
if (ROOT / ".git").exists():
for relative in git("ls-files").splitlines():
if relative not in ALLOWED:
errors.append("unapproved tracked file: " + relative)
for email in set(git("log", "--all", "--format=%ae%n%ce").splitlines()):
if email and not email.endswith("@users.noreply.git.chaegeon.com"):
errors.append("commit author/committer email needs privacy review")
if errors:
raise SystemExit("Publication checks failed:\n" + "\n".join(sorted(set(errors))))
print("PASS: publication allowlist and basic privacy checks (" + str(len(files)) + " files)")
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Run public synthetic tests locally; never contact a vehicle or network."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
def run(*command):
environment = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
print("+ " + " ".join(str(part) for part in command), flush=True)
subprocess.run(command, cwd=ROOT, env=environment, check=True)
def main():
missing = [name for name in ("javac", "java", "php") if not shutil.which(name)]
if missing:
raise SystemExit("Required local tools are missing: " + ", ".join(missing))
run(sys.executable, "tools/check_publication.py")
run(sys.executable, "-B", "-m", "unittest", "discover", "-s", "tests/python", "-v")
sources = sorted(
list((ROOT / "telemetry/java").rglob("*.java"))
+ list((ROOT / "weather/java").rglob("*.java"))
+ list((ROOT / "tests/java").glob("*.java"))
)
with tempfile.TemporaryDirectory(prefix="niro-gen5w-public-tests-") as temporary:
run("javac", "-encoding", "UTF-8", "-d", temporary, *map(str, sources))
for name in (
"com.chaegeon.niro.HscanRpmDecoderTest",
"WeatherRefreshGateTest",
"com.chaegeon.niro.WeatherRefreshCountersTest",
):
run("java", "-cp", temporary, name)
for source in sorted((ROOT / "backend/php").glob("*.php")):
run("php", "-l", str(source))
for test in sorted((ROOT / "tests/php").glob("*Test.php")):
run("php", str(test))
print("PASS: all public synthetic test programs completed")
if __name__ == "__main__":
main()
@@ -0,0 +1,64 @@
package com.chaegeon.niro;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
/** Root-only, per-run E2E evidence. No request, location or secret is stored. */
final class WeatherRefreshCounters {
private final File directory;
private long signal;
private long callback;
WeatherRefreshCounters(File directory) throws IOException {
if (!directory.isDirectory()
|| !directory.getCanonicalPath().equals(directory.getAbsolutePath())) {
throw new IOException("invalid counter directory");
}
this.directory = directory;
writeAtomic("signal.counter", 0L);
writeAtomic("callback.counter", 0L);
}
synchronized void signalAccepted() throws IOException {
if (signal == Long.MAX_VALUE) {
throw new IOException("signal counter overflow");
}
writeAtomic("signal.counter", signal + 1L);
++signal;
}
synchronized void freshCallbackCompleted() throws IOException {
if (callback == Long.MAX_VALUE) {
throw new IOException("callback counter overflow");
}
writeAtomic("callback.counter", callback + 1L);
++callback;
}
private void writeAtomic(String name, long value) throws IOException {
File target = new File(directory, name);
File pending = new File(directory, name + ".new");
if (pending.exists() || !pending.getCanonicalPath().equals(pending.getAbsolutePath())
|| !target.getCanonicalPath().equals(target.getAbsolutePath())) {
throw new IOException("counter path not safe");
}
FileOutputStream output = new FileOutputStream(pending);
try {
if (!pending.setReadable(false, false)
|| !pending.setWritable(false, false)
|| !pending.setExecutable(false, false)
|| !pending.setReadable(true, true)
|| !pending.setWritable(true, true)) {
throw new IOException("counter permission failure");
}
output.write((Long.toString(value) + "\n").getBytes("US-ASCII"));
output.getFD().sync();
} finally {
output.close();
}
if (!pending.renameTo(target)) {
throw new IOException("counter replacement failed");
}
}
}
@@ -0,0 +1,87 @@
package com.chaegeon.niro;
/** Pure-Java admission gate for stock WeatherActivity refresh requests. */
public final class WeatherRefreshGate {
public enum Admission {
FETCH,
REPLAY,
COALESCE,
BACKOFF
}
private final long cooldownMillis;
private final long failureRetryMillis;
private final long replaySpacingMillis;
private boolean running;
private Admission active;
private long nextFetchAt;
private long nextRetryAt;
private long nextReplayAt;
public WeatherRefreshGate(long cooldownMillis, long failureRetryMillis,
long replaySpacingMillis) {
if (cooldownMillis < 0L || failureRetryMillis < 0L
|| replaySpacingMillis < 0L) {
throw new IllegalArgumentException("invalid gate interval");
}
this.cooldownMillis = cooldownMillis;
this.failureRetryMillis = failureRetryMillis;
this.replaySpacingMillis = replaySpacingMillis;
}
public synchronized Admission admit(long elapsedMillis) {
if (running) {
// The in-flight fetch/replay will broadcast to all active listeners.
return Admission.COALESCE;
}
if (elapsedMillis < nextRetryAt) {
// A fast-failing privileged sender must not hammer the root shell
// or online service. There is no successful response to replay.
return Admission.BACKOFF;
}
running = true;
active = elapsedMillis < nextFetchAt ? Admission.REPLAY : Admission.FETCH;
return active;
}
public synchronized void finish(long elapsedMillis, Admission admission,
boolean success) {
if (!running) {
throw new IllegalStateException("not running");
}
if (admission == Admission.COALESCE || admission == Admission.BACKOFF) {
throw new IllegalArgumentException("request did not start a worker");
}
if (admission != active) {
throw new IllegalArgumentException("wrong active request");
}
running = false;
active = null;
if (!success) {
// Never replay a response after a failed fetch or failed replay.
nextFetchAt = 0L;
nextRetryAt = elapsedMillis + failureRetryMillis;
nextReplayAt = 0L;
} else if (admission == Admission.FETCH) {
// Replays do not extend freshness. A new fetch follows 60 s later.
nextFetchAt = elapsedMillis + cooldownMillis;
nextRetryAt = 0L;
nextReplayAt = elapsedMillis + replaySpacingMillis;
} else {
nextRetryAt = 0L;
nextReplayAt = elapsedMillis + replaySpacingMillis;
}
}
public synchronized boolean isRunning() {
return running;
}
/** Delay one admitted replay; new arrivals coalesce while its worker waits. */
public synchronized long replayDelayMillis(long elapsedMillis) {
if (active != Admission.REPLAY) {
throw new IllegalStateException("no admitted replay");
}
return elapsedMillis < nextReplayAt ? nextReplayAt - elapsedMillis : 0L;
}
}