544 lines
19 KiB
Python
Executable File
544 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Copyright (c) 2026, Rick Lan
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
of this software and associated documentation files (the "Software"), to deal
|
|
in the Software without restriction, including without limitation the rights
|
|
to use, copy, modify, merge, publish, distribute, and/or sublicense,
|
|
for non-commercial purposes only, subject to the following conditions:
|
|
|
|
- The above copyright notice and this permission notice shall be included in
|
|
all copies or substantial portions of the Software.
|
|
- Commercial use (e.g. use in a product, service, or activity intended to
|
|
generate revenue) is prohibited without explicit written permission from
|
|
the copyright holder.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
Dashy State Aggregation Daemon
|
|
|
|
Aggregates all cereal topics needed by dashy UI into a single dashyState message.
|
|
serverd then forwards that one message over WebSocket, avoiding per-topic
|
|
serialization for every connected client.
|
|
|
|
All display formatting (units, distances, times) is done here so the frontend
|
|
can be a pure display layer with no conversion logic.
|
|
|
|
Publishes: dashyState (pre-serialized JSON at 15Hz)
|
|
"""
|
|
import json
|
|
import cereal.messaging as messaging
|
|
from openpilot.common.params import Params
|
|
from openpilot.common.realtime import Ratekeeper
|
|
from openpilot.common.swaglog import cloudlog
|
|
from opendbc.car.common.conversions import Conversions
|
|
|
|
# Main loop rate
|
|
LOOP_RATE = 15 # Hz
|
|
|
|
# Downsample factor for modelV2 arrays (33 points -> 17 points)
|
|
DOWNSAMPLE_FACTOR = 2
|
|
|
|
# Unit conversion constants
|
|
M_TO_FT = 3.28084
|
|
|
|
# Global state (refreshed periodically)
|
|
_is_metric = True
|
|
_params = None
|
|
_car_params_cache = None
|
|
|
|
|
|
def _ensure_params():
|
|
"""Ensure Params instance exists."""
|
|
global _params
|
|
if _params is None:
|
|
_params = Params()
|
|
return _params
|
|
|
|
|
|
def refresh_metric_preference():
|
|
"""Refresh metric preference from params (called periodically)."""
|
|
global _is_metric
|
|
try:
|
|
_is_metric = _ensure_params().get_bool("IsMetric")
|
|
except Exception:
|
|
_is_metric = True
|
|
|
|
|
|
def get_car_params_from_params():
|
|
"""Read carParams from Params storage (for immediate availability at startup)."""
|
|
global _car_params_cache
|
|
if _car_params_cache is not None:
|
|
return _car_params_cache
|
|
try:
|
|
from cereal import car
|
|
cp_bytes = _ensure_params().get("CarParams")
|
|
if cp_bytes:
|
|
with car.CarParams.from_bytes(cp_bytes) as cp:
|
|
_car_params_cache = {
|
|
'openpilotLongitudinalControl': bool(cp.openpilotLongitudinalControl),
|
|
}
|
|
return _car_params_cache
|
|
except Exception:
|
|
pass
|
|
return {'openpilotLongitudinalControl': False}
|
|
|
|
|
|
def format_speed(speed_ms: float) -> str:
|
|
"""Format speed for display (m/s -> km/h or mph)."""
|
|
if _is_metric:
|
|
return f"{max(0, speed_ms * Conversions.MS_TO_KPH):.0f}"
|
|
return f"{max(0, speed_ms * Conversions.MS_TO_MPH):.0f}"
|
|
|
|
|
|
def get_speed_unit() -> str:
|
|
"""Get current speed unit string."""
|
|
return "km/h" if _is_metric else "mph"
|
|
|
|
|
|
def get_distance_unit() -> str:
|
|
"""Get current distance unit string."""
|
|
return "km" if _is_metric else "mi"
|
|
|
|
|
|
SET_SPEED_NA = 255
|
|
|
|
|
|
def get_cruise_speed(v_cruise_cluster: float) -> int:
|
|
"""Get cruise speed value for display.
|
|
|
|
Returns the set speed in display units (km/h or mph), or 255 if not set.
|
|
"""
|
|
if not (0 < v_cruise_cluster < SET_SPEED_NA):
|
|
return SET_SPEED_NA
|
|
|
|
set_speed = v_cruise_cluster
|
|
if not _is_metric:
|
|
set_speed *= Conversions.KPH_TO_MPH
|
|
|
|
return round(set_speed)
|
|
|
|
|
|
def downsample(arr):
|
|
"""Downsample list by factor."""
|
|
if not arr:
|
|
return []
|
|
return list(arr[::DOWNSAMPLE_FACTOR])
|
|
|
|
|
|
def safe_get(obj, attr, default=None):
|
|
"""Safely get attribute from object."""
|
|
try:
|
|
return getattr(obj, attr, default)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def extract_car_state(sm):
|
|
"""Extract carState fields used by dashy."""
|
|
cs = sm['carState']
|
|
v_ego = float(cs.vEgo)
|
|
v_ego_cluster = float(cs.vEgoCluster)
|
|
|
|
# Set speed: prefer carState.vCruiseCluster, fall back to carState.vCruise
|
|
# if the cluster value isn't populated. Both are live on carState in 0.11.1;
|
|
# the old controlsState set-speed fields moved into its deprecated group.
|
|
v_cruise = float(cs.vCruiseCluster)
|
|
if not (0 < v_cruise < SET_SPEED_NA):
|
|
v_cruise = float(cs.vCruise)
|
|
set_speed = get_cruise_speed(v_cruise)
|
|
|
|
return {
|
|
'vEgo': v_ego,
|
|
'vEgoCluster': v_ego_cluster,
|
|
'gearShifter': str(cs.gearShifter),
|
|
'aEgo': float(cs.aEgo),
|
|
'steeringAngleDeg': float(cs.steeringAngleDeg),
|
|
'steeringPressed': bool(cs.steeringPressed),
|
|
'gasPressed': bool(cs.gasPressed),
|
|
'leftBlinker': bool(cs.leftBlinker),
|
|
'rightBlinker': bool(cs.rightBlinker),
|
|
'leftBlindspot': bool(cs.leftBlindspot),
|
|
'rightBlindspot': bool(cs.rightBlindspot),
|
|
'cruiseEnabled': bool(cs.cruiseState.enabled),
|
|
# Pre-formatted display values
|
|
'speedDisplay': format_speed(v_ego),
|
|
'speedClusterDisplay': format_speed(v_ego_cluster) if v_ego_cluster > 0 else format_speed(v_ego),
|
|
'setSpeed': set_speed, # 255 = not set, otherwise display value
|
|
'speedUnit': get_speed_unit(),
|
|
}
|
|
|
|
|
|
def extract_selfdrive_state(sm):
|
|
"""Extract selfdriveState fields used by dashy."""
|
|
ss = sm['selfdriveState']
|
|
return {
|
|
'enabled': bool(ss.enabled),
|
|
'activeOverride': int(safe_get(ss, 'activeOverride', 0)),
|
|
'experimentalMode': bool(ss.experimentalMode),
|
|
'alertText1': str(ss.alertText1),
|
|
'alertText2': str(ss.alertText2),
|
|
'alertSize': str(ss.alertSize),
|
|
'alertStatus': str(ss.alertStatus),
|
|
}
|
|
|
|
|
|
def extract_device_state(sm):
|
|
"""Extract deviceState fields used by dashy."""
|
|
ds = sm['deviceState']
|
|
temp_c = float(safe_get(ds, 'maxTempC', 0))
|
|
# Pre-format temperature for display
|
|
if _is_metric:
|
|
temp_display = f"{temp_c:.0f}°" if temp_c > 0 else "--"
|
|
else:
|
|
temp_f = temp_c * 9 / 5 + 32
|
|
temp_display = f"{temp_f:.0f}°" if temp_c > 0 else "--"
|
|
return {
|
|
'cpuUsagePercent': list(ds.cpuUsagePercent) if ds.cpuUsagePercent else [],
|
|
'gpuUsagePercent': int(ds.gpuUsagePercent),
|
|
'memoryUsagePercent': int(ds.memoryUsagePercent),
|
|
'freeSpacePercent': float(ds.freeSpacePercent),
|
|
'maxTempC': temp_c,
|
|
'thermalStatus': str(ds.thermalStatus), # 'green' | 'yellow' | 'red' | 'danger'
|
|
'fanSpeedPercentDesired': int(ds.fanSpeedPercentDesired),
|
|
'powerDrawW': float(safe_get(ds, 'powerDrawW', 0)),
|
|
'deviceType': str(ds.deviceType),
|
|
'tempDisplay': temp_display,
|
|
}
|
|
|
|
|
|
def extract_lead(lead, sm):
|
|
"""Extract lead vehicle data."""
|
|
d_rel = float(lead.dRel)
|
|
v_rel = float(lead.vRel)
|
|
y_rel = float(lead.yRel)
|
|
has_lead = bool(lead.status)
|
|
|
|
# Pre-format lead display values. Each metric ships as a
|
|
# (value, unit) pair so the HUD can tabular-align numbers without
|
|
# regex-parsing on the JS side.
|
|
dist_value = "--"
|
|
dist_unit = ""
|
|
speed_value = "--"
|
|
speed_unit_str = ""
|
|
ttc_value = "—"
|
|
ttc_unit = "s"
|
|
ttc_urgent = False
|
|
if has_lead:
|
|
speed_unit_str = "km/h" if _is_metric else "mph"
|
|
dist_unit = "m" if _is_metric else "ft"
|
|
conv = Conversions.MS_TO_KPH if _is_metric else Conversions.MS_TO_MPH
|
|
dist_value = f"{d_rel:.1f}" if _is_metric else f"{d_rel * M_TO_FT:.1f}"
|
|
v_ego = float(sm['carState'].vEgo) if sm.valid['carState'] else 0
|
|
# Lead's absolute speed = ego + relative (clamped to 0).
|
|
lead_speed_disp = max(0.0, v_ego + v_rel) * conv
|
|
speed_value = f"{lead_speed_disp:.1f}"
|
|
if v_ego > 0:
|
|
ttc = d_rel / v_ego
|
|
if ttc < 5.0:
|
|
ttc_value = f"{ttc:.1f}"
|
|
ttc_urgent = True
|
|
|
|
return {
|
|
'status': has_lead,
|
|
'dRel': d_rel,
|
|
'yRel': y_rel,
|
|
'vRel': v_rel,
|
|
'distValue': dist_value,
|
|
'distUnit': dist_unit,
|
|
'speedValue': speed_value,
|
|
'speedUnit': speed_unit_str,
|
|
'ttcValue': ttc_value,
|
|
'ttcUnit': ttc_unit,
|
|
'ttcUrgent': ttc_urgent,
|
|
}
|
|
|
|
|
|
def extract_radar_state(sm):
|
|
"""Extract radarState fields used by dashy."""
|
|
rs = sm['radarState']
|
|
return {
|
|
'leadOne': extract_lead(rs.leadOne, sm),
|
|
'leadTwo': extract_lead(rs.leadTwo, sm),
|
|
}
|
|
|
|
|
|
def extract_live_tracks(sm):
|
|
"""Extract liveTracks radar points for bird's eye view.
|
|
|
|
Filters out tracks that are already shown as leadOne or leadTwo.
|
|
Uses radarTrackId matching: when radarState matches a liveTrack to a lead,
|
|
radarTrackId changes from -1 (vision-only) to the track's ID.
|
|
"""
|
|
try:
|
|
lt = sm['liveTracks']
|
|
points = []
|
|
|
|
# Get lead vehicle radar track IDs to filter them out
|
|
# radarTrackId = -1 means vision-only (no radar match)
|
|
# radarTrackId >= 0 means matched to a radar track
|
|
lead_track_ids = set()
|
|
if sm.valid.get('radarState', False):
|
|
rs = sm['radarState']
|
|
if rs.leadOne.status and rs.leadOne.radarTrackId >= 0:
|
|
lead_track_ids.add(rs.leadOne.radarTrackId)
|
|
if rs.leadTwo.status and rs.leadTwo.radarTrackId >= 0:
|
|
lead_track_ids.add(rs.leadTwo.radarTrackId)
|
|
|
|
if hasattr(lt, 'points'):
|
|
for pt in lt.points:
|
|
# Skip if this track is already shown as a lead vehicle
|
|
if pt.trackId in lead_track_ids:
|
|
continue
|
|
# Drop stale tracks — radar's predicting, not measuring
|
|
if not pt.measured:
|
|
continue
|
|
# Drop stationary clutter (sign posts, guardrails,
|
|
# parked cars). |vRel| < 0.5 m/s ≈ standing still
|
|
# relative to ego; not relevant traffic.
|
|
if abs(pt.vRel) < 0.5:
|
|
continue
|
|
|
|
points.append({
|
|
'd': float(pt.dRel),
|
|
'y': float(pt.yRel),
|
|
'v': float(pt.vRel),
|
|
'm': bool(pt.measured),
|
|
})
|
|
return {'points': points}
|
|
except Exception as e:
|
|
cloudlog.warning(f"extract_live_tracks error: {e}")
|
|
return {'points': []}
|
|
|
|
|
|
def extract_model_v2(sm):
|
|
"""Extract modelV2 fields used by dashy (downsampled)."""
|
|
model = sm['modelV2']
|
|
|
|
# Position
|
|
pos = model.position
|
|
position = {
|
|
'x': downsample(list(pos.x)),
|
|
'y': downsample(list(pos.y)),
|
|
'z': downsample(list(pos.z)),
|
|
}
|
|
|
|
# Lane lines (4 lines)
|
|
lane_lines = []
|
|
for line in model.laneLines:
|
|
lane_lines.append({
|
|
'x': downsample(list(line.x)),
|
|
'y': downsample(list(line.y)),
|
|
'z': downsample(list(line.z)),
|
|
})
|
|
|
|
# Road edges (2 edges)
|
|
road_edges = []
|
|
for edge in model.roadEdges:
|
|
road_edges.append({
|
|
'x': downsample(list(edge.x)),
|
|
'y': downsample(list(edge.y)),
|
|
'z': downsample(list(edge.z)),
|
|
})
|
|
|
|
return {
|
|
'position': position,
|
|
'laneLines': lane_lines,
|
|
'laneLineProbs': list(model.laneLineProbs) if hasattr(model, 'laneLineProbs') else [0, 0, 0, 0],
|
|
'roadEdges': road_edges,
|
|
'roadEdgeStds': list(model.roadEdgeStds) if hasattr(model, 'roadEdgeStds') else [1, 1],
|
|
}
|
|
|
|
|
|
def extract_live_calibration(sm):
|
|
"""Extract liveCalibration fields used by dashy."""
|
|
cal = sm['liveCalibration']
|
|
return {
|
|
'rpyCalib': list(cal.rpyCalib) if hasattr(cal, 'rpyCalib') and cal.rpyCalib else [],
|
|
'calStatus': str(cal.calStatus) if hasattr(cal, 'calStatus') else 'uncalibrated',
|
|
'height': list(cal.height) if hasattr(cal, 'height') else [],
|
|
}
|
|
|
|
|
|
def extract_longitudinal_plan(sm):
|
|
"""Extract longitudinalPlan fields used by dashy."""
|
|
lp = sm['longitudinalPlan']
|
|
return {
|
|
'allowThrottle': bool(safe_get(lp, 'allowThrottle', True)),
|
|
}
|
|
|
|
|
|
def extract_controls_state_ext(sm):
|
|
"""Extract controlsStateExt fields used by dashy."""
|
|
cse = sm['controlsStateExt']
|
|
return {
|
|
'alkaActive': bool(safe_get(cse, 'alkaActive', False)),
|
|
}
|
|
|
|
|
|
def extract_car_params(sm):
|
|
"""Extract carParams fields used by dashy."""
|
|
cp = sm['carParams']
|
|
return {
|
|
'openpilotLongitudinalControl': bool(safe_get(cp, 'openpilotLongitudinalControl', False)),
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# TOPIC CONFIGURATION
|
|
# =============================================================================
|
|
# Single source of truth for all subscribed topics.
|
|
# Comment out a line to disable that topic entirely.
|
|
#
|
|
# Fields:
|
|
# extractor: function(sm) -> dict, extracts data from message
|
|
# rate: 'fast' = every frame when updated
|
|
# number = slow poll divider (e.g., LOOP_RATE = 1Hz)
|
|
# 'valid' = just track valid state, no extraction
|
|
# 'subscribe' = subscribed but extracted within other extractors
|
|
# default: initial cache value (None if not specified)
|
|
# =============================================================================
|
|
TOPICS = {
|
|
# Fast topics - extract every frame when updated
|
|
'carState': {'extractor': extract_car_state, 'rate': 'fast'},
|
|
'selfdriveState': {'extractor': extract_selfdrive_state, 'rate': 'fast'},
|
|
'radarState': {'extractor': extract_radar_state, 'rate': 'fast'},
|
|
'liveTracks': {'extractor': extract_live_tracks, 'rate': 'fast'},
|
|
'modelV2': {'extractor': extract_model_v2, 'rate': 'fast'},
|
|
'longitudinalPlan': {'extractor': extract_longitudinal_plan, 'rate': 'fast'},
|
|
|
|
# Slow topics - poll at fixed intervals
|
|
'deviceState': {'extractor': extract_device_state, 'rate': LOOP_RATE // 2},
|
|
'liveCalibration': {'extractor': extract_live_calibration, 'rate': LOOP_RATE},
|
|
'carParams': {'extractor': extract_car_params, 'rate': LOOP_RATE * 2},
|
|
|
|
# Valid-only topics - just track valid state
|
|
'roadCameraState': {'rate': 'valid', 'default': False},
|
|
|
|
# Subscribe-only topics - subscribed but extracted within other extractors
|
|
'controlsState': {'rate': 'subscribe'},
|
|
|
|
# Optional/dragonpilot-specific topics - comment out to disable
|
|
'controlsStateExt': {'extractor': extract_controls_state_ext, 'rate': 'fast', 'default': {'alkaActive': False}},
|
|
}
|
|
|
|
|
|
def _available_topics(topics_cfg):
|
|
"""Filter TOPICS to only services this cereal schema knows about.
|
|
|
|
Lets dragonpilot-specific topics like controlsStateExt drop out
|
|
cleanly on a vanilla openpilot schema instead of crashing SubMaster.
|
|
Topics that drop out keep their default cache value (see 'default'
|
|
in the TOPICS entry), which the frontend already null-checks.
|
|
"""
|
|
try:
|
|
from cereal.services import SERVICE_LIST as _services
|
|
except ImportError:
|
|
try:
|
|
from cereal.services import services as _services
|
|
except ImportError:
|
|
return topics_cfg
|
|
|
|
out = {}
|
|
for name, cfg in topics_cfg.items():
|
|
if name in _services:
|
|
out[name] = cfg
|
|
else:
|
|
cloudlog.info(f"dashyd: cereal service '{name}' not available, skipping")
|
|
return out
|
|
|
|
|
|
def main():
|
|
cloudlog.info("dashyd: starting")
|
|
|
|
# Initialize metric preference
|
|
refresh_metric_preference()
|
|
|
|
topics = _available_topics(TOPICS)
|
|
|
|
# Derive services list from filtered topics
|
|
services = list(topics.keys())
|
|
sm = messaging.SubMaster(services)
|
|
pm = messaging.PubMaster(['dashyState'])
|
|
rk = Ratekeeper(LOOP_RATE)
|
|
|
|
# Initialize cache from TOPICS defaults (always include all topics so
|
|
# the frontend gets default values for dropped optional ones too).
|
|
cache = {t: cfg.get('default') for t, cfg in TOPICS.items() if cfg.get('rate') != 'subscribe'}
|
|
cache['carParams'] = get_car_params_from_params() # special: init from Params
|
|
|
|
# Build topic lists from the filtered topics (only subscribed ones run their extractors)
|
|
fast_topics = {t: cfg['extractor'] for t, cfg in topics.items() if cfg.get('rate') == 'fast'}
|
|
slow_topics = {t: (cfg['extractor'], cfg['rate']) for t, cfg in topics.items()
|
|
if isinstance(cfg.get('rate'), int)}
|
|
valid_topics = [t for t, cfg in topics.items() if cfg.get('rate') == 'valid']
|
|
|
|
cache_dirty = True
|
|
frame_count = 0
|
|
|
|
while True:
|
|
sm.update(0)
|
|
frame_count += 1
|
|
|
|
# Refresh metric preference every ~2 seconds
|
|
if frame_count % (LOOP_RATE * 2) == 0:
|
|
refresh_metric_preference()
|
|
cache_dirty = True # Force re-format with new units
|
|
|
|
# Fast topics - extract when updated
|
|
for topic, extractor in fast_topics.items():
|
|
if sm.updated[topic]:
|
|
cache[topic] = extractor(sm)
|
|
cache_dirty = True
|
|
|
|
# Slow topics - extract at fixed intervals (ignore sm.updated)
|
|
for topic, (extractor, divider) in slow_topics.items():
|
|
if frame_count % divider == 0:
|
|
cache[topic] = extractor(sm)
|
|
cache_dirty = True
|
|
|
|
# Valid-only topics - just track valid state
|
|
for topic in valid_topics:
|
|
if sm.updated[topic]:
|
|
new_val = sm.valid[topic]
|
|
if cache[topic] != new_val:
|
|
cache[topic] = new_val
|
|
cache_dirty = True
|
|
|
|
# Only serialize and publish if something changed
|
|
if cache_dirty:
|
|
# Only publish when critical openpilot data exists
|
|
critical_ready = (
|
|
cache.get('carState') is not None and
|
|
cache.get('modelV2') is not None and
|
|
cache.get('selfdriveState') is not None
|
|
)
|
|
|
|
if critical_ready:
|
|
state = {
|
|
'ts': sm.logMonoTime['carState'],
|
|
'display': {
|
|
'isMetric': _is_metric,
|
|
'speedUnit': get_speed_unit(),
|
|
'distanceUnit': get_distance_unit(),
|
|
},
|
|
**cache, # include all cached topics
|
|
}
|
|
msg = messaging.new_message('dashyState')
|
|
msg.dashyState.json = json.dumps(state).encode()
|
|
pm.send('dashyState', msg)
|
|
|
|
cache_dirty = False
|
|
|
|
rk.keep_time()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|