diff --git a/core/frontend/src/components/video-manager/VideoManager.vue b/core/frontend/src/components/video-manager/VideoManager.vue index c872ea3c7b..eb121b95ce 100644 --- a/core/frontend/src/components/video-manager/VideoManager.vue +++ b/core/frontend/src/components/video-manager/VideoManager.vue @@ -68,6 +68,26 @@ label="Raspberry legacy camera support" @change="toggleLegacyMode" /> + + + The Navigator external I²C port is moved to bus 8 in this mode. Current Navigator ArduPilot firmware + probes external sensors on bus 6, so those sensors will not be detected. + @@ -88,7 +108,8 @@ import Vue from 'vue' import SpinningLogo from '@/components/common/SpinningLogo.vue' import Notifier from '@/libs/notifier' import settings from '@/libs/settings' -import commander from '@/store/commander' +import commander, { Pi5I2CMode } from '@/store/commander' +import system_information from '@/store/system-information' import video from '@/store/video' import { commander_service } from '@/types/frontend_services' import { @@ -114,9 +135,14 @@ export default Vue.extend({ return { show_settings_dialog: false, legacy_mode: false, + pi5_mipi_camera_mode: false, + updating_pi5_i2c_mode: false, } }, computed: { + is_raspberry_pi_5(): boolean { + return system_information.platform?.raspberry?.model?.includes('Raspberry Pi 5') ?? false + }, are_video_devices_available(): boolean { return !this.video_devices.isEmpty() }, @@ -163,6 +189,9 @@ export default Vue.extend({ }, async mounted() { await this.updateCameraLegacy() + if (this.is_raspberry_pi_5) { + await this.updatePi5I2CMode() + } }, methods: { async updateCameraLegacy(): Promise { @@ -178,11 +207,29 @@ export default Vue.extend({ async toggleLegacyMode(): Promise { await this.setCameraLegacy(this.legacy_mode) }, + async updatePi5I2CMode(): Promise { + this.pi5_mipi_camera_mode = await commander.getPi5I2CMode() === Pi5I2CMode.MipiCamera + }, + async togglePi5MipiCameraMode(): Promise { + this.updating_pi5_i2c_mode = true + const mode = this.pi5_mipi_camera_mode ? Pi5I2CMode.MipiCamera : Pi5I2CMode.ExternalSensor + const updated = await commander.setPi5I2CMode(mode) + if (updated) { + const message = 'Reboot is required for the Raspberry Pi I²C/MIPI change to take effect.' + notifier.pushInfo('DO_PI5_I2C_MODE_REBOOT_REQUIRED', message, true) + } else { + await this.updatePi5I2CMode() + } + this.updating_pi5_i2c_mode = false + }, is_redirect_source(device: Device): boolean { return device.source === 'Redirect' }, - openSettingsDialog(): void { + async openSettingsDialog(): Promise { this.show_settings_dialog = true + if (this.is_raspberry_pi_5) { + await this.updatePi5I2CMode() + } }, resetSettings(): void { video.resetSettings() diff --git a/core/frontend/src/store/commander.ts b/core/frontend/src/store/commander.ts index ab5c505408..71a67b40c1 100644 --- a/core/frontend/src/store/commander.ts +++ b/core/frontend/src/store/commander.ts @@ -10,6 +10,11 @@ import back_axios, { isBackendOffline } from '@/utils/api' const notifier = new Notifier(commander_service) +export enum Pi5I2CMode { + ExternalSensor = 'external_sensor', + MipiCamera = 'mipi_camera', +} + @Module({ dynamic: true, store, @@ -150,6 +155,45 @@ class CommanderStore extends VuexModule { }) } + @Action + async getPi5I2CMode(): Promise { + return back_axios({ + method: 'get', + url: `${this.API_URL}/raspi_config/pi5_i2c_mode`, + timeout: 5000, + }) + .then((response) => response.data?.mode) + .catch((error) => { + if (isBackendOffline(error)) { + return undefined + } + const message = 'Could not get Raspberry Pi 5 I²C/MIPI mode:' + + ` ${error.response?.data?.detail ?? error.message}.` + notifier.pushError('COMMANDER_GET_PI5_I2C_MODE_FAIL', message, true) + return undefined + }) + } + + @Action + async setPi5I2CMode(mode: Pi5I2CMode): Promise { + return back_axios({ + method: 'post', + url: `${this.API_URL}/raspi_config/pi5_i2c_mode`, + timeout: 5000, + params: { mode }, + }) + .then(() => true) + .catch((error) => { + if (isBackendOffline(error)) { + return false + } + const message = 'Could not set Raspberry Pi 5 I²C/MIPI mode:' + + ` ${error.response?.data?.detail ?? error.message}.` + notifier.pushError('COMMANDER_SET_PI5_I2C_MODE_FAIL', message, true) + return false + }) + } + @Action async getVcgencmd(): Promise> { return back_axios({ diff --git a/core/services/commander/main.py b/core/services/commander/main.py index 044e37235c..98393e7984 100755 --- a/core/services/commander/main.py +++ b/core/services/commander/main.py @@ -11,8 +11,13 @@ import appdirs from commonwealth.utils.apis import GenericErrorHandlingRoute -from commonwealth.utils.commands import run_command -from commonwealth.utils.general import delete_everything, delete_everything_stream +from commonwealth.utils.commands import load_file, locate_file, run_command, save_file +from commonwealth.utils.general import ( + CpuType, + delete_everything, + delete_everything_stream, + get_cpu_type, +) from commonwealth.utils.logs import InterceptHandler, init_logger from commonwealth.utils.sentry_config import init_sentry_async from commonwealth.utils.streaming import streamer @@ -22,6 +27,12 @@ from fastapi_versioning import VersionedFastAPI, version from filebrowser.filebrowser import filebrowser from loguru import logger +from pi5_i2c import ( + Pi5I2CConfigurationError, + Pi5I2CMode, + get_pi5_i2c_mode, + set_pi5_i2c_mode, +) from uvicorn import Config, Server SERVICE_NAME = "commander" @@ -131,6 +142,63 @@ async def raspi_config_camera_legacy_set(enable: bool = True) -> Any: return output +def pi5_boot_config_file() -> str: + if get_cpu_type() != CpuType.PI5: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The I2C/MIPI mode is only available on Raspberry Pi 5.", + ) + config_file = locate_file(["/boot/firmware/config.txt", "/boot/config.txt"]) + if not config_file: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Raspberry Pi boot configuration was not found.", + ) + return config_file + + +@app.get("/raspi_config/pi5_i2c_mode", status_code=status.HTTP_200_OK) +@version(1, 0) +async def raspi_config_pi5_i2c_mode() -> Any: + try: + mode = get_pi5_i2c_mode(load_file(pi5_boot_config_file())) + except Pi5I2CConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(error) + ) from error + return {"mode": mode} + + +@app.post("/raspi_config/pi5_i2c_mode", status_code=status.HTTP_200_OK) +@version(1, 0) +async def raspi_config_pi5_i2c_mode_set(mode: Pi5I2CMode) -> Any: + config_file = pi5_boot_config_file() + config_content = load_file(config_file) + try: + updated_content = set_pi5_i2c_mode(config_content, mode) + except Pi5I2CConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(error) + ) from error + + reboot_required = updated_content != config_content + if reboot_required: + save_file(config_file, updated_content, "before_pi5_i2c_mode") + try: + saved_mode = get_pi5_i2c_mode(load_file(config_file)) + except Pi5I2CConfigurationError as error: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(error) + ) from error + if saved_mode != mode: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Raspberry Pi I2C/MIPI mode could not be saved.", + ) + + return {"mode": mode, "reboot_required": reboot_required} + + @app.get("/raspi/vcgencmd", status_code=status.HTTP_200_OK) @version(1, 0) async def vcgencmd(i_know_what_i_am_doing: bool = False) -> Any: diff --git a/core/services/commander/pi5_i2c.py b/core/services/commander/pi5_i2c.py new file mode 100644 index 0000000000..bc80fe3059 --- /dev/null +++ b/core/services/commander/pi5_i2c.py @@ -0,0 +1,91 @@ +import re +from enum import Enum + + +class Pi5I2CMode(str, Enum): + EXTERNAL_SENSOR = "external_sensor" + MIPI_CAMERA = "mipi_camera" + + +class Pi5I2CConfigurationError(ValueError): + pass + + +def _navigator_i2c_overlay(config_content: str) -> tuple[int, int]: + section = "all" + matches: list[tuple[int, int]] = [] + + for index, line in enumerate(config_content.splitlines()): + section_match = re.match(r"^\s*\[([^]]+)]", line) + if section_match: + section = section_match.group(1).strip().lower() + continue + if section != "pi5": + continue + + configuration = line.partition("#")[0].strip() + if not configuration.startswith("dtoverlay=i2c-gpio,"): + continue + + parameters = { + key.strip(): value.strip() + for parameter in configuration.split(",")[1:] + if "=" in parameter + for key, value in [parameter.split("=", maxsplit=1)] + } + if ( + parameters.get("i2c_gpio_sda") != "22" + or parameters.get("i2c_gpio_scl") != "23" + ): + continue + + try: + bus = int(parameters["bus"]) + except (KeyError, ValueError) as error: + raise Pi5I2CConfigurationError( + "Navigator I2C overlay has no valid bus number." + ) from error + matches.append((index, bus)) + + if not matches: + raise Pi5I2CConfigurationError( + "Navigator I2C overlay was not found in the [pi5] section." + ) + if len(matches) > 1: + raise Pi5I2CConfigurationError( + "Multiple Navigator I2C overlays were found in the [pi5] section." + ) + return matches[0] + + +def get_pi5_i2c_mode(config_content: str) -> Pi5I2CMode: + _, bus = _navigator_i2c_overlay(config_content) + if bus == 6: + return Pi5I2CMode.EXTERNAL_SENSOR + if bus == 8: + return Pi5I2CMode.MIPI_CAMERA + raise Pi5I2CConfigurationError(f"Navigator I2C overlay uses unsupported bus {bus}.") + + +def set_pi5_i2c_mode(config_content: str, mode: Pi5I2CMode) -> str: + line_index, _ = _navigator_i2c_overlay(config_content) + if mode == Pi5I2CMode.EXTERNAL_SENSOR: + bus = 6 + elif mode == Pi5I2CMode.MIPI_CAMERA: + bus = 8 + else: + raise Pi5I2CConfigurationError(f"Unsupported Raspberry Pi 5 I2C mode: {mode}.") + lines = config_content.splitlines(keepends=True) + configuration, comment_separator, comment = lines[line_index].partition("#") + updated_configuration, replacements = re.subn( + r"(?P(?:^|,)\s*bus\s*=\s*)\d+", + rf"\g{bus}", + configuration, + count=1, + ) + if replacements != 1: + raise Pi5I2CConfigurationError( + "Navigator I2C bus parameter could not be updated." + ) + lines[line_index] = updated_configuration + comment_separator + comment + return "".join(lines) diff --git a/core/services/commander/test_pi5_i2c.py b/core/services/commander/test_pi5_i2c.py new file mode 100644 index 0000000000..0f3ceaf6fd --- /dev/null +++ b/core/services/commander/test_pi5_i2c.py @@ -0,0 +1,78 @@ +import pytest +from pi5_i2c import ( + Pi5I2CConfigurationError, + Pi5I2CMode, + get_pi5_i2c_mode, + set_pi5_i2c_mode, +) + +SENSOR_CONFIGURATION = """[all] +dtparam=i2c_arm=on + +[pi5] +dtoverlay=i2c-gpio,i2c_gpio_sda=22,i2c_gpio_scl=23,bus=6,i2c_gpio_delay_us=0 +""" + + +def test_get_pi5_i2c_mode(): + assert get_pi5_i2c_mode(SENSOR_CONFIGURATION) == Pi5I2CMode.EXTERNAL_SENSOR + camera_configuration = SENSOR_CONFIGURATION.replace("bus=6", "bus=8") + assert get_pi5_i2c_mode(camera_configuration) == Pi5I2CMode.MIPI_CAMERA + + +def test_set_pi5_i2c_mode_preserves_other_configuration(): + protected_configuration = SENSOR_CONFIGURATION.replace( + "i2c_gpio_delay_us=0", "i2c_gpio_delay_us=0 # custom" + ) + + camera_configuration = set_pi5_i2c_mode( + protected_configuration, Pi5I2CMode.MIPI_CAMERA + ) + + assert "bus=8" in camera_configuration + assert "i2c_gpio_delay_us=0 # custom" in camera_configuration + assert camera_configuration.replace("bus=8", "bus=6") == protected_configuration + + +def test_set_pi5_i2c_mode_only_changes_pi5_section(): + configuration = SENSOR_CONFIGURATION.replace( + "[all]", + "[all]\ndtoverlay=i2c-gpio,i2c_gpio_sda=22,i2c_gpio_scl=23,bus=4,i2c_gpio_delay_us=0", + ) + + camera_configuration = set_pi5_i2c_mode(configuration, Pi5I2CMode.MIPI_CAMERA) + + assert "bus=4" in camera_configuration + assert camera_configuration.count("bus=8") == 1 + + +def test_set_pi5_i2c_mode_preserves_crlf_and_accepts_spaced_section(): + configuration = SENSOR_CONFIGURATION.replace("[pi5]", "[ pi5 ]").replace( + "\n", "\r\n" + ) + + camera_configuration = set_pi5_i2c_mode(configuration, Pi5I2CMode.MIPI_CAMERA) + + assert camera_configuration.count("\r\n") == configuration.count("\r\n") + assert "bus=8" in camera_configuration + + +def test_set_pi5_i2c_mode_rejects_unknown_mode(): + with pytest.raises(Pi5I2CConfigurationError): + set_pi5_i2c_mode(SENSOR_CONFIGURATION, "automatic") # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "configuration", + [ + "[pi5]\ndtoverlay=i2c1\n", + SENSOR_CONFIGURATION.replace(",bus=6", ""), + SENSOR_CONFIGURATION.replace("bus=6", "bus=7"), + SENSOR_CONFIGURATION + SENSOR_CONFIGURATION.split("[pi5]\n", maxsplit=1)[1], + ], +) +def test_get_pi5_i2c_mode_rejects_ambiguous_or_unsupported_configuration( + configuration: str, +): + with pytest.raises(Pi5I2CConfigurationError): + get_pi5_i2c_mode(configuration) diff --git a/install/boards/bcm_2712.sh b/install/boards/bcm_2712.sh index 0c505718fe..83f18c7e50 100644 --- a/install/boards/bcm_2712.sh +++ b/install/boards/bcm_2712.sh @@ -20,8 +20,19 @@ DTS_NAME="spi0-led" curl -fsSL -o /tmp/$DTS_NAME $DTS_PATH/$DTS_NAME.dts dtc -@ -Hepapr -I dts -O dtb -o /boot/overlays/$DTS_NAME.dtbo /tmp/$DTS_NAME +# Preserve the Pi 5 I2C/MIPI mode selected in Commander across BlueOS updates. +NAVIGATOR_I2C_BUS=6 +if grep -E '^[[:space:]]*dtoverlay=i2c-gpio,' "$CONFIG_FILE" \ + | grep -E '(^|,)[[:space:]]*i2c_gpio_sda[[:space:]]*=[[:space:]]*22([,[:space:]#]|$)' \ + | grep -E '(^|,)[[:space:]]*i2c_gpio_scl[[:space:]]*=[[:space:]]*23([,[:space:]#]|$)' \ + | grep -Eq '(^|,)[[:space:]]*bus[[:space:]]*=[[:space:]]*8([,[:space:]#]|$)'; then + NAVIGATOR_I2C_BUS=8 +fi + # Remove any configuration related to i2c and spi/spi1 and do the necessary changes for navigator echo "- Enable I2C, SPI and UART." +# ArduPilot expects Navigator external sensors on bus 6. Commander can move this software bus to 8 +# when the Pi 5 MIPI camera needs the hardware i2c6 controller. for STRING in \ "enable_uart=" \ "dtoverlay=uart" \ @@ -55,7 +66,7 @@ for STRING in \ "dtoverlay=i2c1" \ "dtoverlay=i2c3-pi5,baudrate=400000" \ "dtoverlay=i2c3-pi5.baudrate=400000" \ - "dtoverlay=i2c-gpio,i2c_gpio_sda=22,i2c_gpio_scl=23,bus=6,i2c_gpio_delay_us=0" \ + "dtoverlay=i2c-gpio,i2c_gpio_sda=22,i2c_gpio_scl=23,bus=$NAVIGATOR_I2C_BUS,i2c_gpio_delay_us=0" \ "dtparam=spi=on" \ "dtoverlay=spi0-led" \ "dtoverlay=spi1-3cs" \