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
21 changes: 11 additions & 10 deletions cmd/pi-obd-meter/atf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,33 @@ func TestATFTempOrZero(t *testing.T) {
name string
data *obd.OBDData
wantTemp float64
wantAlert string
wantLevel string
}{
{"未取得", &obd.OBDData{HasATF: false, ATFTempC: 0}, 0, ""},
{"未取得だが値が残っている", &obd.OBDData{HasATF: false, ATFTempC: 135}, 0, ""},
{"正常域", &obd.OBDData{HasATF: true, ATFTempC: 90}, 90, ""},
{"正常域", &obd.OBDData{HasATF: true, ATFTempC: 85}, 85, ""},
{"高速域", &obd.OBDData{HasATF: true, ATFTempC: 95}, 95, "warm"},
{"氷点", &obd.OBDData{HasATF: true, ATFTempC: 0}, 0, ""},
{"注意域", &obd.OBDData{HasATF: true, ATFTempC: 105}, 105, "ATF注意"},
{"高温", &obd.OBDData{HasATF: true, ATFTempC: 122}, 122, "ATF高温"},
{"危険", &obd.OBDData{HasATF: true, ATFTempC: 131}, 131, "ATF危険"},
{"注意域", &obd.OBDData{HasATF: true, ATFTempC: 105}, 105, "caution"},
{"高温", &obd.OBDData{HasATF: true, ATFTempC: 115}, 115, "hot"},
{"危険", &obd.OBDData{HasATF: true, ATFTempC: 125}, 125, "danger"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := atfTempOrZero(tt.data); got != tt.wantTemp {
t.Errorf("温度 = %.1f, want %.1f", got, tt.wantTemp)
}
if got := atfAlertOrEmpty(tt.data); got != tt.wantAlert {
t.Errorf("警告 = %q, want %q", got, tt.wantAlert)
if got := atfLevelOrEmpty(tt.data); got != tt.wantLevel {
t.Errorf("区分 = %q, want %q", got, tt.wantLevel)
}
})
}
}

// 未取得なのに危険域の警告を出してはいけない。
func TestATFAlert_NotRaisedWhenUnavailable(t *testing.T) {
func TestATFLevel_NotRaisedWhenUnavailable(t *testing.T) {
d := &obd.OBDData{HasATF: false, ATFTempC: 200}
if a := atfAlertOrEmpty(d); a != "" {
t.Errorf("未取得なのに警告 %q を出した", a)
if a := atfLevelOrEmpty(d); a != "" {
t.Errorf("未取得なのに区分 %q を出した", a)
}
}
2 changes: 1 addition & 1 deletion cmd/pi-obd-meter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ type RealtimeData struct {
EngagedGear int `json:"engaged_gear"`
ATFTempC float64 `json:"atf_temp_c"`
ATFValid bool `json:"atf_valid"`
ATFAlert string `json:"atf_alert,omitempty"`
ATFLevel string `json:"atf_level,omitempty"` // 表示色のキー。"" / warm / caution / hot / danger
TripKm float64 `json:"trip_km"`
CoolantTemp float64 `json:"coolant_temp"`
IntakeMAP float64 `json:"intake_map"`
Expand Down
6 changes: 3 additions & 3 deletions cmd/pi-obd-meter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ func (app *App) obdProcessingLoop(ctx context.Context, cancel context.CancelFunc
EngagedGear: data.EngagedGear,
ATFTempC: atfTempOrZero(data),
ATFValid: data.HasATF,
ATFAlert: atfAlertOrEmpty(data),
ATFLevel: atfLevelOrEmpty(data),
TripKm: app.tracker.DistanceKm(),
CoolantTemp: lastCoolant,
IntakeMAP: lastMAP,
Expand Down Expand Up @@ -387,9 +387,9 @@ func atfTempOrZero(d *obd.OBDData) float64 {
}

// atfAlertOrEmpty は未取得のときに警告を出さない。
func atfAlertOrEmpty(d *obd.OBDData) string {
func atfLevelOrEmpty(d *obd.OBDData) string {
if !d.HasATF {
return ""
}
return can.ATFAlert(d.ATFTempC)
return can.ATFLevel(d.ATFTempC)
}
62 changes: 49 additions & 13 deletions internal/can/obd.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,22 +188,58 @@ func DecodeATFTemp(data []byte) (tempC float64, ok bool) {
return (f - atfFtoCOffset) / atfFtoCScale, true
}

// ATFAlert は油温に対する注意喚起を返す。何も無ければ空文字列
// ATF 油温の区分の境目
//
// 目安は業界の経験則による。約95℃を超えると10℃ごとに油の寿命が半減する。
// 「1区分 = 油の寿命が半分になる」で刻んだ。業界の経験則では 20°F (11.1℃)
// 上がるごとに寿命が半減するので、10℃刻みがちょうど1段ぶんにあたる。
//
// 100℃ ワニス (酸化生成物) が出始める
// 120℃ シールが硬化する
// 130℃ 酸化が急加速する
// 150℃ クラッチが焼ける
func ATFAlert(tempC float64) string {
// 劣化速度 = 2^((T-79)/11.1) (79℃ を 1.0 とする)
// 2倍 → 90.1℃
// 4倍 → 101.2℃
// 8倍 → 112.3℃
// 16倍 → 123.4℃
//
// 実測 24.1時間 (2026-08-27〜09-01) での滞在割合と劣化速度:
//
// 温度帯 時間割合 劣化速度
// (無印) 〜 90℃ 59.0% 0.8倍
// warm 90-100 21.4% 2.7倍
// caution 100-110 14.2% 5.2倍
// hot 110-120 5.4% 8.3倍
// danger 120〜 0.0% 未到達
//
// 走行の種類との対応 (実測):
//
// 停車・アイドル 緑93%
// 街乗り (<25km/h) 緑84% / 黄緑14%
// 流れの良い道 緑55% / 黄緑35%
// 高速巡航 (70km/h+) 黄緑39% / 黄35% / 橙15%
//
// 高速に乗ると warm、踏み続けると caution へ移る。danger は 24時間の実測で
// 一度も出ていないので、出たときは何かが違うという意味を持つ。
//
// 参考: 100℃ でワニス (酸化生成物) が出始め、120℃ でシールが硬化し、
// 150℃ でクラッチが焼ける。
const (
atfWarmC = 90.0
atfCautionC = 100.0
atfHotC = 110.0
atfDangerC = 120.0
)

// ATFLevel は油温の区分を返す。正常なら空文字列。
//
// 返り値は表示色を引くためのキーであって、そのまま画面に出す文言ではない。
func ATFLevel(tempC float64) string {
switch {
case tempC >= 130:
return "ATF危険"
case tempC >= 120:
return "ATF高温"
case tempC >= 100:
return "ATF注意"
case tempC >= atfDangerC:
return "danger"
case tempC >= atfHotC:
return "hot"
case tempC >= atfCautionC:
return "caution"
case tempC >= atfWarmC:
return "warm"
}
return ""
}
22 changes: 0 additions & 22 deletions internal/can/obd22_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,3 @@ func TestDecodeATFTemp_Measured(t *testing.T) {
t.Error("空データを受け入れた")
}
}

func TestATFAlert(t *testing.T) {
tests := []struct {
tempC float64
want string
}{
{82, ""},
{92, ""},
{99.9, ""},
{100, "ATF注意"},
{119, "ATF注意"},
{120, "ATF高温"},
{129, "ATF高温"},
{130, "ATF危険"},
{150, "ATF危険"},
}
for _, tt := range tests {
if got := ATFAlert(tt.tempC); got != tt.want {
t.Errorf("ATFAlert(%.1f) = %q, want %q", tt.tempC, got, tt.want)
}
}
}
55 changes: 54 additions & 1 deletion internal/can/obd_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package can

import "testing"
import (
"math"
"testing"
)

// 0x1101 のビット割り当て。2026-08-31 に停車・アイドル・アクセル全閉で
// 固定し、20秒踏む→離す→20秒踏む を実施して同定した。
Expand Down Expand Up @@ -73,3 +76,53 @@ func TestDecodeGrade(t *testing.T) {
t.Error("1バイトを受け入れてはいけない")
}
}

// ATF 油温の区分。「1段 = 油の寿命が半分」で刻んである。
//
// 実測 24.1時間 (2026-08-27〜09-01、426,440サンプル) の分布:
//
// 中央 85℃ p90 107 p99 115 最高 116.7
// 緑59% / 黄緑21% / 黄14% / 橙5% / 赤0%
func TestATFLevel(t *testing.T) {
cases := []struct {
temp float64
want string
why string
}{
{74.7, "", "停車アイドルの中央値"},
{80.3, "", "街乗りの中央値"},
{86.8, "", "流れの良い道の中央値"},
{89.9, "", "境界の直下"},
{90.0, "warm", "劣化2倍。高速に乗るとここから"},
{99.9, "warm", "高速巡航の中央値"},
{100.0, "caution", "劣化4倍"},
{108.3, "caution", "100-120km/h 巡航の中央値"},
{110.0, "hot", "劣化8倍。新東名で58分続いた領域"},
{116.7, "hot", "24時間の実測での最高値"},
{120.0, "danger", "劣化16倍。実測では未到達"},
{135.0, "danger", ""},
}
for _, c := range cases {
if got := ATFLevel(c.temp); got != c.want {
t.Errorf("%.1f℃: %q, want %q (%s)", c.temp, got, c.want, c.why)
}
}
}

// 区分の境目が「劣化2倍ごと」になっていること。
// 業界の経験則では 20°F (11.1℃) ごとに油の寿命が半減する。
func TestATFLevel_BoundariesDoubleDegradation(t *testing.T) {
const step = 20.0 / 1.8 // 20°F を ℃ に
bounds := []float64{atfWarmC, atfCautionC, atfHotC, atfDangerC}
for i := 1; i < len(bounds); i++ {
gap := bounds[i] - bounds[i-1]
if math.Abs(gap-10.0) > 0.01 {
t.Errorf("%.0f℃ と %.0f℃ の間隔が %.1f℃。10℃刻みを崩している", bounds[i-1], bounds[i], gap)
}
// 10℃ は 11.1℃ の 0.90 段ぶん。劣化倍率にして 1.87 倍。
ratio := math.Pow(2, gap/step)
if ratio < 1.7 || ratio > 2.1 {
t.Errorf("%.0f→%.0f℃ で劣化 %.2f倍。1段=2倍という前提から外れている", bounds[i-1], bounds[i], ratio)
}
}
}
64 changes: 44 additions & 20 deletions web/static/js/indicators.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ const ICON_LEAF = 'M0 -12C-5 -4 -7 2 -7 7c0 3 3 6 7 6s7-3 7-6c0-5-2-11-7-19z';
const ICON_ROAD = 'M11 2h2v4h-2zm0 6h2v4h-2zm0 6h2v4h-2zM2 2l4 20h2L5 2zm20 0h-2L16 22h2z';
const ICON_OIL = 'M12 2C12 2 6 10 6 15a6 6 0 0 0 12 0c0-5-6-13-6-13zm0 17a3 3 0 0 1-3-3c0-.5.1-1 .3-1.5.2-.4.8-.3.9.2.1.3.1.6.1.9a1.8 1.8 0 0 0 1.8 1.8c.4 0 .7-.3.6-.7-.3-1.5-1.2-2.8-2.2-3.9-.3-.3 0-.8.4-.6C13.3 12.5 15 14.5 15 16a3 3 0 0 1-3 3z';
// 給油ポンプ (給油までの残距離用)
// 温度計 (ATF 油温)
const ICON_THERMO = 'M12 2a3 3 0 0 0-3 3v8.6a5 5 0 1 0 6 0V5a3 3 0 0 0-3-3zm0 2a1 1 0 0 1 1 1v9.4l.6.4a3 3 0 1 1-3.2 0l.6-.4V5a1 1 0 0 1 1-1zm-.5 3h1v7h-1z';

const ICON_FUELPUMP = 'M19.77 7.23l.01-.01-3.72-3.72L15 4.56l2.11 2.11c-.94.36-1.61 1.26-1.61 2.33 0 1.38 1.12 2.5 2.5 2.5.36 0 .69-.08 1-.21v7.21c0 .55-.45 1-1 1s-1-.45-1-1V14c0-1.1-.9-2-2-2h-1V5c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v16h10v-7.5h1.5v5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V9c0-.69-.28-1.32-.73-1.77zM12 10H6V5h6v5zm6 0c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z';
// 立体トラック描画(SVG radialGradient で内暗→中明→外暗)
let trackGradCount = 100;
Expand Down Expand Up @@ -138,7 +141,7 @@ let mapCur = 0, mapTgt = 0, mapRaf = 0;
let ecoValEl, ecoIconEls;
let rngValEl, rngIconEl;
let tripValEl, tripIconEl;
let oilValEl, oilIconEl, oilLabelEl;
let atfValEl, atfIconEl, atfLabelEl;

// 閾値(config から設定可能、TEMP 削除後も coolant 関連は保持してダミーで吸収)
let coolantColdMax = 60;
Expand Down Expand Up @@ -390,14 +393,20 @@ export function createIndicators(panelEl) {
tripValEl.textContent = '0';
svgEl(svg, 'text', { x: IND_X_UNIT, y: tripY + 4, class: 'g-unit', fill: '#fff', 'font-size': 24, 'text-anchor': 'end' }).textContent = 'km';

// Row 3: OIL
const oilY = IND_Y_START + IND_SPACING * 3;
addIndPanel(oilY);
oilIconEl = createIconPath(svg, IND_X_ICON + 10, oilY - 8, ICON_OIL, 40);
oilValEl = svgEl(svg, 'text', { x: IND_X_VAL, y: oilY + 6, class: 'g-num', fill: '#333', 'font-size': 40, 'text-anchor': 'middle' });
oilValEl.textContent = '--';
oilLabelEl = svgEl(svg, 'text', { x: IND_X_UNIT, y: oilY + 4, class: 'g-unit', fill: '#fff', 'font-size': 24, 'text-anchor': 'end' });
oilLabelEl.textContent = 'km';
// Row 3: ATF 油温
//
// ここは以前オイル交換までの残距離を出していたが、走行中は走った距離ぶん
// しか動かず TRIP の複製でしかなかった。ATF 油温は 1回の走行で 54℃ 動き、
// 新東名では 110℃超が 58分続いた実績がある。運転者はそれを走行中に知る
// 手段が無く、あとからログで確認していた。整備の残距離は停車中に見れば
// 足りるので入れ替えた。
const atfY = IND_Y_START + IND_SPACING * 3;
addIndPanel(atfY);
atfIconEl = createIconPath(svg, IND_X_ICON + 10, atfY - 8, ICON_THERMO, 40);
atfValEl = svgEl(svg, 'text', { x: IND_X_VAL, y: atfY + 6, class: 'g-num', fill: '#333', 'font-size': 40, 'text-anchor': 'middle' });
atfValEl.textContent = '--';
atfLabelEl = svgEl(svg, 'text', { x: IND_X_UNIT, y: atfY + 4, class: 'g-unit', fill: '#fff', 'font-size': 24, 'text-anchor': 'end' });
atfLabelEl.textContent = '\u00b0C';

return {};
}
Expand All @@ -419,9 +428,22 @@ export function restoreMapTransition() {
}
}

// OIL lamp colors
// OIL lamp colors (2画面目へ移した際に再利用する)
const OIL_COLORS = { green: '#69f0ae', yellow: '#fdd835', orange: '#ff9800', red: '#f44336' };

// ATF 油温の色。キーは API の atf_level (空文字 = 正常)。
//
// 区分は「1段 = 油の寿命が半分」で刻んである (internal/can/obd.go を参照)。
// 実測 24.1時間での滞在割合は 緑59% / 黄緑21% / 黄14% / 橙5% / 赤0%。
// 高速に乗ると黄緑が主役になり、踏み続けると黄へ移る。
const ATF_COLORS = {
'': '#69f0ae', // 〜90℃ 緑 普段。街乗りとアイドリングはほぼここ
warm: '#c6ff00', // 90-100 黄緑 高速に乗った。想定内
caution: '#fdd835', // 100-110 黄 踏んでいる。劣化が5倍で進む
hot: '#ff9800', // 110-120 橙 新東名で58分続いた領域
danger: '#f44336', // 120〜 赤 24時間の実測で未到達
};

// updateIndicators: APIデータで更新
export function updateIndicators(dom, d, conf) {
// バキューム (kPa → bar)
Expand Down Expand Up @@ -477,16 +499,18 @@ export function updateIndicators(dom, d, conf) {
tripIconEl.setAttribute('fill', tripCol);
setFilter(tripIconEl.parentNode, 'url(#glow-mid)');

// OIL
const oilAlert = d.oil_alert || 'green';
const oilCurrent = d.oil_current_km;
const oilCol = OIL_COLORS[oilAlert] || OIL_COLORS.green;
if (oilCurrent != null) {
oilValEl.textContent = Math.round(oilCurrent).toLocaleString();
// ATF 油温
//
// 色はアプリ側の判定 (atf_level) に従う。閾値を UI 側で持たないのは、
// 判定を1か所にまとめて食い違いを防ぐため。
const atf = d.atf_temp_c;
const atfCol = ATF_COLORS[d.atf_level || ''] || ATF_COLORS[''];
if (atf != null && atf > 0) {
atfValEl.textContent = atf.toFixed(1);
} else {
oilValEl.textContent = '--';
atfValEl.textContent = '--';
}
oilValEl.setAttribute('fill', oilCol);
oilIconEl.setAttribute('fill', oilCol);
setFilter(oilIconEl.parentNode, 'url(#glow-mid)');
atfValEl.setAttribute('fill', atfCol);
atfIconEl.setAttribute('fill', atfCol);
setFilter(atfIconEl.parentNode, 'url(#glow-mid)');
}
29 changes: 28 additions & 1 deletion web/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ let wsEverConnected = false;
let wsRetryCount = 0;
let usingPolling = false;

// 直前に確定した実ギア。変速の過渡で実ギアが不定 (0) になる間、これを表示する。
//
// 実測 (2026-09-01、114分・17,681サンプル) では実ギアが不定なのは 2.6% で、
// 中央 0.40秒・最長 2.9秒。ここで 0 に落とすと変速のたびに段が消える。
// ロック率が同じ理由で直前値を保持しているのに揃える。
let lastEngagedGear = 0;

// --- データ適用 ---
function applyData(d) {
// OBD 未接続時 (ACC/エンジン停止) はプレースホルダー状態
Expand All @@ -40,10 +47,30 @@ function applyData(d) {
const rpm = obdOn ? (d.rpm || 0) : 0;
gs.update(spd, rpm, speedColor(spd), rpmColor(rpm));
updateThrottle(obdOn ? (d.throttle_pos || 0) : 0);
updateGear(obdOn ? (d.gear || 0) : 0, obdOn ? (d.at_range_str || '--') : '--', obdOn && (d.hold || false), obdOn && (d.tc_locked || false), obdOn ? d.tcc_lock_pct : null);
updateGear(displayGear(obdOn, d), obdOn ? (d.at_range_str || '--') : '--', obdOn && (d.hold || false), obdOn && (d.tc_locked || false), obdOn ? d.tcc_lock_pct : null);
updateIndicators(dom, d, conf);
}

// 表示するギアを決める。
//
// d.gear は「目標ギア」で、変速指令が出た瞬間に切り替わる。実際に噛むのは
// その後なので、そのまま出すと嘘の段が表示される。実測 (2026-09-01) では
// 走行中の 2.4% で目標と実ギアが食い違っていた。114分の走行で約83秒間にあたる。
//
// 目標4→実3 ×179 目標3→実4 ×109 目標3→実2 ×64 目標2→実3 ×48
//
// 「速度が落ち切る前に2速へ落としたとき、まだ3速なのに2速と表示される」
// という報告と一致する。d.engaged_gear (ギア比から求めた実ギア、#153) を使う。
function displayGear(obdOn, d) {
if (!obdOn) {
lastEngagedGear = 0;
return 0;
}
const eng = d.engaged_gear || 0;
if (eng > 0) lastEngagedGear = eng;
return lastEngagedGear;
}

// --- WebSocket 接続 ---
function connectWebSocket() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
Expand Down
Loading