Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion cmd/pi-obd-meter/can_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func canReaderLoop(ctx context.Context, ifname string, intervalMs int, ch chan<-
mu sync.Mutex
atfTempC float64
hasATF bool
brakePedal bool
radiatorFan bool
acCompressor bool
gradeRaw int
hasGrade bool
rpm float64
speedKmh float64
engineLoad float64
Expand Down Expand Up @@ -167,11 +172,25 @@ func canReaderLoop(ctx context.Context, ifname string, intervalMs int, ch chan<-
case can.IDOBDResponse:
// Mode 22 (拡張診断データ) の応答。ATF油温はここから来る。
if pid22, data, ok := can.ParseOBDResponse22(frame); ok {
if pid22 == can.PID22ATFTemp {
switch pid22 {
case can.PID22ATFTemp:
if t, ok := can.DecodeATFTemp(data); ok {
atfTempC = t
hasATF = true
}
case can.PID22Status:
if b, f, ok := can.DecodeStatus1101(data); ok {
brakePedal, radiatorFan = b, f
}
case can.PID22ACCompressor:
if on, ok := can.DecodeACCompressor(data); ok {
acCompressor = on
}
case can.PID22Grade:
if g, ok := can.DecodeGrade(data); ok {
gradeRaw = g
hasGrade = true
}
}
}
// OBD-2 レスポンス処理
Expand Down Expand Up @@ -288,6 +307,21 @@ func canReaderLoop(ctx context.Context, ifname string, intervalMs int, ch chan<-
_ = sock.WriteFrame(can.OBDRequestFrame22(can.PID22ATFTemp))
}

// ブレーキ・ファン・エアコン・勾配 (Mode 22)。
//
// ブレーキは踏んだ瞬間を捉えたいので速く回す。1つの tick に1つずつ
// 送り、4つを順に巡る。intervalMs=50 なら各 PID は 200ms 周期になる。
// ATF (2秒周期) と合わせても Mode 22 の送信は 25Hz 未満で、
// 0x201 が 100Hz で流れているバスに対して十分小さい。
switch tickCount % 4 {
case 0:
_ = sock.WriteFrame(can.OBDRequestFrame22(can.PID22Status))
case 1:
_ = sock.WriteFrame(can.OBDRequestFrame22(can.PID22Grade))
case 2:
_ = sock.WriteFrame(can.OBDRequestFrame22(can.PID22ACCompressor))
}

mu.Lock()
if !hasData {
mu.Unlock()
Expand Down Expand Up @@ -391,6 +425,11 @@ func canReaderLoop(ctx context.Context, ifname string, intervalMs int, ch chan<-
HasMAF: hasMAF,
TCCLockPct: tccLockPct,
SlipRatio: slipRatio,
BrakePedal: brakePedal,
RadiatorFan: radiatorFan,
ACCompressor: acCompressor,
GradeRaw: gradeRaw,
HasGrade: hasGrade,
OdometerCANKm: odometerCANKm,
ElecB0Pct: elecB0Pct,
ElecB1Raw: elecB1Raw,
Expand Down
6 changes: 5 additions & 1 deletion cmd/pi-obd-meter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ type RealtimeData struct {
Hold bool `json:"hold"`
TCLocked bool `json:"tc_locked"`
TCCLockPct float64 `json:"tcc_lock_pct"`
SlipRatio float64 `json:"slip_ratio"` // トルコンの滑り比。1.0=直結、1.05=5%滑り
SlipRatio float64 `json:"slip_ratio"` // トルコンの滑り比。1.0=直結、1.05=5%滑り
BrakePedal bool `json:"brake_pedal"` // ブレーキペダル
RadiatorFan bool `json:"radiator_fan"` // ラジエータファン
ACCompressor bool `json:"ac_compressor"` // エアコンコンプレッサー
GradeRaw int `json:"grade_raw"` // 勾配の生値。負が登り。単位未確定
Shifting bool `json:"shifting"`
OdometerCANKm float64 `json:"odometer_can_km"` // CAN 0x430 由来の累計走行距離(検証用に併記)
ElecB0Pct float64 `json:"elec_b0_pct"` // 0x430 B0 生値/2.55(燃料残量候補・未確定)
Expand Down
4 changes: 4 additions & 0 deletions cmd/pi-obd-meter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ func (app *App) obdProcessingLoop(ctx context.Context, cancel context.CancelFunc
TCLocked: data.TCLocked,
TCCLockPct: data.TCCLockPct,
SlipRatio: data.SlipRatio,
BrakePedal: data.BrakePedal,
RadiatorFan: data.RadiatorFan,
ACCompressor: data.ACCompressor,
GradeRaw: data.GradeRaw,
Shifting: data.Shifting,
OdometerCANKm: data.OdometerCANKm,
ElecB0Pct: data.ElecB0Pct,
Expand Down
61 changes: 61 additions & 0 deletions internal/can/obd.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,67 @@ const (
// 4. 別の日には 49℃ を指しており、単調増加のカウンタではない。
const PID22ATFTemp uint16 = 0x17B3

// PID22Status は 0x1101。ブレーキとラジエータファンのビットを持つ。
//
// 2026-08-31 に停車・アイドル・アクセル全閉で固定し、20秒踏む→離す→20秒踏む
// を実施して同定した。95個の全PIDのうち、この操作に一致したのはこれだけ。
// bit0 は水温が 97→89℃ と下降する場面と一致したのでファン。
const PID22Status uint16 = 0x1101

// PID22ACCompressor は 0x1103。bit2 がエアコンコンプレッサー。
//
// アイドル時の MAP が二峰性 (OFF 30-31kPa / ON 43-46kPa) になることを使って
// 同定した。フラグが立っているとき MAP が低い例は 1%。燃料は +0.40 L/h 増える。
// 0x1104 bit0 も同じ挙動を示すが、こちらだけ読めば足りる。
const PID22ACCompressor uint16 = 0x1103

// PID22Grade は 0x3201。勾配 (符号付き16bit、負が登り)。
//
// 大橋JCT (高低差71m、勾配8.9%、2周の螺旋) の登坂で同定した。
// 74km 走行中に -500 未満が20件あり、うち15件がこの2km区間に集中していた。
// 加速度との相関は r=+0.009 で無関係。登り切った瞬間に符号が反転する。
//
// 単位は未確定。大橋(8.9%)で中央 -690、比叡山の急勾配区間で -2210 が出ている。
// GPS の標高が取れれば換算式を決められる。
const PID22Grade uint16 = 0x3201

const (
statusBitFan = 1 << 0 // ラジエータファン
statusBitBrake = 1 << 1 // ブレーキペダル
acBitCompressor = 1 << 2 // 0x1103 bit2
)

// DecodeStatus1101 は 0x1101 の応答からブレーキとファンの状態を取り出す。
func DecodeStatus1101(data []byte) (brake, fan bool, ok bool) {
if len(data) < 1 {
return false, false, false
}
return data[0]&statusBitBrake != 0, data[0]&statusBitFan != 0, true
}

// DecodeACCompressor は 0x1103 の応答からエアコンコンプレッサーの状態を取り出す。
func DecodeACCompressor(data []byte) (on bool, ok bool) {
if len(data) < 1 {
return false, false
}
return data[0]&acBitCompressor != 0, true
}

// DecodeGrade は 0x3201 の応答から勾配の生値を返す。負が登り。
//
// 単位が未確定なので生値のまま返す。正負と大小には意味があるので、
// 記録して後から較正できるようにしておく。
func DecodeGrade(data []byte) (raw int, ok bool) {
if len(data) < 2 {
return 0, false
}
v := int(data[0])<<8 | int(data[1])
if v > 32767 {
v -= 65536
}
return v, true
}

// ATF 油温の換算係数。ScanGauge の MTH 002A0019FFC7 に由来する。
//
// °F = raw × 42/25 − 57
Expand Down
75 changes: 75 additions & 0 deletions internal/can/obd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package can

import "testing"

// 0x1101 のビット割り当て。2026-08-31 に停車・アイドル・アクセル全閉で
// 固定し、20秒踏む→離す→20秒踏む を実施して同定した。
func TestDecodeStatus1101(t *testing.T) {
cases := []struct {
raw byte
wantBrake, wantFan bool
}{
{0x00, false, false},
{0x01, false, true}, // ファンのみ
{0x02, true, false}, // ブレーキのみ
{0x03, true, true}, // 両方
{0x08, false, false}, // bit3 は別の用途。誤検出しないこと
{0x0A, true, false}, // bit3 + ブレーキ
{0x0B, true, true},
}
for _, c := range cases {
b, f, ok := DecodeStatus1101([]byte{c.raw})
if !ok {
t.Fatalf("0x%02X: デコードできない", c.raw)
}
if b != c.wantBrake || f != c.wantFan {
t.Errorf("0x%02X: ブレーキ=%v ファン=%v, want %v/%v", c.raw, b, f, c.wantBrake, c.wantFan)
}
}
if _, _, ok := DecodeStatus1101(nil); ok {
t.Error("空データを受け入れてはいけない")
}
}

// 0x1103 bit2 = エアコンコンプレッサー。
func TestDecodeACCompressor(t *testing.T) {
for raw, want := range map[byte]bool{0x00: false, 0x04: true, 0x0C: true, 0x08: false, 0x1C: true} {
on, ok := DecodeACCompressor([]byte{raw})
if !ok {
t.Fatalf("0x%02X: デコードできない", raw)
}
if on != want {
t.Errorf("0x%02X: %v, want %v", raw, on, want)
}
}
if _, ok := DecodeACCompressor(nil); ok {
t.Error("空データを受け入れてはいけない")
}
}

// 0x3201 = 勾配。符号付き16bit で負が登り。
// 大橋JCT (8.9%) で -690、比叡山の急勾配区間で -2210 が観測されている。
func TestDecodeGrade(t *testing.T) {
cases := []struct {
hi, lo byte
want int
}{
{0x00, 0x00, 0},
{0x00, 0x1E, 30},
{0xFD, 0x4E, -690}, // 大橋JCT の実測値
{0xF7, 0x5E, -2210}, // 比叡山の実測値
{0x05, 0x28, 1320}, // 登り切って正に反転した瞬間
}
for _, c := range cases {
got, ok := DecodeGrade([]byte{c.hi, c.lo})
if !ok {
t.Fatalf("%02X%02X: デコードできない", c.hi, c.lo)
}
if got != c.want {
t.Errorf("%02X%02X: %d, want %d", c.hi, c.lo, got, c.want)
}
}
if _, ok := DecodeGrade([]byte{0x00}); ok {
t.Error("1バイトを受け入れてはいけない")
}
}
5 changes: 5 additions & 0 deletions internal/obd/pids.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ type OBDData struct {
HasMAF bool // MAFセンサー対応か
TCCLockPct float64 // TCロック率 (0-100%) — RPM÷車速から算出
SlipRatio float64 // トルコンの滑り比 (エンジン回転÷タービン回転)。1.0=直結
BrakePedal bool // ブレーキペダル (Mode22 0x1101 bit1)
RadiatorFan bool // ラジエータファン (Mode22 0x1101 bit0)
ACCompressor bool // エアコンコンプレッサー (Mode22 0x1103 bit2)
GradeRaw int // 勾配の生値 (Mode22 0x3201、符号付き。負が登り。単位未確定)
HasGrade bool // 勾配を受信できたか
OdometerCANKm float64 // 累計走行距離 (km) — CAN 0x430 B4-5 (10km単位)。実機検証済み
ElecB0Pct float64 // 0x430 B0 の生値/2.55。燃料残量の可能性・未確定 (issue #119)
ElecB1Raw float64 // 0x430 B1 の生値。電圧に連動するが電圧ではない (issue #119)
Expand Down
42 changes: 42 additions & 0 deletions scripts/ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,45 @@ FN4A-EL は road load / grade logic で変速線を動かす。登坂で不要
- #165 IMU を追加して勾配・横G・加速度を直接測る
- #166 ブレーキ信号が CAN にあるか未確認 — 0x200 も未同定
- #150 未同定の Mode 22 PID 17個

## 4. 装備の入切で未同定PIDを探す — 停車・アイドルで各1分

```
sudo python3 /opt/pi-obd-meter/scripts/ops/probe-toggle.py
```

ヘッドライト / リアデフォッガー / ブロワー最強 / ステアリング据え切り /
N で空吹かし を、それぞれ **20秒ON → 20秒OFF → 20秒ON** で実施する。
1項目1分、全5項目で5分。

回収して Mac で解析する。

```
scp laurel@<pi>:/var/log/can-verify/toggle_*.labels .
scp laurel@<pi>:/var/log/can-verify/poll22_*.csv .
python3 scripts/ops/find-toggle-pid.py toggle_*.labels poll22_*.csv
```

### なぜこの形か

2026-08-31〜09-01 に7個同定できたが、**成功したのはすべて「他が動かない
状態で、ひとつだけを断続させた」場合**だった。

| 同定できたもの | 決め手 |
|---|---|
| `1101` bit1 ブレーキ | 停車・アイドル・アクセル全閉で 20秒踏む→離す→20秒踏む |
| `1101` bit0 ファン | 水温が 97→89℃ と下降する場面と一致 |
| `1103` bit2 エアコン | 夜間走行中の入切が、アイドル停車中の記録に残っていた |
| `17B1` `17C1` レンジ | 出発 P→D、駐車 D→R→P の逆順 |
| `17BB` `17BC` ソレノイド | 中間値の97〜100%が変速中にしか出ない |
| `3201` 勾配 | 高低差71m・勾配8.9%の大橋JCT螺旋 |

逆に、**走行データの相関だけを見た絞り込みは外した。**

- 平地データから勾配候補を2個に絞ったが、実際の8.9%登坂ではどちらも動かず、
正解は候補外だった
- 夜間走行と昼間走行を比べてヘッドライトを探したが、気温・交通・走り方が
同時に変わるため何も出なかった (電圧の差は +0.03V)

だから **「停車・アイドル・指示されたもの以外は触らない」を厳守する。**
窓を開けた、シフトを動かした、といった操作が混ざると偽陽性が出る。
12 changes: 9 additions & 3 deletions scripts/ops/drive-verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
path = os.path.join(OUT_DIR, "drive-%s.csv" % time.strftime("%m%d-%H%M"))
f = open(path, "w", buffering=1)
f.write("t,speed,rpm,gear,engaged,ratio,mech,slip,tcc,locked,hold,range,shifting,"
"atf,volt,odo,trip_km,fuel_pt,rate_lh,eco,coolant,map,load\n")
"atf,volt,odo,trip_km,fuel_pt,rate_lh,eco,coolant,map,load,"
"brake,fan,ac,grade\n")
print("記録先: %s" % path, flush=True)

while True:
Expand All @@ -44,7 +45,7 @@
# 正しい滑りは rpm / (車速 × 機械ギア比 × k) で、k はロックアップ中に学習する。
# アプリの SlipCalibrator がそれを持っているので、API から受け取る。
slip = d.get("slip_ratio") or 0
f.write("%.1f,%.2f,%.1f,%d,%d,%.3f,%.3f,%.4f,%s,%s,%s,%s,%s,%.1f,%.2f,%.0f,%.5f,%.2f,%.3f,%.2f,%.1f,%.1f,%.1f\n" % (
f.write("%.1f,%.2f,%.1f,%d,%d,%.3f,%.3f,%.4f,%s,%s,%s,%s,%s,%.1f,%.2f,%.0f,%.5f,%.2f,%.3f,%.2f,%.1f,%.1f,%.1f,%s,%s,%s,%d\n" % (
time.time(),
d.get("speed_kmh") or 0, d.get("rpm") or 0, g, eng, r, mech, slip,
d.get("tcc_lock_pct") or 0, d.get("tc_locked"),
Expand All @@ -59,7 +60,12 @@
# 給油記録と違い、上端で振り切れている区間を避けて集められる。
d.get("fuel_rate_lh") or 0, d.get("avg_fuel_economy") or 0,
d.get("coolant_temp") or 0, d.get("intake_map") or 0,
d.get("engine_load") or 0))
d.get("engine_load") or 0,
# 2026-09-01 に同定した信号 (#150)。ブレーキ・ファン・エアコンは真偽値、
# 勾配は符号付きの生値で単位未確定 (負が登り)。
d.get("brake_pedal"), d.get("radiator_fan"), d.get("ac_compressor"),
d.get("grade_raw") or 0,
))
# 0.2秒周期。加速度を差分から求めるため、0.5秒では全開加速のサンプルが
# 数点しか取れずトルク推定の分解能が足りなかった。書き込み量は
# 0.6MB/時 → 1.5MB/時 で、SDへの負担は許容範囲。
Expand Down
Loading
Loading