diff --git a/main.py b/main.py index 8b01a27..bff18ce 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ import dash from dash import dcc, html, Input, Output, State import dash_bootstrap_components as dbc import numpy as np +import pandas as pd from parser import parse_log, parse_csv from decoder import decode_j1939_frames @@ -20,6 +21,7 @@ from stats.frequency import calculate_frequency, plot_frequency from stats.correlation import calculate_correlation, plot_correlation_heatmap from stats.entropy import calculate_byte_entropy, plot_entropy_heatmap from logs.view import get_logs_table_component, prepare_logs_data +from vehicle import get_vehicle_module RAW_LOG_DIR = "data/logs" PAGE_SIZE = 25000 @@ -131,6 +133,18 @@ app.layout = dbc.Container([ dbc.Tab(label="Overview", tab_id="overview", children=[ html.Div(id="overview-content") ]), + dbc.Tab(label="Vehicles", tab_id="vehicles", children=[ + dbc.Row([ + dbc.Col(html.Label("Select Vehicle:", className="mt-2"), width="auto"), + dbc.Col(dcc.Dropdown( + id='vehicles-vehicle-selector', + options=[{'label': v, 'value': v} for v in DATA.keys()], + value=list(DATA.keys())[0] if DATA else None, + clearable=False + ), width=3, className="me-4"), + ], className="mb-3 mt-3", align="end"), + html.Div(id='vehicles-content', className="mt-3") + ]), dbc.Tab(label="Logs", tab_id="logs", children=[ dbc.Row([ dbc.Col(html.Label("Select Vehicle:", className="mt-2"), width="auto"), @@ -391,5 +405,58 @@ def update_corr(method, target, vehicle, bus, tab): return plot_correlation_heatmap(corr_df, target_id=target_id, title=title) +@app.callback( + Output('vehicles-content', 'children'), + Input('vehicles-vehicle-selector', 'value') +) +def render_vehicles(vehicle): + """Render small graph boxes for every decoded signal, combining both buses.""" + if not vehicle or vehicle not in DATA: + return html.Div("No data available", className="text-muted") + + brand = VEHICLE_META.get(vehicle, {}).get("brand", "") + + vehicle_module = get_vehicle_module(brand) + + dfs = [] + for bus_df in DATA[vehicle].values(): + dfs.append(bus_df) + + if not dfs: + return html.Div("No data available", className="text-muted") + + df = pd.concat(dfs, ignore_index=True) + + if 'Timestamp' in df.columns: + df = df.sort_values('Timestamp', kind='stable').reset_index(drop=True) + + cards = [] + for nid, frame_def in vehicle_module.DECODER_RULES.items(): + decoded = vehicle_module.decode_dataframe(df, frame_def.can_id) + + for sig in frame_def.signals: + unit_str = f" ({sig.unit})" if sig.unit else "" + title = f"{frame_def.can_id} - {sig.name}{unit_str}" + fig = vehicle_module.plot_signal(decoded, sig.name, title=title) + + card = dbc.Card([ + dbc.CardBody([ + dcc.Graph(figure=fig, config={'displayModeBar': False}, + style={'height': '280px'}) + ], className="p-2"), + ], className="shadow-sm border-0 h-100") + + cards.append( + dbc.Col(card, xs=12, sm=6, md=4, lg=3, className="mb-3") + ) + + if not cards: + return html.Div( + "No decoded signals available. Add rules in the vehicle module.", + className="text-muted" + ) + + return dbc.Row(cards) + if __name__ == '__main__': app.run(debug=False) diff --git a/vehicle/__init__.py b/vehicle/__init__.py new file mode 100644 index 0000000..7c6a25b --- /dev/null +++ b/vehicle/__init__.py @@ -0,0 +1,19 @@ +# File: vehicle/__init__.py +# Copyright (C) 2026 Erick Ahmed +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib + +def get_vehicle_module(brand: str): + """ + Dynamically imports the correct decoder module based on the vehicle brand. + Falls back to 'vehicle.generic' if a specific brand module is not found. + """ + if not brand: + return importlib.import_module("vehicle.generic") + + module_name = f"vehicle.{brand.lower().replace(' ', '_')}" + try: + return importlib.import_module(module_name) + except ModuleNotFoundError: + return importlib.import_module("vehicle.generic") diff --git a/vehicle/base.py b/vehicle/base.py new file mode 100644 index 0000000..d70e040 --- /dev/null +++ b/vehicle/base.py @@ -0,0 +1,138 @@ +# File: vehicle/base.py +# Copyright (C) 2026 Erick Ahmed +# SPDX-License-Identifier: AGPL-3.0-or-later + +from dataclasses import dataclass, field +from typing import List +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +@dataclass +class SignalDef: + name: str + bit_start: int + bit_length: int + factor: float = 1.0 + offset: float = 0.0 + is_signed: bool = False + byte_order: str = "little" + unit: str = "" + +@dataclass +class FrameDef: + can_id: str + description: str = "" + signals: List[SignalDef] = field(default_factory=list) + +def normalize_id(can_id: str) -> str: + s = str(can_id).strip().upper() + if s.startswith("0X"): + s = s[2:] + return s.lstrip("0") or "0" + +def _extract_signal(bytes_arr: np.ndarray, sig: SignalDef) -> np.ndarray: + if bytes_arr.size == 0: + return np.zeros(0, dtype=np.float64) + + byte_lo = sig.bit_start // 8 + byte_hi = (sig.bit_start + sig.bit_length - 1) // 8 + byte_indices = [i for i in range(byte_lo, byte_hi + 1) if 0 <= i < 8] + + if not byte_indices: + return np.full(bytes_arr.shape[0], np.nan, dtype=np.float64) + + raw = np.zeros(bytes_arr.shape[0], dtype=np.int64) + if sig.byte_order == "little": + for shift, bi in enumerate(byte_indices): + raw += bytes_arr[:, bi].astype(np.int64) << (shift * 8) + else: + for shift, bi in enumerate(reversed(byte_indices)): + raw += bytes_arr[:, bi].astype(np.int64) << (shift * 8) + + intra_byte_shift = sig.bit_start % 8 + raw = raw >> intra_byte_shift + + mask = (1 << sig.bit_length) - 1 + raw = raw & mask + + if sig.is_signed and sig.bit_length < 64: + sign_bit = 1 << (sig.bit_length - 1) + raw = (raw ^ sign_bit) - sign_bit + + return raw.astype(np.float64) * sig.factor + sig.offset + +def decode_dataframe(df: pd.DataFrame, can_id: str, decoder_rules: dict) -> pd.DataFrame: + norm = normalize_id(can_id) + if norm not in decoder_rules: + return pd.DataFrame() + + frame_def = decoder_rules[norm] + + id_col = "ID" if "ID" in df.columns else "Identifier" + df_ids = df[id_col].astype(str).map(normalize_id) + mask = df_ids == norm + sub = df.loc[mask].copy() + if sub.empty: + return pd.DataFrame() + + byte_cols = [f"b{i}" for i in range(8) if f"b{i}" in sub.columns] + if not byte_cols: + return pd.DataFrame() + + arr = np.zeros((len(sub), 8), dtype=np.int64) + for i, c in enumerate(byte_cols): + arr[:, i] = pd.to_numeric(sub[c], errors="coerce").fillna(0).astype(np.int64).to_numpy() + + out = pd.DataFrame() + out["Timestamp"] = sub["Timestamp"].to_numpy() if "Timestamp" in sub.columns else np.arange(len(sub)) + + for sig in frame_def.signals: + out[sig.name] = _extract_signal(arr, sig) + + return out + +def plot_signal(df: pd.DataFrame, signal_name: str, title: str, height: int = 280) -> go.Figure: + fig = go.Figure() + + if df.empty or signal_name not in df.columns: + fig.update_layout( + title=dict(text=title, font=dict(size=14)), + annotations=[dict(text="No data", showarrow=False, x=0.5, y=0.5, + font=dict(size=13, color="#888"))], + height=height, + template="plotly_white", + ) + return fig + + fig.add_trace(go.Scatter( + x=df["Timestamp"], + y=df[signal_name], + mode="lines", + line=dict(width=2, color="#377eb8"), + name=signal_name, + hovertemplate=f"{signal_name}
Time: %{{x}}
Value: %{{y:.2f}}", + )) + + fig.update_layout( + title=dict(text=title, font=dict(size=14, color="#1a1a1a"), + x=0.5, xanchor="center", pad=dict(b=10)), + height=height, + autosize=True, + template="plotly_white", + margin=dict(l=55, r=20, t=55, b=45), + xaxis=dict( + title=dict(text="Time", font=dict(size=11)), + showgrid=True, gridwidth=0.5, gridcolor="#eee", + zeroline=False, linecolor="#bdbdbd", + ), + yaxis=dict( + title=dict(text=signal_name, font=dict(size=11)), + showgrid=True, gridwidth=0.5, gridcolor="#eee", + zeroline=False, linecolor="#bdbdbd", + ), + font=dict(family="Segoe UI, Arial, sans-serif", size=11, color="#2a2a2a"), + hoverlabel=dict(bgcolor="white", font_size=12, + font_family="Segoe UI", bordercolor="#cccccc"), + ) + return fig diff --git a/vehicle/komatsu.py b/vehicle/komatsu.py new file mode 100644 index 0000000..5399f84 --- /dev/null +++ b/vehicle/komatsu.py @@ -0,0 +1,29 @@ +# File: vehicle/komatsu.py +# Copyright (C) 2026 Erick Ahmed +# SPDX-License-Identifier: AGPL-3.0-or-later + +from vehicle.base import ( + SignalDef, FrameDef, normalize_id, decode_dataframe as _decode_dataframe, plot_signal +) + +DECODER_RULES = { + normalize_id("0x011F"): FrameDef( + can_id="0x011F", + description="Engine RPM", + signals=[ + SignalDef( + name="RPM", + bit_start=0, + bit_length=8, + factor=20.0, + offset=0.0, + is_signed=False, + byte_order="little", + unit="rpm", + ), + ], + ), +} + +def decode_dataframe(df, can_id): + return _decode_dataframe(df, can_id, DECODER_RULES)