diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 457b961e94a..bdf14a84585 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -256,7 +256,7 @@ These settings describe the car, harness, and device hardware configuration. Do | Group | Parameters | Purpose | |---|---|---| | Hyundai/Kia | `HyundaiCameraSCC`, `IsLdwsCar`, `HapticFeedbackWhenSpeedCamera` | SCC connection, LDWS behavior, and speed-event haptics | -| CAN FD/HDA | `CanfdHDA2`, `HyundaiCanfdClusterDirectTx`, `CanfdDebug`, `HDPuse` | HDA2 selection, cluster direct send, CAN FD diagnostics, and HDP | +| CAN FD/HDA | `CanfdHDA2`, `HyundaiCanfdClusterDirectTx`, `CanfdDebug`, `HDPuse`, `ClusterLaneTest`, `ClusterLaneTestValue` | HDA2 selection, cluster direct send, CAN FD diagnostics, HDP, and a cluster display test | | Radar | `EnableRadarTracks`, `RadarTrackFlip`, `EnableCornerRadar`, `CarrotRadarMode`, `CarrotRadarCutInSensitivity` | SCC radar, front-track orientation, corner radar, and Carrot Radar processing and cut-in sensitivity | | Driver monitoring | `DriverMonitoringEnabled` (search only), `DriverMonitoringMode`, `CarrotVisionEnabled`, `MuteDoor`, `MuteSeatbelt` | Driver monitoring and selected vehicle alerts | | Vehicle assistance | `MaxAngleFrames`, `SpeedFromPCM` | Steering-angle frames and stock-SCC speed control | @@ -296,7 +296,7 @@ In modes `EnableRadarTracks=1`–`3`, a confirmed departing front lead can recei ## Display -Display contains 34 settings. External-HUD settings control the layout and output of separate display hardware. +Display contains 35 settings. External-HUD settings control the layout and output of separate display hardware. `ClusterHud` (External HUD Display) switches a **direct USB HUD or a registered ESP32-S3 Wi-Fi HUD** on or off. A HUD connected to Jetson starts automatically even when this value is `0`, and normally turns its display off with ignition. Use the existing always-on `ClusterHudDebug` modes to keep it visible offroad. Brightness and layout settings also apply to the Jetson HUD. @@ -313,7 +313,7 @@ With `CarrotVisionEnabled` on, the external HUD and web camera view can be used |---|---|---| | Information | `ShowDebugUI`, `ShowTpms`, `ShowDateTime`, `ShowPathEnd`, `ShowDeviceState`, `ShowLaneInfo`, `ShowRadarInfo`, `ShowRouteInfo`, `ShowPlotMode` | Debug, tire, time, lane, radar, and route information | | Path | `ShowPathMode`, `ShowPathColor`, `ShowPathColorCruiseOff`, `ShowPathModeLane`, `ShowPathColorLane` | Path shape and color by driving state | -| Brightness/on-road view | `ShowCustomBrightness`, `ShowModelView`, `ShowCameraWithCluster` | Brightness, camera/model composition, and the on-device camera while the external HUD is connected | +| Brightness/on-road view | `ShowCustomBrightness`, `ScreenOn`, `ShowModelView`, `ShowCameraWithCluster` | Brightness, C4 screen on/off, camera/model composition, and the on-device camera while the external HUD is connected | | External HUD | `ClusterHud`, `ClusterHudBrightness`, `ClusterHudOrientation`, and related `ClusterHud*` settings | Supported TURZX HUD layout, live brightness, screen rotation, camera, radar, and encoder options | For RAV4 2022 with the ARS display stream, `ShowRadarInfo=3` on the standard device diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index a6f4a8ae4ea..e0a0ec67fbc 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -260,7 +260,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 중분류 | 파라미터 | 용도 | |---|---|---| | 현대·기아 | `HyundaiCameraSCC`, `IsLdwsCar`, `HapticFeedbackWhenSpeedCamera` | SCC 연결 방식, LDWS 차량과 카메라 구간 햅틱 | -| CANFD·HDA | `CanfdHDA2`, `HyundaiCanfdClusterDirectTx`, `CanfdDebug`, `HDPuse` | HDA2 차량, 계기판 직접 송신과 CAN FD 디버그·HDP 기능 | +| CANFD·HDA | `CanfdHDA2`, `HyundaiCanfdClusterDirectTx`, `CanfdDebug`, `HDPuse`, `ClusterLaneTest`, `ClusterLaneTestValue` | HDA2 차량, 계기판 직접 송신과 CAN FD 디버그·HDP 기능, 계기판 표시 테스트 | | 레이더 | `EnableRadarTracks`, `RadarTrackFlip`, `EnableCornerRadar`, `CarrotRadarMode`, `CarrotRadarCutInSensitivity` | SCC 레이더, 전방 트랙 좌우 보정, 코너 레이더와 당근레이더 처리·컷인 감도 | | 운전자 모니터링 | `DriverMonitoringEnabled`(검색 전용), `DriverMonitoringMode`, `CarrotVisionEnabled`, `MuteDoor`, `MuteSeatbelt` | 운전자 모니터링과 일부 차량 경고음 처리 | | 차량 보조 | `MaxAngleFrames`, `SpeedFromPCM` | 최대 조향각 관련 프레임과 순정 SCC 속도 제어 방식 | @@ -295,7 +295,7 @@ Bosch MRRevo14F 수신을 지원하는 버전에서 Bosch MRRevo14F 후보 차 ## 화면 표시 -화면 표시에는 34개 항목이 있습니다. 외부 HUD 항목은 별도 하드웨어의 화면 구성과 출력 방식을 조정합니다. +화면 표시에는 35개 항목이 있습니다. 외부 HUD 항목은 별도 하드웨어의 화면 구성과 출력 방식을 조정합니다. `ClusterHud`(외부 HUD 활성화)는 **본체 USB에 직접 연결한 HUD 또는 등록된 ESP32-S3 Wi-Fi HUD**를 켜고 끄는 설정입니다. Jetson에 연결한 HUD는 이 값이 `0`이어도 자동 실행되며, 기본적으로 시동 OFF 시 화면을 끕니다. 시동 OFF에도 표시하려면 기존 `ClusterHudDebug`의 항상 켜기를 사용합니다. 밝기·화면 구성 설정은 Jetson HUD에도 적용됩니다. @@ -312,7 +312,7 @@ Jetson HUD에는 `jetSON` 아래에 Jetson 내부 센서의 최고 온도가 표 |---|---|---| | 정보 표시 | `ShowDebugUI`, `ShowTpms`, `ShowDateTime`, `ShowPathEnd`, `ShowDeviceState`, `ShowLaneInfo`, `ShowRadarInfo`, `ShowRouteInfo`, `ShowPlotMode` | 주행 화면의 디버그, 타이어, 시간, 차선, 레이더와 경로 정보 | | 경로 표시 | `ShowPathMode`, `ShowPathColor`, `ShowPathColorCruiseOff`, `ShowPathModeLane`, `ShowPathColorLane` | 레인리스·레인모드·크루즈 OFF 상태의 경로 모양과 색상 | -| 밝기·주행화면 | `ShowCustomBrightness`, `ShowModelView`, `ShowCameraWithCluster` | 주행 중 밝기, 카메라·모델 표시 조합과 외부 HUD 연결 중 본체 카메라 표시 | +| 밝기·주행화면 | `ShowCustomBrightness`, `ScreenOn`, `ShowModelView`, `ShowCameraWithCluster` | 주행 중 밝기, C4 화면 켜기·끄기, 카메라·모델 표시 조합과 외부 HUD 연결 중 본체 카메라 표시 | | 외부 HUD·기본 | `ClusterHud`, `ClusterHudBrightness`, `ClusterHudOrientation`, `ClusterHudMirror`, `ClusterHudTheme`, `ClusterNaviMapTheme`, `ClusterNaviMapType`, `ClusterNaviMapFps` | TURZX 외부 HUD, 밝기, 화면 회전, 미러링과 지도 테마 | | 외부 HUD·화면·카메라 | `ClusterHudEncoder`, `ClusterHudScreenMode`, `ClusterHudPanelLayout`, `ClusterHudCameraViewMode` | 인코더와 화면·카메라·좌우 패널 구성 | | 외부 HUD·레이더 표시 | `ClusterHudRadarInfo`, `ClusterHudRadarDisplay`, `ClusterHudRadarSourceColor` | 외부 HUD의 레이더 정보와 색상 | diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index ae07d3d8b7b..a8e01cdaab9 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -864,6 +864,78 @@ def _hide_replaced_adas_service_warning(values): values["FAULT_DAS"] = 0 +LANE_LINE_UNSEEN = 14 # draws nothing on the Ioniq 5 PE cluster; undocumented on purpose, so a cluster update that changes it is noticed +# model laneLineProbs at low speed (10-18 rlogs, night): parking lots 0.01~0.04, city stops 0.13~0.22, highway ~1.0 +LANE_HIDE_PROB, LANE_SHOW_PROB, LANE_HIDE_FRAMES = 0.08, 0.12, 100 # hide after 1 s below 0.08, show again from 0.12 + + +def _lane_unseen(state, side, prob, frame): + hidden, low_since = state.get(side, (False, None)) + if prob >= LANE_SHOW_PROB: + hidden, low_since = False, None + elif prob < LANE_HIDE_PROB: + low_since = frame if low_since is None else low_since + hidden = hidden or frame - low_since >= LANE_HIDE_FRAMES + else: + low_since = None # in between: keep the current state + state[side] = (hidden, low_since) + return hidden + + +# ClusterLaneTest = field, ClusterLaneTestValue = raw value (test only): DBC fields, or 0x162 bits the DBC does not name +CLUSTER_TEST_FIELDS = {1: "SPEEDLIMIT", 2: "SPEEDLIMIT_FLASH", 3: "SIGNS", 4: "SPEEDLIMIT_WEATHER", 5: "COUNTRY"} # 0x162 +CLUSTER_TEST_FIELDS_161 = {6: "SLA_ICON", 12: "HDA_ICON", 13: "LFA_ICON", 14: "SETSPEED", 15: "SETSPEED_HUD", 16: "CAR_CIRCLE", + 17: "DISTANCE", 18: "DISTANCE_CAR", 19: "DISTANCE_SPACING", 20: "DISTANCE_LEAD", + 21: "ALERTS_4", 22: "ALERTS_5", 23: "BACKGROUND", 24: "LANE_HIGHLIGHT", 25: "LANE_ZOOM", 26: "CENTERLINE"} +CLUSTER_TEST_BITS = {7: tuple(range(53, 64)), 8: tuple(range(159, 163)), 9: tuple(range(183, 192)), 10: tuple(range(252, 256)), + 11: (87, 111, 135, 212)} + + +CLUSTER_TEST_SWEEP = -1 # ClusterLaneTestValue -1: step the field every 1 s and show the step in the set speed number + + +def _cluster_test(): + params = Params() + field = params.get_int("ClusterLaneTest") + return (field, params.get_int("ClusterLaneTestValue")) if field > 0 else (0, 0) + + +def _cluster_sweep(packer, field, frame): + # returns (value to send, number to show); bit groups step one bit at a time and show the bit number + state = create_ccnc_messages.__dict__.setdefault('_sweep', {}) + if state.get('field') != field: + state.update(field=field, start=frame) + step = (frame - state['start']) // 100 + if field in CLUSTER_TEST_BITS: + bits = CLUSTER_TEST_BITS[field] + i = step % (len(bits) + 1) + return (0, 0) if i == 0 else (1 << (i - 1), bits[i - 1]) + if field in CLUSTER_TEST_FIELDS: + sig = packer.dbc.name_to_msg['CCNC_0x162'].sigs[CLUSTER_TEST_FIELDS[field]] + elif field in CLUSTER_TEST_FIELDS_161: + sig = packer.dbc.name_to_msg['ADRV_0x161'].sigs[CLUSTER_TEST_FIELDS_161[field]] + else: + return 0, None + value = step % (1 << sig.size) + return value, value + + +def _set_raw_bits(msg, bits, value): + addr, dat, bus = msg + d = bytearray(dat) + for i, b in enumerate(bits): + d[b // 8] = (d[b // 8] & ~(1 << (b % 8))) | (((value >> i) & 1) << (b % 8)) + d[0:2] = hkg_can_fd_checksum(addr, None, d).to_bytes(2, 'little') + return addr, bytes(d), bus + + +def _dashed_lane_line(color, lane_line): + # 0x161 dashed codes seen on the Ioniq 5 PE cluster: 8 white, 10 green (no orange dashed) + if lane_line // 10 > 0 and lane_line % 10 in (0, 5): # camera: dashed / DLM inner dashed (color 0 = no lane info) + return {2: 8, 6: 10}.get(color, color) + return color + + def _select_cluster_background(cruise_enabled, lat_active, paddle_pressed, paddle_mode, turning=False): # Reuse the existing flashing-red background to identify an active model turn. if (lat_active and turning) or (paddle_mode > 0 and paddle_pressed): @@ -992,10 +1064,14 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if HDA_LFA_SymSta == 0 and 0 < frame % 200 < 12: values["LFA_BTN"] = 1 + # The cluster's side objects, lead car and target come from stock SCC MAIN; keep it on from ignition. + # openpilot's own MAIN still follows the driver's button (manual_main_off_latched). + if not CS.MainMode_ACC and not interlock_active and 10 < frame % 200 <= 16: + values["ADAPTIVE_CRUISE_MAIN_BTN"] = 1 + if CC.enabled and not interlock_active: if not CS.MainMode_ACC: - if 10 < frame % 200 <= 16 and CS.out.vEgo > 3.: - values["ADAPTIVE_CRUISE_MAIN_BTN"] = 1 + pass # pressed above elif CS.ACCMode in [0, 4]: if 10 < frame % 200 <= 16 and CS.out.vEgo > 3.: values["CRUISE_BUTTONS"] = 2 @@ -1012,11 +1088,19 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, # --- 0x161/0x200/0x1ea/0x162 (frame%5) --- if frame % 5 == 0: lat_active = CC.latActive + test_field, test_value = _cluster_test() + sweep_display = None + if test_value == CLUSTER_TEST_SWEEP: + test_value, sweep_display = _cluster_sweep(packer, test_field, frame) + else: + create_ccnc_messages.__dict__.pop('_sweep', None) if CS.adrv_0x161 is not None: main_enabled = CS.out.cruiseState.available cruise_enabled = CC.enabled lat_enabled = CS.out.latEnabled + # steering pauses at standstill; keep the cluster's steering/lane-change icons as if it were active + lat_icon = lat_active or (cruise_enabled and lat_enabled and CS.out.standstill) nav_active = hud_control.activeCarrot > 1 vehicle_navi_available = CS.out.vehicleNaviAvailable nav_icon_available = nav_active or vehicle_navi_available @@ -1038,12 +1122,12 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, set_speed_in_units = hud_control.setSpeed * (CV.MS_TO_KPH if CS.is_metric else CV.MS_TO_MPH) values["vSetDis"] = int(set_speed_in_units + 0.5) - values["DISTANCE"] = 4 if hdp_active else hud_control.leadDistanceBars - values["DISTANCE_LEAD"] = 2 if cruise_enabled and lead_visible else 1 if main_enabled and lead_visible else 0 + values["DISTANCE"] = hud_control.leadDistanceBars # real gap bars under the HDP colors too + values["DISTANCE_LEAD"] = (2 if cruise_enabled else 1) if lead_visible else 0 values["DISTANCE_CAR"] = 3 if hdp_active else 2 if cruise_enabled else 1 if main_enabled else 0 values["DISTANCE_SPACING"] = 5 if hdp_active else 1 if cruise_enabled else 0 - values["TARGET"] = 1 if lead_visible and cruise_enabled else 0 + values["TARGET"] = (1 if cruise_enabled else 3) if lead_visible else 0 # blue when engaged, white otherwise values["TARGET_DISTANCE"] = lead_distance values["BACKGROUND"] = _select_cluster_background( @@ -1054,8 +1138,8 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["NAV_ICON"] = 2 if nav_icon_available and cruise_enabled else 1 if main_enabled and nav_icon_available else 0 values["HDA_ICON"] = 5 if hdp_active else 2 if cruise_enabled else 1 if main_enabled else 0 - values["LFA_ICON"] = 5 if hdp_active else 2 if lat_active else 1 if lat_enabled else 0 - values["LKA_ICON"] = 4 if lat_active else 3 if lat_enabled else 0 + values["LFA_ICON"] = 5 if hdp_active else 2 if lat_icon else 1 if lat_enabled else 0 + values["LKA_ICON"] = 4 if lat_icon else 3 if lat_enabled else 0 values["FCA_ALT_ICON"] = 0 if values["ALERTS_2"] in [1, 2, 5, 6, 10, 21, 22]: @@ -1071,6 +1155,14 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["ALERTS_3"] = 0 values["SOUNDS_3"] = 0 + # carrot: with HDP on, a gap change shows the stock "distance N" popup (ALERTS_3 7~10) for 2 s + gap_bars = hud_control.leadDistanceBars + if hdp_active and gap_bars != getattr(create_ccnc_messages, '_gap_bars', gap_bars): + create_ccnc_messages._gap_popup_until = frame + 200 + create_ccnc_messages._gap_bars = gap_bars + if frame < getattr(create_ccnc_messages, '_gap_popup_until', 0) and values["ALERTS_3"] == 0 and 1 <= gap_bars <= 4: + values["ALERTS_3"] = 6 + gap_bars + if values["ALERTS_5"] in [1, 2, 3, 4, 5]: values["ALERTS_5"] = 0 @@ -1085,6 +1177,10 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["LANELINE_CURVATURE_DIRECTION"] = 1 if curvature < 0 and lat_active else 0 trailer_lane_change_blocked = CS.trailer_connected + lane_probs = md.laneLineProbs if md is not None and len(md.laneLineProbs) >= 3 else None + unseen = create_ccnc_messages.__dict__.setdefault('_lane_unseen', {}) + left_seen = hud_control.leftLaneVisible and not (lane_probs is not None and _lane_unseen(unseen, 'L', lane_probs[1], frame)) + right_seen = hud_control.rightLaneVisible and not (lane_probs is not None and _lane_unseen(unseen, 'R', lane_probs[2], frame)) if trailer_lane_change_blocked: values["LANELINE_LEFT"] = 2 if hud_control.leftLaneVisible else 0 values["LANELINE_RIGHT"] = 2 if hud_control.rightLaneVisible else 0 @@ -1098,7 +1194,7 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if hud_control.leftLaneDepart: values["LANELINE_LEFT"] = 4 if (frame // 50) % 2 == 0 else 1 else: - values["LANELINE_LEFT"] = lane_color if hud_control.leftLaneVisible else 0 + values["LANELINE_LEFT"] = _dashed_lane_line(lane_color, CS.out.leftLaneLine) if left_seen or lane_color == 4 else LANE_LINE_UNSEEN # orange stays lane_color = 6 if md is not None and md.meta.laneChangeAvailableRight else 2 if lane_line_check >= 1: @@ -1109,22 +1205,26 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if hud_control.rightLaneDepart: values["LANELINE_RIGHT"] = 4 if (frame // 50) % 2 == 0 else 1 else: - values["LANELINE_RIGHT"] = lane_color if hud_control.rightLaneVisible else 0 + values["LANELINE_RIGHT"] = _dashed_lane_line(lane_color, CS.out.rightLaneLine) if right_seen or lane_color == 4 else LANE_LINE_UNSEEN values["LCA_LEFT_ARROW"] = 2 if CS.out.leftBlinker else 0 values["LCA_RIGHT_ARROW"] = 2 if CS.out.rightBlinker else 0 if trailer_lane_change_blocked: - values["LCA_LEFT_ICON"] = 1 if lat_active else 0 - values["LCA_RIGHT_ICON"] = 1 if lat_active else 0 + values["LCA_LEFT_ICON"] = 1 if lat_icon else 0 + values["LCA_RIGHT_ICON"] = 1 if lat_icon else 0 else: - values["LCA_LEFT_ICON"] = (1 if CS.out.leftBlindspot else 2) if lat_active else 0 - values["LCA_RIGHT_ICON"] = (1 if CS.out.rightBlindspot else 2) if lat_active else 0 + values["LCA_LEFT_ICON"] = (1 if CS.out.leftBlindspot else 2) if lat_icon else 0 + values["LCA_RIGHT_ICON"] = (1 if CS.out.rightBlindspot else 2) if lat_icon else 0 values["LANE_LEFT"] = 0 if trailer_lane_change_blocked else 1 if desire in (1, 3) else 0 values["LANE_RIGHT"] = 0 if trailer_lane_change_blocked else 1 if desire in (2, 4) else 0 _apply_driver_monitoring_alert(values, hud_control, CS.adrv_0x161) + if test_field in CLUSTER_TEST_FIELDS_161: + values[CLUSTER_TEST_FIELDS_161[test_field]] = test_value + if sweep_display is not None: + values["vSetDis"] = min(sweep_display, 255) ret.append(packer.make_can_msg("ADRV_0x161", CAN.ECAN, values, rx_counter = rx_counter)) if CS.adrv_0x200 is not None: @@ -1165,7 +1265,10 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["FAULT_LSS"] = 0 values["FAULT_DAS"] = 0 - ret.append(packer.make_can_msg("CCNC_0x162", CAN.ECAN, values, rx_counter=rx_counter)) + if test_field in CLUSTER_TEST_FIELDS: + values[CLUSTER_TEST_FIELDS[test_field]] = test_value + msg = packer.make_can_msg("CCNC_0x162", CAN.ECAN, values, rx_counter=rx_counter) + ret.append(_set_raw_bits(msg, CLUSTER_TEST_BITS[test_field], test_value) if test_field in CLUSTER_TEST_BITS else msg) # --- NEW_MSG_4B9 (corner radar keep-alive?) --- if enable_corner_radar > 0: diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_ccnc_cluster.py b/opendbc_repo/opendbc/car/hyundai/tests/test_ccnc_cluster.py index 6696d7f49fe..589746ac711 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_ccnc_cluster.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_ccnc_cluster.py @@ -136,3 +136,155 @@ def test_cluster_objects_restore_corner_state_without_blinking_or_distance_clamp else: assert decoded["LANELINE_CURVATURE"] == 2 assert source == original + + +def test_hdp_gap_bars_and_popup(monkeypatch): + from openpilot.cereal import log + monkeypatch.setattr(hyundaicanfd, 'Params', lambda: SimpleNamespace( + get_int=lambda key: 0, get=lambda key: '2' if key == 'HDPuse' else '0')) + for attr in ('_gap_bars', '_gap_popup_until'): + monkeypatch.delattr(hyundaicanfd.create_ccnc_messages, attr, raising=False) + packer = CANPacker('hyundai_canfd_generated') + definition = packer.dbc.name_to_msg['ADRV_0x161'] + state = SimpleNamespace( + adrv_0x161={key: 0 for key in definition.sigs}, out=structs.CarState(), modelV2=log.ModelDataV2.new_message(), + lfahda_cluster=None, cruise_buttons_msg=None, adrv_0x200=None, adrv_0x1ea=None, ccnc_0x162=None, + is_metric=True, paddle_button_prev=0, softHoldActive=0, trailer_connected=False, + ) + control = structs.CarControl(latActive=True, enabled=True) + + def send(frame, bars): + control.hudControl.leadDistanceBars = bars + msg = hyundaicanfd.create_ccnc_messages( + SimpleNamespace(flags=HyundaiFlags.CAMERA_SCC), packer, SimpleNamespace(ECAN=0, CAM=2), frame, control, state, + control.hudControl, 0, False, False, 0, False, 0, 0)[0] + return {key: get_raw_value(msg[1], sig) for key, sig in definition.sigs.items()} + + first = send(5, 2) + assert (first['DISTANCE'], first['ALERTS_3'], first['HDA_ICON']) == (2, 0, 5) # HDP colors keep the real bars + assert send(10, 3)['ALERTS_3'] == 9 # DISTANCE_3 popup after a change + assert send(205, 3)['ALERTS_3'] == 9 + assert send(215, 3)['ALERTS_3'] == 0 # gone after 2 s + + +@pytest.mark.parametrize(('standstill', 'lka', 'lca'), ((True, 4, 2), (False, 3, 0))) +def test_engaged_standstill_keeps_steering_and_lane_change_icons(monkeypatch, standstill, lka, lca): + from openpilot.cereal import log + monkeypatch.setattr(hyundaicanfd, 'Params', lambda: SimpleNamespace(get_int=lambda key: 0, get=lambda key: '0')) + packer = CANPacker('hyundai_canfd_generated') + definition = packer.dbc.name_to_msg['ADRV_0x161'] + out = structs.CarState(standstill=standstill, latEnabled=True) + state = SimpleNamespace( + adrv_0x161={key: 0 for key in definition.sigs}, out=out, modelV2=log.ModelDataV2.new_message(), + lfahda_cluster=None, cruise_buttons_msg=None, adrv_0x200=None, adrv_0x1ea=None, ccnc_0x162=None, + is_metric=True, paddle_button_prev=0, softHoldActive=0, trailer_connected=False, + ) + control = structs.CarControl(latActive=False, enabled=True) # controlsd turns steering off at standstill + msg = hyundaicanfd.create_ccnc_messages( + SimpleNamespace(flags=HyundaiFlags.CAMERA_SCC), packer, SimpleNamespace(ECAN=0, CAM=2), 5, control, state, + control.hudControl, 0, False, False, 0, False, 0, 0)[0] + values = {key: get_raw_value(msg[1], sig) for key, sig in definition.sigs.items()} + assert (values['LKA_ICON'], values['LFA_ICON'] > 1, values['LCA_LEFT_ICON'], values['LCA_RIGHT_ICON']) == (lka, standstill, lca, lca) + + +@pytest.mark.parametrize(('color', 'lane_line', 'expected'), ( + (6, 10, 10), (2, 10, 8), (6, 15, 10), # white dashed / DLM inner dashed -> green or white dashed + (2, 11, 2), (4, 10, 4), (6, 0, 6), # solid line, orange warning, no lane info stay as they were +)) +def test_dashed_lane_line_codes(color, lane_line, expected): + assert hyundaicanfd._dashed_lane_line(color, lane_line) == expected + + +@pytest.mark.parametrize(('blindspot', 'left'), ((False, 14), (True, 4))) # orange (BSD) stays even when unseen +def test_unseen_lane_lines_send_14(monkeypatch, blindspot, left): + from openpilot.cereal import log + monkeypatch.setattr(hyundaicanfd, 'Params', lambda: SimpleNamespace(get_int=lambda key: 0, get=lambda key: '0')) + packer = CANPacker('hyundai_canfd_generated') + definition = packer.dbc.name_to_msg['ADRV_0x161'] + state = SimpleNamespace( + adrv_0x161={key: 0 for key in definition.sigs}, out=structs.CarState(leftBlindspot=blindspot), modelV2=log.ModelDataV2.new_message(), + lfahda_cluster=None, cruise_buttons_msg=None, adrv_0x200=None, adrv_0x1ea=None, ccnc_0x162=None, + is_metric=True, paddle_button_prev=0, softHoldActive=0, trailer_connected=False, + ) + control = structs.CarControl(latActive=True, enabled=True) + control.hudControl.rightLaneVisible = True + msg = hyundaicanfd.create_ccnc_messages( + SimpleNamespace(flags=HyundaiFlags.CAMERA_SCC), packer, SimpleNamespace(ECAN=0, CAM=2), 5, control, state, + control.hudControl, 0, False, False, 0, False, 0, 0)[0] + values = {key: get_raw_value(msg[1], sig) for key, sig in definition.sigs.items()} + assert (values['LANELINE_LEFT'], values['LANELINE_RIGHT']) == (left, 2) + + +def test_lane_unseen_hysteresis(): + state = {} + assert not hyundaicanfd._lane_unseen(state, 'L', 0.02, 0) + assert not hyundaicanfd._lane_unseen(state, 'L', 0.02, 95) # parking lot, under 1 s + assert hyundaicanfd._lane_unseen(state, 'L', 0.02, 100) # hidden after 1 s + assert hyundaicanfd._lane_unseen(state, 'L', 0.10, 120) # between thresholds keeps it hidden + assert not hyundaicanfd._lane_unseen(state, 'L', 0.13, 125) # city stop level shows it again + assert not hyundaicanfd._lane_unseen(state, 'L', 0.05, 130) + assert not hyundaicanfd._lane_unseen(state, 'L', 0.10, 200) # a dip that does not last 1 s resets + assert not hyundaicanfd._lane_unseen(state, 'L', 0.05, 205) + assert not hyundaicanfd._lane_unseen(state, 'R', 0.02, 205) # sides are independent + + +@pytest.mark.parametrize(('main_on', 'v_ego', 'pressed'), ((False, 5.0, 1), (True, 5.0, 0), (False, 0.0, 1))) +def test_stock_main_kept_on_without_engagement(monkeypatch, main_on, v_ego, pressed): + from openpilot.cereal import log + monkeypatch.setattr(hyundaicanfd, 'Params', lambda: SimpleNamespace(get_int=lambda key: 0, get=lambda key: '0')) + packer = CANPacker('hyundai_canfd_generated') + buttons = packer.dbc.name_to_msg['CRUISE_BUTTONS'] + state = SimpleNamespace( + adrv_0x161=None, out=structs.CarState(vEgo=v_ego), modelV2=log.ModelDataV2.new_message(), lfahda_cluster=None, + cruise_buttons_msg={key: 0 for key in buttons.sigs}, cruise_btns_msg_canfd='CRUISE_BUTTONS', MainMode_ACC=main_on, + ACCMode=0, scc_control=None, adrv_0x200=None, adrv_0x1ea=None, ccnc_0x162=None, is_metric=True, + paddle_button_prev=0, softHoldActive=0, trailer_connected=False, + ) + control = structs.CarControl(enabled=False) # not engaged: stock MAIN is still kept on for the cluster + msgs = hyundaicanfd.create_ccnc_messages( + SimpleNamespace(flags=HyundaiFlags.CAMERA_SCC), packer, SimpleNamespace(ECAN=0, CAM=2), 12, control, state, + control.hudControl, 0, False, False, 0, False, 0, 0) + msg = next(m for m in msgs if m[0] == buttons.address) + assert get_raw_value(msg[1], buttons.sigs['ADAPTIVE_CRUISE_MAIN_BTN']) == pressed + + +def test_cluster_test_raw_bits_keep_a_valid_checksum(): + packer = CANPacker('hyundai_canfd_generated') + definition = packer.dbc.name_to_msg['CCNC_0x162'] + msg = packer.make_can_msg('CCNC_0x162', 0, {'SPEEDLIMIT': 80}) + addr, dat, _ = hyundaicanfd._set_raw_bits(msg, hyundaicanfd.CLUSTER_TEST_BITS[7], 0b101) + value = int.from_bytes(dat, 'little') + assert [(value >> b) & 1 for b in range(53, 64)] == [1, 0, 1] + [0] * 8 + assert get_raw_value(dat, definition.sigs['SPEEDLIMIT']) == 80 # named fields untouched + assert get_raw_value(dat, definition.sigs['CHECKSUM']) == hyundaicanfd.hkg_can_fd_checksum(addr, None, bytearray(dat)) + + +@pytest.mark.parametrize(('slot', 'field', 'value'), ((21, 'ALERTS_4', 40), (17, 'DISTANCE', 2), (12, 'HDA_ICON', 7))) +def test_cluster_test_overrides_0x161_fields_last(monkeypatch, slot, field, value): + from openpilot.cereal import log + monkeypatch.setattr(hyundaicanfd, 'Params', lambda: SimpleNamespace( + get_int=lambda key: {'ClusterLaneTest': slot, 'ClusterLaneTestValue': value}.get(key, 0), + get=lambda key: '2' if key == 'HDPuse' else '0')) + packer = CANPacker('hyundai_canfd_generated') + definition = packer.dbc.name_to_msg['ADRV_0x161'] + state = SimpleNamespace( + adrv_0x161={key: 0 for key in definition.sigs}, out=structs.CarState(), modelV2=log.ModelDataV2.new_message(), + lfahda_cluster=None, cruise_buttons_msg=None, adrv_0x200=None, adrv_0x1ea=None, ccnc_0x162=None, + is_metric=True, paddle_button_prev=0, softHoldActive=0, trailer_connected=False, + ) + control = structs.CarControl(latActive=True, enabled=True) + control.hudControl.leadDistanceBars = 3 + msg = hyundaicanfd.create_ccnc_messages( + SimpleNamespace(flags=HyundaiFlags.CAMERA_SCC), packer, SimpleNamespace(ECAN=0, CAM=2), 5, control, state, + control.hudControl, 0, False, False, 0, False, 0, 0)[0] + assert get_raw_value(msg[1], definition.sigs[field]) == value + + +def test_cluster_sweep_steps_every_second(monkeypatch): + monkeypatch.delattr(hyundaicanfd.create_ccnc_messages, '_sweep', raising=False) + packer = CANPacker('hyundai_canfd_generated') + assert [hyundaicanfd._cluster_sweep(packer, 3, f) for f in (1000, 1095, 1100, 1200)] == [(0, 0), (0, 0), (1, 1), (2, 2)] + assert hyundaicanfd._cluster_sweep(packer, 3, 1000 + 256 * 100) == (0, 0) # SIGNS is 8 bits: wraps after 255 + assert hyundaicanfd._cluster_sweep(packer, 7, 2000) == (0, 0) # new field restarts at 0 + assert hyundaicanfd._cluster_sweep(packer, 7, 2100) == (1, 53) # first unnamed bit, shown as its bit number + assert hyundaicanfd._cluster_sweep(packer, 7, 2000 + 11 * 100) == (1 << 10, 63) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 6275f271c5c..cc1fb3a09a5 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -192,6 +192,7 @@ inline static std::unordered_map keys = { {"TimezoneSource", {PERSISTENT, STRING, ""}}, // 타임존 출처 우선순위 추적 ("app" > "wifi" > "gps") {"CarrotTireTrajectory", {PERSISTENT, INT, "0"}}, {"ShowCustomBrightness", {PERSISTENT, INT, "100"}}, + {"ScreenOn", {PERSISTENT, BOOL, "1"}}, {"ShowModelView", {PERSISTENT, INT, "0"}}, {"ShowCameraWithCluster", {PERSISTENT, INT, "0"}}, {"ClusterHud", {PERSISTENT, INT, "0"}}, @@ -214,6 +215,8 @@ inline static std::unordered_map keys = { {"ClusterHudRadarSourceColor", {PERSISTENT, INT, "0"}}, {"RecordRoadCam", {PERSISTENT, INT, "0"}}, {"HDPuse", {PERSISTENT, INT, "0"}}, + {"ClusterLaneTest", {CLEAR_ON_MANAGER_START, INT, "0"}}, + {"ClusterLaneTestValue", {CLEAR_ON_MANAGER_START, INT, "0"}}, {"AutoCruiseControl", {PERSISTENT, INT, "0"}}, {"SoftHoldOnCancel", {PERSISTENT, BOOL, "0"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index 5ab512ff4e4..7caf8ec7978 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -484,6 +484,7 @@ def test_cluster_camera_preference_is_in_brightness_and_view(settings, params): brightness = next(group for group in display["groups"] if group["id"] == "DISP_BRIGHT") assert brightness["params"] == [ "ShowCustomBrightness", + "ScreenOn", "ShowModelView", "ShowCameraWithCluster", ] diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 6d180854d7c..4934567c84f 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -418,7 +418,9 @@ "CanfdHDA2", "HyundaiCanfdClusterDirectTx", "CanfdDebug", - "HDPuse" + "HDPuse", + "ClusterLaneTest", + "ClusterLaneTestValue" ] }, { @@ -510,6 +512,7 @@ "zh": "亮度与视图", "params": [ "ShowCustomBrightness", + "ScreenOn", "ShowModelView", "ShowCameraWithCluster" ] @@ -1856,6 +1859,38 @@ "default": 0, "unit": 1 }, + { + "group": "시작", + "name": "ClusterLaneTest", + "title": "계기판 표시 테스트: 칸(CCNC)", + "descr": "테스트용. 0: 끔. 바꿀 칸 번호를 고르고, 보낼 값은 「계기판 표시 테스트: 값」에서 정합니다. 재부팅하면 0.\n1 속도제한 숫자 · 2 제한표시 색/깜빡임 · 3 표지판 종류 · 4 날씨 보조 · 5 국가 · 6 SLA 아이콘 · 7~11 0x162 이름 없는 비트(7: 53~63, 8: 159~162, 9: 183~191, 10: 252~255, 11: 87/111/135/212) · 12 HDA아이콘 · 13 LFA아이콘 · 14 설정속도 색 · 15 설정속도HUD · 16 차 원 · 17 차간 DISTANCE · 18 차간 차 · 19 차간 간격 · 20 앞차 · 21 ALERTS_4 문구 · 22 ALERTS_5 문구 · 23 배경 · 24 차로강조 · 25 차로줌 · 26 중앙선\n표시만 바뀌고 제어 영향 없음.", + "egroup": "START", + "etitle": "Cluster Display Test: Field (CCNC)", + "edescr": "Test only. 0: off. Pick the field; the value comes from \"Cluster Display Test: Value\". Resets to 0 on reboot.\n1 speed limit · 2 limit color/flash · 3 sign type · 4 weather · 5 country · 6 SLA icon · 7-11 unnamed 0x162 bits (7: 53-63, 8: 159-162, 9: 183-191, 10: 252-255, 11: 87/111/135/212) · 12 HDA icon · 13 LFA icon · 14 set speed color · 15 set speed HUD · 16 car circle · 17 gap DISTANCE · 18 gap car · 19 gap spacing · 20 lead · 21 ALERTS_4 text · 22 ALERTS_5 text · 23 background · 24 lane highlight · 25 lane zoom · 26 center line\nDisplay only; no effect on control.", + "cgroup": "启动设置", + "ctitle": "仪表显示测试: 字段 (CCNC)", + "cdescr": "仅用于测试。0: 关闭。选择字段,发送值在「仪表显示测试: 值」中设定。重启后恢复为 0。\n1 限速数字 · 2 限速显示颜色/闪烁 · 3 标志类型 · 4 天气 · 5 国家 · 6 SLA 图标 · 7~11 0x162 未命名位(7: 53~63, 8: 159~162, 9: 183~191, 10: 252~255, 11: 87/111/135/212) · 12 HDA图标 · 13 LFA图标 · 14 设定速度颜色 · 15 设定速度HUD · 16 车圈 · 17 车距DISTANCE · 18 车距车 · 19 车距间隔 · 20 前车 · 21 ALERTS_4 文字 · 22 ALERTS_5 文字 · 23 背景 · 24 车道高亮 · 25 车道缩放 · 26 中心线\n仅改变显示,不影响控制。", + "min": 0, + "max": 26, + "default": 0, + "unit": 1 + }, + { + "group": "시작", + "name": "ClusterLaneTestValue", + "title": "계기판 표시 테스트: 값(CCNC)", + "descr": "「계기판 표시 테스트: 칸」에 보낼 값. 칸의 비트 수를 넘는 부분은 잘립니다(표지판 0~255, 4비트 칸 0~15 등).\n-1: 자동 순환 — 1초마다 0부터 끝까지 올리고 현재 값을 계기판 설정속도 숫자에 표시(7~11 비트 칸은 한 비트씩, 비트 번호 표시). 재부팅하면 0.", + "egroup": "START", + "etitle": "Cluster Display Test: Value (CCNC)", + "edescr": "Value sent to the field chosen in \"Cluster Display Test: Field\". Bits beyond the field width are dropped (sign 0-255, 4-bit fields 0-15, etc.).\n-1: sweep, stepping from 0 to the field's max every 1 s and showing it in the set speed number (bit fields 7-11 step one bit at a time and show the bit number). Resets to 0 on reboot.", + "cgroup": "启动设置", + "ctitle": "仪表显示测试: 值 (CCNC)", + "cdescr": "发送到「仪表显示测试: 字段」的值。超出字段位数的部分被截断(标志 0~255,4 位字段 0~15 等)。\n-1: 自动循环 — 每 1 秒从 0 递增到最大值,并在设定速度数字处显示当前值(7~11 位字段逐位切换并显示位号)。重启后恢复为 0。", + "min": -1, + "max": 2047, + "default": 0, + "unit": 1 + }, { "group": "시작", "name": "MapboxStyle", @@ -3913,6 +3948,23 @@ "unit": 5, "display_unit": "percent" }, + { + "group": "화면", + "name": "ScreenOn", + "title": "화면 켜기(C4)", + "descr": "기본 켜짐. 끄면 C4 화면과 백라이트를 끕니다(오프로드·주행 모두). 화면을 터치하면 5초간 켜지고, 주행 중 경고가 뜨면 경고가 떠 있는 동안 켜집니다. 경고음과 주행 기능은 그대로 동작합니다.\n다시 켜면 약 5초 안에 화면이 켜집니다. C3/C3X는 영향 없음.", + "egroup": "DISPLAY", + "etitle": "Screen On (C4)", + "edescr": "On by default. Turning it off switches the C4 screen and backlight off (offroad and onroad). A touch wakes the screen for 5 seconds, and it stays on while an onroad alert is shown. Alert sounds and driving functions keep working.\nTurning it back on brings the screen back within about 5 seconds. No effect on C3/C3X.", + "cgroup": "显示", + "ctitle": "开启屏幕(C4)", + "cdescr": "默认开启。关闭后关闭 C4 屏幕和背光(停车和行车时均如此)。触摸屏幕会点亮 5 秒,行车中出现警报时在警报显示期间点亮。提示音和驾驶功能照常工作。\n重新开启后约 5 秒内屏幕点亮。对 C3/C3X 无影响。", + "min": 0, + "max": 1, + "default": 1, + "unit": 1, + "control": "toggle" + }, { "group": "화면", "name": "ShowModelView", diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index b7fade7d46b..33cc8db980e 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -17,7 +17,9 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC -BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 +IS_MICI = HARDWARE.get_device_type() == "mici" +BACKLIGHT_OFFROAD = 65 if IS_MICI else 50 +SCREEN_OFF_TOUCH_WAKE_S = 5.0 class UIStatus(Enum): @@ -109,6 +111,7 @@ def _initialize(self): # Brightness and UI options self.show_brightness_ratio: float = 1.0 + self.screen_off: bool = False self.show_model_view: int = 0 self.share_data: bool = False self.show_camera_with_cluster: bool = False @@ -225,6 +228,7 @@ def update_params(self) -> None: self.show_date_time = self.params.get_int("ShowDateTime") self.show_radar_info = self.params.get_int("ShowRadarInfo") self.show_brightness_ratio: float = self.params.get_int("ShowCustomBrightness") / 100.0 + self.screen_off = IS_MICI and self.params.get("ScreenOn", return_default=True) is False # unset key keeps the screen on self.show_model_view = self.params.get_int("ShowModelView") self.share_data = self.params.get_bool("ShareData") self.show_camera_with_cluster = self.params.get_int("ShowCameraWithCluster") == 1 @@ -259,6 +263,7 @@ def __init__(self): self._brightness_filter = FirstOrderFilter(BACKLIGHT_OFFROAD, 2.00, 1 / gui_app.target_fps) self._brightness_thread: threading.Thread | None = None self._brightness_timer: int = 20 + self._screen_off_wake_until: float = 0.0 @property def awake(self) -> bool: @@ -340,7 +345,8 @@ def _update_wakefulness(self): ignition_just_turned_off = not ui_state.ignition and self._ignition self._ignition = ui_state.ignition - if ignition_just_turned_off or any(ev.left_down for ev in gui_app.mouse_events): + touched = any(ev.left_down for ev in gui_app.mouse_events) + if ignition_just_turned_off or touched: self._reset_interactive_timeout() self._brightness_timer = 20 @@ -350,7 +356,18 @@ def _update_wakefulness(self): callback() self._prev_timed_out = interaction_timeout - self._set_awake(ui_state.ignition or not interaction_timeout or PC) + if touched: + self._screen_off_wake_until = time.monotonic() + SCREEN_OFF_TOUCH_WAKE_S + screen_off = ui_state.screen_off and time.monotonic() > self._screen_off_wake_until and not self._alert_visible() + self._set_awake((ui_state.ignition or not interaction_timeout or PC) and not screen_off) + + @staticmethod + def _alert_visible() -> bool: + if not ui_state.started: + return False + sm = ui_state.sm + # a stale selfdriveState is drawn as a critical alert too + return time.monotonic() - sm.recv_time['selfdriveState'] > 1.0 or sm['selfdriveState'].alertSize != 0 def _set_awake(self, on: bool): if on != self._awake: diff --git a/tools/docs/wiki_settings/tests/test_ci_check.py b/tools/docs/wiki_settings/tests/test_ci_check.py index 1ba2fa27a03..15f0aaa985f 100644 --- a/tools/docs/wiki_settings/tests/test_ci_check.py +++ b/tools/docs/wiki_settings/tests/test_ci_check.py @@ -47,7 +47,7 @@ def test_read_only_check_writes_reports_without_touching_wiki(self): self.assertTrue((output / "summary.md").is_file()) self.assertTrue((output / "pages.diff").is_file()) stored = json.loads((output / "summary.json").read_text(encoding="utf-8")) - self.assertEqual(stored["result"]["settings"], 195) + self.assertEqual(stored["result"]["settings"], 199) self.assertEqual(stored["validationIssues"], []) self.assertIn("Settings-Catalog.md", (output / "pages.diff").read_text(encoding="utf-8")) diff --git a/tools/docs/wiki_settings/tests/test_generate.py b/tools/docs/wiki_settings/tests/test_generate.py index f0607c27ced..bde12d4227a 100644 --- a/tools/docs/wiki_settings/tests/test_generate.py +++ b/tools/docs/wiki_settings/tests/test_generate.py @@ -85,7 +85,7 @@ def test_current_catalog_generates_all_settings_and_valid_markdown(self): catalog_commit=COMMIT, generated_at=STAMP, ) - self.assertEqual(len(result.generated_settings), 196) + self.assertEqual(len(result.generated_settings), 199) self.assertIn("NNFF", result.index["settings"]) self.assertNotIn("SteerHandoverMode", result.index["settings"]) self.assertIn("SignalAssistMode", result.index["settings"]) @@ -98,15 +98,15 @@ def test_current_catalog_generates_all_settings_and_valid_markdown(self): for retired in ("ClusterHudPriority", "ClusterHudCoreMode", "ClusterHudLiveFps", "CanfdStopRetry", "DisableDM", "CarrotYouTubeLive", "CarrotYouTubeQuality", "CarrotYouTubeTimestamp"): self.assertNotIn(retired, result.index["settings"]) - self.assertEqual(result.index["review"], {"current": 0, "needs_review": 196}) + self.assertEqual(result.index["review"], {"current": 0, "needs_review": 199}) self.assertEqual(result.index["locales"], ["ko", "en", "zh"]) - self.assertEqual(len(result.pages), (196 * 3) + 2) + self.assertEqual(len(result.pages), (199 * 3) + 2) setting_pages = { name: text for name, text in result.pages.items() if GENERATOR.GENERATED_PAGE_RE.fullmatch(name) } - self.assertEqual(len(setting_pages), 196 * 3) + self.assertEqual(len(setting_pages), 199 * 3) self.assertTrue(all(text.count("