import re from collections import namedtuple import copy from dataclasses import dataclass, field from enum import Enum from cereal import car from openpilot.common.conversions import Conversions as CV GOOD_TORQUE_THRESHOLD = 1.0 # m/s^2 MODEL_YEARS_RE = r"(?<= )((\d{4}-\d{2})|(\d{4}))(,|$)" class Column(Enum): MAKE = "Make" MODEL = "Model" PACKAGE = "Supported Package" LONGITUDINAL = "ACC" FSR_LONGITUDINAL = "No ACC accel below" FSR_STEERING = "No ALC below" STEERING_TORQUE = "Steering Torque" AUTO_RESUME = "Resume from stop" HARDWARE = "Hardware Needed" VIDEO = "Video" class Star(Enum): FULL = "full" HALF = "half" EMPTY = "empty" # A part + its comprised parts @dataclass class BasePart: name: str parts: list[Enum] = field(default_factory=list) def all_parts(self): # Recursively get all parts _parts = 'parts' parts = [] parts.extend(getattr(self, _parts)) for part in getattr(self, _parts): parts.extend(part.value.all_parts()) return parts class EnumBase(Enum): @property def part_type(self): return PartType(self.__class__) class Mount(EnumBase): mount = BasePart("mount") angled_mount_8_degrees = BasePart("angled mount (8 degrees)") class Cable(EnumBase): rj45_cable_7ft = BasePart("RJ45 cable (7 ft)") long_obdc_cable = BasePart("long OBD-C cable") usb_a_2_a_cable = BasePart("USB A-A cable") usbc_otg_cable = BasePart("USB C OTG cable") usbc_coupler = BasePart("USB-C coupler") obd_c_cable_1_5ft = BasePart("OBD-C cable (1.5 ft)") right_angle_obd_c_cable_1_5ft = BasePart("right angle OBD-C cable (1.5 ft)") class Accessory(EnumBase): harness_box = BasePart("harness box") comma_power_v2 = BasePart("comma power v2") @dataclass class BaseCarHarness(BasePart): parts: list[Enum] = field(default_factory=lambda: [Accessory.harness_box, Accessory.comma_power_v2, Cable.rj45_cable_7ft]) has_connector: bool = True # without are hidden on the harness connector page class CarHarness(EnumBase): nidec = BaseCarHarness("Honda Nidec connector") bosch_a = BaseCarHarness("Honda Bosch A connector") bosch_b = BaseCarHarness("Honda Bosch B connector") toyota_a = BaseCarHarness("Toyota A connector") toyota_b = BaseCarHarness("Toyota B connector") subaru_a = BaseCarHarness("Subaru A connector") subaru_b = BaseCarHarness("Subaru B connector") subaru_c = BaseCarHarness("Subaru C connector") subaru_d = BaseCarHarness("Subaru D connector") fca = BaseCarHarness("FCA connector") ram = BaseCarHarness("Ram connector") vw = BaseCarHarness("VW connector") j533 = BaseCarHarness("J533 connector", parts=[Accessory.harness_box, Cable.long_obdc_cable, Cable.usbc_coupler]) hyundai_a = BaseCarHarness("Hyundai A connector") hyundai_b = BaseCarHarness("Hyundai B connector") hyundai_c = BaseCarHarness("Hyundai C connector") hyundai_d = BaseCarHarness("Hyundai D connector") hyundai_e = BaseCarHarness("Hyundai E connector") hyundai_f = BaseCarHarness("Hyundai F connector") hyundai_g = BaseCarHarness("Hyundai G connector") hyundai_h = BaseCarHarness("Hyundai H connector") hyundai_i = BaseCarHarness("Hyundai I connector") hyundai_j = BaseCarHarness("Hyundai J connector") hyundai_k = BaseCarHarness("Hyundai K connector") hyundai_l = BaseCarHarness("Hyundai L connector") hyundai_m = BaseCarHarness("Hyundai M connector") hyundai_n = BaseCarHarness("Hyundai N connector") hyundai_o = BaseCarHarness("Hyundai O connector") hyundai_p = BaseCarHarness("Hyundai P connector") hyundai_q = BaseCarHarness("Hyundai Q connector") hyundai_r = BaseCarHarness("Hyundai R connector") custom = BaseCarHarness("Developer connector") obd_ii = BaseCarHarness("OBD-II connector", parts=[Cable.long_obdc_cable, Cable.long_obdc_cable], has_connector=False) gm = BaseCarHarness("GM connector", parts=[Accessory.harness_box]) nissan_a = BaseCarHarness("Nissan A connector", parts=[Accessory.harness_box, Cable.rj45_cable_7ft, Cable.long_obdc_cable, Cable.usbc_coupler]) nissan_b = BaseCarHarness("Nissan B connector", parts=[Accessory.harness_box, Cable.rj45_cable_7ft, Cable.long_obdc_cable, Cable.usbc_coupler]) mazda = BaseCarHarness("Mazda connector") ford_q3 = BaseCarHarness("Ford Q3 connector") ford_q4 = BaseCarHarness("Ford Q4 connector", parts=[Accessory.harness_box, Accessory.comma_power_v2, Cable.rj45_cable_7ft, Cable.long_obdc_cable, Cable.usbc_coupler]) class Device(EnumBase): threex = BasePart("comma 3X", parts=[Mount.mount, Cable.right_angle_obd_c_cable_1_5ft]) # variant of comma 3X with angled mounts threex_angled_mount = BasePart("comma 3X", parts=[Mount.angled_mount_8_degrees, Cable.right_angle_obd_c_cable_1_5ft]) red_panda = BasePart("red panda") class Kit(EnumBase): red_panda_kit = BasePart("CAN FD panda kit", parts=[Device.red_panda, Accessory.harness_box, Cable.usb_a_2_a_cable, Cable.usbc_otg_cable, Cable.obd_c_cable_1_5ft]) class Tool(EnumBase): socket_8mm_deep = BasePart("Socket Wrench 8mm or 5/16\" (deep)") pry_tool = BasePart("Pry Tool") class PartType(Enum): accessory = Accessory cable = Cable connector = CarHarness device = Device kit = Kit mount = Mount tool = Tool DEFAULT_CAR_PARTS: list[EnumBase] = [Device.threex] @dataclass class CarParts: parts: list[EnumBase] = field(default_factory=list) def __call__(self): return copy.deepcopy(self) @classmethod def common(cls, add: list[EnumBase] = None, remove: list[EnumBase] = None): p = [part for part in (add or []) + DEFAULT_CAR_PARTS if part not in (remove or [])] return cls(p) def all_parts(self): parts = [] for part in self.parts: parts.extend(part.value.all_parts()) return self.parts + parts CarFootnote = namedtuple("CarFootnote", ["text", "column", "docs_only", "shop_footnote"], defaults=(False, False)) class CommonFootnote(Enum): EXP_LONG_AVAIL = CarFootnote( "openpilot Longitudinal Control (Alpha) is available behind a toggle; " + "the toggle is only available in non-release branches such as `devel` or `master-ci`.", Column.LONGITUDINAL, docs_only=True) EXP_LONG_DSU = CarFootnote( "By default, this car will use the stock Adaptive Cruise Control (ACC) for longitudinal control. " + "If the Driver Support Unit (DSU) is disconnected, openpilot ACC will replace " + "stock ACC. NOTE: disconnecting the DSU disables Automatic Emergency Braking (AEB).", Column.LONGITUDINAL) def get_footnotes(footnotes: list[Enum], column: Column) -> list[Enum]: # Returns applicable footnotes given current column return [fn for fn in footnotes if fn.value.column == column] # TODO: store years as a list def get_year_list(years): years_list = [] if len(years) == 0: return years_list for year in years.split(','): year = year.strip() if len(year) == 4: years_list.append(str(year)) elif "-" in year and len(year) == 7: start, end = year.split("-") years_list.extend(map(str, range(int(start), int(f"20{end}") + 1))) else: raise Exception(f"Malformed year string: {years}") return years_list def split_name(name: str) -> tuple[str, str, str]: make, model = name.split(" ", 1) years = "" match = re.search(MODEL_YEARS_RE, model) if match is not None: years = model[match.start():] model = model[:match.start() - 1] return make, model, years @dataclass class CarDocs: # make + model + model years name: str # Example for Toyota Corolla MY20 # requirements: Lane Tracing Assist (LTA) and Dynamic Radar Cruise Control (DRCC) # US Market reference: "All", since all Corolla in the US come standard with LTA and DRCC # the simplest description of the requirements for the US market package: str # the minimum compatibility requirements for this model, regardless # of market. can be a package, trim, or list of features requirements: str | None = None video_link: str | None = None footnotes: list[Enum] = field(default_factory=list) min_steer_speed: float | None = None min_enable_speed: float | None = None auto_resume: bool | None = None # all the parts needed for the supported car car_parts: CarParts = field(default_factory=CarParts) def __post_init__(self): self.make, self.model, self.years = split_name(self.name) self.year_list = get_year_list(self.years) def init(self, CP: car.CarParams, all_footnotes: dict[Enum, int]): self.car_name = CP.carName self.car_fingerprint = CP.carFingerprint # longitudinal column op_long = "Stock" if CP.experimentalLongitudinalAvailable or CP.enableDsu: op_long = "openpilot available" if CP.enableDsu: self.footnotes.append(CommonFootnote.EXP_LONG_DSU) else: self.footnotes.append(CommonFootnote.EXP_LONG_AVAIL) elif CP.openpilotLongitudinalControl and not CP.enableDsu: op_long = "openpilot" # min steer & enable speed columns # TODO: set all the min steer speeds in carParams and remove this if self.min_steer_speed is not None: assert CP.minSteerSpeed < 0.5, f"{CP.carFingerprint}: Minimum steer speed set in both CarDocs and CarParams" else: self.min_steer_speed = CP.minSteerSpeed # TODO: set all the min enable speeds in carParams correctly and remove this if self.min_enable_speed is None: self.min_enable_speed = CP.minEnableSpeed if self.auto_resume is None: self.auto_resume = CP.autoResumeSng and self.min_enable_speed <= 0 # hardware column hardware_col = "None" if self.car_parts.parts: model_years = self.model + (' ' + self.years if self.years else '') buy_link = f'Buy Here' tools_docs = [part for part in self.car_parts.all_parts() if isinstance(part, Tool)] parts_docs = [part for part in self.car_parts.all_parts() if not isinstance(part, Tool)] def display_func(parts): return '
'.join([f"- {parts.count(part)} {part.value.name}" for part in sorted(set(parts), key=lambda part: str(part.value.name))]) hardware_col = f'
Parts{display_func(parts_docs)}
{buy_link}
' if len(tools_docs): hardware_col += f'
Tools{display_func(tools_docs)}
' self.row: dict[Enum, str | Star] = { Column.MAKE: self.make, Column.MODEL: self.model, Column.PACKAGE: self.package, Column.LONGITUDINAL: op_long, Column.FSR_LONGITUDINAL: f"{max(self.min_enable_speed * CV.MS_TO_MPH, 0):.0f} mph", Column.FSR_STEERING: f"{max(self.min_steer_speed * CV.MS_TO_MPH, 0):.0f} mph", Column.STEERING_TORQUE: Star.EMPTY, Column.AUTO_RESUME: Star.FULL if self.auto_resume else Star.EMPTY, Column.HARDWARE: hardware_col, Column.VIDEO: self.video_link if self.video_link is not None else "", # replaced with an image and link from template in get_column } # Set steering torque star from max lateral acceleration assert CP.maxLateralAccel > 0.1 if CP.maxLateralAccel >= GOOD_TORQUE_THRESHOLD: self.row[Column.STEERING_TORQUE] = Star.FULL self.all_footnotes = all_footnotes self.detail_sentence = self.get_detail_sentence(CP) return self def init_make(self, CP: car.CarParams): """CarDocs subclasses can add make-specific logic for harness selection, footnotes, etc.""" def get_detail_sentence(self, CP): if not CP.notCar: sentence_builder = "openpilot upgrades your {car_model} with automated lane centering{alc} and adaptive cruise control{acc}." if self.min_steer_speed > self.min_enable_speed: alc = f" above {self.min_steer_speed * CV.MS_TO_MPH:.0f} mph," if self.min_steer_speed > 0 else " at all speeds," else: alc = "" # Exception for cars which do not auto-resume yet acc = "" if self.min_enable_speed > 0: acc = f" while driving above {self.min_enable_speed * CV.MS_TO_MPH:.0f} mph" elif self.auto_resume: acc = " that automatically resumes from a stop" if self.row[Column.STEERING_TORQUE] != Star.FULL: sentence_builder += " This car may not be able to take tight turns on its own." # experimental mode exp_link = "Experimental mode" if CP.openpilotLongitudinalControl and not CP.experimentalLongitudinalAvailable: sentence_builder += f" Traffic light and stop sign handling is also available in {exp_link}." return sentence_builder.format(car_model=f"{self.make} {self.model}", alc=alc, acc=acc) else: if CP.carFingerprint == "COMMA_BODY": return "The body is a robotics dev kit that can run openpilot. Learn more." else: raise Exception(f"This notCar does not have a detail sentence: {CP.carFingerprint}") def get_column(self, column: Column, star_icon: str, video_icon: str, footnote_tag: str) -> str: item: str | Star = self.row[column] if isinstance(item, Star): item = star_icon.format(item.value) elif column == Column.MODEL and len(self.years): item += f" {self.years}" elif column == Column.VIDEO and len(item) > 0: item = video_icon.format(item) footnotes = get_footnotes(self.footnotes, column) if len(footnotes): sups = sorted([self.all_footnotes[fn] for fn in footnotes]) item += footnote_tag.format(f'{",".join(map(str, sups))}') return item