diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d63a7c5..ee65954 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,69 +1,68 @@ -# Copyright (c) 2024, Nordic Semiconductor ASA -# SPDX-License-Identifier: Apache-2.0 - -name: Documentation +name: Build and deploy documentation on: - pull_request: push: - branches: - - main + branches: [main] + pull_request: + workflow_dispatch: permissions: contents: read +concurrency: + group: pages + cancel-in-progress: true + jobs: build: - name: Build - runs-on: ubuntu-22.04 - strategy: - matrix: - doxygen-version: [1.9.6, 1.14.0] + name: Build documentation + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: persist-credentials: false - - name: Install dependencies - run: | - DOXYGEN_VERSION="${{ matrix.doxygen-version }}" - wget --no-verbose "https://github.com/doxygen/doxygen/releases/download/Release_${DOXYGEN_VERSION//./_}/doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz" - tar xf doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz - echo "${PWD}/doxygen-${DOXYGEN_VERSION}/bin" >> $GITHUB_PATH - pip install -r doc/requirements.txt + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: doc/requirements.txt - - name: Build + - name: Install system dependencies run: | - cd doc - doxygen + sudo apt-get update + sudo apt-get install --no-install-recommends --yes doxygen - SPHINXOPTS="-W" make html + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r doc/requirements.txt - mkdir deploy - mv _build_doxygen/html deploy/doxygen - mv _build_sphinx/html/* deploy + - name: Build Sphinx HTML + run: python -m sphinx -b html doc doc/_build_sphinx/html - - name: Setup pages - if: github.event_name != 'pull_request' && matrix.doxygen-version == '1.14.0' + - name: Configure GitHub Pages + if: github.event_name != 'pull_request' uses: actions/configure-pages@v5 - - name: Upload pages artifact - if: github.event_name != 'pull_request' && matrix.doxygen-version == '1.14.0' - uses: actions/upload-pages-artifact@v4 + - name: Upload Pages artifact + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v5 with: - path: doc/deploy + path: doc/_build_sphinx/html - - name: Upload artifacts + - name: Upload PR documentation artifact if: github.event_name == 'pull_request' uses: actions/upload-artifact@v4 with: - name: docs-doxygen-${{ matrix.doxygen-version }} - path: doc/deploy + name: documentation-html + path: doc/_build_sphinx/html deploy: - name: Deploy - runs-on: ubuntu-22.04 + name: Deploy documentation + runs-on: ubuntu-latest needs: build if: github.event_name != 'pull_request' permissions: diff --git a/.gitignore b/.gitignore index df35625..321c02d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ __pycache__/ # docs /doc/_build* /app/build/ +/doc/_doxygen/ diff --git a/README.md b/README.md index fb7ddf2..120ed8d 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ Default command responses use: cmd/hsfib-tib/resp/ ``` -MQTT 5 `response_topic` and `correlation_data` are honored when they fit the -fixed command buffers. +MQTT 5 `response_topic` is honored when it fits the fixed topic buffer. +`correlation_data` is echoed when it is 16 bytes or less. Serial commands share the same normalized command path. A bare serial key is a GET; a key with payload is a SET. See: @@ -68,7 +68,9 @@ GET; a key with payload is a SET. See: ## Runtime Shape - `main.c`: boot order, watchdog feed, network/MQTT loop, outbound publish. -- `command.c`: MQTT/serial ingress, serial guard, dispatch, response queues. +- `command.c`: app command queues, serial guard policy, command table, and + command handlers, using `lib/coo_commons/command_dispatch.c` for reusable + MQTT/serial request and response mechanics. - `devices.c`: board strap detection and board-profile setup. - `mems_switching.c`: MEMS switch state, routes, and toggler work. - `attenuator.c`: DAC-backed logical attenuator control and calibration. @@ -76,8 +78,8 @@ GET; a key with payload is a SET. See: - `photodiode.c`: ADS1115 sampling, dark calibration, noise, telemetry. - `tempsense.c`: DS18B20 ambient temperature cache. - `sntp_sync.c`: SNTP sync and time status. -- `app_settings.c`: Zephyr settings-backed app state. -- `app_warning.c`: best-effort warning publication. +- `app_settings.c`: app-owned runtime settings and direct Zephyr NVS persistence. +- `app_identity.c`: selected board-profile MQTT device ID. Architecture pages live in `doc/architecture.md`, `doc/threads.md`, and `doc/queues_and_work.md`. diff --git a/agents.md b/agents.md index f8946e8..dca8252 100644 --- a/agents.md +++ b/agents.md @@ -105,6 +105,11 @@ Build discipline: - newly introduced non-blocking warnings, - tests or static checks run. +Workspace metadata discipline: + +- Do not edit files under `.idea/`. Those files are developer IDE state, not + firmware source or documentation. + --- ## 4. Coding Style and Architecture @@ -406,4 +411,4 @@ Before finalizing, self-review: - Did this add blocking behavior to a timing-sensitive path? - Did this preserve best-effort warnings and telemetry? - Did this require a west build? -- Were build steps sequential? \ No newline at end of file +- Were build steps sequential? diff --git a/app/.idea/pySourceRootDetection.xml b/app/.idea/pySourceRootDetection.xml new file mode 100644 index 0000000..ff55834 --- /dev/null +++ b/app/.idea/pySourceRootDetection.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/vcs.xml b/app/.idea/vcs.xml index 80f298a..740a25e 100644 --- a/app/.idea/vcs.xml +++ b/app/.idea/vcs.xml @@ -64,6 +64,7 @@ + diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 85685d5..394f583 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -18,30 +18,26 @@ project(app LANGUAGES C) target_sources(app PRIVATE src/main.c - src/app_scheduled_actions.c + src/app_identity.c src/app_settings.c - src/app_warning.c src/attenuator.c + src/attenuator_calibration.c + src/attenuator_command.c src/command.c src/devices.c + src/housekeeping.c + src/laser_command.c src/lasers.c + src/laserbank_tempcontrol.c src/maiman.c + src/mems_command.c src/photodiode.c - src/tempsense.c - src/tempsense.h + src/photodiode_command.c + src/throughput_command.c + src/throughput_monitor.c src/mems_switching.c +) + +target_sources_ifdef(CONFIG_SNTP app PRIVATE src/sntp_sync.c - src/attenuator.h - src/app_scheduled_actions.h - src/app_settings.h - src/app_warning.h - src/app_identity.h - src/command.h - src/devices.h - src/lasers.h - src/maiman.h - src/laser_properties.h - src/photodiode.h - src/mems_switching.h - src/sntp_sync.h ) diff --git a/app/Kconfig b/app/Kconfig index 140eaaf..56fb05a 100644 --- a/app/Kconfig +++ b/app/Kconfig @@ -10,6 +10,28 @@ menu "Zephyr" source "Kconfig.zephyr" endmenu +config NET_DHCPV4_OPTION_NTP_SERVER + default y if SNTP && NET_DHCPV4 + +config APP_DEFAULT_DNS_SERVER + string "Default manual DNS server IPv4 address" + default "0.0.0.0" + help + Default app-owned manual DNS server stored in the initial settings + snapshot. Use 0.0.0.0 to leave manual DNS disabled. + +config APP_DEFAULT_NTP_SERVER + string "Default manual NTP server IPv4 address" + default "0.0.0.0" + help + Default app-owned manual NTP server stored in the initial settings + snapshot. The current settings schema stores IPv4 strings, not + hostnames. Use 0.0.0.0 to prefer DHCP-provided NTP. + +choice SNTP_LOG_LEVEL_CHOICE + default SNTP_LOG_LEVEL_DBG if SNTP +endchoice + module = APP module-str = APP source "subsys/logging/Kconfig.template.log_config" diff --git a/app/boards/nucleo_h563zi.overlay b/app/boards/nucleo_h563zi.overlay index e5d9d7c..d988043 100644 --- a/app/boards/nucleo_h563zi.overlay +++ b/app/boards/nucleo_h563zi.overlay @@ -14,7 +14,7 @@ }; zephyr,user { - laser_power_gpios = <&gpiob 2 GPIO_ACTIVE_HIGH>; /* CN9 D72 */ + laser_power_gpios = <&gpiob 2 (GPIO_ACTIVE_HIGH | GPIO_OPEN_DRAIN)>; /* CN9 D72 */ /* Board-type strap inputs are solder jumpers to ground. */ board-type-tib-gpios = <&gpioa 3 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; /* D35 */ @@ -22,27 +22,103 @@ board-type-cal-hk-gpios = <&gpiob 10 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; /* D36 */ board-type-as-gpios = <&gpioe 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; /* D38 */ - mems0-a-gpios = <&pcal6416a 0 GPIO_ACTIVE_HIGH>; - mems0-b-gpios = <&pcal6416a 1 GPIO_ACTIVE_HIGH>; - mems1-a-gpios = <&pcal6416a 2 GPIO_ACTIVE_HIGH>; - mems1-b-gpios = <&pcal6416a 3 GPIO_ACTIVE_HIGH>; - mems2-a-gpios = <&pcal6416a 4 GPIO_ACTIVE_HIGH>; - mems2-b-gpios = <&pcal6416a 5 GPIO_ACTIVE_HIGH>; - mems3-a-gpios = <&pcal6416a 6 GPIO_ACTIVE_HIGH>; - mems3-b-gpios = <&pcal6416a 7 GPIO_ACTIVE_HIGH>; - mems4-a-gpios = <&pcal6416a 8 GPIO_ACTIVE_HIGH>; - mems4-b-gpios = <&pcal6416a 9 GPIO_ACTIVE_HIGH>; - mems5-a-gpios = <&pcal6416a 10 GPIO_ACTIVE_HIGH>; - mems5-b-gpios = <&pcal6416a 11 GPIO_ACTIVE_HIGH>; - mems6-a-gpios = <&pcal6416a 12 GPIO_ACTIVE_HIGH>; - mems6-b-gpios = <&pcal6416a 13 GPIO_ACTIVE_HIGH>; - mems7-a-gpios = <&pcal6416a 14 GPIO_ACTIVE_HIGH>; - mems7-b-gpios = <&pcal6416a 15 GPIO_ACTIVE_HIGH>; - - yj_power_gpios = <&ds2408_pd_power 0 GPIO_ACTIVE_HIGH>; /* DS2408 P1 */ - hk_power_gpios = <&ds2408_pd_power 1 GPIO_ACTIVE_HIGH>; /* DS2408 P2 */ - heater_power_gpios = <&ds2408_pd_power 2 GPIO_ACTIVE_HIGH>; /* DS2408 P3 */ + mems0-a-gpios = <&pcal6416a 0 GPIO_ACTIVE_LOW>; + mems0-b-gpios = <&pcal6416a 1 GPIO_ACTIVE_LOW>; + mems1-a-gpios = <&pcal6416a 2 GPIO_ACTIVE_LOW>; + mems1-b-gpios = <&pcal6416a 3 GPIO_ACTIVE_LOW>; + mems2-a-gpios = <&pcal6416a 4 GPIO_ACTIVE_LOW>; + mems2-b-gpios = <&pcal6416a 5 GPIO_ACTIVE_LOW>; + mems3-a-gpios = <&pcal6416a 6 GPIO_ACTIVE_LOW>; + mems3-b-gpios = <&pcal6416a 7 GPIO_ACTIVE_LOW>; + mems4-a-gpios = <&pcal6416a 8 GPIO_ACTIVE_LOW>; + mems4-b-gpios = <&pcal6416a 9 GPIO_ACTIVE_LOW>; + mems5-a-gpios = <&pcal6416a 10 GPIO_ACTIVE_LOW>; + mems5-b-gpios = <&pcal6416a 11 GPIO_ACTIVE_LOW>; + mems6-a-gpios = <&pcal6416a 12 GPIO_ACTIVE_LOW>; + mems6-b-gpios = <&pcal6416a 13 GPIO_ACTIVE_LOW>; + mems7-a-gpios = <&pcal6416a 14 GPIO_ACTIVE_LOW>; + mems7-b-gpios = <&pcal6416a 15 GPIO_ACTIVE_LOW>; + yj_power_gpios = <&ds2408_pd_power 0 GPIO_ACTIVE_LOW>; /* DS2408 P1 */ + hk_power_gpios = <&ds2408_pd_power 1 GPIO_ACTIVE_LOW>; /* DS2408 P2 */ + heater_power_gpios = <&ds2408_pd_power 2 GPIO_ACTIVE_LOW>; /* DS2408 P3 */ + + }; +}; + +/* The HISPEC-FIB PCB uses PF1/PF0 as I2C2 SCL/SDA. On MB1404 this requires + * the "HSE not used" solder-bridge configuration documented in hardware.md, + * so the application must not inherit Zephyr's default ST-LINK HSE bypass. + */ +&clk_hse { + status = "disabled"; + /delete-property/ hse-bypass; +}; + +&clk_hsi { + status = "okay"; +}; + +&pll { + /* 64 MHz HSI / 16 * 120 / 2 = 240 MHz SYSCLK, matching board default. */ + div-m = <16>; + mul-n = <120>; + div-p = <2>; + div-q = <4>; + div-r = <2>; + clocks = <&clk_hsi>; + status = "okay"; +}; + +&pll2 { + /* Keep PLL2_Q at 160 MHz for peripherals inherited from the Nucleo DTS. */ + div-m = <16>; + mul-n = <120>; + div-p = <2>; + div-q = <3>; + div-r = <2>; + clocks = <&clk_hsi>; + status = "okay"; +}; + +&gpiob { + laser_bank_power_default: laser-bank-power-default { + gpio-hog; + gpios = <2 (GPIO_ACTIVE_HIGH | GPIO_OPEN_DRAIN)>; + output-low; + line-name = "laser-bank-power-default-off"; + }; + + board_type_cal_hk_default: board-type-cal-hk-default { + gpio-hog; + gpios = <10 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; + input; + line-name = "board-type-cal-hk-strap"; + }; +}; + +&gpioa { + board_type_tib_default: board-type-tib-default { + gpio-hog; + gpios = <3 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; + input; + line-name = "board-type-tib-strap"; + }; +}; + +&gpioe { + board_type_cal_yj_default: board-type-cal-yj-default { + gpio-hog; + gpios = <15 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; + input; + line-name = "board-type-cal-yj-strap"; + }; + + board_type_as_default: board-type-as-default { + gpio-hog; + gpios = <6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; + input; + line-name = "board-type-as-strap"; }; }; @@ -58,7 +134,7 @@ status = "okay"; pinctrl-0 = <&i2c1_scl_pb8 &i2c1_sda_pb9>; pinctrl-names = "default"; - clock-frequency = <400000>; + clock-frequency = <100000>; adc1115: adc1115@48 { compatible = "ti,ads1115"; @@ -72,7 +148,7 @@ reg = <0>; zephyr,gain = "ADC_GAIN_1_3"; zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; + zephyr,acquisition-time = ; zephyr,resolution = <16>; zephyr,input-positive = <0>; }; @@ -81,7 +157,7 @@ reg = <2>; zephyr,gain = "ADC_GAIN_1_3"; zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; + zephyr,acquisition-time = ; zephyr,resolution = <16>; zephyr,input-positive = <2>; }; @@ -89,11 +165,13 @@ }; / { - /* 1-Wire bus for DS2408-based relay board control (CN9 D71 / PE9). */ + /* 1-Wire bus for DS2408 relay control (CN9 D71 / PE9). + * MB1404 requires SB35 OFF and SB67 ON for PE9 GPIO routing. + */ w1_pd_power: w1-pd-power { compatible = "zephyr,w1-gpio"; status = "okay"; - gpios = <&gpioe 9 (GPIO_ACTIVE_HIGH | GPIO_OPEN_DRAIN | GPIO_PULL_UP)>; + gpios = <&gpioe 9 (GPIO_ACTIVE_HIGH | GPIO_OPEN_DRAIN)>; ds2408_pd_power: ds2408-pd-power { compatible = "maxim,ds2408"; @@ -101,6 +179,10 @@ gpio-controller; #gpio-cells = <2>; ngpios = <8>; + init-gpios = <&ds2408_pd_power 0 GPIO_ACTIVE_LOW>, + <&ds2408_pd_power 1 GPIO_ACTIVE_LOW>, + <&ds2408_pd_power 2 GPIO_ACTIVE_LOW>; + output-low; }; }; @@ -108,7 +190,7 @@ w1_temp: w1-temp { compatible = "zephyr,w1-gpio"; status = "okay"; - gpios = <&gpiog 1 (GPIO_ACTIVE_HIGH | GPIO_OPEN_DRAIN | GPIO_PULL_UP)>; + gpios = <&gpiog 1 (GPIO_ACTIVE_HIGH | GPIO_OPEN_DRAIN)>; ds18b20 { compatible = "maxim,ds18b20"; @@ -126,37 +208,72 @@ pinctrl-names = "default"; clock-frequency = <400000>; - dac7578: dac7578@48 { - compatible = "ti,dac7578"; + dac7678: dac7678@48 { + compatible = "ti,dac7678"; reg = <0x48>; status = "okay"; + #io-channel-cells = <1>; + ti,clear-mode = "disabled"; + ti,reference = "internal-static"; }; - dac7578_b: dac7578@4a { - compatible = "ti,dac7578"; + dac7678_b: dac7678@4a { + compatible = "ti,dac7678"; reg = <0x4a>; status = "okay"; + #io-channel-cells = <1>; + ti,clear-mode = "disabled"; + ti,reference = "internal-static"; }; - pcal6416a: pcal6416a@33 { + pcal6416a: pcal6416a@21 { compatible = "nxp,pcal6416a"; - reg = <0x33>; + reg = <0x21>; status = "okay"; gpio-controller; #gpio-cells = <2>; ngpios = <16>; + + /* The external MEMS control line is active-high, but the Nucleo + * PCAL drive stage is active-low at the expander pin. These hogs + * deliberately omit GPIO_OPEN_DRAIN and idle the switch lines low. + */ + mems_drive_default: mems-drive-default { + gpio-hog; + gpios = <0 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <1 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <2 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <3 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <4 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <5 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <7 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <8 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <9 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <10 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <11 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <12 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <13 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <14 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>, + <15 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; + output-low; + line-name = "mems-drive-default-low"; + }; }; }; -/* USART2 on CN9 (PD5/PD6, optional RTS PD4) for modbus laser controller */ +/* USART2 on CN9 for the Modbus laser controller. + * PD4 is the RS-485 driver-enable line, controlled by Zephyr Modbus as GPIO. + */ &usart2 { status = "okay"; - pinctrl-0 = <&usart2_tx_pd5 &usart2_rx_pd6 &usart2_rts_pd4>; + pinctrl-0 = <&usart2_tx_pd5 &usart2_rx_pd6>; pinctrl-names = "default"; current-speed = <115200>; modbus0: modbus0 { compatible = "zephyr,modbus-serial"; status = "okay"; + de-gpios = <&gpiod 4 GPIO_ACTIVE_HIGH>; /* CN9 D54 / USART_B_RTS */ }; }; diff --git a/app/boards/qemu_cortex_m3.overlay b/app/boards/qemu_cortex_m3.overlay deleted file mode 100644 index 5dac869..0000000 --- a/app/boards/qemu_cortex_m3.overlay +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2024 Caltech Optical Observatories - * SPDX-License-Identifier: Apache-2.0 - */ - -/* This devicetree overlay enables the COO template application to run on - * QEMU Cortex-M3 emulation. This is useful for testing ARM-specific code, - * persistent settings, and watchdog behavior without physical hardware. - * - * Note: QEMU Cortex-M3 has limited GPIO support, so example_sensor and - * blink_led devices are disabled. The main application logic, NVS, and - * watchdog can still be tested. For a complete QEMU test with peripherals, - * consider native_sim or native_posix targets which have better peripheral - * emulation. - */ diff --git a/app/boards/w5500_evb_pico2_rp2350a_m33.overlay b/app/boards/w5500_evb_pico2_rp2350a_m33.overlay deleted file mode 100644 index 9f72a08..0000000 --- a/app/boards/w5500_evb_pico2_rp2350a_m33.overlay +++ /dev/null @@ -1,199 +0,0 @@ -/* Include RP2350b pin functions: */ -#include - -/ { - zephyr,user { - power-gpios = <&gpio0 6 GPIO_ACTIVE_HIGH>; - mems0-a-gpios = <&pcal6416a 0 GPIO_ACTIVE_HIGH>; - mems0-b-gpios = <&pcal6416a 1 GPIO_ACTIVE_HIGH>; - mems1-a-gpios = <&pcal6416a 2 GPIO_ACTIVE_HIGH>; - mems1-b-gpios = <&pcal6416a 3 GPIO_ACTIVE_HIGH>; - mems2-a-gpios = <&pcal6416a 4 GPIO_ACTIVE_HIGH>; - mems2-b-gpios = <&pcal6416a 5 GPIO_ACTIVE_HIGH>; - mems3-a-gpios = <&pcal6416a 6 GPIO_ACTIVE_HIGH>; - mems3-b-gpios = <&pcal6416a 7 GPIO_ACTIVE_HIGH>; - mems4-a-gpios = <&pcal6416a 8 GPIO_ACTIVE_HIGH>; - mems4-b-gpios = <&pcal6416a 9 GPIO_ACTIVE_HIGH>; - mems5-a-gpios = <&pcal6416a 10 GPIO_ACTIVE_HIGH>; - mems5-b-gpios = <&pcal6416a 11 GPIO_ACTIVE_HIGH>; - mems6-a-gpios = <&pcal6416a 12 GPIO_ACTIVE_HIGH>; - mems6-b-gpios = <&pcal6416a 13 GPIO_ACTIVE_HIGH>; - mems7-a-gpios = <&pcal6416a 14 GPIO_ACTIVE_HIGH>; - mems7-b-gpios = <&pcal6416a 15 GPIO_ACTIVE_HIGH>; - }; -}; - -&pinctrl { /* Define UART1 pinmux groups */ -// with the rPI pico2 sdk the pins actually used are -// UART_TX_PIN GP8 -// UART_RX_PIN GP9 -// I2C0_SDA_PIN GP4 -// I2C0_SCK_PIN GP5 -// I2C1_SDA_PIN GP2 -// I2C1_SCK_PIN GP3 - uart1_pins: uart1_default { - group1 { - pinmux = ; - }; - group2 { - pinmux = ; - input-enable; - bias-pull-up; - }; - }; - i2c0_pins: i2c0_default { - group1 { - pinmux = ; - input-enable; - bias-pull-up; - }; - group2 { - pinmux = ; - input-enable; - bias-pull-up; - }; - }; - i2c1_pins: i2c1_default { - group1 { - pinmux = ; - input-enable; - bias-pull-up; - }; - group2 { - pinmux = ; - input-enable; - bias-pull-up; - }; - }; -}; - - -&uart1 { - status = "okay"; - current-speed = <115200>; /* UART baud (optional default) */ - pinctrl-0 = <&uart1_pins>; - pinctrl-names = "default"; -// rs485-rx-during-tx; //we want full duplex - /* (No flow control by default; ensure /delete-property/ hw-flow-control if set) */ - - modbus0: modbus0 { - compatible = "zephyr,modbus-serial"; - status = "okay"; -// We don't have any "de-gpios" or "re-gpios" since we’re not using RS485 driver-enable -// Docs say safe to remove: https://docs.zephyrproject.org/latest/samples/subsys/modbus/rtu_client/README.html#modbus-rtu-client - }; -}; - - -/* Enable and configure I2C0 and I2C1 devices */ -&i2c0 { - status = "okay"; - clock-frequency = <400000>; - pinctrl-0 = <&i2c0_pins>; - pinctrl-names = "default"; - - /* DAC7578 at I2C address 0x4C */ - dac7578: dac7578@4c { - status = "okay"; - compatible = "ti,dac7578"; - reg = <0x4C>; - }; - - /* PCAL6416A I/O expander at I2C address 0x20 */ - pcal6416a: pcal6416a@20 { - status = "okay"; - gpio-controller; - compatible = "nxp,pcal6416a"; - reg = <0x20>; - ngpios = <16>; /* 16 GPIOs on expander */ - #gpio-cells=<2>; -// We don't use any alert or supply pins -// int-gpios = <&gpio0 2 GPIO_ACTIVE_LOW>; /* Example: MCU GPIO0.2 as expander interrupt */ -// reset-gpios = <&gpio0 3 GPIO_ACTIVE_LOW>; /* Example: MCU GPIO0.3 as reset line (if used) */ - }; -}; - -&i2c1 { - status = "okay"; - clock-frequency = <400000>; - pinctrl-0 = <&i2c1_pins>; - pinctrl-names = "default"; - - /* ADS1115 ADC at I2C address 0x48 */ - adc1115: adc1115@48 { - status = "okay"; - compatible = "ti,ads1115"; - reg = <0x48>; - #io-channel-cells = < 1 >; - #address-cells = < 1 >; - #size-cells = < 0 >; - -// I suspect these aren't necessary as its only boottime config that is irrelevant and code uses adc_channel_setup() -// See also: https://github.com/zephyrproject-rtos/zephyr/discussions/61960 - channel@0 { - status = "okay"; - reg = <0>; - zephyr,gain = "ADC_GAIN_1_3"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; //128Hz - zephyr,resolution = < 16 >; - zephyr,input-positive = <0>; - }; - channel@1 { - status = "okay"; - reg = <1>; - zephyr,gain = "ADC_GAIN_1_3"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; //128Hz - zephyr,resolution = < 16 >; - zephyr,input-positive = <1>; - }; - channel@2 { - status = "okay"; - reg = <2>; - zephyr,gain = "ADC_GAIN_1_3"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; //128Hz - zephyr,resolution = < 16 >; - zephyr,input-positive = <1>; - }; - channel@3 { - status = "okay"; - reg = <3>; - zephyr,gain = "ADC_GAIN_1_3"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; //128Hz - zephyr,resolution = < 16 >; - zephyr,input-positive = <1>; - }; -// We don't use any alert or supply pins -// alert-rdy-gpios = <&gpio0 4 GPIO_ACTIVE_LOW>; /* Example: ALERT/RDY pin to MCU GPIO0.4 */ -// supply-gpios = <&gpio0 5 GPIO_ACTIVE_HIGH>; /* Power switch GPIO controlling ADS1115 Vcc */ - }; -}; - - -/ { - chosen { - zephyr,flash-controller = &qmi; - }; -}; - -// Need to enable the flash controller -&qmi { - status = "okay"; -}; - -&flash0 { - /* Bind this flash controller to a set of named partitions */ - partitions { - compatible = "fixed-partitions"; - #address-cells = <1>; - #size-cells = <1>; - - storage_partition: partition@1f8000 { - label = "storage"; - reg = <0x001f8000 DT_SIZE_K(32)>; // last 32 KiB of flash - }; - }; -}; \ No newline at end of file diff --git a/app/prj.conf b/app/prj.conf index 5b6be7c..d505b2a 100644 --- a/app/prj.conf +++ b/app/prj.conf @@ -2,95 +2,168 @@ # Copyright (c) 2025 Caltech Optical Observatories # SPDX-License-Identifier: Apache-2.0 +# Bring-up diagnostics. Thread names and monitor data feed thread_analyzer and +# crash output; keep enabled while stack sizing and worker ownership are moving. +# Later removal command: set these three symbols to n after thread stacks settle. CONFIG_THREAD_MONITOR=y CONFIG_THREAD_NAME=y CONFIG_DEBUG_THREAD_INFO=y -# General OS and logging +# Assertions and stack sentinels are useful during board bring-up because they +# fail close to the bad API use or stack overflow. Production firmware may turn +# them off after field soak testing. +# Later removal command: set CONFIG_ASSERT=n and CONFIG_STACK_SENTINEL=n. +CONFIG_ASSERT=y +CONFIG_STACK_SENTINEL=y + +# General OS and logging. Immediate mode keeps console output simple during +# bring-up, but it can perturb timing because logs are printed in caller context. +# Later removal command for timing-sensitive releases: set +# CONFIG_LOG_MODE_IMMEDIATE=n and use deferred logging. CONFIG_LOG=y CONFIG_LOG_MODE_IMMEDIATE=y -CONFIG_SNTP_LOG_LEVEL_DBG=y -CONFIG_NVS_LOG_LEVEL_DBG=y +# Global rate-limit support. Code still has to use LOG_*_RATELIMIT_* at repeated +# call sites; this does not automatically throttle every LOG_ERR. +CONFIG_LOG_RATELIMIT=y +CONFIG_LOG_RATELIMIT_INTERVAL_MS=10000 + +# Per-subsystem debug controls. ADC and I2C are intentionally verbose for board +# bring-up. NVS/GPIO debug is explicitly off because those logs are currently not +# useful enough to justify console volume. +# Later removal command: delete the explicit =n lines after defaults are trusted. +CONFIG_NVS_LOG_LEVEL_DBG=n CONFIG_ADC_LOG_LEVEL_DBG=y -CONFIG_GPIO_LOG_LEVEL_DBG=y - -# Main thread stack (adjust as needed for networking) -CONFIG_MAIN_STACK_SIZE=2048 - +CONFIG_GPIO_LOG_LEVEL_DBG=n +CONFIG_I2C_LOG_LEVEL_DBG=y + +# Main and system-workqueue stacks. Networking, MQTT, settings, and JSON parsing +# all run early enough that conservative stack sizes are more useful than tight +# release sizing right now. +# Later trimming command: lower these after thread_analyzer shows stable margin. +CONFIG_MAIN_STACK_SIZE=8192 +CONFIG_MAIN_THREAD_PRIORITY=4 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048 + +# Console and formatted output. printk/stdout/UART console are used by Zephyr +# logging and serial bring-up; floating-point printf is used for engineering +# telemetry and calibration/status text. +# Later removal command: set CONFIG_CBPRINTF_FP_SUPPORT=n if all formatted +# floats are removed from console/MQTT responses. CONFIG_PRINTK=y +CONFIG_CBPRINTF_FP_SUPPORT=y CONFIG_STDOUT_CONSOLE=y -CONFIG_POSIX_API=y CONFIG_CONSOLE=y CONFIG_UART_CONSOLE=y CONFIG_CONSOLE_SUBSYS=y -CONFIG_CONSOLE_GETLINE=y - -# Enable JSON +CONFIG_CONSOLE_GETCHAR=y +CONFIG_CONSOLE_GETCHAR_BUFSIZE=128 + +# Stack initialization and automatic thread reports catch stack margin problems. +# The analyzer is intentionally noisy-but-bounded at 20 s, not a fast loop. +# Later removal command: set CONFIG_THREAD_ANALYZER_AUTO=n, then disable +# CONFIG_THREAD_ANALYZER and CONFIG_INIT_STACKS when stack sizes are settled. +CONFIG_INIT_STACKS=y +CONFIG_THREAD_ANALYZER=n +CONFIG_THREAD_ANALYZER_AUTO=n +CONFIG_THREAD_ANALYZER_AUTO_INTERVAL=20 + +# JSON parsing/formatting is used by command payloads and MQTT/status messages. +# Attenuator, MEMS route-loss, photodiode calibration, and throughput commands +# parse numeric JSON fields as float/double; without JSON FP support those fields +# look missing even when the payload contains them. CONFIG_JSON_LIBRARY=y +CONFIG_JSON_LIBRARY_FP_SUPPORT=y + +# Scientific math for attenuator transmission models. +CONFIG_ZSL=y -# COO Commons Library with MQTT and Network support +# COO Commons library and app defaults. These are app-level defaults; persisted +# settings can override broker/DNS/NTP values at runtime. CONFIG_COO_COMMONS=y CONFIG_COO_NETWORK=y CONFIG_COO_MQTT=y CONFIG_COO_JSON=y -CONFIG_COO_MQTT_BROKER_HOSTNAME="jebcontrol.caltech.edu" +CONFIG_COO_CMD_SERIAL_GUARD=y +CONFIG_COO_CMD_SERIAL_GUARD_DEFAULT_SECONDS=30 +CONFIG_COO_MQTT_BROKER_HOSTNAME="hispec.caltech.edu" CONFIG_COO_MQTT_BROKER_PORT="1883" -CONFIG_COO_MQTT_PAYLOAD_SIZE=512 +CONFIG_COO_MQTT_PAYLOAD_SIZE=1024 CONFIG_NETWORK_HELPER_FALLBACK_IPV4_ADDR="192.168.88.2" CONFIG_NETWORK_HELPER_FALLBACK_IPV4_NETMASK="255.255.255.0" CONFIG_NETWORK_HELPER_FALLBACK_IPV4_GW="0.0.0.0" +CONFIG_APP_DEFAULT_DNS_SERVER="8.8.8.8" +# time.nist.gov resolved IPv4; app settings currently store NTP as IPv4 only. +CONFIG_APP_DEFAULT_NTP_SERVER="132.163.96.4" -# I2C and sensor drivers + +# I2C bus core. Device drivers below are selected by devicetree/Kconfig, but the +# bus subsystem itself is explicit because most board devices depend on it. CONFIG_I2C=y -# DAC support for attenuators (out-of-tree driver via west.yml) -CONFIG_DAC=y -CONFIG_DAC7578=y +# DAC support for attenuators. CONFIG_DAC7X78 is the out-of-tree DAC7578/DAC7678 +# module driver from west.yml; DAC nodes in the overlay instantiate devices. +CONFIG_DAC=y +CONFIG_DAC7X78=y -# Note: PCAL64XXA and ADS1X1X drivers are in tree and should not need to be explicitly enabled if used in the device tree -# I suspect same is true for 1-wire because of the Dallas DS18B20 sensor -# GPIO expander +# GPIO, 1-Wire, and external GPIO expanders. DS2408 is explicit because it is an +# out-of-tree driver; W1/DS18B20 support is used for housekeeping temperature. CONFIG_GPIO=y CONFIG_W1=y CONFIG_W1_NET=y CONFIG_W1_ZEPHYR_GPIO=y CONFIG_GPIO_DS2408=y +# DS2408 must initialize before GPIO hogs can apply overlay defaults to DS2408 +# pins. Keep this ordering paired with the overlay relay defaults. CONFIG_GPIO_DS2408_INIT_PRIORITY=80 +CONFIG_GPIO_HOGS_INIT_PRIORITY=90 + +# PCAL64XXA is selected by its devicetree compatible when the node is enabled. +# It is intentionally left commented to document the dependency without forcing +# the driver globally. Removal command: delete this commented line once stable. # CONFIG_GPIO_PCAL64XXA=y -# ADC for photodiodes +# ADC for photodiodes. ADS1X1X is selected by devicetree, so the commented line +# is a reminder rather than a needed enable. Removal command: delete the comment +# if the overlay remains the only ADC source of truth. CONFIG_ADC=y # CONFIG_ADC_ADS1X1X=y +# Sensor core is needed by DS18B20 and STM32 internal sensors. LED is currently +# board-diagnostic support; if no LED diagnostics remain, it is a trim candidate. +# Later removal command for LED: set CONFIG_LED=n and remove LED devicetree use. CONFIG_SENSOR=y CONFIG_LED=y -# UART and Modbus for Maiman lasers +# UART and Modbus for Maiman lasers. UART interrupt-driven mode is required by +# Zephyr serial/Modbus RTU paths. UART line control is disabled because RS-485 +# direction control is not currently using RTS/DTR line-control APIs. CONFIG_SERIAL=y CONFIG_UART_INTERRUPT_DRIVEN=y CONFIG_UART_LINE_CTRL=n CONFIG_MODBUS=y CONFIG_MODBUS_SERIAL=y CONFIG_MODBUS_ROLE_CLIENT=y +# Maiman reference tooling uses 115200 8N1. Zephyr's compliant Modbus default +# forces no-parity RTU to 2 stop bits unless this option is enabled. +CONFIG_MODBUS_NONCOMPLIANT_SERIAL_MODE=y -# Storage - NVS for persistent settings +# Storage: direct NVS for persistent app settings. MPU flash write permission is +# required on this SoC so NVS can update the flash partition at runtime. CONFIG_FLASH=y CONFIG_FLASH_PAGE_LAYOUT=y CONFIG_FLASH_MAP=y -CONFIG_SETTINGS=y -CONFIG_SETTINGS_RUNTIME=y CONFIG_NVS=y -CONFIG_SETTINGS_NVS=y CONFIG_MPU_ALLOW_FLASH_WRITE=y -# Watchdog support +# Watchdog and reboot support. Reboot is used by commands and watchdog recovery paths. CONFIG_WATCHDOG=y CONFIG_REBOOT=y -# Network stack - Ethernet +# Network stack: Ethernet, IPv4, TCP/UDP, DHCP. IPv6 is explicitly off to keep +# address handling and memory use focused during bring-up. CONFIG_NETWORKING=y CONFIG_NET_LOG=y CONFIG_NET_L2_ETHERNET=y @@ -98,35 +171,46 @@ CONFIG_NET_TCP=y CONFIG_NET_UDP=y CONFIG_NET_IPV4=y CONFIG_NET_DHCPV4=y -CONFIG_NET_DHCPV4_OPTION_NTP_SERVER=y CONFIG_NET_IPV6=n -# Connection manager +# Connection manager and static fallback settings. NET_CONFIG_* values are +# currently development fallbacks; app settings and DHCP can supersede them. +# Later removal command: delete NET_CONFIG_MY_* once field addressing policy is +# finalized and tested through app settings/DHCP only. CONFIG_NET_CONNECTION_MANAGER=y CONFIG_NET_HOSTNAME_ENABLE=y CONFIG_NET_BUF_RX_COUNT=100 CONFIG_NET_MGMT_EVENT=y CONFIG_NET_MGMT=y +CONFIG_NET_MGMT_EVENT_STACK_SIZE=1600 CONFIG_NET_CONFIG_SETTINGS=y CONFIG_NET_CONFIG_INIT_TIMEOUT=6 CONFIG_NET_CONFIG_MY_IPV4_ADDR="192.168.1.111" CONFIG_NET_CONFIG_MY_IPV4_NETMASK="255.255.255.0" CONFIG_NET_CONFIG_MY_IPV4_GW="192.168.1.1" -# DNS and time sync +# DNS and time sync. DNS is needed for broker/NTP names; SNTP owns wall-clock +# sync and is also backed by the numeric app default above. CONFIG_DNS_RESOLVER=y +CONFIG_DNS_SERVER_IP_ADDRESSES=y +CONFIG_DNS_SERVER1="8.8.8.8" CONFIG_SNTP=y -# Sockets for MQTT +# Sockets for MQTT. TLS is off for the current internal broker path; enabling it +# later will require certificate storage and larger socket/MQTT buffers. CONFIG_NET_SOCKETS=y CONFIG_NET_SOCKETS_SOCKOPT_TLS=n -# MQTT library +# MQTT library. MQTT v5 is used by the command/status path; keep TLS paired with +# the socket TLS setting above. CONFIG_MQTT_LIB=y CONFIG_MQTT_LIB_TLS=n CONFIG_MQTT_KEEPALIVE=60 CONFIG_MQTT_VERSION_5_0=y -# Random number generation +# Random number generation. STM32 entropy is preferred; TEST_RANDOM_GENERATOR is +# a development fallback that prevents networking/MQTT bring-up from blocking on +# entropy policy. Remove before production security review if true randomness is +# required: set CONFIG_TEST_RANDOM_GENERATOR=n and verify STM32 entropy remains. CONFIG_ENTROPY_GENERATOR=y CONFIG_TEST_RANDOM_GENERATOR=y diff --git a/app/src/app_identity.c b/app/src/app_identity.c new file mode 100644 index 0000000..1797b71 --- /dev/null +++ b/app/src/app_identity.c @@ -0,0 +1,28 @@ +/** + * @file app_identity.c + * @brief Board-profile MQTT device identity. + * + * The selected board strap owns the MQTT device namespace. Command-dispatch + * helpers format the command, response, warning, and telemetry topic templates. + */ + +#include "app_identity.h" + +#include "devices.h" + +const char *app_mqtt_device_id(void) +{ + switch (devices_board_type()) { + case HISPEC_BOARD_TIB: + return "hsfib-tib"; + case HISPEC_BOARD_CAL_HK: + return "hsfib-rcal"; + case HISPEC_BOARD_CAL_YJ: + return "hsfib-bcal"; + case HISPEC_BOARD_AS: + return "hsfib-as"; + case HISPEC_BOARD_UNKNOWN: + default: + return "hsfib-unknown"; + } +} diff --git a/app/src/app_identity.h b/app/src/app_identity.h index 32f4de4..4f9c75c 100644 --- a/app/src/app_identity.h +++ b/app/src/app_identity.h @@ -1,13 +1,12 @@ /** * @file app_identity.h - * @brief MQTT device identity and fixed command topic prefixes. + * @brief MQTT device identity selected from the board profile. */ #ifndef HISPEC_APP_IDENTITY_H #define HISPEC_APP_IDENTITY_H -#define APP_MQTT_DEVICE_ID "hsfib-tib" -#define APP_MQTT_CMD_PREFIX "cmd/" APP_MQTT_DEVICE_ID "/req/" -#define APP_MQTT_RESP_PREFIX "cmd/" APP_MQTT_DEVICE_ID "/resp/" +/** @brief Return the MQTT device ID selected from the detected board strap. */ +const char *app_mqtt_device_id(void); #endif /* HISPEC_APP_IDENTITY_H */ diff --git a/app/src/app_scheduled_actions.c b/app/src/app_scheduled_actions.c deleted file mode 100644 index 1a4c0ce..0000000 --- a/app/src/app_scheduled_actions.c +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @file app_scheduled_actions.c - * @brief Fixed table of named delayable-work actions. - * - * Wrapping k_work_delayable here keeps delayed command side effects explicit: - * serial guard expiration and delayed reboot are named firmware behaviors. - * - * Copyright (c) 2026 Caltech Optical Observatories - * SPDX-License-Identifier: Apache-2.0 - */ - -#include "app_scheduled_actions.h" - -#include -#include -#include -#include - -LOG_MODULE_REGISTER(app_scheduled_actions, LOG_LEVEL_INF); - -struct app_scheduled_action { - const char *name; - struct k_work_delayable work; - app_scheduled_action_handler_t handler; - void *user_data; - atomic_t pending; -}; - -static struct app_scheduled_action g_actions[APP_SCHEDULED_ACTION_COUNT] = { - [APP_SCHEDULED_ACTION_SERIAL_GUARD_EXPIRE] = { - .name = "serial_guard_expire", - }, - [APP_SCHEDULED_ACTION_REBOOT] = { - .name = "reboot", - }, -}; - -static bool g_initialized; - -static bool valid_id(enum app_scheduled_action_id id) -{ - return id >= 0 && id < APP_SCHEDULED_ACTION_COUNT; -} - -static void scheduled_action_work_handler(struct k_work *work) -{ - struct k_work_delayable *dwork = k_work_delayable_from_work(work); - struct app_scheduled_action *action = - CONTAINER_OF(dwork, struct app_scheduled_action, work); - enum app_scheduled_action_id id = - (enum app_scheduled_action_id)(action - g_actions); - - (void)atomic_clear(&action->pending); - - if (action->handler == NULL) { - LOG_WRN("Scheduled action %s has no handler", action->name); - return; - } - - action->handler(id, action->user_data); -} - -int app_scheduled_actions_init(void) -{ - if (g_initialized) { - return 0; - } - - for (size_t i = 0; i < ARRAY_SIZE(g_actions); ++i) { - /* k_work_init_delayable() binds each named action to the Zephyr - * system workqueue; no action creates its own thread. - */ - k_work_init_delayable(&g_actions[i].work, scheduled_action_work_handler); - (void)atomic_clear(&g_actions[i].pending); - } - - g_initialized = true; - return 0; -} - -int app_scheduled_action_register(enum app_scheduled_action_id id, - app_scheduled_action_handler_t handler, - void *user_data) -{ - if (!valid_id(id) || handler == NULL) { - return -EINVAL; - } - if (!g_initialized) { - return -EAGAIN; - } - - g_actions[id].handler = handler; - g_actions[id].user_data = user_data; - return 0; -} - -int app_scheduled_action_schedule(enum app_scheduled_action_id id, - k_timeout_t delay) -{ - int rc; - - if (!valid_id(id)) { - return -EINVAL; - } - if (!g_initialized) { - return -EAGAIN; - } - - /* k_work_reschedule() is the Zephyr API that implements "do this later - * unless refreshed"; it updates the same pending work item in place. - */ - rc = k_work_reschedule(&g_actions[id].work, delay); - if (rc >= 0) { - (void)atomic_set(&g_actions[id].pending, 1); - } - return rc; -} - -int app_scheduled_action_cancel(enum app_scheduled_action_id id) -{ - if (!valid_id(id)) { - return -EINVAL; - } - if (!g_initialized) { - return -EAGAIN; - } - - /* k_work_cancel_delayable() prevents a pending delayed action from being - * submitted; work already running may still finish in the system queue. - */ - (void)atomic_clear(&g_actions[id].pending); - return k_work_cancel_delayable(&g_actions[id].work); -} - -bool app_scheduled_action_is_pending(enum app_scheduled_action_id id) -{ - if (!valid_id(id)) { - return false; - } - - return atomic_get(&g_actions[id].pending) != 0; -} - -int app_scheduled_action_remaining_ms(enum app_scheduled_action_id id, - int64_t *remaining_ms) -{ - k_ticks_t remaining_ticks; - - if (!valid_id(id) || remaining_ms == NULL) { - return -EINVAL; - } - if (!g_initialized) { - return -EAGAIN; - } - - /* k_work_delayable_remaining_get() reads the live Zephyr timer state. - * It returns zero when the action is not currently scheduled. - */ - remaining_ticks = k_work_delayable_remaining_get(&g_actions[id].work); - *remaining_ms = k_ticks_to_ms_floor64(remaining_ticks); - return 0; -} - -const char *app_scheduled_action_name(enum app_scheduled_action_id id) -{ - if (!valid_id(id)) { - return "unknown"; - } - - return g_actions[id].name; -} diff --git a/app/src/app_scheduled_actions.h b/app/src/app_scheduled_actions.h deleted file mode 100644 index 85fd7db..0000000 --- a/app/src/app_scheduled_actions.h +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @file app_scheduled_actions.h - * @brief Small named k_work_delayable actions used by command handlers. - * - * This is a fixed firmware-action table, not a user-programmable scheduler. - * Callbacks run in Zephyr system workqueue context. - * - * Copyright (c) 2026 Caltech Optical Observatories - * SPDX-License-Identifier: Apache-2.0 - */ - -#ifndef HISPEC_APP_SCHEDULED_ACTIONS_H -#define HISPEC_APP_SCHEDULED_ACTIONS_H - -#include -#include -#include - -/** - * @brief Explicit delayed actions owned by the application. - * - * Keep this enum short. These are named system behaviors, not user-created - * jobs, so command handlers can reschedule/cancel known actions without - * spawning arbitrary threads or timers. - */ -enum app_scheduled_action_id { - APP_SCHEDULED_ACTION_SERIAL_GUARD_EXPIRE = 0, - APP_SCHEDULED_ACTION_REBOOT, - APP_SCHEDULED_ACTION_COUNT, -}; - -typedef void (*app_scheduled_action_handler_t)(enum app_scheduled_action_id id, - void *user_data); - -/** - * @brief Initialize all named delayable-work objects. - * - * This wraps Zephyr's k_work_delayable API so the rest of the app deals with - * stable action names instead of scattered work items. - */ -int app_scheduled_actions_init(void); - -/** - * @brief Attach the callback for one named action. - * - * The callback runs in Zephyr's system workqueue context. It must stay short: - * update state, log, enqueue work, or schedule another thread to do slow I/O. - */ -int app_scheduled_action_register(enum app_scheduled_action_id id, - app_scheduled_action_handler_t handler, - void *user_data); - -/** - * @brief Schedule or reschedule one named action after @p delay. - * - * Uses k_work_reschedule(), so repeated calls refresh the same delayed action - * rather than creating another task or timer. - */ -int app_scheduled_action_schedule(enum app_scheduled_action_id id, - k_timeout_t delay); - -/** - * @brief Cancel one named action if it has not run yet. - */ -int app_scheduled_action_cancel(enum app_scheduled_action_id id); - -/** - * @brief Return whether a named action is currently pending. - */ -bool app_scheduled_action_is_pending(enum app_scheduled_action_id id); - -/** - * @brief Get the approximate remaining delay in milliseconds. - */ -int app_scheduled_action_remaining_ms(enum app_scheduled_action_id id, - int64_t *remaining_ms); - -/** - * @brief Human-readable name for logs and status output. - */ -const char *app_scheduled_action_name(enum app_scheduled_action_id id); - -#endif /* HISPEC_APP_SCHEDULED_ACTIONS_H */ diff --git a/app/src/app_settings.c b/app/src/app_settings.c index 223b5a4..bc3c759 100644 --- a/app/src/app_settings.c +++ b/app/src/app_settings.c @@ -1,10 +1,11 @@ /** * @file app_settings.c - * @brief Runtime defaults, settings callbacks, and persistent app state writes. + * @brief Runtime defaults and direct-NVS persistence for app-owned settings. * - * Settings callbacks run during Zephyr settings load and update the protected - * runtime snapshot. Public update helpers may call settings_save_one() and can - * block on the configured settings backend. + * Public update helpers copy into the protected runtime snapshot. When + * persistence is requested they write one numeric Zephyr NVS ID and may block + * on flash I/O. The app owns this fixed NVS ID map; no string setting names + * are stored in flash. * * Copyright (c) 2026 Caltech Optical Observatories * SPDX-License-Identifier: Apache-2.0 @@ -12,44 +13,109 @@ #include "app_settings.h" -#include #include #include #include +#include +#include #include +#include #include -#include +#include #include -#include - LOG_MODULE_REGISTER(app_settings, LOG_LEVEL_INF); -#define APP_SETTINGS_SERIAL_HOLDOFF_DEFAULT_S 30U - -#define KEY_BOARD_TYPE "board/type" -#define KEY_SERIAL_HOLDOFF "serial/holdoff_s" -#define KEY_BOOT_COUNT "boot/count" -#define KEY_IP_TRY_DHCP "ip/trydhcpfirst" -#define KEY_IP_PREF_DNS "ip/preferdhcpdns" -#define KEY_IP_PREF_NTP "ip/preferdhcpntp" -#define KEY_IP_ADDR "ip/ip" -#define KEY_IP_SUBNET "ip/subnet" -#define KEY_IP_GATEWAY "ip/gateway" -#define KEY_IP_DNS "ip/dns" -#define KEY_IP_NTP "ip/ntp" -#define KEY_MQTT_BROKER "mqtt/broker" -#define KEY_ATTEN_PREFIX "atten" -#define KEY_PD_YJ_DARK_MV "pd/yj/dark_mv" -#define KEY_PD_YJ_LOWEST_DARK_MV "pd/yj/lowest_dark_mv" -#define KEY_PD_YJ_LOWEST_DARK_VALID "pd/yj/lowest_dark_valid" -#define KEY_PD_YJ_NOISE_WARN_MV "pd/yj/noise_warn_rms_mv" -#define KEY_PD_YJ_GAIN_V_PER_UW "pd/yj/gain_v_per_uw" -#define KEY_PD_HK_DARK_MV "pd/hk/dark_mv" -#define KEY_PD_HK_LOWEST_DARK_MV "pd/hk/lowest_dark_mv" -#define KEY_PD_HK_LOWEST_DARK_VALID "pd/hk/lowest_dark_valid" -#define KEY_PD_HK_NOISE_WARN_MV "pd/hk/noise_warn_rms_mv" -#define KEY_PD_HK_GAIN_V_PER_UW "pd/hk/gain_v_per_uw" +#define APP_NVS_SCHEMA_MAGIC 0x48535653U /* "HSVS" */ +#define APP_NVS_SCHEMA_VERSION 1U + +enum app_nvs_id { + APP_NVS_ID_SCHEMA = 0x0001, + APP_NVS_ID_BOARD_TYPE = 0x0002, + APP_NVS_ID_SERIAL_HOLDOFF_UNUSED = 0x0003, + APP_NVS_ID_BOOT_COUNT = 0x0004, + APP_NVS_ID_IP = 0x0005, + APP_NVS_ID_MQTT = 0x0006, + APP_NVS_ID_LASERBANK = 0x0007, + APP_NVS_ID_LAST_KNOWN_UTC_MS = 0x0008, + APP_NVS_ID_LAST_COMMAND = APP_SETTINGS_NVS_ID_LAST_COMMAND, + APP_NVS_ID_ATTEN_CH0 = 0x0100, + APP_NVS_ID_PD_CH0 = 0x0200, + APP_NVS_ID_LASER_POLICY_CH0 = 0x0300, + APP_NVS_ID_LASER_TOTAL_CH0 = 0x0340, + APP_NVS_ID_ROUTE_LOSS_CH0 = 0x0400, +}; + +BUILD_ASSERT(APP_NVS_ID_ROUTE_LOSS_CH0 + APP_ROUTE_LOSS_RECORD_COUNT < 0x8000, + "app NVS IDs must stay below Zephyr settings backend IDs"); +BUILD_ASSERT(APP_NVS_ID_ATTEN_CH0 + APP_ATTENUATOR_CHANNEL_COUNT <= APP_NVS_ID_PD_CH0, + "attenuator NVS ID block overlaps photodiode block"); +BUILD_ASSERT(APP_NVS_ID_PD_CH0 + APP_PD_CHANNEL_COUNT <= APP_NVS_ID_LASER_POLICY_CH0, + "photodiode NVS ID block overlaps laser policy block"); +BUILD_ASSERT(APP_NVS_ID_LASER_POLICY_CH0 + APP_LASER_CHANNEL_COUNT <= APP_NVS_ID_LASER_TOTAL_CH0, + "laser policy NVS ID block overlaps laser total block"); +BUILD_ASSERT(APP_NVS_ID_LASER_TOTAL_CH0 + APP_LASER_CHANNEL_COUNT <= APP_NVS_ID_ROUTE_LOSS_CH0, + "laser total NVS ID block overlaps route-loss block"); +BUILD_ASSERT(APP_NVS_ID_ROUTE_LOSS_CH0 + APP_ROUTE_LOSS_RECORD_COUNT <= 0x8000, + "route-loss NVS ID block overlaps reserved Zephyr settings IDs"); + +struct app_nvs_schema_marker { + uint32_t magic; + uint16_t version; + uint16_t reserved; +}; + +struct app_nvs_ip_settings { + uint8_t try_dhcp_first; + uint8_t prefer_dhcp_dns; + uint8_t prefer_dhcp_ntp; + uint8_t reserved; + char ip[NET_IPV4_ADDR_LEN]; + char subnet[NET_IPV4_ADDR_LEN]; + char gateway[NET_IPV4_ADDR_LEN]; + char dns[NET_IPV4_ADDR_LEN]; + char ntp[NET_IPV4_ADDR_LEN]; +}; + +struct app_nvs_pd_channel { + float dark_mv; + float lowest_dark_mv; + uint8_t lowest_dark_valid; + uint8_t reserved[3]; + float noise_warn_rms_mv; + double responsivity_a_per_w; + double transimpedance_v_per_a; +}; + +struct app_nvs_laser_policy { + float nominal_current_ma; + float max_current_ma; + float threshold_current_ma; + float efficiency_mw_per_ma; + float wavelength_nm; + float current_set_calibration_pct; + float operating_temp_min_c; + float operating_temp_max_c; + float operating_temp_c; + uint16_t tec_pid_p; + uint16_t tec_pid_i; + uint16_t tec_pid_d; + uint8_t disable_tec_at_autooff; + uint8_t reserved; + float dlambda_dT_nm_per_k; + float dlambda_dA_nm_per_ma; + uint32_t autooff_s; + float tune_delta_nm; +}; + +static const laserprops_t *const default_laser_props[APP_LASER_CHANNEL_COUNT] = { + &LASER_1028, + &LASER_1270, + &LASER_1430, + &LASER_1430, + &LASER_1510, + &LASER_2330, +}; struct app_settings_state { struct app_settings_snapshot snapshot; @@ -57,6 +123,33 @@ struct app_settings_state { }; static struct app_settings_state g_settings; +static struct nvs_fs app_nvs; +static bool app_nvs_ready; + +static uint16_t attenuator_nvs_id(uint8_t channel) +{ + return APP_NVS_ID_ATTEN_CH0 + channel; +} + +static uint16_t pd_nvs_id(uint8_t channel) +{ + return APP_NVS_ID_PD_CH0 + channel; +} + +static uint16_t laser_policy_nvs_id(uint8_t channel) +{ + return APP_NVS_ID_LASER_POLICY_CH0 + channel; +} + +static uint16_t laser_total_nvs_id(uint8_t channel) +{ + return APP_NVS_ID_LASER_TOTAL_CH0 + channel; +} + +static uint16_t route_loss_nvs_id(uint8_t index) +{ + return APP_NVS_ID_ROUTE_LOSS_CH0 + index; +} static void str_set(char *dst, size_t dst_size, const char *src) { @@ -73,6 +166,16 @@ static void str_set(char *dst, size_t dst_size, const char *src) dst[dst_size - 1] = '\0'; } +static bool float_in_range(float value, float min_value, float max_value) +{ + return value >= min_value && value <= max_value; +} + +static bool double_in_range(double value, double min_value, double max_value) +{ + return value >= min_value && value <= max_value; +} + static void settings_defaults(struct app_settings_snapshot *s) { unsigned long broker_port = 1883UL; @@ -88,8 +191,8 @@ static void settings_defaults(struct app_settings_snapshot *s) str_set(s->ip.ip, sizeof(s->ip.ip), CONFIG_NET_CONFIG_MY_IPV4_ADDR); str_set(s->ip.subnet, sizeof(s->ip.subnet), CONFIG_NET_CONFIG_MY_IPV4_NETMASK); str_set(s->ip.gateway, sizeof(s->ip.gateway), CONFIG_NET_CONFIG_MY_IPV4_GW); - str_set(s->ip.dns, sizeof(s->ip.dns), "0.0.0.0"); - str_set(s->ip.ntp, sizeof(s->ip.ntp), "0.0.0.0"); + str_set(s->ip.dns, sizeof(s->ip.dns), CONFIG_APP_DEFAULT_DNS_SERVER); + str_set(s->ip.ntp, sizeof(s->ip.ntp), CONFIG_APP_DEFAULT_NTP_SERVER); broker_port = strtoul(default_port_str, &end, 10); if (end == NULL || end == default_port_str || *end != '\0' || broker_port > UINT16_MAX) { broker_port = 1883UL; @@ -97,473 +200,625 @@ static void settings_defaults(struct app_settings_snapshot *s) str_set(s->mqtt.broker_host, sizeof(s->mqtt.broker_host), CONFIG_COO_MQTT_BROKER_HOSTNAME); s->mqtt.broker_port = (uint16_t)broker_port; for (uint8_t ch = 0U; ch < APP_ATTENUATOR_CHANNEL_COUNT; ++ch) { - for (uint8_t i = 0U; i < APP_ATTENUATOR_COEFF_COUNT; ++i) { - /* Calibration defaults are explicit zero coefficients until - * lab-measured values are stored with atten//coeff. + for (uint8_t physical = 0U; physical < APP_ATTENUATOR_PHYSICAL_COUNT; ++physical) { + /* Default maps the full 0-5000 mV attenuator drive span + * onto b=0..8 until lab-measured coefficients are stored. */ - s->attenuator.channel[ch].db_to_volt[i] = 0.0f; - s->attenuator.channel[ch].volt_to_db[i] = 0.0f; + s->attenuator.channel[ch].physical[physical].slope = (float)(8.0 / 5000.0f); + s->attenuator.channel[ch].physical[physical].offset = 0.0f; } } s->photodiode.channel[0].dark_mv = 0.0f; s->photodiode.channel[0].lowest_dark_mv = 0.0f; s->photodiode.channel[0].lowest_dark_valid = false; s->photodiode.channel[0].noise_warn_rms_mv = 3.0f; - s->photodiode.channel[0].gain_v_per_uw = 47500.0f; + s->photodiode.channel[0].responsivity_a_per_w = 0.93; + s->photodiode.channel[0].transimpedance_v_per_a = 5.0e10; s->photodiode.channel[1].dark_mv = 0.0f; s->photodiode.channel[1].lowest_dark_mv = 0.0f; s->photodiode.channel[1].lowest_dark_valid = false; s->photodiode.channel[1].noise_warn_rms_mv = 1.0f; - s->photodiode.channel[1].gain_v_per_uw = 3.0875f; - s->serial_holdoff_s = APP_SETTINGS_SERIAL_HOLDOFF_DEFAULT_S; + s->photodiode.channel[1].responsivity_a_per_w = 0.60971; + s->photodiode.channel[1].transimpedance_v_per_a = 2.375e9; + s->laserbank.heater_mode = LASERBANK_HEATER_MODE_AUTO; + for (uint8_t i = 0U; i < APP_LASER_CHANNEL_COUNT; ++i) { + s->laser.channel[i].properties = *default_laser_props[i]; + s->laser.channel[i].current_set_calibration_pct = 100.0f; + s->laser.channel[i].disable_tec_at_autooff = true; + s->laser.channel[i].autooff_s = 3U * 3600U; + s->laser.channel[i].tune_delta_nm = 0.0f; + s->laser.channel[i].total_emitting_s = 0.0; + } s->boot_count = 0U; s->mqtt_revision = 0U; } -static int read_bool(settings_read_cb read_cb, void *cb_arg, bool *out) +static int app_nvs_mount(void) { - uint8_t value = 0; - int rc = read_cb(cb_arg, &value, sizeof(value)); + struct flash_pages_info page_info; + int rc; - if (rc == sizeof(value)) { - *out = (value != 0U); - return 0; + app_nvs.flash_device = PARTITION_DEVICE(storage_partition); + if (!device_is_ready(app_nvs.flash_device)) { + LOG_ERR("NVS flash device is not ready"); + return -ENODEV; } - return -EINVAL; -} + app_nvs.offset = PARTITION_OFFSET(storage_partition); + rc = flash_get_page_info_by_offs(app_nvs.flash_device, app_nvs.offset, &page_info); + if (rc != 0) { + LOG_ERR("flash_get_page_info_by_offs failed (%d)", rc); + return rc; + } -static int read_u32(settings_read_cb read_cb, void *cb_arg, uint32_t *out) -{ - int rc = read_cb(cb_arg, out, sizeof(*out)); - return (rc == sizeof(*out)) ? 0 : -EINVAL; -} + app_nvs.sector_size = page_info.size; + app_nvs.sector_count = PARTITION_SIZE(storage_partition) / page_info.size; + rc = nvs_mount(&app_nvs); + if (rc != 0) { + LOG_ERR("nvs_mount failed (%d)", rc); + return rc; + } -static int read_float(settings_read_cb read_cb, void *cb_arg, float *out) -{ - int rc = read_cb(cb_arg, out, sizeof(*out)); - return (rc == sizeof(*out)) ? 0 : -EINVAL; + app_nvs_ready = true; + return 0; } -static int read_str(settings_read_cb read_cb, void *cb_arg, char *out, size_t out_size) +static int app_nvs_write(uint16_t id, const void *data, size_t len) { int rc; - if (out == NULL || out_size == 0U) { - return -EINVAL; + if (!app_nvs_ready) { + return -EIO; } - memset(out, 0, out_size); - rc = read_cb(cb_arg, out, out_size - 1U); + rc = nvs_write(&app_nvs, id, data, len); if (rc < 0) { + LOG_WRN("NVS write id 0x%04x failed (%d)", id, rc); return rc; } - out[out_size - 1U] = '\0'; return 0; } -static void read_valid_float_or_warn(settings_read_cb read_cb, void *cb_arg, - const char *name, float *out, - float min_value, float max_value) +static int app_nvs_delete(uint16_t id) { - float value; + int rc; - if (read_float(read_cb, cb_arg, &value) != 0 || - !(value >= min_value && value <= max_value)) { - LOG_WRN("Ignoring invalid stored setting %s", name); - return; + if (!app_nvs_ready) { + return -EIO; } - *out = value; + rc = nvs_delete(&app_nvs, id); + if (rc != 0 && rc != -ENOENT) { + LOG_WRN("NVS delete id 0x%04x failed (%d)", id, rc); + } + + return rc == -ENOENT ? 0 : rc; } -static int parse_key_index(const char **cursor, uint8_t max_value, uint8_t *out) +static bool app_nvs_read_exact(uint16_t id, void *data, size_t len, const char *name) { - char *end = NULL; - unsigned long value; + int rc; - if (cursor == NULL || *cursor == NULL || out == NULL || - !isdigit((unsigned char)**cursor)) { - return -EINVAL; + if (!app_nvs_ready || data == NULL) { + return false; } - errno = 0; - value = strtoul(*cursor, &end, 10); - if (errno != 0 || end == *cursor || value > max_value) { - return -EINVAL; + rc = nvs_read(&app_nvs, id, data, len); + if (rc == (int)len) { + return true; + } + if (rc != -ENOENT) { + LOG_WRN("Ignoring invalid NVS %s id 0x%04x length (%d)", name, id, rc); } - *out = (uint8_t)value; - *cursor = end; - return 0; + return false; } -static bool parse_attenuator_coeff_name(const char *name, - uint8_t *channel, - bool *db_to_volt, - uint8_t *coeff_index) +static int app_nvs_write_schema(void) { - const char *cursor = name; + const struct app_nvs_schema_marker marker = { + .magic = APP_NVS_SCHEMA_MAGIC, + .version = APP_NVS_SCHEMA_VERSION, + }; - if (name == NULL || channel == NULL || db_to_volt == NULL || - coeff_index == NULL) { - return false; + return app_nvs_write(APP_NVS_ID_SCHEMA, &marker, sizeof(marker)); +} + +static int app_nvs_ensure_schema(void) +{ + struct app_nvs_schema_marker marker = {0}; + int rc; + + if (app_nvs_read_exact(APP_NVS_ID_SCHEMA, &marker, sizeof(marker), "schema") && + marker.magic == APP_NVS_SCHEMA_MAGIC && + marker.version == APP_NVS_SCHEMA_VERSION) { + return 0; } - if (parse_key_index(&cursor, APP_ATTENUATOR_CHANNEL_COUNT - 1U, channel) != 0 || - *cursor != '/') { - return false; + LOG_INF("Initializing app NVS schema %u; clearing old storage layout", + APP_NVS_SCHEMA_VERSION); + rc = nvs_clear(&app_nvs); + if (rc != 0) { + LOG_ERR("nvs_clear failed (%d)", rc); + app_nvs_ready = false; + return rc; } - cursor++; - if (strncmp(cursor, "db2volt/", 8U) == 0) { - *db_to_volt = true; - cursor += 8U; - } else if (strncmp(cursor, "volt2db/", 8U) == 0) { - *db_to_volt = false; - cursor += 8U; - } else { - return false; + app_nvs_ready = false; + rc = app_nvs_mount(); + if (rc != 0) { + return rc; } - return parse_key_index(&cursor, APP_ATTENUATOR_COEFF_COUNT - 1U, coeff_index) == 0 && - *cursor == '\0'; + return app_nvs_write_schema(); } -static int settings_set_cb(const char *name, size_t len, settings_read_cb read_cb, void *cb_arg) +static void app_nvs_persist_board_type(const char *board_type) { - uint8_t atten_channel; - uint8_t atten_coeff; - bool atten_db_to_volt; + char value[APP_SETTINGS_BOARD_TYPE_MAX_LEN] = {0}; - ARG_UNUSED(len); + str_set(value, sizeof(value), board_type); + (void)app_nvs_write(APP_NVS_ID_BOARD_TYPE, value, sizeof(value)); +} - k_mutex_lock(&g_settings.lock, K_FOREVER); +static void app_nvs_persist_ip(const struct app_ip_settings *ip) +{ + struct app_nvs_ip_settings stored = {0}; - if (strcmp(name, "type") == 0) { - (void)read_str(read_cb, cb_arg, - g_settings.snapshot.board_type, - sizeof(g_settings.snapshot.board_type)); - goto out; + if (ip == NULL) { + return; } - if (strcmp(name, "holdoff_s") == 0) { - (void)read_u32(read_cb, cb_arg, &g_settings.snapshot.serial_holdoff_s); - goto out; - } + stored.try_dhcp_first = ip->try_dhcp_first ? 1U : 0U; + stored.prefer_dhcp_dns = ip->prefer_dhcp_dns ? 1U : 0U; + stored.prefer_dhcp_ntp = ip->prefer_dhcp_ntp ? 1U : 0U; + str_set(stored.ip, sizeof(stored.ip), ip->ip); + str_set(stored.subnet, sizeof(stored.subnet), ip->subnet); + str_set(stored.gateway, sizeof(stored.gateway), ip->gateway); + str_set(stored.dns, sizeof(stored.dns), ip->dns); + str_set(stored.ntp, sizeof(stored.ntp), ip->ntp); + (void)app_nvs_write(APP_NVS_ID_IP, &stored, sizeof(stored)); +} - if (strcmp(name, "count") == 0) { - (void)read_u32(read_cb, cb_arg, &g_settings.snapshot.boot_count); - goto out; - } +static void app_nvs_persist_mqtt(const struct app_mqtt_settings *mqtt) +{ + struct app_mqtt_settings stored = {0}; - if (strcmp(name, "trydhcpfirst") == 0) { - (void)read_bool(read_cb, cb_arg, &g_settings.snapshot.ip.try_dhcp_first); - goto out; + if (mqtt == NULL) { + return; } - if (strcmp(name, "preferdhcpdns") == 0) { - (void)read_bool(read_cb, cb_arg, &g_settings.snapshot.ip.prefer_dhcp_dns); - goto out; - } + str_set(stored.broker_host, sizeof(stored.broker_host), mqtt->broker_host); + stored.broker_port = mqtt->broker_port; + (void)app_nvs_write(APP_NVS_ID_MQTT, &stored, sizeof(stored)); +} - if (strcmp(name, "preferdhcpntp") == 0) { - (void)read_bool(read_cb, cb_arg, &g_settings.snapshot.ip.prefer_dhcp_ntp); - goto out; +static void app_nvs_persist_attenuator_channel(uint8_t channel, + const struct app_attenuator_channel_settings *atten) +{ + if (atten == NULL || channel >= APP_ATTENUATOR_CHANNEL_COUNT) { + return; } - if (strcmp(name, "ip") == 0) { - (void)read_str(read_cb, cb_arg, g_settings.snapshot.ip.ip, sizeof(g_settings.snapshot.ip.ip)); - goto out; - } + (void)app_nvs_write(attenuator_nvs_id(channel), atten, sizeof(*atten)); +} - if (strcmp(name, "subnet") == 0) { - (void)read_str(read_cb, cb_arg, g_settings.snapshot.ip.subnet, sizeof(g_settings.snapshot.ip.subnet)); - goto out; - } +static void app_nvs_persist_pd_channel(uint8_t channel, + const struct app_pd_channel_settings *pd) +{ + struct app_nvs_pd_channel stored = {0}; - if (strcmp(name, "gateway") == 0) { - (void)read_str(read_cb, cb_arg, g_settings.snapshot.ip.gateway, sizeof(g_settings.snapshot.ip.gateway)); - goto out; + if (pd == NULL || channel >= APP_PD_CHANNEL_COUNT) { + return; } - if (strcmp(name, "dns") == 0) { - (void)read_str(read_cb, cb_arg, g_settings.snapshot.ip.dns, sizeof(g_settings.snapshot.ip.dns)); - goto out; - } + stored.dark_mv = pd->dark_mv; + stored.lowest_dark_mv = pd->lowest_dark_mv; + stored.lowest_dark_valid = pd->lowest_dark_valid ? 1U : 0U; + stored.noise_warn_rms_mv = pd->noise_warn_rms_mv; + stored.responsivity_a_per_w = pd->responsivity_a_per_w; + stored.transimpedance_v_per_a = pd->transimpedance_v_per_a; + (void)app_nvs_write(pd_nvs_id(channel), &stored, sizeof(stored)); +} - if (strcmp(name, "ntp") == 0) { - (void)read_str(read_cb, cb_arg, g_settings.snapshot.ip.ntp, sizeof(g_settings.snapshot.ip.ntp)); - goto out; +static void laser_policy_from_settings(struct app_nvs_laser_policy *stored, + const struct app_laser_channel_settings *laser) +{ + if (stored == NULL || laser == NULL) { + return; } - if (strcmp(name, "broker") == 0) { - char endpoint[160]; - struct coo_mqtt_broker_config parsed; - - if (read_str(read_cb, cb_arg, endpoint, sizeof(endpoint)) == 0 && - coo_mqtt_parse_broker_endpoint(endpoint, &parsed)) { - str_set(g_settings.snapshot.mqtt.broker_host, - sizeof(g_settings.snapshot.mqtt.broker_host), parsed.host); - g_settings.snapshot.mqtt.broker_port = parsed.port; - } else { - LOG_WRN("Ignoring invalid stored setting mqtt/broker"); - } - goto out; - } + memset(stored, 0, sizeof(*stored)); + stored->nominal_current_ma = laser->properties.nominal_current_ma; + stored->max_current_ma = laser->properties.max_current_ma; + stored->threshold_current_ma = laser->properties.threshold_current_ma; + stored->efficiency_mw_per_ma = laser->properties.efficiency_mw_per_ma; + stored->wavelength_nm = laser->properties.wavelength_nm; + stored->current_set_calibration_pct = laser->current_set_calibration_pct; + stored->operating_temp_min_c = laser->properties.operating_temp_range_c.min_c; + stored->operating_temp_max_c = laser->properties.operating_temp_range_c.max_c; + stored->operating_temp_c = laser->properties.operating_temp_c; + stored->tec_pid_p = laser->properties.tec_pid.kp; + stored->tec_pid_i = laser->properties.tec_pid.ki; + stored->tec_pid_d = laser->properties.tec_pid.kd; + stored->disable_tec_at_autooff = laser->disable_tec_at_autooff ? 1U : 0U; + stored->dlambda_dT_nm_per_k = laser->properties.dlambda_dT_nm_per_k; + stored->dlambda_dA_nm_per_ma = laser->properties.dlambda_dA_nm_per_ma; + stored->autooff_s = laser->autooff_s; + stored->tune_delta_nm = laser->tune_delta_nm; +} - if (parse_attenuator_coeff_name(name, &atten_channel, &atten_db_to_volt, - &atten_coeff)) { - float *target = atten_db_to_volt ? - &g_settings.snapshot.attenuator.channel[atten_channel].db_to_volt[atten_coeff] : - &g_settings.snapshot.attenuator.channel[atten_channel].volt_to_db[atten_coeff]; +static void app_nvs_persist_laser_channel(uint8_t channel, + const struct app_laser_channel_settings *laser) +{ + struct app_nvs_laser_policy stored; - read_valid_float_or_warn(read_cb, cb_arg, name, target, - -1000000000.0f, 1000000000.0f); - goto out; + if (laser == NULL || channel >= APP_LASER_CHANNEL_COUNT) { + return; } - if (strcmp(name, "yj/dark_mv") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[0].dark_mv, - -5000.0f, 5000.0f); - goto out; - } + laser_policy_from_settings(&stored, laser); + (void)app_nvs_write(laser_policy_nvs_id(channel), &stored, sizeof(stored)); + (void)app_nvs_write(laser_total_nvs_id(channel), + &laser->total_emitting_s, + sizeof(laser->total_emitting_s)); +} - if (strcmp(name, "yj/lowest_dark_mv") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[0].lowest_dark_mv, - -5000.0f, 5000.0f); - goto out; +static void app_nvs_persist_laser_total(uint8_t channel, double total_emitting_s) +{ + if (channel >= APP_LASER_CHANNEL_COUNT) { + return; } - if (strcmp(name, "yj/lowest_dark_valid") == 0) { - (void)read_bool(read_cb, cb_arg, - &g_settings.snapshot.photodiode.channel[0].lowest_dark_valid); - goto out; - } + (void)app_nvs_write(laser_total_nvs_id(channel), + &total_emitting_s, + sizeof(total_emitting_s)); +} - if (strcmp(name, "yj/noise_warn_rms_mv") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[0].noise_warn_rms_mv, - 0.0f, 5000.0f); - goto out; - } +static void app_nvs_persist_laserbank(const struct app_laserbank_settings *laserbank) +{ + uint32_t mode; - if (strcmp(name, "yj/gain_v_per_uw") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[0].gain_v_per_uw, - 0.000001f, 1000000000.0f); - goto out; + if (laserbank == NULL) { + return; } - if (strcmp(name, "hk/dark_mv") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[1].dark_mv, - -5000.0f, 5000.0f); - goto out; - } + mode = (uint32_t)laserbank->heater_mode; + (void)app_nvs_write(APP_NVS_ID_LASERBANK, &mode, sizeof(mode)); +} - if (strcmp(name, "hk/lowest_dark_mv") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[1].lowest_dark_mv, - -5000.0f, 5000.0f); - goto out; +static void app_nvs_persist_route_loss_index(uint8_t index, + const struct app_route_loss_record *record) +{ + if (record == NULL || index >= APP_ROUTE_LOSS_RECORD_COUNT) { + return; } - if (strcmp(name, "hk/lowest_dark_valid") == 0) { - (void)read_bool(read_cb, cb_arg, - &g_settings.snapshot.photodiode.channel[1].lowest_dark_valid); - goto out; - } + (void)app_nvs_write(route_loss_nvs_id(index), record, sizeof(*record)); +} - if (strcmp(name, "hk/noise_warn_rms_mv") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[1].noise_warn_rms_mv, - 0.0f, 5000.0f); - goto out; +static bool attenuator_channel_valid(const struct app_attenuator_channel_settings *atten) +{ + if (atten == NULL) { + return false; } - if (strcmp(name, "hk/gain_v_per_uw") == 0) { - read_valid_float_or_warn(read_cb, cb_arg, name, - &g_settings.snapshot.photodiode.channel[1].gain_v_per_uw, - 0.000001f, 1000000000.0f); - goto out; + for (uint8_t physical = 0U; physical < APP_ATTENUATOR_PHYSICAL_COUNT; ++physical) { + const struct app_attenuator_physical_settings *p = &atten->physical[physical]; + + if (!float_in_range(p->slope, -1000000000.0f, 1000000000.0f) || + !float_in_range(p->offset, -1000000000.0f, 1000000000.0f)) { + return false; + } } -out: - k_mutex_unlock(&g_settings.lock); - return 0; + return true; } -SETTINGS_STATIC_HANDLER_DEFINE(board_settings, "board", NULL, settings_set_cb, NULL, NULL); -SETTINGS_STATIC_HANDLER_DEFINE(serial_settings, "serial", NULL, settings_set_cb, NULL, NULL); -SETTINGS_STATIC_HANDLER_DEFINE(boot_settings, "boot", NULL, settings_set_cb, NULL, NULL); -SETTINGS_STATIC_HANDLER_DEFINE(ip_settings, "ip", NULL, settings_set_cb, NULL, NULL); -SETTINGS_STATIC_HANDLER_DEFINE(mqtt_settings, "mqtt", NULL, settings_set_cb, NULL, NULL); -SETTINGS_STATIC_HANDLER_DEFINE(atten_settings, "atten", NULL, settings_set_cb, NULL, NULL); -SETTINGS_STATIC_HANDLER_DEFINE(pd_settings, "pd", NULL, settings_set_cb, NULL, NULL); - -static void persist_bool(const char *key, bool value) +static bool pd_channel_valid(const struct app_nvs_pd_channel *pd) { - uint8_t v = value ? 1U : 0U; - (void)settings_save_one(key, &v, sizeof(v)); + return pd != NULL && + float_in_range(pd->dark_mv, -5000.0f, 5000.0f) && + float_in_range(pd->lowest_dark_mv, -5000.0f, 5000.0f) && + float_in_range(pd->noise_warn_rms_mv, 0.0f, 5000.0f) && + double_in_range(pd->responsivity_a_per_w, 0.000001, 10.0) && + double_in_range(pd->transimpedance_v_per_a, 1.0, 1.0e12); } -static void persist_u32(const char *key, uint32_t value) +static bool laser_policy_valid(const struct app_nvs_laser_policy *laser) { - (void)settings_save_one(key, &value, sizeof(value)); + return laser != NULL && + float_in_range(laser->nominal_current_ma, 0.0f, 1000.0f) && + float_in_range(laser->max_current_ma, 0.0f, 1000.0f) && + float_in_range(laser->threshold_current_ma, 0.0f, 1000.0f) && + float_in_range(laser->efficiency_mw_per_ma, 0.0f, 100.0f) && + float_in_range(laser->wavelength_nm, 1.0f, 10000.0f) && + float_in_range(laser->current_set_calibration_pct, 95.0f, 105.0f) && + float_in_range(laser->operating_temp_min_c, 15.0f, 40.0f) && + float_in_range(laser->operating_temp_max_c, 15.0f, 40.0f) && + float_in_range(laser->operating_temp_c, 15.0f, 40.0f) && + float_in_range(laser->dlambda_dT_nm_per_k, -10.0f, 10.0f) && + float_in_range(laser->dlambda_dA_nm_per_ma, -10.0f, 10.0f); } -static void persist_float(const char *key, float value) +static bool route_loss_record_valid(struct app_route_loss_record *record) { - (void)settings_save_one(key, &value, sizeof(value)); + if (record == NULL || !record->configured) { + return false; + } + + record->route[sizeof(record->route) - 1U] = '\0'; + record->laser[sizeof(record->laser) - 1U] = '\0'; + return record->route[0] != '\0' && + record->laser[0] != '\0' && + double_in_range(record->transmission, 0.000000001, 1.0); } -static void persist_str(const char *key, const char *value) +static void app_nvs_load_board_type(struct app_settings_snapshot *s) { - const size_t len = strlen(value) + 1U; - (void)settings_save_one(key, value, len); + char value[APP_SETTINGS_BOARD_TYPE_MAX_LEN] = {0}; + + if (app_nvs_read_exact(APP_NVS_ID_BOARD_TYPE, value, sizeof(value), "board_type")) { + value[sizeof(value) - 1U] = '\0'; + str_set(s->board_type, sizeof(s->board_type), value); + } } -static void attenuator_coeff_key(char *key, size_t key_len, - uint8_t channel, bool db_to_volt, - uint8_t coeff_index) +static void app_nvs_load_ip(struct app_settings_snapshot *s) { - (void)snprintk(key, key_len, "%s/%u/%s/%u", - KEY_ATTEN_PREFIX, channel, - db_to_volt ? "db2volt" : "volt2db", - coeff_index); + struct app_nvs_ip_settings stored; + + if (!app_nvs_read_exact(APP_NVS_ID_IP, &stored, sizeof(stored), "ip")) { + return; + } + + stored.ip[sizeof(stored.ip) - 1U] = '\0'; + stored.subnet[sizeof(stored.subnet) - 1U] = '\0'; + stored.gateway[sizeof(stored.gateway) - 1U] = '\0'; + stored.dns[sizeof(stored.dns) - 1U] = '\0'; + stored.ntp[sizeof(stored.ntp) - 1U] = '\0'; + s->ip.try_dhcp_first = stored.try_dhcp_first != 0U; + s->ip.prefer_dhcp_dns = stored.prefer_dhcp_dns != 0U; + s->ip.prefer_dhcp_ntp = stored.prefer_dhcp_ntp != 0U; + str_set(s->ip.ip, sizeof(s->ip.ip), stored.ip); + str_set(s->ip.subnet, sizeof(s->ip.subnet), stored.subnet); + str_set(s->ip.gateway, sizeof(s->ip.gateway), stored.gateway); + str_set(s->ip.dns, sizeof(s->ip.dns), stored.dns); + str_set(s->ip.ntp, sizeof(s->ip.ntp), stored.ntp); } -static void persist_attenuator_channel(uint8_t channel, - const struct app_attenuator_channel_settings *atten) +static void app_nvs_load_mqtt(struct app_settings_snapshot *s) { - char key[40]; + struct app_mqtt_settings stored; - if (atten == NULL || channel >= APP_ATTENUATOR_CHANNEL_COUNT) { + if (!app_nvs_read_exact(APP_NVS_ID_MQTT, &stored, sizeof(stored), "mqtt")) { + return; + } + + stored.broker_host[sizeof(stored.broker_host) - 1U] = '\0'; + if (stored.broker_host[0] == '\0' || stored.broker_port == 0U) { + LOG_WRN("Ignoring invalid stored MQTT settings"); return; } - for (uint8_t i = 0U; i < APP_ATTENUATOR_COEFF_COUNT; ++i) { - attenuator_coeff_key(key, sizeof(key), channel, true, i); - persist_float(key, atten->db_to_volt[i]); + str_set(s->mqtt.broker_host, sizeof(s->mqtt.broker_host), stored.broker_host); + s->mqtt.broker_port = stored.broker_port; +} + +static void app_nvs_load_attenuator(struct app_settings_snapshot *s) +{ + for (uint8_t channel = 0U; channel < APP_ATTENUATOR_CHANNEL_COUNT; ++channel) { + struct app_attenuator_channel_settings stored; + + if (!app_nvs_read_exact(attenuator_nvs_id(channel), &stored, + sizeof(stored), "attenuator")) { + continue; + } + if (!attenuator_channel_valid(&stored)) { + LOG_WRN("Ignoring invalid stored attenuator channel %u", channel); + continue; + } - attenuator_coeff_key(key, sizeof(key), channel, false, i); - persist_float(key, atten->volt_to_db[i]); + s->attenuator.channel[channel] = stored; } } -static void persist_photodiode_channel(uint8_t channel, - const struct app_pd_channel_settings *pd) +static void app_nvs_load_photodiode(struct app_settings_snapshot *s) { - if (channel == 0U) { - persist_float(KEY_PD_YJ_DARK_MV, pd->dark_mv); - persist_float(KEY_PD_YJ_LOWEST_DARK_MV, pd->lowest_dark_mv); - persist_bool(KEY_PD_YJ_LOWEST_DARK_VALID, pd->lowest_dark_valid); - persist_float(KEY_PD_YJ_NOISE_WARN_MV, pd->noise_warn_rms_mv); - persist_float(KEY_PD_YJ_GAIN_V_PER_UW, pd->gain_v_per_uw); + for (uint8_t channel = 0U; channel < APP_PD_CHANNEL_COUNT; ++channel) { + struct app_nvs_pd_channel stored; + struct app_pd_channel_settings *pd = &s->photodiode.channel[channel]; + + if (!app_nvs_read_exact(pd_nvs_id(channel), &stored, + sizeof(stored), "photodiode")) { + continue; + } + if (!pd_channel_valid(&stored)) { + LOG_WRN("Ignoring invalid stored photodiode channel %u", channel); + continue; + } + + pd->dark_mv = stored.dark_mv; + pd->lowest_dark_mv = stored.lowest_dark_mv; + pd->lowest_dark_valid = stored.lowest_dark_valid != 0U; + pd->noise_warn_rms_mv = stored.noise_warn_rms_mv; + pd->responsivity_a_per_w = stored.responsivity_a_per_w; + pd->transimpedance_v_per_a = stored.transimpedance_v_per_a; + } +} + +static void app_nvs_load_laserbank(struct app_settings_snapshot *s) +{ + uint32_t value; + + if (!app_nvs_read_exact(APP_NVS_ID_LASERBANK, &value, sizeof(value), "laserbank")) { + return; + } + if (value > LASERBANK_HEATER_MODE_OVERRIDE_OFF) { + LOG_WRN("Ignoring invalid stored laser-bank heater mode"); return; } - if (channel == 1U) { - persist_float(KEY_PD_HK_DARK_MV, pd->dark_mv); - persist_float(KEY_PD_HK_LOWEST_DARK_MV, pd->lowest_dark_mv); - persist_bool(KEY_PD_HK_LOWEST_DARK_VALID, pd->lowest_dark_valid); - persist_float(KEY_PD_HK_NOISE_WARN_MV, pd->noise_warn_rms_mv); - persist_float(KEY_PD_HK_GAIN_V_PER_UW, pd->gain_v_per_uw); - } -} - -static const char *const resettable_setting_keys[] = { - KEY_SERIAL_HOLDOFF, - KEY_BOOT_COUNT, - KEY_IP_TRY_DHCP, - KEY_IP_PREF_DNS, - KEY_IP_PREF_NTP, - KEY_IP_ADDR, - KEY_IP_SUBNET, - KEY_IP_GATEWAY, - KEY_IP_DNS, - KEY_IP_NTP, - KEY_MQTT_BROKER, - KEY_PD_YJ_DARK_MV, - KEY_PD_YJ_LOWEST_DARK_MV, - KEY_PD_YJ_LOWEST_DARK_VALID, - KEY_PD_YJ_NOISE_WARN_MV, - KEY_PD_YJ_GAIN_V_PER_UW, - KEY_PD_HK_DARK_MV, - KEY_PD_HK_LOWEST_DARK_MV, - KEY_PD_HK_LOWEST_DARK_VALID, - KEY_PD_HK_NOISE_WARN_MV, - KEY_PD_HK_GAIN_V_PER_UW, -}; + s->laserbank.heater_mode = (enum laserbank_heater_mode)value; +} + +static void app_nvs_apply_laser_policy(struct app_laser_channel_settings *laser, + const struct app_nvs_laser_policy *stored) +{ + laser->properties.nominal_current_ma = stored->nominal_current_ma; + laser->properties.max_current_ma = stored->max_current_ma; + laser->properties.threshold_current_ma = stored->threshold_current_ma; + laser->properties.efficiency_mw_per_ma = stored->efficiency_mw_per_ma; + laser->properties.wavelength_nm = stored->wavelength_nm; + laser->current_set_calibration_pct = stored->current_set_calibration_pct; + laser->properties.operating_temp_range_c.min_c = stored->operating_temp_min_c; + laser->properties.operating_temp_range_c.max_c = stored->operating_temp_max_c; + laser->properties.operating_temp_c = stored->operating_temp_c; + laser->properties.tec_pid.kp = stored->tec_pid_p; + laser->properties.tec_pid.ki = stored->tec_pid_i; + laser->properties.tec_pid.kd = stored->tec_pid_d; + laser->disable_tec_at_autooff = stored->disable_tec_at_autooff != 0U; + laser->properties.dlambda_dT_nm_per_k = stored->dlambda_dT_nm_per_k; + laser->properties.dlambda_dA_nm_per_ma = stored->dlambda_dA_nm_per_ma; + laser->autooff_s = stored->autooff_s; + laser->tune_delta_nm = stored->tune_delta_nm; +} -static void delete_setting_key(const char *key) +static void app_nvs_load_laser(struct app_settings_snapshot *s) { - int rc = settings_delete(key); + for (uint8_t channel = 0U; channel < APP_LASER_CHANNEL_COUNT; ++channel) { + struct app_nvs_laser_policy policy; + double total_emitting_s; + + if (app_nvs_read_exact(laser_policy_nvs_id(channel), &policy, + sizeof(policy), "laser policy")) { + if (laser_policy_valid(&policy)) { + app_nvs_apply_laser_policy(&s->laser.channel[channel], &policy); + } else { + LOG_WRN("Ignoring invalid stored laser policy channel %u", channel); + } + } - if (rc != 0 && rc != -ENOENT) { - LOG_WRN("settings_delete(%s) failed (%d)", key, rc); + if (app_nvs_read_exact(laser_total_nvs_id(channel), &total_emitting_s, + sizeof(total_emitting_s), "laser total") && + double_in_range(total_emitting_s, 0.0, 1.0e12)) { + s->laser.channel[channel].total_emitting_s = total_emitting_s; + } } } -static void delete_attenuator_settings(void) +static void app_nvs_load_route_loss(struct app_settings_snapshot *s) { - char key[40]; + for (uint8_t i = 0U; i < APP_ROUTE_LOSS_RECORD_COUNT; ++i) { + struct app_route_loss_record stored; - for (uint8_t channel = 0U; channel < APP_ATTENUATOR_CHANNEL_COUNT; ++channel) { - for (uint8_t i = 0U; i < APP_ATTENUATOR_COEFF_COUNT; ++i) { - attenuator_coeff_key(key, sizeof(key), channel, true, i); - delete_setting_key(key); - - attenuator_coeff_key(key, sizeof(key), channel, false, i); - delete_setting_key(key); + if (!app_nvs_read_exact(route_loss_nvs_id(i), &stored, + sizeof(stored), "route loss")) { + continue; + } + if (!route_loss_record_valid(&stored)) { + LOG_WRN("Ignoring invalid stored route-loss record %u", i); + continue; } + + s->route_loss.record[i] = stored; } } +static void app_nvs_load_all(struct app_settings_snapshot *s) +{ + uint32_t value; + + app_nvs_load_board_type(s); + if (app_nvs_read_exact(APP_NVS_ID_BOOT_COUNT, &value, sizeof(value), "boot count")) { + s->boot_count = value; + } + if (app_nvs_read_exact(APP_NVS_ID_LAST_KNOWN_UTC_MS, &s->last_known_utc_ms, + sizeof(s->last_known_utc_ms), "last known UTC") && + s->last_known_utc_ms == 0U) { + LOG_WRN("Ignoring invalid stored last known UTC"); + } + app_nvs_load_ip(s); + app_nvs_load_mqtt(s); + app_nvs_load_attenuator(s); + app_nvs_load_photodiode(s); + app_nvs_load_laserbank(s); + app_nvs_load_laser(s); + app_nvs_load_route_loss(s); +} + static void delete_resettable_settings(void) { - for (uint8_t i = 0; i < ARRAY_SIZE(resettable_setting_keys); ++i) { - /* settings_delete() removes one persisted key from the Zephyr - * settings backend; missing keys are fine during first boot. - */ - delete_setting_key(resettable_setting_keys[i]); + (void)app_nvs_delete(APP_NVS_ID_SERIAL_HOLDOFF_UNUSED); + (void)app_nvs_delete(APP_NVS_ID_BOOT_COUNT); + (void)app_nvs_delete(APP_NVS_ID_LAST_KNOWN_UTC_MS); + (void)app_nvs_delete(APP_NVS_ID_LAST_COMMAND); + (void)app_nvs_delete(APP_NVS_ID_IP); + (void)app_nvs_delete(APP_NVS_ID_MQTT); + (void)app_nvs_delete(APP_NVS_ID_LASERBANK); + + for (uint8_t channel = 0U; channel < APP_ATTENUATOR_CHANNEL_COUNT; ++channel) { + (void)app_nvs_delete(attenuator_nvs_id(channel)); + } + for (uint8_t channel = 0U; channel < APP_PD_CHANNEL_COUNT; ++channel) { + (void)app_nvs_delete(pd_nvs_id(channel)); + } + for (uint8_t channel = 0U; channel < APP_LASER_CHANNEL_COUNT; ++channel) { + (void)app_nvs_delete(laser_policy_nvs_id(channel)); + (void)app_nvs_delete(laser_total_nvs_id(channel)); + } + for (uint8_t i = 0U; i < APP_ROUTE_LOSS_RECORD_COUNT; ++i) { + (void)app_nvs_delete(route_loss_nvs_id(i)); + } +} + +static int route_loss_record_index_locked(const char *route, const char *laser, + bool allocate) +{ + int first_free = -1; + + for (uint8_t i = 0U; i < APP_ROUTE_LOSS_RECORD_COUNT; ++i) { + struct app_route_loss_record *record = + &g_settings.snapshot.route_loss.record[i]; + + if (!record->configured) { + if (first_free < 0) { + first_free = i; + } + continue; + } + + if (strcmp(record->route, route) == 0 && + strcmp(record->laser, laser) == 0) { + return i; + } } - delete_attenuator_settings(); + return allocate ? first_free : -1; } int app_settings_init(void) { - static const char *const app_settings_subtrees[] = { - "board", "serial", "boot", "ip", "mqtt", "atten", "pd", - }; int rc; k_mutex_init(&g_settings.lock); settings_defaults(&g_settings.snapshot); - /* settings_subsys_init() attaches the configured Zephyr settings backend - * before any app-owned keys can be loaded or saved. + /* Direct NVS keeps Zephyr's flash wear-leveling and recovery behavior + * without storing human-readable setting names alongside small values. */ - rc = settings_subsys_init(); + rc = app_nvs_mount(); if (rc != 0) { - LOG_ERR("settings_subsys_init failed (%d)", rc); return rc; } - for (uint8_t i = 0U; i < ARRAY_SIZE(app_settings_subtrees); ++i) { - rc = settings_load_subtree(app_settings_subtrees[i]); - if (rc != 0) { - LOG_WRN("settings_load_subtree('%s') failed (%d)", - app_settings_subtrees[i], rc); - return rc; - } + rc = app_nvs_ensure_schema(); + if (rc != 0) { + return rc; } + app_nvs_load_all(&g_settings.snapshot); return 0; } @@ -580,13 +835,9 @@ void app_settings_get_snapshot(struct app_settings_snapshot *out) int app_settings_note_board_type(const char *board_type, bool *changed) { - bool changed_local = false; bool persist_needed = false; bool reset_needed = false; - if (changed != NULL) { - *changed = false; - } if (board_type == NULL || board_type[0] == '\0' || strlen(board_type) >= APP_SETTINGS_BOARD_TYPE_MAX_LEN) { return -EINVAL; @@ -608,9 +859,8 @@ int app_settings_note_board_type(const char *board_type, bool *changed) str_set(g_settings.snapshot.board_type, sizeof(g_settings.snapshot.board_type), board_type); - changed_local = true; - reset_needed = true; persist_needed = true; + reset_needed = true; } k_mutex_unlock(&g_settings.lock); @@ -618,10 +868,10 @@ int app_settings_note_board_type(const char *board_type, bool *changed) delete_resettable_settings(); } if (persist_needed) { - persist_str(KEY_BOARD_TYPE, board_type); + app_nvs_persist_board_type(board_type); } if (changed != NULL) { - *changed = changed_local; + *changed = reset_needed; } return 0; @@ -649,14 +899,7 @@ void app_settings_update_ip(const struct app_ip_settings *ip, bool persist) k_mutex_unlock(&g_settings.lock); if (persist) { - persist_bool(KEY_IP_TRY_DHCP, ip->try_dhcp_first); - persist_bool(KEY_IP_PREF_DNS, ip->prefer_dhcp_dns); - persist_bool(KEY_IP_PREF_NTP, ip->prefer_dhcp_ntp); - persist_str(KEY_IP_ADDR, ip->ip); - persist_str(KEY_IP_SUBNET, ip->subnet); - persist_str(KEY_IP_GATEWAY, ip->gateway); - persist_str(KEY_IP_DNS, ip->dns); - persist_str(KEY_IP_NTP, ip->ntp); + app_nvs_persist_ip(ip); } } @@ -683,15 +926,7 @@ void app_settings_update_mqtt(const struct app_mqtt_settings *mqtt, bool persist k_mutex_unlock(&g_settings.lock); if (persist) { - struct coo_mqtt_broker_config cfg = { - .port = mqtt->broker_port, - }; - char endpoint[160]; - - str_set(cfg.host, sizeof(cfg.host), mqtt->broker_host); - if (coo_mqtt_format_broker_endpoint(&cfg, endpoint, sizeof(endpoint)) == 0) { - persist_str(KEY_MQTT_BROKER, endpoint); - } + app_nvs_persist_mqtt(mqtt); } } @@ -719,7 +954,7 @@ void app_settings_update_attenuator_channel(uint8_t channel, k_mutex_unlock(&g_settings.lock); if (persist) { - persist_attenuator_channel(channel, atten); + app_nvs_persist_attenuator_channel(channel, atten); } } @@ -745,8 +980,8 @@ void app_settings_update_photodiode(const struct app_photodiode_settings *pd, bo k_mutex_unlock(&g_settings.lock); if (persist) { - persist_photodiode_channel(0U, &pd->channel[0]); - persist_photodiode_channel(1U, &pd->channel[1]); + app_nvs_persist_pd_channel(0U, &pd->channel[0]); + app_nvs_persist_pd_channel(1U, &pd->channel[1]); } } @@ -763,41 +998,172 @@ void app_settings_update_photodiode_channel(uint8_t channel, k_mutex_unlock(&g_settings.lock); if (persist) { - persist_photodiode_channel(channel, pd); + app_nvs_persist_pd_channel(channel, pd); } } -uint32_t app_settings_get_mqtt_revision(void) +void app_settings_get_laserbank(struct app_laserbank_settings *out) { - uint32_t value; + if (out == NULL) { + return; + } k_mutex_lock(&g_settings.lock, K_FOREVER); - value = g_settings.snapshot.mqtt_revision; + *out = g_settings.snapshot.laserbank; k_mutex_unlock(&g_settings.lock); +} - return value; +void app_settings_update_laserbank(const struct app_laserbank_settings *laserbank, + bool persist) +{ + if (laserbank == NULL) { + return; + } + + k_mutex_lock(&g_settings.lock, K_FOREVER); + g_settings.snapshot.laserbank = *laserbank; + k_mutex_unlock(&g_settings.lock); + + if (persist) { + app_nvs_persist_laserbank(laserbank); + } } -uint32_t app_settings_get_serial_holdoff_s(void) +void app_settings_get_laser(struct app_laser_settings *out) { - uint32_t value; + if (out == NULL) { + return; + } k_mutex_lock(&g_settings.lock, K_FOREVER); - value = g_settings.snapshot.serial_holdoff_s; + *out = g_settings.snapshot.laser; k_mutex_unlock(&g_settings.lock); +} - return value; +int app_settings_get_laser_channel(uint8_t channel, + struct app_laser_channel_settings *out) +{ + if (out == NULL || channel >= APP_LASER_CHANNEL_COUNT) { + return -EINVAL; + } + + k_mutex_lock(&g_settings.lock, K_FOREVER); + *out = g_settings.snapshot.laser.channel[channel]; + k_mutex_unlock(&g_settings.lock); + return 0; +} + +int app_settings_update_laser_channel(uint8_t channel, + const struct app_laser_channel_settings *laser, + bool persist) +{ + if (laser == NULL || channel >= APP_LASER_CHANNEL_COUNT) { + return -EINVAL; + } + + k_mutex_lock(&g_settings.lock, K_FOREVER); + g_settings.snapshot.laser.channel[channel] = *laser; + k_mutex_unlock(&g_settings.lock); + + if (persist) { + app_nvs_persist_laser_channel(channel, laser); + } + + return 0; +} + +int app_settings_update_laser_total_emitting(uint8_t channel, + double total_emitting_s, + bool persist) +{ + if (channel >= APP_LASER_CHANNEL_COUNT || !(total_emitting_s >= 0.0)) { + return -EINVAL; + } + + k_mutex_lock(&g_settings.lock, K_FOREVER); + g_settings.snapshot.laser.channel[channel].total_emitting_s = total_emitting_s; + k_mutex_unlock(&g_settings.lock); + + if (persist) { + app_nvs_persist_laser_total(channel, total_emitting_s); + } + + return 0; +} + +int app_settings_get_route_loss(const char *route, const char *laser, + double *transmission) +{ + int index; + + if (transmission == NULL || route == NULL || laser == NULL) { + return -EINVAL; + } + if (route[0] == '\0' || laser[0] == '\0' || + strlen(route) >= APP_ROUTE_LOSS_ROUTE_MAX_LEN || + strlen(laser) >= APP_ROUTE_LOSS_LASER_MAX_LEN) { + return -EINVAL; + } + + *transmission = 1.0; + + k_mutex_lock(&g_settings.lock, K_FOREVER); + index = route_loss_record_index_locked(route, laser, false); + if (index >= 0) { + *transmission = g_settings.snapshot.route_loss.record[index].transmission; + } + k_mutex_unlock(&g_settings.lock); + + return 0; } -void app_settings_set_serial_holdoff_s(uint32_t seconds, bool persist) +int app_settings_set_route_loss(const char *route, const char *laser, + double transmission, bool persist) { + int index; + struct app_route_loss_record record; + + if (route == NULL || laser == NULL || + route[0] == '\0' || laser[0] == '\0' || + strlen(route) >= APP_ROUTE_LOSS_ROUTE_MAX_LEN || + strlen(laser) >= APP_ROUTE_LOSS_LASER_MAX_LEN || + !(transmission > 0.0 && transmission <= 1.0)) { + return -EINVAL; + } + + memset(&record, 0, sizeof(record)); + record.configured = true; + str_set(record.route, sizeof(record.route), route); + str_set(record.laser, sizeof(record.laser), laser); + record.transmission = transmission; + k_mutex_lock(&g_settings.lock, K_FOREVER); - g_settings.snapshot.serial_holdoff_s = seconds; + index = route_loss_record_index_locked(route, laser, true); + if (index >= 0) { + g_settings.snapshot.route_loss.record[index] = record; + } k_mutex_unlock(&g_settings.lock); + if (index < 0) { + return -ENOSPC; + } + if (persist) { - persist_u32(KEY_SERIAL_HOLDOFF, seconds); + app_nvs_persist_route_loss_index((uint8_t)index, &record); } + + return 0; +} + +uint32_t app_settings_get_mqtt_revision(void) +{ + uint32_t value; + + k_mutex_lock(&g_settings.lock, K_FOREVER); + value = g_settings.snapshot.mqtt_revision; + k_mutex_unlock(&g_settings.lock); + + return value; } uint32_t app_settings_get_boot_count(void) @@ -820,5 +1186,47 @@ void app_settings_increment_boot_count(void) value = g_settings.snapshot.boot_count; k_mutex_unlock(&g_settings.lock); - persist_u32(KEY_BOOT_COUNT, value); + (void)app_nvs_write(APP_NVS_ID_BOOT_COUNT, &value, sizeof(value)); +} + +bool app_settings_get_last_known_utc_ms(uint64_t *utc_ms) +{ + uint64_t value; + + if (utc_ms == NULL) { + return false; + } + + k_mutex_lock(&g_settings.lock, K_FOREVER); + value = g_settings.snapshot.last_known_utc_ms; + k_mutex_unlock(&g_settings.lock); + + if (value == 0U) { + return false; + } + + *utc_ms = value; + return true; +} + +void app_settings_note_time_utc_ms(uint64_t utc_ms) +{ + if (utc_ms == 0U) { + return; + } + + k_mutex_lock(&g_settings.lock, K_FOREVER); + if (utc_ms == g_settings.snapshot.last_known_utc_ms) { + k_mutex_unlock(&g_settings.lock); + return; + } + g_settings.snapshot.last_known_utc_ms = utc_ms; + k_mutex_unlock(&g_settings.lock); + + (void)app_nvs_write(APP_NVS_ID_LAST_KNOWN_UTC_MS, &utc_ms, sizeof(utc_ms)); +} + +struct nvs_fs *app_settings_nvs_fs(void) +{ + return app_nvs_ready ? &app_nvs : NULL; } diff --git a/app/src/app_settings.h b/app/src/app_settings.h index 8370659..336fd17 100644 --- a/app/src/app_settings.h +++ b/app/src/app_settings.h @@ -1,10 +1,10 @@ /** * @file app_settings.h - * @brief Zephyr settings-backed app configuration and calibration ownership. + * @brief Direct-NVS app configuration and calibration ownership. * - * App-owned top-level settings subtrees store board identity, boot count, - * operator network/MQTT configuration, serial guard duration, attenuator - * coefficients, and photodiode calibration/noise thresholds. + * App-owned numeric NVS IDs store board identity, boot count, operator + * network/MQTT configuration, attenuator coefficients, photodiode + * calibration/response settings, laser policy, and route loss. * * Copyright (c) 2026 Caltech Optical Observatories * SPDX-License-Identifier: Apache-2.0 @@ -17,6 +17,13 @@ #include #include +#include "laser_properties.h" +#include "laserbank_tempcontrol.h" + +#define APP_SETTINGS_NVS_ID_LAST_COMMAND 0x0009U + +struct nvs_fs; + struct app_ip_settings { bool try_dhcp_first; bool prefer_dhcp_dns; @@ -36,15 +43,24 @@ struct app_mqtt_settings { /** Number of logical attenuator channels whose calibration may be persisted. */ #define APP_ATTENUATOR_CHANNEL_COUNT 6 -/** Number of quadratic coefficients per attenuator calibration polynomial. */ -#define APP_ATTENUATOR_COEFF_COUNT 3 +/** Number of model coefficients per physical attenuator: b = slope * voltage + offset. */ +#define APP_ATTENUATOR_COEFF_COUNT 2 +#define APP_ATTENUATOR_PHYSICAL_COUNT 2 #define APP_PD_CHANNEL_COUNT 2 #define APP_SETTINGS_BOARD_TYPE_MAX_LEN 16 +#define APP_ROUTE_LOSS_RECORD_COUNT 18 +#define APP_ROUTE_LOSS_ROUTE_MAX_LEN 24 +#define APP_ROUTE_LOSS_LASER_MAX_LEN 16 +#define APP_LASER_CHANNEL_COUNT 6 + +struct app_attenuator_physical_settings { + float slope; + float offset; +}; /** Persisted/runtime calibration for one logical attenuator channel. */ struct app_attenuator_channel_settings { - float db_to_volt[APP_ATTENUATOR_COEFF_COUNT]; - float volt_to_db[APP_ATTENUATOR_COEFF_COUNT]; + struct app_attenuator_physical_settings physical[APP_ATTENUATOR_PHYSICAL_COUNT]; }; /** Persisted/runtime attenuator calibration snapshot. */ @@ -52,19 +68,50 @@ struct app_attenuator_settings { struct app_attenuator_channel_settings channel[APP_ATTENUATOR_CHANNEL_COUNT]; }; -/** Photodiode calibration and warning thresholds owned by app settings. */ +/** Photodiode calibration, response, and warning thresholds owned by app settings. */ struct app_pd_channel_settings { float dark_mv; float lowest_dark_mv; bool lowest_dark_valid; float noise_warn_rms_mv; - float gain_v_per_uw; + double responsivity_a_per_w; + double transimpedance_v_per_a; }; struct app_photodiode_settings { struct app_pd_channel_settings channel[APP_PD_CHANNEL_COUNT]; }; +struct app_laserbank_settings { + enum laserbank_heater_mode heater_mode; +}; + +/** App-owned laser policy/calibration settings. Driver EEPROM owns raw driver persistence. */ +struct app_laser_channel_settings { + laserprops_t properties; + float current_set_calibration_pct; + bool disable_tec_at_autooff; + uint32_t autooff_s; + float tune_delta_nm; + double total_emitting_s; +}; + +struct app_laser_settings { + struct app_laser_channel_settings channel[APP_LASER_CHANNEL_COUNT]; +}; + +/** User-provided optical route transmission keyed by route and laser names. */ +struct app_route_loss_record { + bool configured; + char route[APP_ROUTE_LOSS_ROUTE_MAX_LEN]; + char laser[APP_ROUTE_LOSS_LASER_MAX_LEN]; + double transmission; +}; + +struct app_route_loss_settings { + struct app_route_loss_record record[APP_ROUTE_LOSS_RECORD_COUNT]; +}; + /** Persisted/runtime settings snapshot copied under a module mutex. */ struct app_settings_snapshot { char board_type[APP_SETTINGS_BOARD_TYPE_MAX_LEN]; @@ -72,16 +119,20 @@ struct app_settings_snapshot { struct app_mqtt_settings mqtt; struct app_attenuator_settings attenuator; struct app_photodiode_settings photodiode; - uint32_t serial_holdoff_s; + struct app_laserbank_settings laserbank; + struct app_laser_settings laser; + struct app_route_loss_settings route_loss; uint32_t boot_count; + uint64_t last_known_utc_ms; uint32_t mqtt_revision; }; /** - * @brief Initialize Zephyr settings, load app subtrees, and keep defaults on failure. + * @brief Mount app NVS storage, load persisted values, and keep defaults on failure. * - * Calls `settings_subsys_init()` and `settings_load_subtree()`, so it may - * block on the configured settings backend. + * Uses Zephyr NVS directly with app-owned numeric IDs, so it may block on + * flash I/O. If the stored schema marker is missing or incompatible, app NVS + * storage is cleared and defaults are used. */ int app_settings_init(void); void app_settings_get_snapshot(struct app_settings_snapshot *out); @@ -108,7 +159,7 @@ void app_settings_get_attenuator(struct app_attenuator_settings *out); * * @param channel Zero-based logical attenuator channel index. * @param atten Channel coefficient settings copied into the runtime snapshot. - * @param persist If true, save this channel's coefficients through Zephyr settings. + * @param persist If true, save this channel's coefficients through Zephyr NVS. */ void app_settings_update_attenuator_channel(uint8_t channel, const struct app_attenuator_channel_settings *atten, @@ -122,20 +173,62 @@ void app_settings_update_photodiode(const struct app_photodiode_settings *pd, bo * * @param channel Zero-based photodiode channel index. * @param pd Channel settings to copy into the runtime snapshot. - * @param persist If true, save only this channel's keys through Zephyr settings. + * @param persist If true, save only this channel's NVS record. */ void app_settings_update_photodiode_channel(uint8_t channel, const struct app_pd_channel_settings *pd, bool persist); +/** @brief Copy current laser-bank heater mode setting. */ +void app_settings_get_laserbank(struct app_laserbank_settings *out); +/** @brief Replace laser-bank heater mode setting. */ +void app_settings_update_laserbank(const struct app_laserbank_settings *laserbank, + bool persist); +/** @brief Copy app-owned laser settings and lifetime counters. */ +void app_settings_get_laser(struct app_laser_settings *out); +/** @brief Copy one app-owned laser channel's settings. */ +int app_settings_get_laser_channel(uint8_t channel, + struct app_laser_channel_settings *out); +/** + * @brief Replace one laser channel's app-owned settings. + * + * Driver-backed values are only app intent here; lasers.c owns applying those + * values to the Maiman module. Laser output current is intentionally absent. + */ +int app_settings_update_laser_channel(uint8_t channel, + const struct app_laser_channel_settings *laser, + bool persist); +/** @brief Update the persisted/runtime total emitting counter for one laser. */ +int app_settings_update_laser_total_emitting(uint8_t channel, + double total_emitting_s, + bool persist); +/** + * @brief Get one route-loss record. + * + * Missing records are not errors; @p transmission is returned as 1.0 so + * optical math can treat unspecified routes as loss-free. + */ +int app_settings_get_route_loss(const char *route, const char *laser, + double *transmission); +/** + * @brief Store or update one route-loss record. + * + * The record is keyed only by route and laser names. It does not change MEMS + * route structs or switch state. If @p persist is true, the indexed route-loss + * record is saved via Zephyr NVS and may block on flash I/O. + */ +int app_settings_set_route_loss(const char *route, const char *laser, + double transmission, bool persist); /** @brief Monotonic runtime counter used by main.c to reconnect MQTT. */ uint32_t app_settings_get_mqtt_revision(void); -/** @brief Get serial-command guard duration in seconds. */ -uint32_t app_settings_get_serial_holdoff_s(void); -/** @brief Set serial-command guard duration and optionally persist. */ -void app_settings_set_serial_holdoff_s(uint32_t seconds, bool persist); /** @brief Get persisted boot count. */ uint32_t app_settings_get_boot_count(void); /** @brief Increment and persist boot count. */ void app_settings_increment_boot_count(void); +/** @brief Return true and copy the last persisted UTC time if one is known. */ +bool app_settings_get_last_known_utc_ms(uint64_t *utc_ms); +/** @brief Store a known-good UTC time for boot-time clock initialization. */ +void app_settings_note_time_utc_ms(uint64_t utc_ms); +/** @brief Return app NVS storage for command-dispatch owned records, or NULL if unavailable. */ +struct nvs_fs *app_settings_nvs_fs(void); #endif /* HISPEC_APP_SETTINGS_H */ diff --git a/app/src/app_warning.c b/app/src/app_warning.c deleted file mode 100644 index 266c103..0000000 --- a/app/src/app_warning.c +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @file app_warning.c - * @brief Build warning JSON and enqueue best-effort MQTT warning messages. - * - * This module logs locally and attempts a non-blocking put to outbound_queue. - * It never publishes MQTT directly and drops warnings when queueing fails. - * - * Copyright (c) 2026 Caltech Optical Observatories - * SPDX-License-Identifier: Apache-2.0 - */ - -#include "app_warning.h" - -#include -#include -#include -#include - -#include "app_identity.h" -#include "command.h" - -LOG_MODULE_REGISTER(app_warning, LOG_LEVEL_INF); - -#define APP_WARNING_TOPIC "dt/" APP_MQTT_DEVICE_ID "/warning" - -static int append_json_string(char *buf, size_t buf_len, size_t *off, - const char *text) -{ - const char *s = text != NULL ? text : ""; - - if (buf == NULL || off == NULL || *off >= buf_len) { - return -EINVAL; - } - - for (; *s != '\0'; ++s) { - int written; - - if (*s == '"' || *s == '\\') { - written = snprintk(buf + *off, buf_len - *off, "\\%c", *s); - } else if ((unsigned char)*s < 0x20U) { - written = snprintk(buf + *off, buf_len - *off, "?"); - } else { - written = snprintk(buf + *off, buf_len - *off, "%c", *s); - } - - if (written < 0 || written >= (int)(buf_len - *off)) { - return -ENOSPC; - } - *off += (size_t)written; - } - - return 0; -} - -static int build_warning_payload(char *buf, size_t buf_len, - const char *code, const char *msg, - const char *context) -{ - size_t off; - int written; - - written = snprintk(buf, buf_len, - "{\"severity\":\"warning\",\"code\":\""); - if (written < 0 || written >= (int)buf_len) { - return -ENOSPC; - } - off = (size_t)written; - - if (append_json_string(buf, buf_len, &off, code) != 0) { - return -ENOSPC; - } - - written = snprintk(buf + off, buf_len - off, "\",\"msg\":\""); - if (written < 0 || written >= (int)(buf_len - off)) { - return -ENOSPC; - } - off += (size_t)written; - - if (append_json_string(buf, buf_len, &off, msg) != 0) { - return -ENOSPC; - } - - written = snprintk(buf + off, buf_len - off, "\",\"context\":\""); - if (written < 0 || written >= (int)(buf_len - off)) { - return -ENOSPC; - } - off += (size_t)written; - - if (append_json_string(buf, buf_len, &off, context) != 0) { - return -ENOSPC; - } - - written = snprintk(buf + off, buf_len - off, - "\",\"uptime_ms\":%lld}", - (long long)k_uptime_get()); - if (written < 0 || written >= (int)(buf_len - off)) { - return -ENOSPC; - } - - return 0; -} - -void app_warning_emit(const char *code, const char *msg, const char *context) -{ - struct OutMsg out = {0}; - - LOG_WRN("%s: %s%s%s", - code != NULL ? code : "warning", - msg != NULL ? msg : "", - context != NULL && context[0] != '\0' ? " context=" : "", - context != NULL ? context : ""); - - out.msg_type = RESP_OK; - out.target = OUT_TARGET_MQTT_BEST_EFFORT; - out.qos = 0U; - snprintk(out.topic, sizeof(out.topic), APP_WARNING_TOPIC); - - if (build_warning_payload(out.payload, sizeof(out.payload), code, msg, context) != 0) { - LOG_WRN("warning payload too large; MQTT warning dropped"); - return; - } - out.payload_len = strlen(out.payload); - - /* Non-blocking by design: warning publication must not break command - * execution or timing-sensitive loops. - */ - if (k_msgq_put(&outbound_queue, &out, K_NO_WAIT) != 0) { - LOG_WRN("warning MQTT queue full; warning was only logged locally"); - } -} diff --git a/app/src/app_warning.h b/app/src/app_warning.h deleted file mode 100644 index 82b5e3c..0000000 --- a/app/src/app_warning.h +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file app_warning.h - * @brief Best-effort warning logging and MQTT publication entry point. - * - * Warnings enqueue outbound MQTT messages without blocking command execution or - * timing-sensitive work. Delivery is intentionally lossy. - * - * Copyright (c) 2026 Caltech Optical Observatories - * SPDX-License-Identifier: Apache-2.0 - */ - -#ifndef HISPEC_APP_WARNING_H -#define HISPEC_APP_WARNING_H - -/** - * @brief Emit a lightweight warning to local logs and best-effort MQTT. - * - * Warnings are for suspicious or degraded conditions that should be visible but - * should not make a command fail by themselves. Publication uses a non-blocking - * queue put and may be dropped if MQTT is unavailable or the queue is full. - * - * @param code Short stable warning code, for example "serial_guard_active". - * @param msg Human-readable warning text. - * @param context Optional short context string, such as a command key. - */ -void app_warning_emit(const char *code, const char *msg, const char *context); - -#endif /* HISPEC_APP_WARNING_H */ diff --git a/app/src/attenuator.c b/app/src/attenuator.c index 5209e12..044e2f2 100644 --- a/app/src/attenuator.c +++ b/app/src/attenuator.c @@ -1,112 +1,468 @@ /** * @file attenuator.c - * @brief DAC write/read helpers for logical attenuator channels. + * @brief DAC write/read helpers for paired physical attenuators. */ -//TODO The attenuators had a non-linear relationship between dB and voltage that we want to calibrate and then -// then set on dB, not voltage - #include "attenuator.h" -#include "app_warning.h" -#include "devices.h" -#include "drivers/dac/dac7578.h" -LOG_MODULE_REGISTER(attenuator, LOG_LEVEL_INF); +#include "command.h" +#include "drivers/dac/dac7x78.h" -//See -//https://docs.zephyrproject.org/apidoc/latest/group__dac__interface.html#gab8be77003ba8fd7225c0808f95602a56 -//https://github.com/zephyrproject-rtos/zephyr/blob/main/samples/drivers/dac/src/main.c +#include +#include +#include +#include +LOG_MODULE_REGISTER(attenuator, LOG_LEVEL_INF); + +#define DAC_RESOLUTION_BITS 12 +#define DAC_MAX_CODE ((1 << DAC_RESOLUTION_BITS) - 1) +#define MODEL_ERF_SCALE 4.0 +#define MODEL_MAX_DB 120.0 +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif -#define DAC_RESOLUTION_BITS 12 -#define DAC_MAX_CODE ((1 << DAC_RESOLUTION_BITS) - 1) -#define MAX_VOLTAGE 4096.0 +int attenuator_index_from_laser_id(enum hispec_laser_id laser, uint8_t *index) +{ + if (index == NULL || laser < 0 || laser >= HISPEC_LASER_COUNT) { + return -EINVAL; + } -// static const struct device *dac_dev = DEVICE_DT_GET(DT_NODELABEL(dac7578)); //or DEVICE_DT_GET_OR_NULL + *index = (uint8_t)laser; + return 0; +} -bool attenuator_init(struct attenuator *drv, uint8_t channel) { - drv->voltage = 0.0; - drv->cfg.channel_id = channel; - drv->cfg.resolution = DAC_RESOLUTION_BITS; +static void attenuator_cfg_init(struct attenuator_dac_cfg *dac_cfg, + const struct device *dev, uint8_t channel) +{ + dac_cfg->dev = dev; + dac_cfg->voltage = 0.0; + dac_cfg->attenuation_db = 0.0; + dac_cfg->cfg.channel_id = channel; + dac_cfg->cfg.resolution = DAC_RESOLUTION_BITS; #if defined(CONFIG_DAC_BUFFER_NOT_SUPPORT) - drv->cfg.buffered = false; + dac_cfg->cfg.buffered = false; #else - drv->cfg.buffered = true; + dac_cfg->cfg.buffered = true; #endif +} + +static bool attenuator_channel_setup(struct attenuator_dac_cfg *dac_cfg) +{ + int err; - if (!device_is_ready(dac_dev)) { - LOG_ERR("DAC device %s not ready", dac_dev->name); + if (dac_cfg->dev == NULL || !device_is_ready(dac_cfg->dev)) { + LOG_ERR("DAC device unavailable for channel %u", + dac_cfg->cfg.channel_id); return false; } /* dac_channel_setup() prepares the selected channel in the DAC driver and * may perform I2C transactions depending on the underlying implementation. */ - int err = dac_channel_setup(dac_dev, &drv->cfg); + err = dac_channel_setup(dac_cfg->dev, &dac_cfg->cfg); if (err != 0) { LOG_ERR("DAC channel setup failed: %d", err); return false; } + return true; } -bool attenuator_set(struct attenuator *drv, double value, bool raw) { - /* Clamp voltage to [0, MAX_VOLTAGE] */ - double voltage; - double unclamped_voltage; - int err; - char context[48]; +bool attenuator_init(struct attenuator *drv, + const struct device *dac1, uint8_t channel1, + const struct device *dac2, uint8_t channel2) +{ + if (drv == NULL) { + return false; + } - if (raw) { - voltage = value; + attenuator_cfg_init(&drv->dac_cfg1, dac1, channel1); + attenuator_cfg_init(&drv->dac_cfg2, dac2, channel2); + drv->attenuation_db = 0.0; + + return attenuator_channel_setup(&drv->dac_cfg1) && + attenuator_channel_setup(&drv->dac_cfg2); +} + +double attenuator_model_voltage_to_db(const struct attenuator_model_coeffs *coeffs, + double voltage) +{ + double b; + double transmission; + + if (coeffs == NULL) { + return 0.0; } - else { - voltage = drv->coeff_db_to_volt[0]+ - drv->coeff_db_to_volt[1]*value+ - drv->coeff_db_to_volt[2]*value*value; + + b = coeffs->slope * voltage + coeffs->offset; + transmission = attenuator_model_b_to_linear(b); + + if (transmission <= 0.0) { + return MODEL_MAX_DB; + } + if (transmission >= 1.0) { + return 0.0; + } + + return -10.0 * (double)ZSL_LOG10((zsl_real_t)transmission); +} + +bool attenuator_model_db_to_voltage(const struct attenuator_model_coeffs *coeffs, + double attenuation_db, double *voltage) +{ + const zsl_real_t erf_scale = ZSL_ERF((zsl_real_t)MODEL_ERF_SCALE); + zsl_real_t transmission; + zsl_real_t erf_arg; + zsl_real_t inv; + double b; + + if (coeffs == NULL || voltage == NULL || coeffs->slope == 0.0 || + attenuation_db < 0.0) { + return false; + } + + transmission = ZSL_POW((zsl_real_t)10.0, + (zsl_real_t)(-attenuation_db / 10.0)); + erf_arg = ((zsl_real_t)2.0 * erf_scale * transmission) - erf_scale; + if (erf_arg <= (zsl_real_t)-1.0 || erf_arg >= (zsl_real_t)1.0) { + return false; + } + + inv = zsl_prob_erf_inv(&erf_arg); + b = MODEL_ERF_SCALE - (double)inv; + *voltage = (b - coeffs->offset) / coeffs->slope; + + return true; +} + +static bool attenuator_write_voltage(struct attenuator_dac_cfg *dac_cfg, + const struct attenuator_model_coeffs *coeffs, + double voltage) +{ + double unclamped_voltage = voltage; + uint32_t code; + int err; + char context[64]; + + if (dac_cfg == NULL || coeffs == NULL || dac_cfg->dev == NULL) { + return false; } - unclamped_voltage = voltage; if (voltage < 0.0) { voltage = 0.0; - } else if (voltage > MAX_VOLTAGE) { - voltage = MAX_VOLTAGE; + } else if (voltage > ATTENUATOR_DRIVE_MAX_MV) { + voltage = ATTENUATOR_DRIVE_MAX_MV; } + if (voltage != unclamped_voltage) { - snprintk(context, sizeof(context), "channel=%u requested=%.3f clamped=%.3f", - drv->cfg.channel_id, unclamped_voltage, voltage); - app_warning_emit("attenuator_clamped", - "attenuator command exceeded DAC range and was clamped", + snprintk(context, sizeof(context), + "channel=%u requested=%.3f clamped=%.3f", + dac_cfg->cfg.channel_id, unclamped_voltage, voltage); + coo_cmd_runtime_warning_emit(command_runtime_get(), "attenuator_clamped", + "attenuator command exceeded drive range and was clamped", context); } - drv->voltage = voltage; - uint32_t code = (uint32_t)((drv->voltage / MAX_VOLTAGE) * DAC_MAX_CODE); + code = (uint32_t)((voltage / ATTENUATOR_DRIVE_MAX_MV) * DAC_MAX_CODE); /* dac_write_value() is the hardware side effect: it can block on I2C and * changes the analog attenuation control voltage. */ - err = dac_write_value(dac_dev, drv->cfg.channel_id, code); + err = dac_write_value(dac_cfg->dev, dac_cfg->cfg.channel_id, code); if (err != 0) { LOG_ERR("DAC write failed: %d", err); return false; } + dac_cfg->voltage = voltage; + dac_cfg->attenuation_db = attenuator_model_voltage_to_db(coeffs, voltage); + return true; } -bool attenuator_get(struct attenuator *drv, double *value, bool raw) { - //NB Reads the register, no checking for chip power down state - uint32_t code; - double volt; - dac7578_read_value(dac_dev, drv->cfg.channel_id, &code); - drv->voltage = ((double) MAX_VOLTAGE/(double) DAC_MAX_CODE)*(double) code; - volt = drv->voltage; - if (raw) { - *value = volt; - } else { - *value = drv->coeff_volt_to_db[0] + - volt*drv->coeff_volt_to_db[1] + - volt*volt*drv->coeff_volt_to_db[2]; +static bool attenuator_set_physical_db(struct attenuator_dac_cfg *dac_cfg, + const struct attenuator_model_coeffs *coeffs, + double attenuation_db) +{ + double voltage; + double max_db; + + if (dac_cfg == NULL || coeffs == NULL || attenuation_db < 0.0) { + return false; + } + + max_db = attenuator_model_voltage_to_db(coeffs, ATTENUATOR_DRIVE_MAX_MV); + if (max_db > 0.0 && attenuation_db >= max_db) { + return attenuator_write_voltage(dac_cfg, coeffs, ATTENUATOR_DRIVE_MAX_MV); + } + + if (!attenuator_model_db_to_voltage(coeffs, attenuation_db, &voltage)) { + return false; + } + + return attenuator_write_voltage(dac_cfg, coeffs, voltage); +} + +bool attenuator_set_dac1_db(struct attenuator *drv, double attenuation_db) +{ + return drv != NULL && + attenuator_set_physical_db(&drv->dac_cfg1, &drv->coeff1, + attenuation_db); +} + +bool attenuator_set_dac2_db(struct attenuator *drv, double attenuation_db) +{ + return drv != NULL && + attenuator_set_physical_db(&drv->dac_cfg2, &drv->coeff2, + attenuation_db); +} + +bool attenuator_set_dac1_voltage(struct attenuator *drv, double voltage) +{ + return drv != NULL && + attenuator_write_voltage(&drv->dac_cfg1, &drv->coeff1, voltage); +} + +bool attenuator_set_dac2_voltage(struct attenuator *drv, double voltage) +{ + return drv != NULL && + attenuator_write_voltage(&drv->dac_cfg2, &drv->coeff2, voltage); +} + +bool attenuator_set_physical_voltage(struct attenuator *drv, + uint8_t physical_index, + double voltage) +{ + if (drv == NULL) { + return false; + } + + switch (physical_index) { + case 0: + return attenuator_set_dac1_voltage(drv, voltage); + case 1: + return attenuator_set_dac2_voltage(drv, voltage); + default: + return false; + } +} + +static double attenuator_physical_max_db(const struct attenuator_model_coeffs *coeffs) +{ + return attenuator_model_voltage_to_db(coeffs, ATTENUATOR_DRIVE_MAX_MV); +} + +bool attenuator_set_db(struct attenuator *drv, double attenuation_db) +{ + double max_db1; + double max_total_db; + double db1; + double db2; + char context[64]; + + if (drv == NULL || attenuation_db < 0.0) { + return false; + } + + max_db1 = attenuator_physical_max_db(&drv->coeff1); + max_total_db = max_db1 + attenuator_physical_max_db(&drv->coeff2); + + if (attenuation_db > max_total_db) { + snprintk(context, sizeof(context), + "requested=%.3f clamped=%.3f", attenuation_db, max_total_db); + coo_cmd_runtime_warning_emit(command_runtime_get(), "attenuator_clamped", + "attenuator command exceeded modeled range and was clamped", + context); + attenuation_db = max_total_db; + } + + db1 = attenuation_db < max_db1 ? attenuation_db : max_db1; + db2 = attenuation_db - db1; + + if (!attenuator_set_dac1_db(drv, db1)) { + return false; + } + if (!attenuator_set_dac2_db(drv, db2)) { + return false; + } + + drv->attenuation_db = db1 + db2; + + return true; +} + +bool attenuator_set_linear(struct attenuator *drv, double linear) +{ + double attenuation_db; + + if (linear <= 0.0 || linear > 1.0) { + return false; + } + + attenuation_db = -10.0 * (double)ZSL_LOG10((zsl_real_t)linear); + + return attenuator_set_db(drv, attenuation_db); +} + +static bool attenuator_read_physical(struct attenuator_dac_cfg *dac_cfg, + const struct attenuator_model_coeffs *coeffs) +{ + uint32_t code = 0U; + int err; + + err = dac7x78_read_value(dac_cfg->dev, dac_cfg->cfg.channel_id, &code); + if (err != 0) { + LOG_ERR("DAC read failed: %d", err); + return false; + } + + dac_cfg->voltage = (ATTENUATOR_DRIVE_MAX_MV / (double)DAC_MAX_CODE) * (double)code; + dac_cfg->attenuation_db = + attenuator_model_voltage_to_db(coeffs, dac_cfg->voltage); + + return true; +} + +bool attenuator_get(struct attenuator *drv, struct attenuator_status *out) +{ + double total_db; + + if (drv == NULL || out == NULL) { + return false; + } + + if (!attenuator_read_physical(&drv->dac_cfg1, &drv->coeff1) || + !attenuator_read_physical(&drv->dac_cfg2, &drv->coeff2)) { + return false; + } + + total_db = drv->dac_cfg1.attenuation_db + drv->dac_cfg2.attenuation_db; + drv->attenuation_db = total_db; + + out->attenuation_db = total_db; + out->linear = (double)ZSL_POW((zsl_real_t)10.0, + (zsl_real_t)(-total_db / 10.0)); + out->voltage1 = drv->dac_cfg1.voltage; + out->voltage2 = drv->dac_cfg2.voltage; + out->attenuation_db1 = drv->dac_cfg1.attenuation_db; + out->attenuation_db2 = drv->dac_cfg2.attenuation_db; + + return true; +} + +double attenuator_model_b_to_linear(double b) +{ + const zsl_real_t erf_scale = ZSL_ERF((zsl_real_t)MODEL_ERF_SCALE); + double transmission = (double)((erf_scale + + ZSL_ERF((zsl_real_t)MODEL_ERF_SCALE - + (zsl_real_t)b)) / + ((zsl_real_t)2.0 * erf_scale)); + + if (transmission < 0.0) { + return 0.0; + } + if (transmission > 1.0) { + return 1.0; + } + + return transmission; +} + +bool attenuator_model_linear_to_b(double linear, double *b) +{ + const zsl_real_t erf_scale = ZSL_ERF((zsl_real_t)MODEL_ERF_SCALE); + zsl_real_t erf_arg; + zsl_real_t inv; + + if (b == NULL || linear <= 0.0 || linear >= 1.0) { + return false; } + erf_arg = ((zsl_real_t)2.0 * erf_scale * (zsl_real_t)linear) - erf_scale; + if (erf_arg <= (zsl_real_t)-1.0 || erf_arg >= (zsl_real_t)1.0) { + return false; + } + + inv = zsl_prob_erf_inv(&erf_arg); + *b = MODEL_ERF_SCALE - (double)inv; return true; } + +static double attenuator_model_dlinear_db(double b) +{ + const double erf_scale = erf(MODEL_ERF_SCALE); + + return -exp(-((MODEL_ERF_SCALE - b) * (MODEL_ERF_SCALE - b))) / + (sqrt(M_PI) * erf_scale); +} + +bool attenuator_estimate_transmission(struct attenuator *drv, + double sigma_b1, double sigma_b2, + struct attenuator_transmission_estimate *out) +{ + struct attenuator_status status; + double b1; + double b2; + double tx1; + double tx2; + double dtx1_db; + double dtx2_db; + double var; + + if (drv == NULL || out == NULL || sigma_b1 < 0.0 || sigma_b2 < 0.0) { + return false; + } + + if (!attenuator_get(drv, &status)) { + return false; + } + + b1 = drv->coeff1.slope * status.voltage1 + drv->coeff1.offset; + b2 = drv->coeff2.slope * status.voltage2 + drv->coeff2.offset; + tx1 = attenuator_model_b_to_linear(b1); + tx2 = attenuator_model_b_to_linear(b2); + dtx1_db = attenuator_model_dlinear_db(b1); + dtx2_db = attenuator_model_dlinear_db(b2); + var = (tx2 * dtx1_db * sigma_b1) * (tx2 * dtx1_db * sigma_b1) + + (tx1 * dtx2_db * sigma_b2) * (tx1 * dtx2_db * sigma_b2); + + out->linear = tx1 * tx2; + out->linear_err = sqrt(var); + out->attenuation_db = status.attenuation_db; + out->attenuation_db1 = status.attenuation_db1; + out->attenuation_db2 = status.attenuation_db2; + out->voltage1 = status.voltage1; + out->voltage2 = status.voltage2; + + return true; +} + +int attenuator_apply_coefficients_preserve_db( + struct attenuator *drv, + const struct attenuator_model_coeffs physical[ATTENUATOR_PHYSICAL_COUNT]) +{ + struct attenuator_model_coeffs old_coeff1; + struct attenuator_model_coeffs old_coeff2; + struct attenuator_status status = {0}; + + if (drv == NULL || physical == NULL) { + return -EINVAL; + } + + if (!attenuator_get(drv, &status)) { + return -EIO; + } + + old_coeff1 = drv->coeff1; + old_coeff2 = drv->coeff2; + drv->coeff1 = physical[0]; + drv->coeff2 = physical[1]; + + if (!attenuator_set_db(drv, status.attenuation_db)) { + drv->coeff1 = old_coeff1; + drv->coeff2 = old_coeff2; + return -EIO; + } + + return 0; +} diff --git a/app/src/attenuator.h b/app/src/attenuator.h index b346358..fc5327d 100644 --- a/app/src/attenuator.h +++ b/app/src/attenuator.h @@ -1,10 +1,10 @@ /** * @file attenuator.h - * @brief DAC7578-backed logical attenuator channel helpers. + * @brief DAC7x78-backed logical attenuator channel helpers. * - * Each logical attenuator stores runtime polynomial coefficients and the last - * read/write voltage. Persistence is owned by app_settings; this module only - * applies coefficients and performs DAC I/O. + * Each logical attenuator owns two physical DAC-backed FVOAs. Persistence is + * owned by app_settings; this module only applies model coefficients and + * performs DAC I/O. */ #ifndef ATTENUATOR_H #define ATTENUATOR_H @@ -16,37 +16,170 @@ #include #include -/** Number of quadratic coefficients in each attenuator calibration polynomial. */ -#define ATTENUATOR_COEFF_COUNT 3 +#include "lasers.h" + +#define ATTENUATOR_PHYSICAL_COUNT 2 +#define ATTENUATOR_COEFF_COUNT 2 +/* Post-op-amp attenuator drive span. The DAC itself is 12-bit, 0..3.3 V. */ +#define ATTENUATOR_DRIVE_MAX_MV 5000.0 + +struct attenuator_model_coeffs { + double slope; + double offset; +}; + +struct attenuator_dac_cfg { + const struct device *dev; + struct dac_channel_cfg cfg; + double voltage; + double attenuation_db; +}; + +struct attenuator_status { + double attenuation_db; + double linear; + double voltage1; + double voltage2; + double attenuation_db1; + double attenuation_db2; +}; + +struct attenuator_transmission_estimate { + double linear; + double linear_err; + double attenuation_db; + double attenuation_db1; + double attenuation_db2; + double voltage1; + double voltage2; +}; /** * Attenuator driver structure. */ struct attenuator { - double coeff_db_to_volt[ATTENUATOR_COEFF_COUNT]; - double coeff_volt_to_db[ATTENUATOR_COEFF_COUNT]; - double voltage; - struct dac_channel_cfg cfg; + struct attenuator_model_coeffs coeff1; + struct attenuator_model_coeffs coeff2; + struct attenuator_dac_cfg dac_cfg1; + struct attenuator_dac_cfg dac_cfg2; + double attenuation_db; }; /** Initialize a DAC channel. May block on I2C through the DAC driver. */ -bool attenuator_init(struct attenuator *drv, uint8_t channel); +bool attenuator_init(struct attenuator *drv, + const struct device *dac1, uint8_t channel1, + const struct device *dac2, uint8_t channel2); + +/** @brief Map a laser-bank channel to the matching logical attenuator index. */ +int attenuator_index_from_laser_id(enum hispec_laser_id laser, uint8_t *index); /** - * @brief Set attenuation by raw millivolts or calibrated dB. + * @brief Convert a physical attenuator voltage to modeled attenuation in dB. * - * If @p raw is false, @p voltage is interpreted as dB and converted using the - * runtime `coeff_db_to_volt` polynomial. The final voltage is clamped to the - * DAC range, may enqueue a warning, and is written over I2C. + * The model is transmission = (erf(4) + erf(4 - b)) / (2 * erf(4)), where + * b = slope * voltage + offset. This helper does not perform DAC I/O. */ -bool attenuator_set(struct attenuator *drv, double voltage, bool raw); +double attenuator_model_voltage_to_db(const struct attenuator_model_coeffs *coeffs, + double voltage); /** - * @brief Read back the DAC register and return raw millivolts or estimated dB. + * @brief Convert the model coordinate b to linear transmission. + * + * This helper performs no I/O. It is used by fit/residual code that needs the + * same physical model as normal attenuator control. + */ +double attenuator_model_b_to_linear(double b); + +/** + * @brief Convert linear transmission to the model coordinate b. + * + * Returns false when @p linear is outside the invertible open interval. + */ +bool attenuator_model_linear_to_b(double linear, double *b); + +/** + * @brief Convert modeled attenuation in dB to a physical attenuator voltage. + * + * This is the inverse of attenuator_model_voltage_to_db(). Returns false when + * the coefficient slope is zero or the requested attenuation is outside the + * model domain. + */ +bool attenuator_model_db_to_voltage(const struct attenuator_model_coeffs *coeffs, + double attenuation_db, double *voltage); + +/** Set physical attenuator 1 by modeled dB. May block on I2C. */ +bool attenuator_set_dac1_db(struct attenuator *drv, double attenuation_db); + +/** Set physical attenuator 2 by modeled dB. May block on I2C. */ +bool attenuator_set_dac2_db(struct attenuator *drv, double attenuation_db); + +/** Set physical attenuator 1 by raw attenuator-drive millivolts. May block on I2C. */ +bool attenuator_set_dac1_voltage(struct attenuator *drv, double voltage); + +/** Set physical attenuator 2 by raw attenuator-drive millivolts. May block on I2C. */ +bool attenuator_set_dac2_voltage(struct attenuator *drv, double voltage); + +/** + * @brief Set one physical attenuator by raw attenuator-drive millivolts. May block on I2C. + * + * @p physical_index is zero for dac1 and one for dac2. This bypasses the + * calibration model and is intended for calibration sweeps. + */ +bool attenuator_set_physical_voltage(struct attenuator *drv, + uint8_t physical_index, + double voltage); + +/** + * @brief Set total logical attenuation in dB. + * + * The first physical attenuator is driven to its modeled maximum before the + * second attenuator is used. This overrides any prior individual physical + * attenuator set point and may enqueue a warning if the requested attenuation + * exceeds the modeled drive range. + */ +bool attenuator_set_db(struct attenuator *drv, double attenuation_db); + +/** + * @brief Set total logical transmission as a linear fraction in (0, 1]. + * + * This converts the requested transmission to dB attenuation and delegates to + * attenuator_set_db(). + */ +bool attenuator_set_linear(struct attenuator *drv, double linear); + +/** + * @brief Read back both DAC registers and return logical/physical state. * * The read may block on I2C. It does not verify that the attenuator hardware is * powered or optically calibrated. */ -bool attenuator_get(struct attenuator *drv, double *voltage, bool raw); +bool attenuator_get(struct attenuator *drv, struct attenuator_status *out); + +/** + * @brief Read logical transmission and propagate physical FVOA b uncertainty. + * + * This may block on I2C through attenuator_get(). The uncertainty inputs are + * standard deviations in the model coordinate b for physical attenuator 1 and + * 2. A zero uncertainty reports the nominal transmission with zero error. + */ +bool attenuator_estimate_transmission(struct attenuator *drv, + double sigma_b1, double sigma_b2, + struct attenuator_transmission_estimate *out); + +/** + * @brief Replace both physical model coefficients and preserve logical dB. + * + * Reads the current logical attenuation, installs the supplied coefficients, + * and reapplies that logical attenuation using the new model. This may block on + * DAC I2C through attenuator_get() and attenuator_set_db(). On failure while + * applying the new model, the previous coefficients are restored in RAM. + * + * @retval 0 Coefficients were applied and logical attenuation was reissued. + * @retval -EINVAL Bad arguments. + * @retval -EIO DAC read or write failed. + */ +int attenuator_apply_coefficients_preserve_db( + struct attenuator *drv, + const struct attenuator_model_coeffs physical[ATTENUATOR_PHYSICAL_COUNT]); #endif /* ATTENUATOR_H */ diff --git a/app/src/attenuator_calibration.c b/app/src/attenuator_calibration.c new file mode 100644 index 0000000..d00d8a9 --- /dev/null +++ b/app/src/attenuator_calibration.c @@ -0,0 +1,1047 @@ +/** + * @file attenuator_calibration.c + * @brief Automatic and manual attenuator calibration. + */ + +#include "attenuator_calibration.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "app_settings.h" +#include "attenuator.h" +#include "command.h" +#include "devices.h" +#include "housekeeping.h" +#include "mems_switching.h" +#include "throughput_monitor.h" + +LOG_MODULE_REGISTER(attenuator_calibration, LOG_LEVEL_INF); + +#define ATTEN_CAL_DEFAULT_DWELL_MS 300U +#define ATTEN_CAL_MAX_DWELL_MS 2000U +#define ATTEN_CAL_STEP_SETTLE_MS 50U +#define ATTEN_CAL_PD_POWER_SETTLE_MS 1000U +#define ATTEN_CAL_SIGNAL_MIN_MV 20.0 +#define ATTEN_CAL_HIGH_MV 3500.0 +#define ATTEN_CAL_SAT_RAW (INT16_MAX - 1024) +#define ATTEN_CAL_OTHER_STEP_MV 512.0 +#define ATTEN_CAL_MIN_FIT_POINTS ATTENUATOR_CAL_MIN_BATCH_POINTS +#define ATTEN_CAL_MIN_TX 1.0e-10 +#define ATTEN_CAL_MAX_TX 0.999999 + +enum atten_cal_state { + ATTEN_CAL_STATE_INACTIVE = 0, + ATTEN_CAL_STATE_RUNNING, + ATTEN_CAL_STATE_WAITING, + ATTEN_CAL_STATE_COMPLETE, + ATTEN_CAL_STATE_ERROR, +}; + +enum atten_cal_mode { + ATTEN_CAL_MODE_NONE = 0, + ATTEN_CAL_MODE_TIB_AUTO, + ATTEN_CAL_MODE_MANUAL, +}; + +enum atten_cal_auto_phase { + ATTEN_CAL_AUTO_NONE = 0, + ATTEN_CAL_AUTO_PD_SETTLE, + ATTEN_CAL_AUTO_SIGNAL_SET, + ATTEN_CAL_AUTO_SIGNAL_SETTLE, + ATTEN_CAL_AUTO_SIGNAL_AVG, + ATTEN_CAL_AUTO_POINT_SET, + ATTEN_CAL_AUTO_POINT_SETTLE, + ATTEN_CAL_AUTO_POINT_AVG, + ATTEN_CAL_AUTO_ADJUST_SET, + ATTEN_CAL_AUTO_ADJUST_SETTLE, + ATTEN_CAL_AUTO_ADJUST_AVG, + ATTEN_CAL_AUTO_FIT, +}; + +struct atten_cal_point { + double voltage_mv; + double flux; + bool valid; + bool saturated; +}; + +struct atten_cal_state_data { + enum atten_cal_state state; + enum atten_cal_mode mode; + enum atten_cal_auto_phase phase; + uint8_t attenuator_index; + uint8_t physical_index; + uint8_t point_index; + uint32_t dwell_ms; + bool persistent; + enum hispec_laser_id laser; + enum photodiode_channel channel; + double other_mv; + double laser_percent; + double scale; + double pending_before_mv; + int64_t wait_until_ms; + bool adjust_uses_laser; + int last_error; + struct atten_cal_point points[2][ATTENUATOR_CAL_POINT_COUNT]; + struct attenuator_calibration_fit_metrics fit[2]; +}; + +static const double voltage_schedule[ATTENUATOR_CAL_POINT_COUNT] = { + 5000.0, 4750.0, 4500.0, 4250.0, 4000.0, + 3750.0, 3500.0, 3250.0, 3000.0, 2750.0, + 2500.0, 2250.0, 2000.0, 1750.0, 1500.0, + 1200.0, 900.0, 600.0, 300.0, 0.0, +}; + +static struct atten_cal_state_data cal; +static K_MUTEX_DEFINE(cal_lock); + +static const char *state_name(enum atten_cal_state state) +{ + switch (state) { + case ATTEN_CAL_STATE_INACTIVE: + return "inactive"; + case ATTEN_CAL_STATE_RUNNING: + return "running"; + case ATTEN_CAL_STATE_WAITING: + return "waiting"; + case ATTEN_CAL_STATE_COMPLETE: + return "complete"; + case ATTEN_CAL_STATE_ERROR: + return "error"; + default: + return "unknown"; + } +} + +static const char *mode_name(enum atten_cal_mode mode) +{ + switch (mode) { + case ATTEN_CAL_MODE_TIB_AUTO: + return "tib_auto"; + case ATTEN_CAL_MODE_MANUAL: + return "manual"; + case ATTEN_CAL_MODE_NONE: + default: + return "none"; + } +} + +static const char *physical_name(uint8_t physical_index) +{ + return physical_index == 0U ? "dac1" : "dac2"; +} + +static uint32_t clamp_dwell(uint32_t dwell_ms) +{ + if (dwell_ms == 0U) { + return ATTEN_CAL_DEFAULT_DWELL_MS; + } + if (dwell_ms > ATTEN_CAL_MAX_DWELL_MS) { + return ATTEN_CAL_MAX_DWELL_MS; + } + return dwell_ms; +} + +static bool sample_is_saturated(const struct photodiode_average_status *avg) +{ + return avg->result.max_raw >= ATTEN_CAL_SAT_RAW || + avg->result.mean_mv >= (float)ATTEN_CAL_HIGH_MV; +} + +static void copy_voltage_schedule(double out[ATTENUATOR_CAL_POINT_COUNT]) +{ + memcpy(out, voltage_schedule, sizeof(voltage_schedule)); +} + +static uint8_t complete_percent_locked(void) +{ + uint16_t complete; + + if (cal.state == ATTEN_CAL_STATE_INACTIVE) { + return 0U; + } + if (cal.state == ATTEN_CAL_STATE_COMPLETE) { + return 100U; + } + if (cal.mode == ATTEN_CAL_MODE_MANUAL) { + complete = (uint16_t)cal.physical_index * ATTENUATOR_CAL_POINT_COUNT + + cal.point_index; + return (uint8_t)((complete * 100U) / + (ATTENUATOR_CAL_POINT_COUNT * 2U)); + } + if (cal.mode == ATTEN_CAL_MODE_TIB_AUTO) { + complete = (uint16_t)cal.physical_index * ATTENUATOR_CAL_POINT_COUNT + + cal.point_index; + return (uint8_t)((complete * 100U) / + (ATTENUATOR_CAL_POINT_COUNT * 2U)); + } + return 0U; +} + +static void copy_status_locked(struct attenuator_calibration_status *status) +{ + if (status == NULL) { + return; + } + + memset(status, 0, sizeof(*status)); + status->state = state_name(cal.state); + status->mode = mode_name(cal.mode); + status->physical = physical_name(cal.physical_index); + status->fit = (cal.fit[0].valid && cal.fit[1].valid) ? "ok" : + (cal.last_error != 0 ? "failed" : "none"); + status->attenuator_index = cal.attenuator_index; + status->physical_index = cal.physical_index; + status->point_index = cal.point_index; + status->point_count = ATTENUATOR_CAL_POINT_COUNT; + status->dwell_ms = cal.dwell_ms == 0U ? ATTEN_CAL_DEFAULT_DWELL_MS : cal.dwell_ms; + status->complete_pct = complete_percent_locked(); + status->current_mv = voltage_schedule[MIN(cal.point_index, + ATTENUATOR_CAL_POINT_COUNT - 1U)]; + status->other_mv = (cal.state == ATTEN_CAL_STATE_INACTIVE && cal.other_mv == 0.0) ? + ATTENUATOR_DRIVE_MAX_MV : cal.other_mv; + status->last_error = cal.last_error; + status->include_voltage_schedule = + cal.mode == ATTEN_CAL_MODE_MANUAL && + (cal.state == ATTEN_CAL_STATE_WAITING || + cal.state == ATTEN_CAL_STATE_COMPLETE || + cal.state == ATTEN_CAL_STATE_INACTIVE); + copy_voltage_schedule(status->voltage_schedule_mv); + memcpy(status->fit_metrics, cal.fit, sizeof(status->fit_metrics)); +} + +static void reset_locked(enum atten_cal_state state) +{ + memset(&cal, 0, sizeof(cal)); + cal.state = state; + cal.mode = ATTEN_CAL_MODE_NONE; + cal.dwell_ms = ATTEN_CAL_DEFAULT_DWELL_MS; + cal.other_mv = ATTENUATOR_DRIVE_MAX_MV; + cal.laser_percent = 100.0; + cal.scale = 1.0; +} + +static int route_input_for_laser(enum hispec_laser_id laser, char *out, size_t out_len) +{ + const char *name; + + if (out == NULL || out_len == 0U) { + return -EINVAL; + } + + switch (laser) { + case HISPEC_LASER_1430_YJ: + name = "yj_1430"; + break; + case HISPEC_LASER_1430_HK: + name = "hk_1430"; + break; + case HISPEC_LASER_1028_Y: + case HISPEC_LASER_1270_J: + name = "yj_laser"; + break; + case HISPEC_LASER_1510_H: + case HISPEC_LASER_2330_K: + name = "hk_laser"; + break; + default: + return -EINVAL; + } + + if (snprintk(out, out_len, "%s", name) >= out_len) { + return -ENOSPC; + } + return 0; +} + +static int pd_route_for_auto(enum hispec_laser_id laser, char fiber, + enum photodiode_channel *channel, + char *input, size_t input_len, + char *output, size_t output_len) +{ + const char *prefix; + const char *kind; + + if (channel == NULL || input == NULL || output == NULL || + input_len == 0U || output_len == 0U || + (fiber != 'M' && fiber != 'S')) { + return -EINVAL; + } + + switch (laser) { + case HISPEC_LASER_1028_Y: + case HISPEC_LASER_1270_J: + case HISPEC_LASER_1430_YJ: + *channel = PHOTODIODE_CHANNEL_YJ; + prefix = "yj"; + break; + case HISPEC_LASER_1430_HK: + case HISPEC_LASER_1510_H: + case HISPEC_LASER_2330_K: + *channel = PHOTODIODE_CHANNEL_HK; + prefix = "hk"; + break; + default: + return -EINVAL; + } + + kind = fiber == 'M' ? "mm" : "sm"; + if (snprintk(input, input_len, "%s_%s", prefix, kind) >= input_len || + snprintk(output, output_len, "%s_pd", prefix) >= output_len) { + return -ENOSPC; + } + return 0; +} + +static int apply_route_pair(const char *input, const char *output) +{ + return mems_router_apply_named_route(&router, input, output, NULL, NULL); +} + +static bool set_physical_pair(uint8_t attenuator_index, + uint8_t sweep_physical, + double sweep_mv, + double other_mv) +{ + struct attenuator *atten; + + if (!devices_attenuator_channel_available(attenuator_index) || + sweep_physical >= ATTENUATOR_PHYSICAL_COUNT) { + return false; + } + + atten = &attenuators[attenuator_index]; + if (!attenuator_set_physical_voltage(atten, sweep_physical, sweep_mv)) { + return false; + } + return attenuator_set_physical_voltage(atten, sweep_physical == 0U ? 1U : 0U, + other_mv); +} + +static bool point_valid_for_fit(double tx) +{ + return tx > ATTEN_CAL_MIN_TX && tx <= ATTEN_CAL_MAX_TX; +} + +static int fit_one_physical(uint8_t attenuator_index, + uint8_t physical_index, + const struct atten_cal_point points[ATTENUATOR_CAL_POINT_COUNT], + bool persistent, + struct attenuator_calibration_fit_metrics *out) +{ + zsl_real_t x_data[ATTENUATOR_CAL_POINT_COUNT]; + zsl_real_t y_data[ATTENUATOR_CAL_POINT_COUNT]; + struct zsl_vec x = { + .sz = 0U, + .data = x_data, + }; + struct zsl_vec y = { + .sz = 0U, + .data = y_data, + }; + struct zsl_sta_linreg reg = {0}; + double max_flux = 0.0; + double min_tx = 1.0; + double max_tx = 0.0; + double min_v = ATTENUATOR_DRIVE_MAX_MV; + double max_v = 0.0; + double sum_sq_db = 0.0; + double max_abs_db = 0.0; + int rc; + + if (out == NULL || attenuator_index >= NUM_ATTENUATORS || + physical_index >= ATTENUATOR_PHYSICAL_COUNT) { + return -EINVAL; + } + ARG_UNUSED(persistent); + memset(out, 0, sizeof(*out)); + + for (uint8_t i = 0U; i < ATTENUATOR_CAL_POINT_COUNT; ++i) { + if (points[i].valid && !points[i].saturated && points[i].flux > max_flux) { + max_flux = points[i].flux; + } + } + if (!(max_flux > 0.0)) { + return -ERANGE; + } + + for (uint8_t i = 0U; i < ATTENUATOR_CAL_POINT_COUNT; ++i) { + double tx; + double b; + + if (!points[i].valid || points[i].saturated || points[i].flux <= 0.0) { + continue; + } + tx = points[i].flux / max_flux; + if (tx > ATTEN_CAL_MAX_TX) { + tx = ATTEN_CAL_MAX_TX; + } + if (!point_valid_for_fit(tx) || + !attenuator_model_linear_to_b(tx, &b)) { + continue; + } + + x_data[x.sz] = (zsl_real_t)points[i].voltage_mv; + y_data[y.sz] = (zsl_real_t)b; + x.sz++; + y.sz++; + if (tx < min_tx) { + min_tx = tx; + } + if (tx > max_tx) { + max_tx = tx; + } + if (points[i].voltage_mv < min_v) { + min_v = points[i].voltage_mv; + } + if (points[i].voltage_mv > max_v) { + max_v = points[i].voltage_mv; + } + } + + if (x.sz < ATTEN_CAL_MIN_FIT_POINTS || !(max_v > min_v)) { + return -ERANGE; + } + + rc = zsl_sta_linear_reg(&x, &y, ®); + if (rc != 0 || !(reg.slope > 0.0)) { + return rc != 0 ? rc : -ERANGE; + } + + for (size_t i = 0U; i < x.sz; ++i) { + double measured_tx = attenuator_model_b_to_linear((double)y.data[i]); + double predicted_tx = + attenuator_model_b_to_linear((double)reg.slope * (double)x.data[i] + + (double)reg.intercept); + double residual_db; + + if (!(measured_tx > 0.0) || !(predicted_tx > 0.0)) { + continue; + } + residual_db = 10.0 * log10(predicted_tx / measured_tx); + sum_sq_db += residual_db * residual_db; + if (fabs(residual_db) > max_abs_db) { + max_abs_db = fabs(residual_db); + } + } + + out->valid = true; + out->points = (uint8_t)x.sz; + out->slope = (double)reg.slope; + out->offset = (double)reg.intercept; + out->correlation = (double)reg.correlation; + out->rms_db = sqrt(sum_sq_db / (double)x.sz); + out->max_abs_db = max_abs_db; + out->min_tx = min_tx; + out->max_tx = max_tx; + out->voltage_span_mv = max_v - min_v; + + return 0; +} + +static int apply_fit_to_settings(uint8_t attenuator_index, + const struct attenuator_calibration_fit_metrics fit[2], + bool persistent) +{ + struct app_attenuator_channel_settings stored = {0}; + struct attenuator_model_coeffs physical[ATTENUATOR_PHYSICAL_COUNT]; + + if (attenuator_index >= NUM_ATTENUATORS || !fit[0].valid || !fit[1].valid) { + return -EINVAL; + } + + physical[0].slope = fit[0].slope; + physical[0].offset = fit[0].offset; + physical[1].slope = fit[1].slope; + physical[1].offset = fit[1].offset; + + if (attenuator_apply_coefficients_preserve_db(&attenuators[attenuator_index], + physical) != 0) { + return -EIO; + } + + stored.physical[0].slope = (float)fit[0].slope; + stored.physical[0].offset = (float)fit[0].offset; + stored.physical[1].slope = (float)fit[1].slope; + stored.physical[1].offset = (float)fit[1].offset; + app_settings_update_attenuator_channel(attenuator_index, &stored, persistent); + return 0; +} + +static int fit_current_locked(bool apply_settings) +{ + int rc; + + for (uint8_t physical = 0U; physical < ATTENUATOR_PHYSICAL_COUNT; ++physical) { + rc = fit_one_physical(cal.attenuator_index, physical, + cal.points[physical], cal.persistent, + &cal.fit[physical]); + if (rc != 0) { + cal.last_error = rc; + cal.state = ATTEN_CAL_STATE_ERROR; + return rc; + } + } + + if (apply_settings) { + rc = apply_fit_to_settings(cal.attenuator_index, cal.fit, cal.persistent); + if (rc != 0) { + cal.last_error = rc; + cal.state = ATTEN_CAL_STATE_ERROR; + return rc; + } + } + + cal.state = ATTEN_CAL_STATE_COMPLETE; + cal.phase = ATTEN_CAL_AUTO_NONE; + cal.point_index = ATTENUATOR_CAL_POINT_COUNT; + return 0; +} + +static void start_next_physical_locked(void) +{ + cal.point_index = 0U; + cal.other_mv = ATTENUATOR_DRIVE_MAX_MV; + cal.scale = 1.0; + cal.phase = ATTEN_CAL_AUTO_SIGNAL_SET; +} + +static void auto_error_locked(int error) +{ + cal.last_error = error; + cal.state = ATTEN_CAL_STATE_ERROR; + cal.phase = ATTEN_CAL_AUTO_NONE; +} + +static void record_current_point_locked(const struct photodiode_average_status *avg) +{ + struct atten_cal_point *point; + + point = &cal.points[cal.physical_index][cal.point_index]; + point->voltage_mv = voltage_schedule[cal.point_index]; + point->saturated = sample_is_saturated(avg); + point->valid = avg->state == PHOTODIODE_AVERAGE_COMPLETE && + !point->saturated && + avg->result.mean_net_mv > 0.0f; + point->flux = point->valid ? (double)avg->result.mean_net_mv * cal.scale : 0.0; +} + +static void auto_finish_point_locked(void) +{ + cal.point_index++; + if (cal.point_index < ATTENUATOR_CAL_POINT_COUNT) { + cal.phase = ATTEN_CAL_AUTO_POINT_SET; + return; + } + + if (cal.physical_index == 0U) { + cal.physical_index = 1U; + start_next_physical_locked(); + return; + } + + cal.phase = ATTEN_CAL_AUTO_FIT; +} + +static void auto_tick_locked(int64_t now_ms) +{ + struct photodiode_average_status avg = {0}; + double sweep_mv; + int rc; + + if (cal.state != ATTEN_CAL_STATE_RUNNING || + cal.mode != ATTEN_CAL_MODE_TIB_AUTO) { + return; + } + + switch (cal.phase) { + case ATTEN_CAL_AUTO_PD_SETTLE: + if (now_ms < cal.wait_until_ms) { + return; + } + start_next_physical_locked(); + return; + case ATTEN_CAL_AUTO_SIGNAL_SET: + sweep_mv = voltage_schedule[0]; + if (!set_physical_pair(cal.attenuator_index, cal.physical_index, + sweep_mv, cal.other_mv)) { + auto_error_locked(-EIO); + return; + } + cal.wait_until_ms = now_ms + ATTEN_CAL_STEP_SETTLE_MS; + cal.phase = ATTEN_CAL_AUTO_SIGNAL_SETTLE; + return; + case ATTEN_CAL_AUTO_SIGNAL_SETTLE: + if (now_ms < cal.wait_until_ms) { + return; + } + rc = photodiode_start_average(cal.channel, cal.dwell_ms, NULL); + if (rc != 0) { + auto_error_locked(rc); + return; + } + cal.phase = ATTEN_CAL_AUTO_SIGNAL_AVG; + return; + case ATTEN_CAL_AUTO_SIGNAL_AVG: + rc = photodiode_get_average_status(cal.channel, &avg); + if (rc != 0) { + auto_error_locked(rc); + return; + } + if (avg.state == PHOTODIODE_AVERAGE_MEASURING) { + return; + } + if (avg.state != PHOTODIODE_AVERAGE_COMPLETE) { + auto_error_locked(avg.last_error == 0 ? -EIO : avg.last_error); + return; + } + if ((double)avg.result.mean_net_mv < ATTEN_CAL_SIGNAL_MIN_MV && + cal.other_mv > 0.0) { + cal.other_mv -= ATTEN_CAL_OTHER_STEP_MV; + if (cal.other_mv < 0.0) { + cal.other_mv = 0.0; + } + cal.phase = ATTEN_CAL_AUTO_SIGNAL_SET; + return; + } + cal.phase = ATTEN_CAL_AUTO_POINT_SET; + return; + case ATTEN_CAL_AUTO_POINT_SET: + sweep_mv = voltage_schedule[cal.point_index]; + if (!set_physical_pair(cal.attenuator_index, cal.physical_index, + sweep_mv, cal.other_mv)) { + auto_error_locked(-EIO); + return; + } + cal.wait_until_ms = now_ms + ATTEN_CAL_STEP_SETTLE_MS; + cal.phase = ATTEN_CAL_AUTO_POINT_SETTLE; + return; + case ATTEN_CAL_AUTO_POINT_SETTLE: + if (now_ms < cal.wait_until_ms) { + return; + } + rc = photodiode_start_average(cal.channel, cal.dwell_ms, NULL); + if (rc != 0) { + auto_error_locked(rc); + return; + } + cal.phase = ATTEN_CAL_AUTO_POINT_AVG; + return; + case ATTEN_CAL_AUTO_POINT_AVG: + rc = photodiode_get_average_status(cal.channel, &avg); + if (rc != 0) { + auto_error_locked(rc); + return; + } + if (avg.state == PHOTODIODE_AVERAGE_MEASURING) { + return; + } + if (avg.state != PHOTODIODE_AVERAGE_COMPLETE) { + auto_error_locked(avg.last_error == 0 ? -EIO : avg.last_error); + return; + } + if ((double)avg.result.mean_net_mv > ATTEN_CAL_HIGH_MV && !sample_is_saturated(&avg) && + (cal.other_mv < ATTENUATOR_DRIVE_MAX_MV || cal.laser_percent > 3.0)) { + cal.pending_before_mv = (double)avg.result.mean_net_mv; + cal.adjust_uses_laser = cal.other_mv >= ATTENUATOR_DRIVE_MAX_MV; + cal.phase = ATTEN_CAL_AUTO_ADJUST_SET; + return; + } + record_current_point_locked(&avg); + auto_finish_point_locked(); + return; + case ATTEN_CAL_AUTO_ADJUST_SET: + if (cal.adjust_uses_laser) { + cal.laser_percent /= 3.0; + if (cal.laser_percent < 1.0) { + cal.laser_percent = 1.0; + } + rc = hispec_laser_set_output_percent_autooff(cal.laser, + (float)cal.laser_percent, + 0U); + if (rc != 0) { + auto_error_locked(rc); + return; + } + } else { + cal.other_mv += ATTEN_CAL_OTHER_STEP_MV; + if (cal.other_mv > ATTENUATOR_DRIVE_MAX_MV) { + cal.other_mv = ATTENUATOR_DRIVE_MAX_MV; + } + if (!set_physical_pair(cal.attenuator_index, cal.physical_index, + voltage_schedule[cal.point_index], + cal.other_mv)) { + auto_error_locked(-EIO); + return; + } + } + cal.wait_until_ms = now_ms + ATTEN_CAL_STEP_SETTLE_MS; + cal.phase = ATTEN_CAL_AUTO_ADJUST_SETTLE; + return; + case ATTEN_CAL_AUTO_ADJUST_SETTLE: + if (now_ms < cal.wait_until_ms) { + return; + } + rc = photodiode_start_average(cal.channel, cal.dwell_ms, NULL); + if (rc != 0) { + auto_error_locked(rc); + return; + } + cal.phase = ATTEN_CAL_AUTO_ADJUST_AVG; + return; + case ATTEN_CAL_AUTO_ADJUST_AVG: + rc = photodiode_get_average_status(cal.channel, &avg); + if (rc != 0) { + auto_error_locked(rc); + return; + } + if (avg.state == PHOTODIODE_AVERAGE_MEASURING) { + return; + } + if (avg.state != PHOTODIODE_AVERAGE_COMPLETE) { + auto_error_locked(avg.last_error == 0 ? -EIO : avg.last_error); + return; + } + if (avg.result.mean_net_mv > 0.0f && !sample_is_saturated(&avg)) { + cal.scale *= cal.pending_before_mv / (double)avg.result.mean_net_mv; + } + record_current_point_locked(&avg); + auto_finish_point_locked(); + return; + case ATTEN_CAL_AUTO_FIT: + (void)fit_current_locked(true); + return; + case ATTEN_CAL_AUTO_NONE: + default: + return; + } +} + +int attenuator_calibration_start_auto( + const struct attenuator_calibration_auto_request *request, + struct attenuator_calibration_status *status) +{ + char route_input[MEMS_SOURCEDEST_MAX_LEN] = {0}; + char pd_input[MEMS_SOURCEDEST_MAX_LEN] = {0}; + char pd_output[MEMS_SOURCEDEST_MAX_LEN] = {0}; + enum photodiode_channel channel; + uint8_t attenuator_index; + bool replacing; + int rc; + + if (devices_board_type() != HISPEC_BOARD_TIB) { + return -ENODEV; + } + + if (request == NULL || request->output == NULL || + request->output[0] == '\0' || + attenuator_index_from_laser_id(request->laser, &attenuator_index) != 0 || + !devices_attenuator_channel_available(attenuator_index)) { + return -EINVAL; + } + + rc = route_input_for_laser(request->laser, route_input, sizeof(route_input)); + if (rc != 0) { + return rc; + } + rc = pd_route_for_auto(request->laser, request->fiber, &channel, + pd_input, sizeof(pd_input), + pd_output, sizeof(pd_output)); + if (rc != 0) { + return rc; + } + + k_mutex_lock(&cal_lock, K_FOREVER); + replacing = cal.state == ATTEN_CAL_STATE_RUNNING || + cal.state == ATTEN_CAL_STATE_WAITING; + k_mutex_unlock(&cal_lock); + if (replacing) { + coo_cmd_runtime_warning_emit(command_runtime_get(), + "atten_calibration_replaced", + "attenuator calibration was replaced", + NULL); + } + + if (throughput_monitor_any_active()) { + coo_cmd_runtime_warning_emit(command_runtime_get(), + "throughput_stopped", + "throughput monitoring was stopped for attenuator calibration", + NULL); + } + (void)throughput_monitor_stop(PHOTODIODE_CHANNEL_COUNT, NULL); + rc = apply_route_pair(route_input, request->output); + if (rc == 0) { + rc = apply_route_pair(pd_input, pd_output); + } + if (rc != 0) { + return rc; + } + rc = housekeeping_power_set((enum housekeeping_power_output)channel, true); + if (rc != 0) { + return rc; + } + + if (!set_physical_pair(attenuator_index, 0U, + ATTENUATOR_DRIVE_MAX_MV, ATTENUATOR_DRIVE_MAX_MV)) { + return -EIO; + } + rc = hispec_laser_set_output_percent_autooff(request->laser, 100.0f, 0U); + if (rc != 0) { + return rc; + } + + k_mutex_lock(&cal_lock, K_FOREVER); + reset_locked(ATTEN_CAL_STATE_RUNNING); + cal.mode = ATTEN_CAL_MODE_TIB_AUTO; + cal.phase = ATTEN_CAL_AUTO_PD_SETTLE; + cal.attenuator_index = attenuator_index; + cal.physical_index = 0U; + cal.point_index = 0U; + cal.dwell_ms = clamp_dwell(request->dwell_ms); + cal.persistent = request->persistent; + cal.laser = request->laser; + cal.channel = channel; + cal.wait_until_ms = k_uptime_get() + ATTEN_CAL_PD_POWER_SETTLE_MS; + copy_status_locked(status); + k_mutex_unlock(&cal_lock); + return 0; +} + +static int manual_apply_current_locked(void) +{ + if (!set_physical_pair(cal.attenuator_index, cal.physical_index, + voltage_schedule[cal.point_index], cal.other_mv)) { + return -EIO; + } + return 0; +} + +int attenuator_calibration_start_manual(uint8_t attenuator_index, + uint32_t dwell_ms, + bool persistent, + struct attenuator_calibration_status *status) +{ + if (!devices_attenuator_channel_available(attenuator_index)) { + return -ENODEV; + } + + k_mutex_lock(&cal_lock, K_FOREVER); + reset_locked(ATTEN_CAL_STATE_WAITING); + cal.mode = ATTEN_CAL_MODE_MANUAL; + cal.attenuator_index = attenuator_index; + cal.physical_index = 0U; + cal.point_index = 0U; + cal.dwell_ms = clamp_dwell(dwell_ms); + cal.persistent = persistent; + cal.other_mv = ATTENUATOR_DRIVE_MAX_MV; + cal.last_error = manual_apply_current_locked(); + if (cal.last_error != 0) { + cal.state = ATTEN_CAL_STATE_ERROR; + } + copy_status_locked(status); + k_mutex_unlock(&cal_lock); + return status != NULL && status->last_error != 0 ? status->last_error : 0; +} + +int attenuator_calibration_manual_continue(bool has_other_mv, + double other_mv, + struct attenuator_calibration_status *status) +{ + int rc = 0; + + k_mutex_lock(&cal_lock, K_FOREVER); + if (cal.mode != ATTEN_CAL_MODE_MANUAL || + cal.state != ATTEN_CAL_STATE_WAITING) { + rc = -EINVAL; + goto out; + } + + if (has_other_mv) { + if (other_mv < 0.0) { + other_mv = 0.0; + } else if (other_mv > ATTENUATOR_DRIVE_MAX_MV) { + other_mv = ATTENUATOR_DRIVE_MAX_MV; + } + cal.other_mv = other_mv; + } + + cal.point_index++; + if (cal.point_index >= ATTENUATOR_CAL_POINT_COUNT) { + if (cal.physical_index == 0U) { + cal.physical_index = 1U; + cal.point_index = 0U; + } else { + cal.state = ATTEN_CAL_STATE_COMPLETE; + cal.point_index = ATTENUATOR_CAL_POINT_COUNT; + goto out; + } + } + + cal.last_error = manual_apply_current_locked(); + if (cal.last_error != 0) { + cal.state = ATTEN_CAL_STATE_ERROR; + rc = cal.last_error; + } + +out: + copy_status_locked(status); + k_mutex_unlock(&cal_lock); + return rc; +} + +int attenuator_calibration_fit_manual( + uint8_t attenuator_index, + const struct attenuator_calibration_batch physical[2], + bool persistent, + struct attenuator_calibration_status *status) +{ + int rc = 0; + + if (physical == NULL || !devices_attenuator_channel_available(attenuator_index)) { + return -EINVAL; + } + + k_mutex_lock(&cal_lock, K_FOREVER); + reset_locked(ATTEN_CAL_STATE_RUNNING); + cal.mode = ATTEN_CAL_MODE_MANUAL; + cal.attenuator_index = attenuator_index; + cal.persistent = persistent; + + for (uint8_t p = 0U; p < ATTENUATOR_PHYSICAL_COUNT; ++p) { + if (physical[p].len > ATTENUATOR_CAL_POINT_COUNT) { + rc = -EINVAL; + break; + } + for (size_t i = 0U; i < physical[p].len; ++i) { + cal.points[p][i].voltage_mv = physical[p].voltage_mv[i]; + cal.points[p][i].flux = physical[p].flux[i]; + cal.points[p][i].valid = physical[p].flux[i] > 0.0; + } + } + if (rc == 0) { + rc = fit_current_locked(true); + } + if (rc != 0) { + cal.last_error = rc; + cal.state = ATTEN_CAL_STATE_ERROR; + } + copy_status_locked(status); + k_mutex_unlock(&cal_lock); + return rc; +} + +int attenuator_calibration_stop(struct attenuator_calibration_status *status) +{ + enum atten_cal_mode old_mode; + + k_mutex_lock(&cal_lock, K_FOREVER); + old_mode = cal.mode; + reset_locked(ATTEN_CAL_STATE_INACTIVE); + copy_status_locked(status); + k_mutex_unlock(&cal_lock); + if (status != NULL && old_mode == ATTEN_CAL_MODE_MANUAL) { + status->mode = "manual"; + status->include_voltage_schedule = true; + copy_voltage_schedule(status->voltage_schedule_mv); + } + return 0; +} + +void attenuator_calibration_get_status(struct attenuator_calibration_status *status) +{ + k_mutex_lock(&cal_lock, K_FOREVER); + copy_status_locked(status); + k_mutex_unlock(&cal_lock); +} + +static int append_fit_json(char *payload, size_t payload_len, size_t *off, + const char *name, + const struct attenuator_calibration_fit_metrics *fit) +{ + if (coo_json_append(payload, payload_len, off, ",\"%s\":{", name) != 0) { + return -ENOSPC; + } + if (fit == NULL || !fit->valid) { + return coo_json_append(payload, payload_len, off, "\"valid\":false}"); + } + + return coo_json_append(payload, payload_len, off, + "\"valid\":true,\"points\":%u,\"slope\":%.12g," + "\"offset\":%.12g,\"corr\":%.12g,\"rms_db\":%.12g," + "\"max_abs_db\":%.12g,\"min_tx\":%.12g," + "\"max_tx\":%.12g,\"voltage_span_mv\":%.6f}", + fit->points, fit->slope, fit->offset, fit->correlation, + fit->rms_db, fit->max_abs_db, fit->min_tx, fit->max_tx, + fit->voltage_span_mv); +} + +int attenuator_calibration_format_status( + char *payload, size_t payload_len, + const struct attenuator_calibration_status *status) +{ + size_t off = 0U; + + if (payload == NULL || status == NULL) { + return -EINVAL; + } + + if (coo_json_append(payload, payload_len, &off, + "{\"state\":\"%s\",\"mode\":\"%s\",\"physical\":\"%s\"," + "\"fit\":\"%s\",\"n\":%u,\"t_ms\":%u," + "\"complete_pct\":%u,\"point\":\"%u/%u\"," + "\"mv\":%.6f,\"other_mv\":%.6f,\"error\":%d", + status->state != NULL ? status->state : "inactive", + status->mode != NULL ? status->mode : "none", + status->physical != NULL ? status->physical : "dac1", + status->fit != NULL ? status->fit : "none", + status->point_count, status->dwell_ms, status->complete_pct, + MIN(status->point_index + 1U, status->point_count), + status->point_count, + status->current_mv, status->other_mv, status->last_error) != 0 || + append_fit_json(payload, payload_len, &off, "dac1", + &status->fit_metrics[0]) != 0 || + append_fit_json(payload, payload_len, &off, "dac2", + &status->fit_metrics[1]) != 0) { + return -ENOSPC; + } + + if (status->include_voltage_schedule) { + if (coo_json_append(payload, payload_len, &off, ",\"voltage_mv\":[") != 0) { + return -ENOSPC; + } + for (uint8_t i = 0U; i < ATTENUATOR_CAL_POINT_COUNT; ++i) { + if (coo_json_append(payload, payload_len, &off, + "%s%.6f", i == 0U ? "" : ",", + status->voltage_schedule_mv[i]) != 0) { + return -ENOSPC; + } + } + if (coo_json_append(payload, payload_len, &off, "]") != 0) { + return -ENOSPC; + } + } + + if (coo_json_append(payload, payload_len, &off, "}") != 0) { + return -ENOSPC; + } + return 0; +} + +void attenuator_calibration_tick(const struct photodiode_status *pd_status, + int64_t now_ms) +{ + ARG_UNUSED(pd_status); + + k_mutex_lock(&cal_lock, K_FOREVER); + auto_tick_locked(now_ms); + k_mutex_unlock(&cal_lock); +} diff --git a/app/src/attenuator_calibration.h b/app/src/attenuator_calibration.h new file mode 100644 index 0000000..abcbe22 --- /dev/null +++ b/app/src/attenuator_calibration.h @@ -0,0 +1,107 @@ +/** + * @file attenuator_calibration.h + * @brief Attenuator calibration state machine shared by commands and throughput thread. + * + * Commands start/stop/manual-step calibration and format compact status + * replies. The throughput monitor thread calls attenuator_calibration_tick() + * so automatic TIB calibration reuses the existing photodiode polling thread + * instead of creating another worker. + */ +#ifndef HISPEC_ATTENUATOR_CALIBRATION_H +#define HISPEC_ATTENUATOR_CALIBRATION_H + +#include +#include +#include + +#include "lasers.h" +#include "photodiode.h" + +#define ATTENUATOR_CAL_POINT_COUNT 20U +#define ATTENUATOR_CAL_MIN_BATCH_POINTS 6U + +struct attenuator_calibration_fit_metrics { + bool valid; + uint8_t points; + double slope; + double offset; + double correlation; + double rms_db; + double max_abs_db; + double min_tx; + double max_tx; + double voltage_span_mv; +}; + +struct attenuator_calibration_batch { + double voltage_mv[ATTENUATOR_CAL_POINT_COUNT]; + double flux[ATTENUATOR_CAL_POINT_COUNT]; + size_t len; +}; + +struct attenuator_calibration_status { + const char *state; + const char *mode; + const char *physical; + const char *fit; + uint8_t attenuator_index; + uint8_t physical_index; + uint8_t point_index; + uint8_t point_count; + uint32_t dwell_ms; + uint8_t complete_pct; + double current_mv; + double other_mv; + int last_error; + bool include_voltage_schedule; + double voltage_schedule_mv[ATTENUATOR_CAL_POINT_COUNT]; + struct attenuator_calibration_fit_metrics fit_metrics[2]; +}; + +struct attenuator_calibration_auto_request { + enum hispec_laser_id laser; + const char *output; + char fiber; + uint32_t dwell_ms; + bool persistent; +}; + +/** Start automatic TIB calibration for the laser's logical attenuator pair. */ +int attenuator_calibration_start_auto( + const struct attenuator_calibration_auto_request *request, + struct attenuator_calibration_status *status); + +/** Start manual stepping for one logical attenuator pair. */ +int attenuator_calibration_start_manual(uint8_t attenuator_index, + uint32_t dwell_ms, + bool persistent, + struct attenuator_calibration_status *status); + +/** Advance manual stepping by one point; optional other_mv adjusts the held DAC. */ +int attenuator_calibration_manual_continue(bool has_other_mv, + double other_mv, + struct attenuator_calibration_status *status); + +/** Fit manual batch flux feedback for one logical attenuator pair. */ +int attenuator_calibration_fit_manual( + uint8_t attenuator_index, + const struct attenuator_calibration_batch physical[2], + bool persistent, + struct attenuator_calibration_status *status); + +/** Cancel calibration and return inactive state plus any schedule captured so far. */ +int attenuator_calibration_stop(struct attenuator_calibration_status *status); + +/** Copy current calibration status. */ +void attenuator_calibration_get_status(struct attenuator_calibration_status *status); + +/** Append the compact calibration status JSON to @p payload. */ +int attenuator_calibration_format_status( + char *payload, size_t payload_len, + const struct attenuator_calibration_status *status); + +/** Advance automatic calibration. Called only by the throughput monitor thread. */ +void attenuator_calibration_tick(const struct photodiode_status *pd_status, + int64_t now_ms); + +#endif /* HISPEC_ATTENUATOR_CALIBRATION_H */ diff --git a/app/src/attenuator_command.c b/app/src/attenuator_command.c new file mode 100644 index 0000000..45a85d1 --- /dev/null +++ b/app/src/attenuator_command.c @@ -0,0 +1,474 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "attenuator_command.h" + +#include +#include + +#include + +#include "app_settings.h" +#include "attenuator.h" +#include "attenuator_calibration.h" +#include "devices.h" +#include "lasers.h" +#include "throughput_monitor.h" + +#include +#include + +enum attenuator_setting { + ATTENUATOR_SETTING_COEFF = 0, + ATTENUATOR_SETTING_VALUE, + ATTENUATOR_SETTING_VALUEDB, +}; + +static const struct coo_json_string_choice attenuator_setting_choices[] = { + { "coeff", ATTENUATOR_SETTING_COEFF }, + { "value", ATTENUATOR_SETTING_VALUE }, + { "valuedb", ATTENUATOR_SETTING_VALUEDB }, +}; + +static const struct coo_json_string_choice attenuator_cal_fiber_choices[] = { + { "m", 'M' }, + { "s", 'S' }, +}; + +static int attenuator_index_from_name(const char *name, uint8_t *attenuator_index) +{ + enum hispec_laser_id laser_id; + + if (name == NULL || attenuator_index == NULL) { + return -EINVAL; + } + if (strcmp(name, "lfc") == 0) { + enum hispec_board_type board = devices_board_type(); + + if (board != HISPEC_BOARD_CAL_YJ && board != HISPEC_BOARD_CAL_HK) { + return -ENOENT; + } + *attenuator_index = HISPEC_ATTENUATOR_LFC_INDEX; + return devices_attenuator_channel_available(*attenuator_index) ? 0 : -ENODEV; + } + if (devices_board_type() != HISPEC_BOARD_TIB) { + return -ENOENT; + } + if (hispec_laser_id_from_name(name, &laser_id) != 0 || + attenuator_index_from_laser_id(laser_id, attenuator_index) != 0) { + return -ENOENT; + } + return devices_attenuator_channel_available(*attenuator_index) ? 0 : -ENODEV; +} + +static int attenuator_index_from_command(const struct coo_cmd_request *cmd, + enum attenuator_setting *setting, + uint8_t *attenuator_index) +{ + char laser_name[16] = {0}; + char setting_name[16] = {0}; + int setting_value; + + if (cmd == NULL || setting == NULL || attenuator_index == NULL || + coo_cmd_key_suffix_pair_copy(cmd->key, "atten", + laser_name, sizeof(laser_name), + setting_name, sizeof(setting_name)) != 0) { + return -EINVAL; + } + + if (coo_json_match_string_choice(setting_name, attenuator_setting_choices, + ARRAY_SIZE(attenuator_setting_choices), + &setting_value) != 0) { + return -ENOTSUP; + } + + { + int rc = attenuator_index_from_name(laser_name, attenuator_index); + + if (rc != 0) { + return rc; + } + } + *setting = (enum attenuator_setting)setting_value; + return 0; +} + +struct coo_cmd_response atten_setting_get(const struct coo_cmd_request *cmd) +{ + enum attenuator_setting setting; + uint8_t attenuator_index; + int rc; + char payload[MAX_PAYLOAD_LEN] = {0}; + + rc = attenuator_index_from_command(cmd, &setting, &attenuator_index); + if (rc == -EINVAL) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Failed to parse atten/setting\"}"); + } + if (rc == -ENOTSUP) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"Invalid setting\"}"); + } + if (rc == -ENOENT) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"Invalid attenuator\"}"); + } + if (rc != 0) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Attenuator unavailable on this board\"}"); + } + + switch (setting) { + case ATTENUATOR_SETTING_COEFF: + snprintk(payload, sizeof(payload), + "{\"dac1\":[%.12g,%.12g],\"dac2\":[%.12g,%.12g]}", + attenuators[attenuator_index].coeff1.slope, + attenuators[attenuator_index].coeff1.offset, + attenuators[attenuator_index].coeff2.slope, + attenuators[attenuator_index].coeff2.offset); + break; + case ATTENUATOR_SETTING_VALUE: + case ATTENUATOR_SETTING_VALUEDB: { + struct attenuator_status status = {0}; + + if (!attenuator_get(&attenuators[attenuator_index], &status)) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Failed to read attenuator\"}"); + } + snprintk(payload, sizeof(payload), + "{\"db\":%.6f,\"linear\":%.12g," + "\"v1_mv\":%.6f,\"v2_mv\":%.6f," + "\"db1\":%.6f,\"db2\":%.6f}", + status.attenuation_db, + status.linear, + status.voltage1, + status.voltage2, + status.attenuation_db1, + status.attenuation_db2); + break; + } + default: + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"Invalid setting\"}"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +struct coo_cmd_response atten_setting_set(const struct coo_cmd_request *cmd) +{ + enum attenuator_setting setting; + uint8_t attenuator_index; + int rc; + + rc = attenuator_index_from_command(cmd, &setting, &attenuator_index); + if (rc == -EINVAL) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Failed to parse laser/setting\"}"); + } + if (rc == -ENOTSUP) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"Invalid setting\"}"); + } + if (rc == -ENOENT) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"Invalid attenuator\"}"); + } + if (rc != 0) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Attenuator unavailable on this board\"}"); + } + + switch (setting) { + case ATTENUATOR_SETTING_COEFF: { + double dac1_coeffs[ATTENUATOR_COEFF_COUNT] = {0}; + double dac2_coeffs[ATTENUATOR_COEFF_COUNT] = {0}; + size_t dac1_len = 0U; + size_t dac2_len = 0U; + struct app_attenuator_channel_settings stored_coeffs = {0}; + struct attenuator_model_coeffs physical[ATTENUATOR_PHYSICAL_COUNT]; + bool persist = false; + int parse_rc; + + parse_rc = coo_json_extract_double_array(cmd->payload, "dac1", + dac1_coeffs, + ATTENUATOR_COEFF_COUNT, + &dac1_len); + if (parse_rc != COO_JSON_EXTRACT_OK || dac1_len != ATTENUATOR_COEFF_COUNT) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Improper arguments\"}"); + } + + parse_rc = coo_json_extract_double_array(cmd->payload, "dac2", + dac2_coeffs, + ATTENUATOR_COEFF_COUNT, + &dac2_len); + if (parse_rc != COO_JSON_EXTRACT_OK || dac2_len != ATTENUATOR_COEFF_COUNT) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Improper arguments\"}"); + } + + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persist, NULL) != 0) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Invalid persistent flag\"}"); + } + + physical[0].slope = dac1_coeffs[0]; + physical[0].offset = dac1_coeffs[1]; + physical[1].slope = dac2_coeffs[0]; + physical[1].offset = dac2_coeffs[1]; + stored_coeffs.physical[0].slope = dac1_coeffs[0]; + stored_coeffs.physical[0].offset = dac1_coeffs[1]; + stored_coeffs.physical[1].slope = dac2_coeffs[0]; + stored_coeffs.physical[1].offset = dac2_coeffs[1]; + + if (attenuator_apply_coefficients_preserve_db( + &attenuators[attenuator_index], physical) != 0) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Failed to apply coefficients\"}"); + } + app_settings_update_attenuator_channel(attenuator_index, + &stored_coeffs, + persist); + break; + } + case ATTENUATOR_SETTING_VALUE: + case ATTENUATOR_SETTING_VALUEDB: { + double value; + + if (coo_json_extract_double(cmd->payload, "value", &value) != + COO_JSON_EXTRACT_OK) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Missing setting value\"}"); + } + + if (setting == ATTENUATOR_SETTING_VALUE) { + if (!attenuator_set_linear(&attenuators[attenuator_index], value)) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Invalid linear transmission\"}"); + } + } else { + if (!attenuator_set_db(&attenuators[attenuator_index], value)) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Invalid dB attenuation\"}"); + } + } + break; + } + default: + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"Invalid setting\"}"); + } + + throughput_monitor_note_attenuator_changed(attenuator_index); + + return coo_cmd_ok(cmd); +} + +static struct coo_cmd_response atten_calibration_status_reply( + const struct coo_cmd_request *cmd, + const struct attenuator_calibration_status *status, + enum coo_cmd_msg_type type) +{ + char payload[MAX_PAYLOAD_LEN] = {0}; + + if (attenuator_calibration_format_status(payload, sizeof(payload), status) != 0) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Calibration status payload too large\"}"); + } + return coo_cmd_reply(cmd, type, payload); +} + +static int parse_calibration_batch_object( + const char *json, + const char *key, + struct attenuator_calibration_batch *batch) +{ + char object_json[MAX_PAYLOAD_LEN] = {0}; + size_t voltage_len = 0U; + size_t flux_len = 0U; + int rc; + + if (batch == NULL) { + return -EINVAL; + } + + memset(batch, 0, sizeof(*batch)); + rc = coo_json_extract_object(json, key, object_json, sizeof(object_json)); + if (rc == COO_JSON_EXTRACT_MISSING) { + return COO_JSON_EXTRACT_MISSING; + } + if (rc != COO_JSON_EXTRACT_OK) { + return COO_JSON_EXTRACT_ERR; + } + + rc = coo_json_extract_double_array(object_json, "voltage_mv", + batch->voltage_mv, + ATTENUATOR_CAL_POINT_COUNT, + &voltage_len); + if (rc != COO_JSON_EXTRACT_OK) { + return COO_JSON_EXTRACT_ERR; + } + rc = coo_json_extract_double_array(object_json, "flux", + batch->flux, + ATTENUATOR_CAL_POINT_COUNT, + &flux_len); + if (rc != COO_JSON_EXTRACT_OK || + voltage_len != flux_len || + voltage_len < ATTENUATOR_CAL_MIN_BATCH_POINTS) { + return COO_JSON_EXTRACT_ERR; + } + + batch->len = voltage_len; + return COO_JSON_EXTRACT_OK; +} + +struct coo_cmd_response atten_calibration_get(const struct coo_cmd_request *cmd) +{ + struct attenuator_calibration_status status = {0}; + + attenuator_calibration_get_status(&status); + return atten_calibration_status_reply(cmd, &status, COO_CMD_RESP_OK); +} + +struct coo_cmd_response atten_calibration_set(const struct coo_cmd_request *cmd) +{ + struct attenuator_calibration_status status = {0}; + struct attenuator_calibration_batch batch[2]; + char mode[16] = {0}; + char atten_name[16] = "lfc"; + char laser_name[16] = {0}; + char output[MEMS_SOURCEDEST_MAX_LEN] = {0}; + struct attenuator_calibration_auto_request request = { + .output = output, + .fiber = 'M', + }; + uint32_t dwell_ms = 0U; + bool persistent = false; + bool stop = false; + bool cont = false; + bool cont_present = false; + bool other_present = false; + double other_mv = ATTENUATOR_DRIVE_MAX_MV; + int rc; + int parse_rc; + int choice_value; + uint8_t attenuator_index; + + if (coo_json_extract_optional_bool(cmd->payload, "stop", &stop, NULL) != 0) { + return coo_cmd_error(cmd, "invalid stop"); + } + if (stop) { + (void)attenuator_calibration_stop(&status); + return atten_calibration_status_reply(cmd, &status, COO_CMD_RESP_OK); + } + + if (coo_json_extract_optional_bool(cmd->payload, "continue", + &cont, &cont_present) != 0) { + return coo_cmd_error(cmd, "invalid continue"); + } + if (cont_present) { + if (coo_json_extract_optional_double_range(cmd->payload, "other_mv", + &other_mv, + &other_present, + 0.0, + ATTENUATOR_DRIVE_MAX_MV) != 0) { + return coo_cmd_error(cmd, "invalid other_mv"); + } + if (cont) { + rc = attenuator_calibration_manual_continue(other_present, + other_mv, + &status); + if (rc != 0) { + return atten_calibration_status_reply(cmd, &status, + COO_CMD_RESP_ERROR); + } + } else { + attenuator_calibration_get_status(&status); + } + return atten_calibration_status_reply(cmd, &status, COO_CMD_RESP_OK); + } + + parse_rc = parse_calibration_batch_object(cmd->payload, "dac1", &batch[0]); + if (parse_rc == COO_JSON_EXTRACT_OK) { + if (parse_calibration_batch_object(cmd->payload, "dac2", &batch[1]) != + COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "manual fit requires dac1 and dac2 batches"); + } + (void)coo_json_extract_string(cmd->payload, "attenuator", + atten_name, sizeof(atten_name)); + if (attenuator_index_from_name(atten_name, &attenuator_index) != 0) { + return coo_cmd_error(cmd, "invalid attenuator"); + } + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persistent, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); + } + rc = attenuator_calibration_fit_manual(attenuator_index, batch, + persistent, &status); + return atten_calibration_status_reply( + cmd, &status, rc == 0 ? COO_CMD_RESP_OK : COO_CMD_RESP_ERROR); + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid manual fit batch"); + } + + (void)coo_json_extract_string(cmd->payload, "mode", mode, sizeof(mode)); + if (strcmp(mode, "manual") == 0) { + (void)coo_json_extract_string(cmd->payload, "attenuator", + atten_name, sizeof(atten_name)); + if (attenuator_index_from_name(atten_name, &attenuator_index) != 0) { + return coo_cmd_error(cmd, "invalid attenuator"); + } + if (coo_json_extract_optional_u32(cmd->payload, "dwell_ms", + &dwell_ms, NULL) != 0) { + return coo_cmd_error(cmd, "invalid dwell_ms"); + } + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persistent, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); + } + rc = attenuator_calibration_start_manual(attenuator_index, + dwell_ms, + persistent, + &status); + return atten_calibration_status_reply( + cmd, &status, rc == 0 ? COO_CMD_RESP_OK : COO_CMD_RESP_ERROR); + } + + parse_rc = coo_json_extract_string(cmd->payload, "laser", + laser_name, sizeof(laser_name)); + if (parse_rc != COO_JSON_EXTRACT_OK || + hispec_laser_id_from_name(laser_name, &request.laser) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser"); + } + parse_rc = coo_json_extract_string(cmd->payload, "output", + output, sizeof(output)); + if (parse_rc != COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "missing or invalid output"); + } + parse_rc = coo_json_extract_string_choice(cmd->payload, "fiber", + attenuator_cal_fiber_choices, + ARRAY_SIZE(attenuator_cal_fiber_choices), + &choice_value); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "fiber must be M or S"); + } + if (parse_rc == COO_JSON_EXTRACT_OK) { + request.fiber = (char)choice_value; + } + if (coo_json_extract_optional_u32(cmd->payload, "dwell_ms", + &dwell_ms, NULL) != 0) { + return coo_cmd_error(cmd, "invalid dwell_ms"); + } + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persistent, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); + } + request.dwell_ms = dwell_ms; + request.persistent = persistent; + + rc = attenuator_calibration_start_auto(&request, &status); + if (rc != 0 && status.state == NULL) { + return coo_cmd_error(cmd, "attenuator calibration start failed"); + } + return atten_calibration_status_reply( + cmd, &status, rc == 0 ? COO_CMD_RESP_OK : COO_CMD_RESP_ERROR); +} diff --git a/app/src/attenuator_command.h b/app/src/attenuator_command.h new file mode 100644 index 0000000..fe26c8e --- /dev/null +++ b/app/src/attenuator_command.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef HISPEC_ATTENUATOR_COMMAND_H +#define HISPEC_ATTENUATOR_COMMAND_H + +#include "command.h" + +/** + * @file attenuator_command.h + * @brief Command adapters for logical attenuator values and calibration. + */ + +/** + * @brief Return one logical attenuator's value or model coefficients. + * + * Parses `atten//`, checks that the mapped logical + * attenuator belongs to the active board profile, and may block on DAC I2C for + * value readback. It does not modify hardware or settings. + */ +struct coo_cmd_response atten_setting_get(const struct coo_cmd_request *cmd); + +/** + * @brief Update one logical attenuator's value or model coefficients. + * + * Parses and validates the command payload, writes DAC-backed attenuator state, + * and optionally persists coefficient changes through app settings. It may + * block on DAC I2C and can enqueue attenuator range warnings through the domain + * driver. + */ +struct coo_cmd_response atten_setting_set(const struct coo_cmd_request *cmd); + +/** @brief Query compact attenuator-calibration state. */ +struct coo_cmd_response atten_calibration_get(const struct coo_cmd_request *cmd); + +/** @brief Start/continue/stop attenuator calibration or fit manual batches. */ +struct coo_cmd_response atten_calibration_set(const struct coo_cmd_request *cmd); + +#endif /* HISPEC_ATTENUATOR_COMMAND_H */ diff --git a/app/src/command.c b/app/src/command.c index a47a970..aaad69a 100644 --- a/app/src/command.c +++ b/app/src/command.c @@ -1,60 +1,57 @@ /** * @file command.c - * @brief Command normalization, execution, and outbound response publication. + * @brief HISPEC command table, request classification, and app command handlers. * - * The module owns the static command table and the two Zephyr message queues - * that connect ingress, command execution, and MQTT/serial output. Hardware - * side effects are still delegated to the domain modules where practical. + * The common command runtime owns MQTT/serial topic handling, executor loops, + * warning publication, and outbound drain behavior. This file supplies the + * static command spec table, app serial shorthand callback, help metadata, and + * command handlers that cut across domains. */ #include "command.h" -// #include "devices.h" -#include #include -#include -#include -#include +#include #include -#include -#include -#include +#include #include #include #include #include -#include #include "devices.h" +#include "lasers.h" #include "app_identity.h" #include "app_settings.h" -#include "app_scheduled_actions.h" -#include "app_warning.h" #include "attenuator.h" -#include "maiman.h" +#include "attenuator_command.h" +#include "laser_command.h" +#include "laserbank_tempcontrol.h" +#include "mems_command.h" #include "mems_switching.h" -#include "photodiode.h" +#include "photodiode_command.h" +#include "throughput_command.h" +#include "throughput_monitor.h" +#if defined(CONFIG_SNTP) #include "sntp_sync.h" -#include "tempsense.h" +#endif +#include "housekeeping.h" #include #include #include LOG_MODULE_REGISTER(command, LOG_LEVEL_DBG); -#define SERIAL_LINE_MAX 220 -#define SERIAL_WRAP_COLUMN 80U -#define LASERBANK_FAULT_CLEAR_OFF_MS 250U +#define SERIAL_WRAP_COLUMN COO_CMD_SERIAL_WRAP_COLUMN +#define COMMAND_REBOOT_DELAY_MS 3000U static uint16_t mqtt_msg_id = 1; -static atomic_t serial_network_ignore_active; - /* MQTT and serial ingress use k_msgq so callbacks never execute hardware work. * Depth is intentionally small: clients should retry instead of letting stale * hardware commands pile up. */ K_MSGQ_DEFINE(inbound_queue, - sizeof(struct Command), + sizeof(struct coo_cmd_request), MAX_PENDING_COMMANDS, /* depth */ 4); /* 4‐byte align */ @@ -62,1192 +59,605 @@ K_MSGQ_DEFINE(inbound_queue, * queue. The main loop owns MQTT publish retries and serial printing. */ K_MSGQ_DEFINE(outbound_queue, - sizeof(struct OutMsg), + sizeof(struct coo_cmd_response), 8, 4); -extern const struct gpio_dt_spec laser_power_gpio; extern struct mems_switch mems_switches[MEMS_ROUTER_MAX_SWITCHES]; extern struct mems_router router; // extern struct attenuator attenuators[NUM_ATTENUATORS]; -struct json_value_uint16 { - uint16_t value; -}; - -struct json_value_bool { - bool value; +static bool command_tib_supported(const struct coo_cmd_spec *spec, void *user_data); +static enum coo_cmd_msg_type classify_route_loss(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data); +static enum coo_cmd_msg_type classify_laser_level(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data); +static enum coo_cmd_msg_type classify_laser_tune(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data); +static enum coo_cmd_msg_type classify_laser_settings(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data); +static enum coo_cmd_msg_type classify_pd(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data); +static int serial_mems_switch_shorthand(const char *key, const char *payload, + char *out, size_t out_len, + void *user_data); +static void command_prepare_reboot(void *user_data); + +#define CMD_HELP(_usage, _args, _values, _notes, _flags) \ + .help = &(const struct coo_cmd_help_entry){ \ + .usage = (_usage), .args = (_args), .values = (_values), \ + .notes = (_notes), .flags = (_flags) } + +#define CMD_SPEC(_key, _get, _set, _class, _guard, _usage, _args, _values, _notes, _flags) \ + { .key = (_key), .query_handler = (_get), .effect_handler = (_set), \ + .class_policy = (_class), .mqtt_query_allowed_during_serial_guard = (_guard), \ + CMD_HELP(_usage, _args, _values, _notes, _flags) } + +#define CMD_SPEC_CUSTOM(_key, _get, _set, _classify, _guard, _usage, _args, _values, _notes, _flags) \ + { .key = (_key), .query_handler = (_get), .effect_handler = (_set), \ + .class_policy = COO_CMD_CLASS_CUSTOM, .custom_classify = (_classify), \ + .mqtt_query_allowed_during_serial_guard = (_guard), \ + CMD_HELP(_usage, _args, _values, _notes, _flags) } + +#define CMD_SPEC_TIB(_key, _get, _set, _class, _guard, _usage, _args, _values, _notes, _flags) \ + { .key = (_key), .query_handler = (_get), .effect_handler = (_set), \ + .class_policy = (_class), .supported = command_tib_supported, \ + .mqtt_query_allowed_during_serial_guard = (_guard), \ + CMD_HELP(_usage, _args, _values, _notes, _flags) } + +#define CMD_SPEC_TIB_CUSTOM(_key, _get, _set, _classify, _guard, _usage, _args, _values, _notes, _flags) \ + { .key = (_key), .query_handler = (_get), .effect_handler = (_set), \ + .class_policy = COO_CMD_CLASS_CUSTOM, .custom_classify = (_classify), \ + .supported = command_tib_supported, \ + .mqtt_query_allowed_during_serial_guard = (_guard), \ + CMD_HELP(_usage, _args, _values, _notes, _flags) } + +#define CMD_HELP_ONLY(_key, _supported, _usage, _args, _values, _notes, _flags) \ + { .key = (_key), .supported = (_supported), \ + CMD_HELP(_usage, _args, _values, _notes, _flags) } + +/* + * One static row owns dispatch, classification, serial-guard query allowance, + * and help for a command key. Help-only rows document parameterized command + * forms whose real dispatch is handled by a shorter prefix row. + */ +static const struct coo_cmd_spec command_specs[] = { + CMD_SPEC("ip", ip_get, ip_set, COO_CMD_CLASS_DEFAULT, true, + "ip [trydhcpfirst= preferdhcpdns= preferdhcpntp= ip= subnet= gateway= dns= ntp= persistent=]", + "query with no payload; effect when any listed field is supplied", + "bool: true|false|on|off|yes|no", + "reconfigures IPv4 immediately; persistent=true stores app-owned IP settings", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + { .key = "mqtt", .query_handler = mqtt_get, .effect_handler = mqtt_set, + .class_policy = COO_CMD_CLASS_DEFAULT, + .serial_positional = { .field = { "broker", "persistent" }, .required_count = 1U }, + .mqtt_query_allowed_during_serial_guard = true, + CMD_HELP("mqtt [broker= persistent=]", + "broker is required for effect; persistent is optional", + "broker examples: 192.168.1.5:1883, hispec.caltech.edu:1883", + "runtime broker changes cause the main loop to reconnect", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY) }, + { .key = "time", .query_handler = time_get, .effect_handler = time_set, + .class_policy = COO_CMD_CLASS_DEFAULT, + .serial_positional = { .field = { "linuxtime_ms" }, .required_count = 1U, + .numeric_mask = BIT(0) }, + .mqtt_query_allowed_during_serial_guard = true, + CMD_HELP("time [linuxtime_ms=]", + "linuxtime_ms required for effect", + "unsigned millisecond Unix epoch", + "sets Zephyr realtime clock and records last known UTC", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY) }, + CMD_SPEC("temp", temp_get, NULL, COO_CMD_CLASS_DEFAULT, true, + "temp", "none", NULL, "cached housekeeping temperature status", + COO_CMD_HELP_QUERY | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC("status", status_get, NULL, COO_CMD_CLASS_ALWAYS_QUERY, true, + "status [ip= lasers= attens=]", + "all fields optional", + "bool: true|false|on|off|yes|no", + "query-only firmware and subsystem status", + COO_CMD_HELP_QUERY | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_CUSTOM("memsroute/route_loss", memsroute_get, memsroute_set, + classify_route_loss, true, + "memsroute/route_loss route= [= ... persistent=]", + "route required for effect; laser fields optional by query/effect mode", + "laser fields: 1028y,1270j,1430yj,1430hk,1510h,2330k,split", + "stores user route-loss estimates used by optical calculations", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC("memsroute", memsroute_get, memsroute_set, + COO_CMD_CLASS_DEFAULT, true, + "memsroute [route=: persistent=]", + "route required for effect; persistent optional", + "route names are board profile input/output route keys", + "applies a named static MEMS route", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + { .key = "mems", .query_handler = mems_get, .effect_handler = mems_set, + .class_policy = COO_CMD_CLASS_DEFAULT, + .serial_shorthand = serial_mems_switch_shorthand, + .mqtt_query_allowed_during_serial_guard = true }, + CMD_HELP_ONLY("mems/", NULL, + "mems/ [state= duty_cycle=<0..1> toggle_rate_hz= stopafter_s=]", + "state required for effect; duty/toggle/stop fields optional", + "switchname is one active board MEMS switch name", + "serial shorthand accepts: mems/ A [duty_cycle] [stopafter_s]", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC("split", splitting_get, splitting_set, + COO_CMD_CLASS_DEFAULT, true, + "split [channel= ratio1=<0..1> ratio2=<0..1> ratio3=<0..1> stopafter_s=]", + "channel, ratio1, and ratio2 are required for effect; ratio3 and stopafter_s optional", + "channel: yj,hk", + "split/yj and split/hk query current splitter state", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB("measure_throughput", NULL, measure_throughput_set, + COO_CMD_CLASS_DEFAULT, false, + "measure_throughput action= channel= [duration_ms= output= autolevel= max_flux_ph_s=]", + "action and channel required", + "channel: yj,hk", + "TIB-only throughput monitor command", + COO_CMD_HELP_EFFECT), + CMD_SPEC_TIB_CUSTOM("laser", laser_get, laser_set, classify_laser_level, + true, + "laser name= [level= autooff_s=]", + "name required; level makes it an effect", + "laser: 1028y,1270j,1430yj,1430hk,1510h,2330k", + "TIB-only laser output status/set command", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB_CUSTOM("laser/tune", laser_tune_get, laser_tune_set, + classify_laser_tune, true, + "laser/tune name= [tune_nm=|delta_nm=]", + "name required; tune_nm or delta_nm makes it an effect", + "laser: 1028y,1270j,1430yj,1430hk,1510h,2330k", + "TIB-only stored tune request", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB("laser/status", laser_get, NULL, + COO_CMD_CLASS_ALWAYS_QUERY, true, + "laser/status name=", + "name required", + "laser: 1028y,1270j,1430yj,1430hk,1510h,2330k", + "TIB-only compact operational status", + COO_CMD_HELP_QUERY | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB("laser/engstatus", laser_engstatus_get, NULL, + COO_CMD_CLASS_ALWAYS_QUERY, true, + "laser/engstatus name=", + "name required", + "laser: 1028y,1270j,1430yj,1430hk,1510h,2330k", + "TIB-only engineering status; may perform slow Modbus reads", + COO_CMD_HELP_QUERY | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB_CUSTOM("laser/settings", laser_settings_get, laser_settings_set, + classify_laser_settings, true, + "laser/settings name= [settings={...} persistent=]", + "name required; settings object required for effect", + "laser: 1028y,1270j,1430yj,1430hk,1510h,2330k", + "TIB-only app-owned laser policy/settings wrapper", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB("laserbank/power", laserbank_power, laserbank_power, + COO_CMD_CLASS_SUFFIX_OR_PAYLOAD_EFFECT, true, + "laserbank/power [override=]", + "override required for effect; suffix form laserbank/power/ also works", + "mode: auto,override_on,override_off", + "TIB-only laser-bank supply override", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC_TIB("laserbank/clearfaults", NULL, laserbank_clearfaults, + COO_CMD_CLASS_ALWAYS_EFFECT, false, + "laserbank/clearfaults", + "none", NULL, + "TIB-only power-cycles the laser bank to clear latched faults", + COO_CMD_HELP_EFFECT), + CMD_SPEC_TIB("laserbank/heater", laserbank_heater, laserbank_heater, + COO_CMD_CLASS_SUFFIX_OR_PAYLOAD_EFFECT, true, + "laserbank/heater [override=]", + "override required for effect; suffix form laserbank/heater/ also works", + "mode: auto,override_on,override_off", + "TIB-only laser-bank heater relay mode", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_SPEC("atten/calibrate", atten_calibration_get, atten_calibration_set, + COO_CMD_CLASS_DEFAULT, true, + "atten/calibrate action= [attenuator= fiber= other_mv= dwell_ms= persistent=]", + "action required for effect; no payload queries calibration state", + "name: 1028y,1270j,1430yj,1430hk,1510h,2330k,lfc", + "calibration actions are mode-specific and may run across commands", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + { .key = "atten", .query_handler = atten_setting_get, + .effect_handler = atten_setting_set, + .class_policy = COO_CMD_CLASS_DEFAULT, + .mqtt_query_allowed_during_serial_guard = true }, + CMD_HELP_ONLY("atten//value", NULL, + "atten//value [value=]", + "value required for effect", + "name: 1028y,1270j,1430yj,1430hk,1510h,2330k,lfc", + "sets or queries total logical transmission", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_HELP_ONLY("atten//valuedb", NULL, + "atten//valuedb [value=]", + "value required for effect", + "name: 1028y,1270j,1430yj,1430hk,1510h,2330k,lfc", + "serial shorthand wraps a single numeric value field", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + CMD_HELP_ONLY("atten//coeff", NULL, + "atten//coeff [dac1=[slope,offset] dac2=[slope,offset] persistent=]", + "dac1 and dac2 arrays required for effect", + "name: 1028y,1270j,1430yj,1430hk,1510h,2330k,lfc", + "send JSON for coeff updates; key=value shorthand cannot express arrays", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), + { .key = "pd", .query_handler = pd_get, .effect_handler = pd_set, + .class_policy = COO_CMD_CLASS_CUSTOM, + .custom_classify = classify_pd, + .supported = command_tib_supported, + .mqtt_query_allowed_during_serial_guard = true, + CMD_HELP("pd [channel= action= duration_ms= store= persistent=]", + "channel and action required for effects; no payload queries live values", + "channel: yj,hk; action: measure_dark,dark_status,reset_lowest_dark", + "TIB-only photodiode status and dark-calibration actions", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY) }, + { .key = "pdsettings", .query_handler = pd_settings_get, + .effect_handler = pd_settings_set, + .class_policy = COO_CMD_CLASS_DEFAULT, + .supported = command_tib_supported, + .mqtt_query_allowed_during_serial_guard = true }, + CMD_HELP_ONLY("pdsettings/", command_tib_supported, + "pdsettings/ [dark_mv= noise_rms_mV= responsivity_a_per_w= transimpedance_v_per_a= persistent=]", + "channel required in key; listed fields optional for effect", + "channel: yj,hk", + "TIB-only app-owned photodiode calibration/settings", + COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | COO_CMD_HELP_SERIAL_GUARD_QUERY), }; +#undef CMD_HELP +#undef CMD_SPEC +#undef CMD_SPEC_CUSTOM +#undef CMD_SPEC_TIB +#undef CMD_SPEC_TIB_CUSTOM +#undef CMD_HELP_ONLY -typedef enum laser_t { - LASER_1028_Y=1, - LASER_1270_J=1, - LASER_1430_YJ=2, - LASER_1430_HK=3, - LASER_1510_H=4, - LASER_2330_K=5, - LASER_UNKNOWN=6 -} laser_t; - -static bool attenuator_channel_available(laser_t laser_id) -{ - enum hispec_board_type board = devices_board_type(); - - if (board == HISPEC_BOARD_TIB) { - return laser_id != LASER_UNKNOWN && (uint8_t)laser_id < NUM_ATTENUATORS; - } - - if (board == HISPEC_BOARD_CAL_YJ || board == HISPEC_BOARD_CAL_HK) { - return laser_id == LASER_1510_H; - } - - return false; -} - - +static struct coo_cmd_runtime command_runtime; - -const struct DispatchEntry dispatch_table[] = { - { "help", help_get, NULL }, - { "ip", ip_get, ip_set }, - { "mqtt", mqtt_get, mqtt_set }, - { "time", time_get, time_set }, - { "reboot", NULL, reboot_set }, - { "serialguard", serial_guard_get, serial_guard_set }, - { "memsroute", memsroute_get, memsroute_set }, - { "mems", mems_get, mems_set }, - { "split", splitting_get, splitting_set }, - { "laserbank/poweron", laserbank_poweron, laserbank_poweron }, - { "laserbank/poweroff", laserbank_poweroff, laserbank_poweroff }, - { "laserbank/clearfaults", laserbank_clearfaults, laserbank_clearfaults }, - { "laser", laser_setting_get,laser_setting_set}, - { "atten", atten_setting_get, atten_setting_set }, - { "pdsettings", pd_settings_get, pd_settings_set }, - { "pd", pd_get, pd_set }, - { "temp", temp_get, NULL }, - { "status", status_get, NULL },// GET only - //todo add reset for system -}; - - -const struct DispatchEntry *find_dispatch(const char *key) +static bool command_tib_supported(const struct coo_cmd_spec *spec, void *user_data) { - const struct DispatchEntry *best = NULL; - size_t best_len = 0; - - for (size_t i = 0; i < ARRAY_SIZE(dispatch_table); ++i) { - const char *candidate = dispatch_table[i].key; - size_t len = strlen(candidate); - - if (strncmp(key, candidate, len) != 0) { - continue; - } - - if (key[len] != '\0' && key[len] != '/') { - continue; - } - - if (len > best_len) { - best = &dispatch_table[i]; - best_len = len; - } - } - - return best; -} - - -struct OutMsg dispatch_command(const struct Command *cmd) { - LOG_INF("Dispatching: %s", cmd->key); - struct OutMsg r; + ARG_UNUSED(spec); + ARG_UNUSED(user_data); - const struct DispatchEntry *entry = find_dispatch(cmd->key); - if (!entry) { - r = unknown_response(cmd); - } else { - DispatchFunc func = (cmd->msg_type == MSG_SET) ? entry->set_handler : entry->get_handler; - r = func==NULL ? unsupported_response(cmd) : func(cmd); - } - return r; + return devices_board_type() == HISPEC_BOARD_TIB; } - -int parse_key_pair(const char *key, - char *out_name, size_t max_name, - char *out_setting, size_t max_setting) +static bool route_loss_payload_has_value(const char *payload) { - /* Find the first slash */ - const char *slash = strchr(key, '/'); - if (!slash) { - return -1; - } - - size_t name_len = slash - key; - if (name_len == 0 || name_len >= max_name) { - /* Name empty or too long for buffer (including null) */ - return -2; - } - - /* Copy name */ - memcpy(out_name, key, name_len); - out_name[name_len] = '\0'; - - /* Copy setting, up to max_setting-1 characters, null terminated */ - const char *setting_start = slash + 1; - size_t setting_len = strcspn(setting_start, "/"); /* Up to next '/', or full string */ - if (setting_len == 0 || setting_len >= max_setting) { - /* Setting empty or too long for buffer */ - return -3; - } - memcpy(out_setting, setting_start, setting_len); - out_setting[setting_len] = '\0'; - - return 0; - -} - - + static const char *const route_loss_value_keys[] = { + "1028y", "1270j", "1430yj", "1430hk", "1510h", "2330k", "split", + }; + char text[32]; + double value; -bool parse_msg_type_from_payload(const char *payload, enum MsgType *msg_type_out) -{ - enum coo_msg_type msg_type; - if (!coo_json_parse_msg_type(payload, &msg_type)) { + if (payload == NULL) { return false; } - if (msg_type == COO_MSG_GET) { - *msg_type_out = MSG_GET; - return true; - } - if (msg_type == COO_MSG_SET) { - *msg_type_out = MSG_SET; - return true; - } - return false; -} - -static int parse_atten_key(const char *key, - char *laser_name, size_t laser_name_len, - char *setting, size_t setting_len) -{ - const char prefix[] = "atten/"; - const char *laser_start; - const char *slash; - size_t laser_len; - size_t parsed_setting_len; - - if (key == NULL || laser_name == NULL || setting == NULL || - strncmp(key, prefix, strlen(prefix)) != 0) { - return -EINVAL; - } - - laser_start = key + strlen(prefix); - slash = strchr(laser_start, '/'); - if (slash == NULL) { - return -EINVAL; - } - - laser_len = (size_t)(slash - laser_start); - parsed_setting_len = strcspn(slash + 1, "/"); - if (laser_len == 0U || laser_len >= laser_name_len || - parsed_setting_len == 0U || parsed_setting_len >= setting_len || - (slash + 1)[parsed_setting_len] != '\0') { - return -EINVAL; - } - - memcpy(laser_name, laser_start, laser_len); - laser_name[laser_len] = '\0'; - memcpy(setting, slash + 1, parsed_setting_len); - setting[parsed_setting_len] = '\0'; - return 0; -} - -struct OutMsg _msg_builder(const struct Command *cmd, enum MsgType msgtyp, const char *msg) { - struct OutMsg r = { 0 }; - r.msg_type = msgtyp; - r.target = (cmd && cmd->source == CMD_SRC_SERIAL) ? OUT_TARGET_SERIAL : OUT_TARGET_MQTT; - r.qos = MQTT_QOS_1_AT_LEAST_ONCE; - - // snprintf(r.payload, MAX_PAYLOAD_LEN, "{\"error\":\"Invalid route\"}"); - - - /* MQTT 5 response_topic is authoritative when supplied; otherwise the - * firmware derives cmd//resp/ during ingress. - */ - snprintk(r.topic, sizeof(r.topic), APP_MQTT_RESP_PREFIX); - if (cmd && strlen(cmd->response_topic) > 0 && strlen(cmd->response_topic) < sizeof(r.topic)) { - strncpy(r.topic, cmd->response_topic, sizeof(r.topic) - 1); - } - - /* MQTT 5 correlation_data is opaque requester state and must be echoed - * exactly so clients can match command responses. - */ - if (cmd && cmd->corr_len > 0 && cmd->corr_len <= sizeof(r.correlation_data)) { - memcpy(r.correlation_data, cmd->correlation_data, cmd->corr_len); - r.corr_len = cmd->corr_len; - } - - if (msg != NULL && strlen(msg) >= sizeof(r.payload)) { - static const char overflow_msg[] = "{\"status\":\"error\",\"msg\":\"response too large\"}"; - - r.msg_type = RESP_ERROR; - snprintk(r.payload, sizeof(r.payload), "%s", overflow_msg); - r.payload_len = strlen(r.payload); - return r; - } + for (uint8_t i = 0U; i < ARRAY_SIZE(route_loss_value_keys); ++i) { + const char *key = route_loss_value_keys[i]; - snprintk(r.payload, sizeof(r.payload), "%s", msg != NULL ? msg : ""); - r.payload_len = strlen(r.payload); - return r; -} - -static bool copy_topic(const struct mqtt_utf8 *topic, char *out, size_t out_len) -{ - if (topic == NULL || out == NULL || topic->size == 0U || topic->size >= out_len) { - return false; + if (coo_json_extract_double(payload, key, &value) == COO_JSON_EXTRACT_OK || + coo_json_extract_string(payload, key, text, sizeof(text)) == COO_JSON_EXTRACT_OK) { + return true; + } } - memcpy(out, topic->utf8, topic->size); - out[topic->size] = '\0'; - return true; + return strstr(payload, "\"split\"") != NULL; } -static bool mqtt_get_allowed_during_serial_guard(const char *key) +static enum coo_cmd_msg_type classify_route_loss(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data) { - const struct DispatchEntry *entry = find_dispatch(key); - - if (entry == NULL || entry->get_handler == NULL) { - return false; - } - - /* Some legacy GET handlers currently have side effects. Keep those blocked - * under serial guard until their command shape is corrected. - */ - if (strncmp(entry->key, "laserbank/", strlen("laserbank/")) == 0 || - strcmp(entry->key, "laser") == 0) { - return false; - } + ARG_UNUSED(spec); + ARG_UNUSED(user_data); - return true; + return route_loss_payload_has_value(cmd != NULL ? cmd->payload : NULL) ? + COO_CMD_EFFECT : COO_CMD_QUERY; } -static bool derive_default_response_topic(const char *key, char *topic_out, size_t topic_out_len) +static enum coo_cmd_msg_type classify_laser_level(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data) { - const int n = snprintk(topic_out, topic_out_len, "%s%s", APP_MQTT_RESP_PREFIX, key); - - return n > 0 && n < (int)topic_out_len; -} + float fval; -static void enqueue_serial_error(const char *msg) -{ - struct OutMsg out = {0}; - - out.target = OUT_TARGET_SERIAL; - out.msg_type = RESP_ERROR; - snprintk(out.topic, sizeof(out.topic), APP_MQTT_RESP_PREFIX "serial"); - out.payload_len = snprintk(out.payload, sizeof(out.payload), - "{\"status\":\"error\",\"msg\":\"%s\"}", msg); - (void)k_msgq_put(&outbound_queue, &out, K_NO_WAIT); -} + ARG_UNUSED(spec); + ARG_UNUSED(user_data); -static const char *skip_serial_space(const char *s) -{ - while (s != NULL && (*s == ' ' || *s == '\t')) { - s++; - } - return s; + return cmd != NULL && + coo_json_extract_float(cmd->payload, "level", &fval) != COO_JSON_EXTRACT_MISSING ? + COO_CMD_EFFECT : COO_CMD_QUERY; } -static bool next_serial_token(const char **cursor, char *out, size_t out_len) +static enum coo_cmd_msg_type classify_laser_tune(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data) { - const char *start; - size_t len; + float fval; - if (cursor == NULL || *cursor == NULL || out == NULL || out_len == 0U) { - return false; - } - - start = skip_serial_space(*cursor); - if (*start == '\0') { - *cursor = start; - return false; - } - - len = strcspn(start, " \t"); - if (len >= out_len) { - len = out_len - 1U; - } - - memcpy(out, start, len); - out[len] = '\0'; - *cursor = start + strcspn(start, " \t"); - return true; -} + ARG_UNUSED(spec); + ARG_UNUSED(user_data); -static bool serial_token_has_extra(const char *cursor) -{ - cursor = skip_serial_space(cursor); - return cursor != NULL && *cursor != '\0'; + return cmd != NULL && + (coo_json_extract_float(cmd->payload, "tune_nm", &fval) != COO_JSON_EXTRACT_MISSING || + coo_json_extract_float(cmd->payload, "delta_nm", &fval) != COO_JSON_EXTRACT_MISSING) ? + COO_CMD_EFFECT : COO_CMD_QUERY; } -static bool serial_token_is_number(const char *token) +static enum coo_cmd_msg_type classify_laser_settings(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data) { - char *end = NULL; + char settings_json[MAX_PAYLOAD_LEN]; - if (token == NULL || token[0] == '\0') { - return false; - } + ARG_UNUSED(spec); + ARG_UNUSED(user_data); - (void)strtod(token, &end); - return end != token && end != NULL && *end == '\0'; + return cmd != NULL && + coo_json_extract_object(cmd->payload, "settings", + settings_json, sizeof(settings_json)) != COO_JSON_EXTRACT_MISSING ? + COO_CMD_EFFECT : COO_CMD_QUERY; } -static const char *serial_token_bool_json(const char *token) +static enum coo_cmd_msg_type classify_pd(const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data) { - if (token == NULL) { - return NULL; - } - - if (strcasecmp(token, "true") == 0 || strcasecmp(token, "on") == 0 || - strcasecmp(token, "yes") == 0) { - return "true"; - } - if (strcasecmp(token, "false") == 0 || strcasecmp(token, "off") == 0 || - strcasecmp(token, "no") == 0) { - return "false"; - } - - return NULL; -} + char action[20] = {0}; -static int append_serial_json_value(char *out, size_t out_len, size_t *off, - const char *token) -{ - const char *bool_json = serial_token_bool_json(token); - int written; + ARG_UNUSED(spec); + ARG_UNUSED(user_data); - if (out == NULL || off == NULL || token == NULL) { - return -EINVAL; + if (cmd == NULL || coo_cmd_payload_empty(cmd)) { + return COO_CMD_QUERY; } - - if (bool_json != NULL) { - written = snprintk(out + *off, out_len - *off, "%s", bool_json); - } else if (serial_token_is_number(token) || strcasecmp(token, "null") == 0) { - written = snprintk(out + *off, out_len - *off, "%s", token); - } else { - if (strchr(token, '"') != NULL || strchr(token, '\\') != NULL) { - return -EINVAL; - } - written = snprintk(out + *off, out_len - *off, "\"%s\"", token); + if (coo_json_extract_string(cmd->payload, "action", + action, sizeof(action)) == COO_JSON_EXTRACT_OK && + strcasecmp(action, "dark_status") == 0) { + return COO_CMD_QUERY; } - if (written < 0 || written >= (int)(out_len - *off)) { - return -ENOSPC; - } - *off += (size_t)written; - return 0; + return COO_CMD_EFFECT; } -static int append_serial_json_field(char *out, size_t out_len, size_t *off, - const char *key, const char *token, - bool comma) +static int serial_read_three_tokens(const char *payload, + char *t0, size_t t0_len, + char *t1, size_t t1_len, + char *t2, size_t t2_len) { - int written; - int rc; + const char *cursor = payload; - if (key == NULL || token == NULL || key[0] == '\0' || - strchr(key, '"') != NULL || strchr(key, '\\') != NULL) { + if (!coo_cmd_serial_next_token(&cursor, t0, t0_len)) { return -EINVAL; } - - written = snprintk(out + *off, out_len - *off, "%s\"%s\":", comma ? "," : "", key); - if (written < 0 || written >= (int)(out_len - *off)) { - return -ENOSPC; - } - *off += (size_t)written; - - rc = append_serial_json_value(out, out_len, off, token); - return rc; + (void)coo_cmd_serial_next_token(&cursor, t1, t1_len); + (void)coo_cmd_serial_next_token(&cursor, t2, t2_len); + return coo_cmd_serial_has_extra(cursor) ? -EINVAL : 0; } -/* Convert a serial payload like "state=A stopafter_s=30" into a compact JSON - * object. This function does not validate command-specific meaning; handlers - * still parse and validate the resulting JSON in the normal dispatch path. - */ -static int serial_payload_from_key_values(const char *payload, char *out, size_t out_len) +static int serial_single_value_payload(const char *token, char *out, size_t out_len) { - const char *cursor = payload; - char token[128]; - bool first = true; size_t off = 0; int written; - written = snprintk(out, out_len, "{"); + written = snprintk(out, out_len, "{\"value\":"); if (written < 0 || written >= (int)out_len) { return -ENOSPC; } off = (size_t)written; - - while (next_serial_token(&cursor, token, sizeof(token))) { - char *eq = strchr(token, '='); - - if (eq == NULL || eq == token || eq[1] == '\0') { - return -EINVAL; - } - *eq = '\0'; - - if (append_serial_json_field(out, out_len, &off, token, eq + 1, !first) != 0) { - return -EINVAL; - } - first = false; + if (coo_cmd_serial_append_json_value(out, out_len, &off, token) != 0) { + return -EINVAL; } - written = snprintk(out + off, out_len - off, "}"); - if (written < 0 || written >= (int)(out_len - off)) { - return -ENOSPC; - } - return 0; + return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; } /* Convert a few common human serial shorthands into the same JSON payloads MQTT - * uses. This is deliberately a small translation table, not another dispatcher. - * Examples: "power on", "serialguard off", "mems/foo A 0.5 30". + * uses. These are per-command translations, not another dispatcher. */ -static int serial_payload_from_shorthand(const char *key, const char *payload, - char *out, size_t out_len) +static int serial_mems_switch_shorthand(const char *key, const char *payload, + char *out, size_t out_len, + void *user_data) { - const char *cursor = payload; char t0[96] = {0}; char t1[96] = {0}; char t2[96] = {0}; size_t off = 0; int written; - if (!next_serial_token(&cursor, t0, sizeof(t0))) { - return -EINVAL; - } - (void)next_serial_token(&cursor, t1, sizeof(t1)); - (void)next_serial_token(&cursor, t2, sizeof(t2)); - if (serial_token_has_extra(cursor)) { - return -EINVAL; - } - - if (strncmp(key, "mems/", 5) == 0) { - written = snprintk(out, out_len, "{\"state\":"); - if (written < 0 || written >= (int)out_len) { - return -ENOSPC; - } - off = (size_t)written; - if (append_serial_json_value(out, out_len, &off, t0) != 0) { - return -EINVAL; - } - if (t1[0] != '\0' && - append_serial_json_field(out, out_len, &off, "duty_cycle", t1, true) != 0) { - return -EINVAL; - } - if (t2[0] != '\0' && - append_serial_json_field(out, out_len, &off, "stopafter_s", t2, true) != 0) { - return -EINVAL; - } - written = snprintk(out + off, out_len - off, "}"); - return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; - } - - if (strcmp(key, "serialguard") == 0) { - const char *seconds = (strcasecmp(t0, "off") == 0) ? "0" : t0; - - written = snprintk(out, out_len, "{\"seconds\":"); - if (written < 0 || written >= (int)out_len) { - return -ENOSPC; - } - off = (size_t)written; - if (append_serial_json_value(out, out_len, &off, seconds) != 0) { - return -EINVAL; - } - if (t1[0] != '\0' && - append_serial_json_field(out, out_len, &off, "persistent", t1, true) != 0) { - return -EINVAL; - } - written = snprintk(out + off, out_len - off, "}"); - return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; - } + ARG_UNUSED(user_data); - if (strcmp(key, "mqtt") == 0) { - written = snprintk(out, out_len, "{\"broker\":"); - if (written < 0 || written >= (int)out_len) { - return -ENOSPC; - } - off = (size_t)written; - if (append_serial_json_value(out, out_len, &off, t0) != 0) { - return -EINVAL; - } - if (t1[0] != '\0' && - append_serial_json_field(out, out_len, &off, "persistent", t1, true) != 0) { - return -EINVAL; - } - if (t2[0] != '\0') { - return -EINVAL; - } - written = snprintk(out + off, out_len - off, "}"); - return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; + if (serial_read_three_tokens(payload, t0, sizeof(t0), t1, sizeof(t1), + t2, sizeof(t2)) != 0) { + return -EINVAL; } - - if (strcmp(key, "time") == 0) { - if (!serial_token_is_number(t0)) { + if (strchr(key, '/') == NULL) { + if (t1[0] != '\0' || t2[0] != '\0') { return -EINVAL; } - written = snprintk(out, out_len, "{\"linuxtime_ms\":%s}", t0); - return (written < 0 || written >= (int)out_len) ? -ENOSPC : 0; - } - - if (t1[0] != '\0' || t2[0] != '\0') { - return -EINVAL; + return serial_single_value_payload(t0, out, out_len); } - written = snprintk(out, out_len, "{\"value\":"); + written = snprintk(out, out_len, "{\"state\":"); if (written < 0 || written >= (int)out_len) { return -ENOSPC; } off = (size_t)written; - if (append_serial_json_value(out, out_len, &off, t0) != 0) { + if (coo_cmd_serial_append_json_value(out, out_len, &off, t0) != 0) { + return -EINVAL; + } + if (t1[0] != '\0' && + coo_cmd_serial_append_json_field(out, out_len, &off, "duty_cycle", t1, true) != 0) { + return -EINVAL; + } + if (t2[0] != '\0' && + coo_cmd_serial_append_json_field(out, out_len, &off, "stopafter_s", t2, true) != 0) { return -EINVAL; } written = snprintk(out + off, out_len - off, "}"); return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; } -/* Top-level serial payload policy: - * - no payload becomes "{}" and is dispatched as MSG_GET; - * - raw JSON beginning with "{" is copied unchanged, not parsed or rebuilt; - * - key=value tokens are wrapped into a JSON object; - * - selected shorthands are translated by serial_payload_from_shorthand(). - */ -static int normalize_serial_payload(const char *key, const char *payload, - char *out, size_t out_len) +static void command_prepare_reboot(void *user_data) { - payload = skip_serial_space(payload); - if (payload == NULL || payload[0] == '\0') { - int written = snprintk(out, out_len, "{}"); - return (written < 0 || written >= (int)out_len) ? -ENOSPC : 0; - } + ARG_UNUSED(user_data); + + LOG_WRN("Preparing hardware for reboot"); + if (devices_board_type() == HISPEC_BOARD_TIB) { + struct throughput_monitor_status monitor_status; + int rc; + (void)throughput_monitor_stop(PHOTODIODE_CHANNEL_COUNT, &monitor_status); + (void)housekeeping_power_set(HOUSEKEEPING_POWER_YJ_PHOTODIODE, false); + (void)housekeeping_power_set(HOUSEKEEPING_POWER_HK_PHOTODIODE, false); + - if (payload[0] == '{') { - if (strlen(payload) >= out_len) { - return -ENOSPC; + rc = hispec_laser_stop_all_outputs(true); + if (rc != 0) { + LOG_WRN("Laser output shutdown before reboot failed (%d)", rc); } - strncpy(out, payload, out_len - 1U); - out[out_len - 1U] = '\0'; - return 0; - } - if (strchr(payload, '=') != NULL) { - return serial_payload_from_key_values(payload, out, out_len); + (void)laserbank_tempcontrol_set_heater_mode(LASERBANK_HEATER_MODE_OVERRIDE_OFF, false); + (void)housekeeping_power_set(HOUSEKEEPING_POWER_BANK_HEATER, false); } - - return serial_payload_from_shorthand(key, payload, out, out_len); } -void command_handle_mqtt_publish(const struct mqtt_publish_param *pub) +int command_runtime_init(void) { - struct Command cmd = {0}; - char req_topic[MAX_TOPIC_LEN]; - const char *suffix; - size_t prefix_len; - size_t suffix_len; + const struct coo_cmd_runtime_config cfg = { + .device_id = app_mqtt_device_id(), + .inbound_queue = &inbound_queue, + .outbound_queue = &outbound_queue, + .mqtt_msg_id = &mqtt_msg_id, + .serial_wrap_column = SERIAL_WRAP_COLUMN, + .command_specs = command_specs, + .command_spec_count = ARRAY_SIZE(command_specs), + .lastcommand_nvs = app_settings_nvs_fs(), + .lastcommand_nvs_id = APP_SETTINGS_NVS_ID_LAST_COMMAND, +#if defined(CONFIG_COO_CMD_REBOOT) + .reboot_delay_ms = COMMAND_REBOOT_DELAY_MS, + .reboot_prepare = command_prepare_reboot, +#endif + }; + int rc; - if (pub == NULL || !copy_topic(&pub->message.topic.topic, req_topic, sizeof(req_topic))) { - return; + rc = coo_cmd_runtime_configure(&command_runtime, &cfg); + if (rc != 0) { + return rc; } - prefix_len = strlen(APP_MQTT_CMD_PREFIX); - if (strncmp(req_topic, APP_MQTT_CMD_PREFIX, prefix_len) != 0) { - return; - } + return 0; +} - suffix = req_topic + prefix_len; - suffix_len = strlen(suffix); - if (suffix_len == 0U || suffix_len >= sizeof(cmd.key)) { - LOG_WRN("Invalid MQTT command topic suffix"); - return; - } +struct coo_cmd_runtime *command_runtime_get(void) +{ + return &command_runtime; +} - cmd.source = CMD_SRC_MQTT; - memcpy(cmd.key, suffix, suffix_len); - cmd.key[suffix_len] = '\0'; - if (pub->message.payload.len >= MAX_PAYLOAD_LEN) { - struct OutMsg r = invalid_command_response(&cmd); - (void)k_msgq_put(&outbound_queue, &r, K_NO_WAIT); - return; - } - if (pub->message.payload.len > 0U) { - memcpy(cmd.payload, pub->message.payload.data, pub->message.payload.len); - cmd.payload[pub->message.payload.len] = '\0'; - cmd.payload_len = pub->message.payload.len; - if (!parse_msg_type_from_payload(cmd.payload, &cmd.msg_type)) { - cmd.msg_type = MSG_SET; - } - } else { - cmd.msg_type = MSG_GET; - snprintk(cmd.payload, sizeof(cmd.payload), "{}"); - cmd.payload_len = strlen(cmd.payload); - } - if (pub->prop.response_topic.utf8 != NULL && - pub->prop.response_topic.size > 0U && - pub->prop.response_topic.size < sizeof(cmd.response_topic)) { - memcpy(cmd.response_topic, pub->prop.response_topic.utf8, pub->prop.response_topic.size); - cmd.response_topic[pub->prop.response_topic.size] = '\0'; - } else if (!derive_default_response_topic(cmd.key, cmd.response_topic, sizeof(cmd.response_topic))) { - struct OutMsg r = invalid_command_response(&cmd); - (void)k_msgq_put(&outbound_queue, &r, K_NO_WAIT); - return; - } - if (pub->prop.correlation_data.len > 0U && - pub->prop.correlation_data.len <= sizeof(cmd.correlation_data)) { - memcpy(cmd.correlation_data, - pub->prop.correlation_data.data, - pub->prop.correlation_data.len); - cmd.corr_len = pub->prop.correlation_data.len; - } +/* COMMAND HANDLERS */ - if (!command_network_mqtt_allowed() && - (cmd.msg_type != MSG_GET || !mqtt_get_allowed_during_serial_guard(cmd.key))) { - struct OutMsg r = serial_active_response(&cmd); - LOG_WRN("Rejecting MQTT command '%s': local serial control is active", cmd.key); - (void)k_msgq_put(&outbound_queue, &r, K_NO_WAIT); - app_warning_emit("serial_guard_active", - "MQTT command rejected while serial command guard is active", - cmd.key); - return; - } +static int ip_status_payload(char *payload, size_t payload_len) +{ + struct app_ip_settings ip_cfg; + struct network_ipv4_info net = {0}; +#if defined(CONFIG_SNTP) + struct sntp_sync_status sntp = {0}; + const char *ntp_source; + const char *ntp_server; +#else + const char *ntp_source = "unsupported"; + const char *ntp_server = ""; +#endif + int written; - if (k_msgq_put(&inbound_queue, &cmd, K_NO_WAIT) != 0) { - struct OutMsg r = busy_response(&cmd); - (void)k_msgq_put(&outbound_queue, &r, K_NO_WAIT); - } -} + app_settings_get_ip(&ip_cfg); + (void)network_get_ipv4_info(&net); +#if defined(CONFIG_SNTP) + sntp_sync_get_status(&sntp); + ntp_source = sntp_sync_source_str(sntp.source); + ntp_server = sntp.server; +#endif -void command_serial_note_activity(void) + written = snprintk(payload, payload_len, + "{\"source\":\"%s\",\"trydhcpfirst\":%s," + "\"preferdhcpdns\":%s,\"preferdhcpntp\":%s," + "\"manual\":{\"ip\":\"%s\",\"subnet\":\"%s\",\"gateway\":\"%s\",\"dns\":\"%s\",\"ntp\":\"%s\"}," + "\"active\":{\"ready\":%s,\"ip\":\"%s\"}," + "\"ntp\":{\"source\":\"%s\",\"server\":\"%s\"}}", + network_ipv4_source_str(net.source), + ip_cfg.try_dhcp_first ? "true" : "false", + ip_cfg.prefer_dhcp_dns ? "true" : "false", + ip_cfg.prefer_dhcp_ntp ? "true" : "false", + ip_cfg.ip, ip_cfg.subnet, ip_cfg.gateway, ip_cfg.dns, ip_cfg.ntp, + net.link_ready ? "true" : "false", + net.ip, + ntp_source, + ntp_server); + + return (written >= 0 && (size_t)written < payload_len) ? 0 : -ENOSPC; +} + +struct coo_cmd_response ip_get(const struct coo_cmd_request *cmd) { - const uint32_t holdoff_s = app_settings_get_serial_holdoff_s(); - int rc; - - if (holdoff_s == 0U) { - (void)atomic_clear(&serial_network_ignore_active); - (void)app_scheduled_action_cancel(APP_SCHEDULED_ACTION_SERIAL_GUARD_EXPIRE); - return; - } + char payload[MAX_PAYLOAD_LEN]; - (void)atomic_set(&serial_network_ignore_active, 1); - rc = app_scheduled_action_schedule(APP_SCHEDULED_ACTION_SERIAL_GUARD_EXPIRE, - K_SECONDS(holdoff_s)); - if (rc < 0) { - (void)atomic_clear(&serial_network_ignore_active); - LOG_ERR("Failed to schedule serial guard expiration (%d)", rc); + if (ip_status_payload(payload, sizeof(payload)) != 0) { + return coo_cmd_error(cmd, "ip response too large"); } -} -bool command_network_mqtt_allowed(void) -{ - return atomic_get(&serial_network_ignore_active) == 0; + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); } -void command_parse_serial_line(char *line) +static void network_config_from_app_ip(const struct app_ip_settings *ip_cfg, + struct network_config *net_cfg) { - struct Command cmd = {0}; - char *cursor = line; - char *key; - char *payload = NULL; - char *sep; - - while (*cursor == ' ' || *cursor == '\t') { - cursor++; - } +#if defined(CONFIG_NET_DHCPV4) + const bool dhcp_supported = true; +#else + const bool dhcp_supported = false; +#endif - if (*cursor == '\0') { + if (ip_cfg == NULL || net_cfg == NULL) { return; } - command_serial_note_activity(); + network_config_defaults(net_cfg); + net_cfg->try_dhcp_first = ip_cfg->try_dhcp_first && dhcp_supported; + net_cfg->prefer_dhcp_dns = ip_cfg->prefer_dhcp_dns; + net_cfg->prefer_dhcp_ntp = ip_cfg->prefer_dhcp_ntp; - /* Serial syntax is one line: " [payload]". There are no get/set - * words. An empty payload is a GET; any payload is normalized to JSON and - * dispatched as a SET through the same handlers MQTT uses. - */ - sep = strpbrk(cursor, " \t"); - if (sep == NULL) { - key = cursor; - cmd.msg_type = MSG_GET; - } else { - *sep = '\0'; - key = cursor; - cursor = sep + 1; - while (*cursor == ' ' || *cursor == '\t') { - cursor++; - } - payload = cursor; - cmd.msg_type = (*payload == '\0') ? MSG_GET : MSG_SET; - } - - if (key == NULL || *key == '\0') { - enqueue_serial_error("missing command key"); - return; - } - - cmd.source = CMD_SRC_SERIAL; - strncpy(cmd.key, key, sizeof(cmd.key) - 1); - cmd.key[sizeof(cmd.key) - 1] = '\0'; - if (!derive_default_response_topic(cmd.key, cmd.response_topic, sizeof(cmd.response_topic))) { - enqueue_serial_error("invalid command key"); - return; - } - - if (normalize_serial_payload(cmd.key, payload, cmd.payload, sizeof(cmd.payload)) != 0) { - enqueue_serial_error("invalid serial payload"); - return; - } - cmd.payload_len = strlen(cmd.payload); - - if (k_msgq_put(&inbound_queue, &cmd, K_NO_WAIT) != 0) { - struct OutMsg r = busy_response(&cmd); - (void)k_msgq_put(&outbound_queue, &r, K_NO_WAIT); - } -} - -void command_executor_thread(void *p1, void *p2, void *p3) -{ - struct Command cmd; - struct OutMsg out; - - ARG_UNUSED(p1); - ARG_UNUSED(p2); - ARG_UNUSED(p3); - - while (1) { - /* K_FOREVER sleeps this thread until ingress queues a complete command. */ - k_msgq_get(&inbound_queue, &cmd, K_FOREVER); - out = dispatch_command(&cmd); - if (k_msgq_put(&outbound_queue, &out, K_NO_WAIT) != 0) { - LOG_WRN("Outbound queue full; dropping command response"); - } - } -} - -void command_serial_thread(void *p1, void *p2, void *p3) -{ - - char *line; - - ARG_UNUSED(p1); - ARG_UNUSED(p2); - ARG_UNUSED(p3); - - /* Initialize Zephyr's line-oriented console input once for this thread. */ - console_getline_init(); - - while (1) { - /* Blocks until a full line is available from the configured console. */ - line = console_getline(); - if (line != NULL && line[0] != '\0') { - command_parse_serial_line(line); - } - } - -} - -static int publish_outmsg(struct mqtt_client *client, const struct OutMsg *out) -{ - struct mqtt_publish_param param; - - if (client == NULL || out == NULL) { - return -EINVAL; - } - - memset(¶m, 0, sizeof(param)); - param.message.topic.qos = out->qos; - param.message.topic.topic.utf8 = (uint8_t *)out->topic; - param.message.topic.topic.size = strlen(out->topic); - param.message.payload.data = (uint8_t *)out->payload; - param.message.payload.len = out->payload_len; - param.prop.correlation_data.data = (uint8_t *)out->correlation_data; - param.prop.correlation_data.len = out->corr_len; - param.message_id = mqtt_msg_id++; - param.dup_flag = 0U; - param.retain_flag = 0U; - - /* mqtt_publish() may block in the socket layer and is kept out of timing - * sensitive work items and sampler threads. - */ - return mqtt_publish(client, ¶m); -} - -/* Serial responses intentionally reuse the OutMsg generated for MQTT. The - * topic is printed first, then the payload is wrapped at print time with tab - * indentation so response builders do not need serial-specific formatting. - */ -static void print_serial_response(const struct OutMsg *out) -{ - size_t len; - uint16_t col = 0U; - - if (out == NULL) { - return; - } - - printk("%s\n\t", out->topic[0] != '\0' ? out->topic : "serial"); - col = 8U; - len = out->payload_len > 0U ? out->payload_len : strlen(out->payload); - - for (size_t i = 0; i < len && out->payload[i] != '\0'; ++i) { - const char ch = out->payload[i]; - - if (ch == '\n' || col >= SERIAL_WRAP_COLUMN) { - printk("\n\t"); - col = 8U; - if (ch == '\n') { - continue; - } - } - - printk("%c", ch); - col++; - - if ((ch == ',' || ch == '}') && col >= (SERIAL_WRAP_COLUMN - 8U) && - i + 1U < len) { - printk("\n\t"); - col = 8U; - } - } - - printk("\n"); -} - -void command_drain_outbound_queue(struct mqtt_client *client, bool mqtt_available) -{ - struct OutMsg out; - int budget = 8; - - while (budget-- > 0 && k_msgq_get(&outbound_queue, &out, K_NO_WAIT) == 0) { - const bool best_effort = (out.target == OUT_TARGET_MQTT_BEST_EFFORT); - - if (out.target == OUT_TARGET_SERIAL) { - print_serial_response(&out); - continue; - } - - if (!mqtt_available) { - if (best_effort) { - LOG_DBG("Dropping best-effort MQTT msg while MQTT unavailable"); - continue; - } - if (k_msgq_put(&outbound_queue, &out, K_NO_WAIT) != 0) { - LOG_WRN("Dropping MQTT msg (queue full while requeueing)"); - } - continue; - } - - if (publish_outmsg(client, &out) != 0) { - if (best_effort) { - LOG_WRN("Best-effort MQTT publish failed; dropping msg"); - continue; - } - LOG_WRN("MQTT publish failed; will retry"); - if (k_msgq_put(&outbound_queue, &out, K_NO_WAIT) != 0) { - LOG_WRN("Dropping MQTT msg (queue full after publish failure)"); - } - break; - } - } -} - - - - - -laser_t get_laser_channel(const char *laser_name) { - // Case-insensitive check for supported types - if (strncasecmp(laser_name, "1028y", 7) == 0) { - return LASER_1028_Y; - } - if (strncasecmp(laser_name, "1270j", 7) == 0) { - return LASER_1270_J; - } - if (strncasecmp(laser_name, "1430yj", 7) == 0) { - return LASER_1430_YJ; - } - if (strncasecmp(laser_name, "1430hk", 7) == 0) { - return LASER_1430_HK; - } - if (strncasecmp(laser_name, "1510h", 7) == 0) { - return LASER_1510_H; - } - if (strncasecmp(laser_name, "2330k", 7) == 0) { - return LASER_2330_K; - } - - return LASER_UNKNOWN; -} - -void wait_laser_boot() { - k_sleep(K_MSEC(1000)); -} - -bool power_enabled() { - if (devices_board_type() != HISPEC_BOARD_TIB) { - return false; - } - if (!gpio_is_ready_dt(&laser_power_gpio)) { - return false; - } - int val = gpio_pin_get_dt(&laser_power_gpio); - return val==1; -} - -bool enable_power() { - if (devices_board_type() != HISPEC_BOARD_TIB) { - LOG_WRN("Laser power GPIO unavailable on board %s", devices_board_type_name()); - return false; - } - if (!gpio_is_ready_dt(&laser_power_gpio)) { - LOG_ERR("POWER_GPIO not ready"); - return false; - } - if (power_enabled()) - return false; - int err = gpio_pin_set_dt(&laser_power_gpio, 1); - if (err) { - LOG_ERR("Failed to set POWER_GPIO high\n"); - } - return true; -} - -bool disable_power() { - if (devices_board_type() != HISPEC_BOARD_TIB) { - LOG_WRN("Laser power GPIO unavailable on board %s", devices_board_type_name()); - return false; - } - if (!gpio_is_ready_dt(&laser_power_gpio)) { - LOG_ERR("POWER_GPIO not ready"); - return false; - } - if (!power_enabled()) - return false; - int err = gpio_pin_set_dt(&laser_power_gpio, 0); - if (err) { - LOG_ERR("Failed to set POWER_GPIO low\n"); - } - return true; - -} - -struct OutMsg laserbank_poweron(const struct Command *cmd) -{ - bool transitioned; - char payload[MAX_PAYLOAD_LEN] = {0}; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Laser bank unavailable on this board\"}"); - } - - transitioned = enable_power(); - if (transitioned) { - wait_laser_boot(); - } - - if (!power_enabled()) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"laser bank power did not turn on\"}"); - } - - snprintf(payload, sizeof(payload), - "{\"status\":\"OK\",\"laser_power\":true,\"transitioned\":%s}", - transitioned ? "true" : "false"); - return _msg_builder(cmd, RESP_OK, payload); -} - -struct OutMsg laserbank_poweroff(const struct Command *cmd) -{ - bool was_powered; - bool transitioned; - char payload[MAX_PAYLOAD_LEN] = {0}; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Laser bank unavailable on this board\"}"); - } - - was_powered = power_enabled(); - transitioned = disable_power(); - if (power_enabled()) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"laser bank power did not turn off\"}"); - } - - snprintf(payload, sizeof(payload), - "{\"status\":\"OK\",\"laser_power\":false,\"was_powered\":%s," - "\"transitioned\":%s}", - was_powered ? "true" : "false", - transitioned ? "true" : "false"); - return _msg_builder(cmd, RESP_OK, payload); -} + strncpy(net_cfg->static_profile.ip, ip_cfg->ip, + sizeof(net_cfg->static_profile.ip) - 1U); + net_cfg->static_profile.ip[sizeof(net_cfg->static_profile.ip) - 1U] = '\0'; + strncpy(net_cfg->static_profile.subnet, ip_cfg->subnet, + sizeof(net_cfg->static_profile.subnet) - 1U); + net_cfg->static_profile.subnet[sizeof(net_cfg->static_profile.subnet) - 1U] = '\0'; + strncpy(net_cfg->static_profile.gateway, ip_cfg->gateway, + sizeof(net_cfg->static_profile.gateway) - 1U); + net_cfg->static_profile.gateway[sizeof(net_cfg->static_profile.gateway) - 1U] = '\0'; -struct OutMsg laserbank_clearfaults(const struct Command *cmd) -{ - bool was_powered; - bool turned_on; - char payload[MAX_PAYLOAD_LEN] = {0}; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Laser bank unavailable on this board\"}"); - } - - was_powered = power_enabled(); - if (was_powered) { - (void)disable_power(); - if (power_enabled()) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\"," - "\"msg\":\"laser bank power cycle could not turn off\"}"); - } - } - - /* k_sleep() is a bounded Zephyr delay that gives the laser-bank supply - * time to drop before re-enabling it for a fault-clear cycle. - */ - k_sleep(K_MSEC(LASERBANK_FAULT_CLEAR_OFF_MS)); - turned_on = enable_power(); - if (turned_on) { - wait_laser_boot(); - } - - if (!power_enabled()) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\"," - "\"msg\":\"laser bank power cycle could not turn on\"}"); - } - - snprintf(payload, sizeof(payload), - "{\"status\":\"OK\",\"laser_power\":true,\"was_powered\":%s," - "\"off_ms\":%u,\"fault_detection\":\"power_cycle_only\"}", - was_powered ? "true" : "false", - LASERBANK_FAULT_CLEAR_OFF_MS); - return _msg_builder(cmd, RESP_OK, payload); -} - -static void serial_guard_expire_handler(enum app_scheduled_action_id id, void *user_data) -{ - ARG_UNUSED(id); - ARG_UNUSED(user_data); - - (void)atomic_clear(&serial_network_ignore_active); - LOG_INF("Serial guard expired; MQTT command execution is enabled"); -} - -static void reboot_action_handler(enum app_scheduled_action_id id, void *user_data) -{ - ARG_UNUSED(id); - ARG_UNUSED(user_data); - - sys_reboot(SYS_REBOOT_COLD); -} - -int command_runtime_init(void) -{ - int rc; - - rc = app_scheduled_actions_init(); - if (rc != 0) { - return rc; - } - - rc = app_scheduled_action_register(APP_SCHEDULED_ACTION_SERIAL_GUARD_EXPIRE, - serial_guard_expire_handler, NULL); - if (rc != 0) { - return rc; - } - - return app_scheduled_action_register(APP_SCHEDULED_ACTION_REBOOT, - reboot_action_handler, NULL); -} - - - - - -/* COMMAND HANDLERS */ - - -struct OutMsg invalid_command_response(const struct Command *cmd) { - const char *err = "{\"error\":\"Invalid or unrecognized command\"}"; - return _msg_builder(cmd, RESP_ERROR, err); -} - -struct OutMsg unknown_response(const struct Command *cmd) { - const char *err = "{\"error\":\"Unknown request\"}"; - return _msg_builder(cmd, RESP_ERROR, err); -} - -struct OutMsg unsupported_response(const struct Command *cmd) { - const char *err = "{\"error\":\"Unsupported operation\"}"; - return _msg_builder(cmd, RESP_ERROR, err); -} - -struct OutMsg busy_response(const struct Command *cmd) { - const char *err = "{\"error\":\"busy\"}"; - return _msg_builder(cmd, RESP_ERROR, err); -} - -struct OutMsg serial_active_response(const struct Command *cmd) { - const char *err = "{\"error\":\"try later. local serial commands active\"}"; - return _msg_builder(cmd, RESP_ERROR, err); -} - -struct OutMsg help_get(const struct Command *cmd) -{ - return _msg_builder(cmd, RESP_OK, - "{\"help\":\"help,ip,mqtt,time,temp,status,reboot,serialguard," - "memsroute,mems,split,laser,laserbank,atten,pd,pdsettings\"}"); -} - -struct OutMsg ip_get(const struct Command *cmd) -{ - struct app_ip_settings ip_cfg; - struct network_ipv4_info net = {0}; - struct sntp_sync_status sntp = {0}; - char payload[MAX_PAYLOAD_LEN]; - - app_settings_get_ip(&ip_cfg); - (void)network_get_ipv4_info(&net); - sntp_sync_get_status(&sntp); +#if defined(CONFIG_DNS_RESOLVER) + strncpy(net_cfg->static_profile.dns, ip_cfg->dns, + sizeof(net_cfg->static_profile.dns) - 1U); + net_cfg->static_profile.dns[sizeof(net_cfg->static_profile.dns) - 1U] = '\0'; +#endif - snprintk(payload, sizeof(payload), - "{\"source\":\"%s\",\"trydhcpfirst\":%s," - "\"preferdhcpdns\":%s,\"preferdhcpntp\":%s," - "\"manual\":{\"ip\":\"%s\",\"subnet\":\"%s\",\"gateway\":\"%s\",\"dns\":\"%s\",\"ntp\":\"%s\"}," - "\"active\":{\"ready\":%s,\"ip\":\"%s\"}," - "\"ntp\":{\"source\":\"%s\",\"server\":\"%s\"}}", - network_ipv4_source_str(net.source), - ip_cfg.try_dhcp_first ? "true" : "false", - ip_cfg.prefer_dhcp_dns ? "true" : "false", - ip_cfg.prefer_dhcp_ntp ? "true" : "false", - ip_cfg.ip, ip_cfg.subnet, ip_cfg.gateway, ip_cfg.dns, ip_cfg.ntp, - net.link_ready ? "true" : "false", - net.ip, - sntp_sync_source_str(sntp.source), - sntp.server); - - return _msg_builder(cmd, RESP_OK, payload); +#if defined(CONFIG_SNTP) + strncpy(net_cfg->static_profile.ntp, ip_cfg->ntp, + sizeof(net_cfg->static_profile.ntp) - 1U); + net_cfg->static_profile.ntp[sizeof(net_cfg->static_profile.ntp) - 1U] = '\0'; +#endif } -struct OutMsg ip_set(const struct Command *cmd) +struct coo_cmd_response ip_set(const struct coo_cmd_request *cmd) { struct app_ip_settings ip_cfg; char response[MAX_PAYLOAD_LEN]; @@ -1288,7 +698,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; network_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid trydhcpfirst\"}"); + return coo_cmd_error(cmd, "invalid trydhcpfirst"); } } @@ -1302,7 +712,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; network_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid preferdhcpdns\"}"); + return coo_cmd_error(cmd, "invalid preferdhcpdns"); } } @@ -1316,7 +726,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; ntp_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid preferdhcpntp\"}"); + return coo_cmd_error(cmd, "invalid preferdhcpntp"); } } @@ -1327,7 +737,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; network_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid ip\"}"); + return coo_cmd_error(cmd, "invalid ip"); } parse_rc = coo_json_extract_string(cmd->payload, "subnet", buf, sizeof(buf)); @@ -1337,7 +747,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; network_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid subnet\"}"); + return coo_cmd_error(cmd, "invalid subnet"); } parse_rc = coo_json_extract_string(cmd->payload, "gateway", buf, sizeof(buf)); @@ -1347,7 +757,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; network_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid gateway\"}"); + return coo_cmd_error(cmd, "invalid gateway"); } parse_rc = coo_json_extract_string(cmd->payload, "dns", buf, sizeof(buf)); @@ -1362,7 +772,7 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; network_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid dns\"}"); + return coo_cmd_error(cmd, "invalid dns"); } } @@ -1378,62 +788,52 @@ struct OutMsg ip_set(const struct Command *cmd) changed = true; ntp_changed = true; } else if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid ntp\"}"); + return coo_cmd_error(cmd, "invalid ntp"); } } - parse_rc = coo_json_extract_bool(cmd->payload, "persistent", &persist); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid persistent\"}"); + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persist, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); } if (!changed && !(unsupported_dhcp || unsupported_dns || unsupported_ntp)) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"no recognized ip fields\"}"); + return coo_cmd_error(cmd, "no recognized ip fields"); + } + + if (network_changed) { + struct network_config net_cfg; + int rc; + + network_config_from_app_ip(&ip_cfg, &net_cfg); + rc = network_reconfigure(&net_cfg); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "network reconfigure failed", rc); + } } if (changed) { app_settings_update_ip(&ip_cfg, persist); - if (ntp_changed && ntp_supported) { +#if defined(CONFIG_SNTP) + if (ntp_changed) { sntp_sync_schedule_now(); } +#endif } if (unsupported_dhcp || unsupported_dns || unsupported_ntp) { - const char *apply = - network_changed ? "reboot_required" : - (ntp_changed ? "immediate" : "none"); snprintk(response, sizeof(response), - "{\"status\":\"partial\",\"dhcp\":\"%s\",\"dns\":\"%s\",\"ntp\":\"%s\",\"apply\":\"%s\"}", + "{\"dhcp\":\"%s\",\"dns\":\"%s\",\"ntp\":\"%s\"}", unsupported_dhcp ? "unsupported" : "ok", unsupported_dns ? "unsupported" : "ok", - unsupported_ntp ? "unsupported" : "ok", - apply); - return _msg_builder(cmd, RESP_OK, response); - } - - if (network_changed) { - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\",\"apply\":\"reboot_required\"}"); - } - - if (ntp_changed) { - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\",\"apply\":\"immediate\"}"); - } - - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\"}"); -} - -static bool mqtt_host_is_numeric_ipv4(const char *host) -{ - struct in_addr addr = {0}; - - if (host == NULL || host[0] == '\0') { - return false; + unsupported_ntp ? "unsupported" : "ok"); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, response); } - return net_addr_pton(AF_INET, host, &addr) == 0; + return coo_cmd_ok(cmd); } -struct OutMsg mqtt_get(const struct Command *cmd) +struct coo_cmd_response mqtt_get(const struct coo_cmd_request *cmd) { struct app_mqtt_settings mqtt_cfg = {0}; struct coo_mqtt_broker_config broker_cfg = {0}; @@ -1454,78 +854,70 @@ struct OutMsg mqtt_get(const struct Command *cmd) "{\"broker\":\"%s\",\"dns_supported\":%s}", endpoint, dns_supported ? "true" : "false"); - return _msg_builder(cmd, RESP_OK, payload); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); } -struct OutMsg mqtt_set(const struct Command *cmd) +struct coo_cmd_response mqtt_set(const struct coo_cmd_request *cmd) { struct app_mqtt_settings mqtt_cfg = {0}; struct coo_mqtt_broker_config broker_cfg = {0}; char endpoint[160] = {0}; + char resolved_ip[NET_IPV4_ADDR_LEN] = {0}; bool persist = false; int parse_rc; -#if defined(CONFIG_DNS_RESOLVER) - const bool dns_supported = true; -#else - const bool dns_supported = false; -#endif + int rc; app_settings_get_mqtt(&mqtt_cfg); parse_rc = coo_json_extract_string(cmd->payload, "broker", endpoint, sizeof(endpoint)); if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"missing broker\"}"); + return coo_cmd_error(cmd, "missing broker"); } if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid broker\"}"); + return coo_cmd_error(cmd, "invalid broker"); } if (!coo_mqtt_parse_broker_endpoint(endpoint, &broker_cfg)) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"broker must be host-or-ip:port\"}"); + return coo_cmd_error(cmd, "broker must be host-or-ip:port"); } - if (!mqtt_host_is_numeric_ipv4(broker_cfg.host) && !dns_supported) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"broker hostname requires DNS\"}"); + rc = coo_mqtt_resolve_broker_config(&broker_cfg, resolved_ip, sizeof(resolved_ip)); + if (rc == -ENOTSUP) { + return coo_cmd_error(cmd, "broker hostname requires DNS"); + } + if (rc != 0) { + return coo_cmd_error(cmd, "broker host did not resolve"); } strncpy(mqtt_cfg.broker_host, broker_cfg.host, sizeof(mqtt_cfg.broker_host) - 1U); mqtt_cfg.broker_host[sizeof(mqtt_cfg.broker_host) - 1U] = '\0'; mqtt_cfg.broker_port = broker_cfg.port; - parse_rc = coo_json_extract_bool(cmd->payload, "persistent", &persist); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid persistent\"}"); + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persist, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); } app_settings_update_mqtt(&mqtt_cfg, persist); - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\",\"apply\":\"reconnect\"}"); + return coo_cmd_ok(cmd); } -struct OutMsg time_get(const struct Command *cmd) +struct coo_cmd_response time_get(const struct coo_cmd_request *cmd) { struct timespec ts = {0}; - struct sntp_sync_status sntp = {0}; uint64_t utc_ms; char payload[MAX_PAYLOAD_LEN]; - clock_gettime(CLOCK_REALTIME, &ts); - sntp_sync_get_status(&sntp); + if (sys_clock_gettime(SYS_CLOCK_REALTIME, &ts) != 0) { + return coo_cmd_error(cmd, "clock read failed"); + } utc_ms = ((uint64_t)ts.tv_sec * 1000ULL) + ((uint64_t)ts.tv_nsec / 1000000ULL); snprintk(payload, sizeof(payload), - "{\"utc\":%llu,\"ticks\":%u,\"uptime\":%lld," - "\"ntp\":{\"source\":\"%s\",\"server\":\"%s\",\"synced\":%s," - "\"last_sync_utc\":%llu,\"last_error\":%d}}", - (unsigned long long)utc_ms, k_cycle_get_32(), (long long)k_uptime_get(), - sntp_sync_source_str(sntp.source), - sntp.server, - sntp.synced ? "true" : "false", - (unsigned long long)sntp.last_sync_utc_ms, - sntp.last_error); - - return _msg_builder(cmd, RESP_OK, payload); + "{\"utc\":%llu,\"uptime_s\":%lld}", + (unsigned long long)utc_ms, (long long)k_uptime_get()/1000); + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); } -struct OutMsg time_set(const struct Command *cmd) +struct coo_cmd_response time_set(const struct coo_cmd_request *cmd) { uint64_t utc_ms = 0; struct timespec ts = {0}; @@ -1533,1525 +925,214 @@ struct OutMsg time_set(const struct Command *cmd) parse_rc = coo_json_extract_u64(cmd->payload, "linuxtime_ms", &utc_ms); if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"missing linuxtime_ms\"}"); + return coo_cmd_error(cmd, "missing linuxtime_ms"); } if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid linuxtime_ms\"}"); + return coo_cmd_error(cmd, "invalid linuxtime_ms"); } ts.tv_sec = utc_ms / 1000ULL; ts.tv_nsec = (utc_ms % 1000ULL) * 1000000ULL; - if (clock_settime(CLOCK_REALTIME, &ts) != 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"clock_settime failed\"}"); + if (sys_clock_settime(SYS_CLOCK_REALTIME, &ts) != 0) { + return coo_cmd_error(cmd, "clock set failed"); } + app_settings_note_time_utc_ms(utc_ms); - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\"}"); + return coo_cmd_ok(cmd); } -struct OutMsg reboot_set(const struct Command *cmd) +struct coo_cmd_response status_get(const struct coo_cmd_request *cmd) { - int rc; - - rc = app_scheduled_action_schedule(APP_SCHEDULED_ACTION_REBOOT, K_MSEC(250)); - if (rc < 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"failed to schedule reboot\"}"); + struct housekeeping_temperature_status ts = {0}; + bool include_ip = false; + bool include_lasers = false; + bool include_attens = false; + struct coo_cmd_lastcommand lastcommand; + bool has_lastcommand; + char payload[MAX_PAYLOAD_LEN] = {0}; + size_t off = 0U; + + if (coo_json_extract_optional_bool(cmd->payload, "ip", + &include_ip, NULL) != 0) { + return coo_cmd_error(cmd, "invalid ip"); + } + if (coo_json_extract_optional_bool(cmd->payload, "lasers", + &include_lasers, NULL) != 0) { + return coo_cmd_error(cmd, "invalid lasers"); + } + if (coo_json_extract_optional_bool(cmd->payload, "attens", + &include_attens, NULL) != 0) { + return coo_cmd_error(cmd, "invalid attens"); + } + + housekeeping_get_temperature_status(&ts); + has_lastcommand = coo_cmd_runtime_get_lastcommand(&command_runtime, &lastcommand); + if (coo_json_append(payload, sizeof(payload), &off, + "{\"fwversion\":\"%s\",\"bootcount\":%u," + "\"board_type\":\"%s\",\"board_valid\":%s," + "\"mems_switches\":%u,\"relay_gpio_error\":%d," + "\"temp_c\":", + APP_VERSION_STRING, + app_settings_get_boot_count(), + devices_board_type_name(), + devices_board_type() != HISPEC_BOARD_UNKNOWN ? "true" : "false", + router.num_switches, + devices_relay_gpio_last_error()) != 0 || + coo_json_append_float_or_null(payload, sizeof(payload), &off, + ts.valid ? ts.ambient_c : NAN, 3) != 0 || + coo_json_append(payload, sizeof(payload), &off, + ",\"pd_ontime\":%.1f," + "\"laserbank_ontime\":%u", + (double)MAX(housekeeping_power_on_time_s(HOUSEKEEPING_POWER_YJ_PHOTODIODE), + housekeeping_power_on_time_s(HOUSEKEEPING_POWER_HK_PHOTODIODE)), + hispec_laser_bank_power_on_duration_s()) != 0) { + return coo_cmd_error(cmd, "status response too large"); + } + + if (include_ip) { + char ip_payload[MAX_PAYLOAD_LEN]; + + if (ip_status_payload(ip_payload, sizeof(ip_payload)) != 0 || + coo_json_append(payload, sizeof(payload), &off, + ",\"ip\":%s", ip_payload) != 0) { + return coo_cmd_error(cmd, "status response too large"); + } + } + + if (include_lasers) { + if (coo_json_append(payload, sizeof(payload), &off, ",\"lasers\":{") != 0) { + return coo_cmd_error(cmd, "status response too large"); + } + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + struct hispec_laser_status laser = {0}; + int rc = hispec_laser_get_status((enum hispec_laser_id)i, &laser); + + if (coo_json_append(payload, sizeof(payload), &off, + "%s\"%s\":{\"power_mw\":", + i == 0U ? "" : ",", + hispec_laser_name((enum hispec_laser_id)i)) != 0 || + coo_json_append_float_or_null(payload, sizeof(payload), &off, + rc == 0 ? laser.estimated_power_mw : NAN, 3) != 0 || + coo_json_append(payload, sizeof(payload), &off, + ",\"tec_on_time_s\":%.1f,\"offin_s\":%lld}", + rc == 0 ? (double)laser.tec_on_time_s : 0.0, + rc == 0 ? (long long)laser.off_in_s : 0LL) != 0) { + return coo_cmd_error(cmd, "status response too large"); + } + } + if (coo_json_append(payload, sizeof(payload), &off, "}") != 0) { + return coo_cmd_error(cmd, "status response too large"); + } } - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\"}"); -} + if (include_attens) { + bool first = true; -struct OutMsg serial_guard_get(const struct Command *cmd) -{ - char payload[MAX_PAYLOAD_LEN]; - int64_t remaining_ms = 0; + if (coo_json_append(payload, sizeof(payload), &off, ",\"attens\":{") != 0) { + return coo_cmd_error(cmd, "status response too large"); + } + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + uint8_t atten_index; + struct attenuator_status atten = {0}; + bool valid; - (void)app_scheduled_action_remaining_ms(APP_SCHEDULED_ACTION_SERIAL_GUARD_EXPIRE, - &remaining_ms); - snprintk(payload, sizeof(payload), - "{\"serialguard_s\":%u,\"active\":%s,\"remaining_ms\":%lld}", - app_settings_get_serial_holdoff_s(), - command_network_mqtt_allowed() ? "false" : "true", - (long long)remaining_ms); - return _msg_builder(cmd, RESP_OK, payload); -} + if (attenuator_index_from_laser_id((enum hispec_laser_id)i, &atten_index) != 0 || + !devices_attenuator_channel_available(atten_index)) { + continue; + } -struct OutMsg serial_guard_set(const struct Command *cmd) -{ - uint32_t holdoff_s = 0; - bool persist = false; - int parse_rc_seconds; - int parse_rc_value; - int parse_rc_persist; - - parse_rc_seconds = coo_json_extract_u32(cmd->payload, "seconds", &holdoff_s); - parse_rc_value = coo_json_extract_u32(cmd->payload, "value", &holdoff_s); - if (parse_rc_seconds == COO_JSON_EXTRACT_ERR || parse_rc_value == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid seconds\"}"); - } - if (parse_rc_seconds == COO_JSON_EXTRACT_MISSING && - parse_rc_value == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"missing seconds\"}"); + valid = attenuator_get(&attenuators[atten_index], &atten); + + if (coo_json_append(payload, sizeof(payload), &off, + "%s\"%s\":{\"level_%%\":", + first ? "" : ",", + hispec_laser_name((enum hispec_laser_id)i)) != 0 || + coo_json_append_float_or_null(payload, sizeof(payload), &off, + valid ? atten.linear * 100.0 : (double)NAN, 3) != 0 || + coo_json_append(payload, sizeof(payload), &off, "}") != 0) { + return coo_cmd_error(cmd, "status response too large"); + } + first = false; + } + if (coo_json_append(payload, sizeof(payload), &off, "}") != 0) { + return coo_cmd_error(cmd, "status response too large"); + } } - parse_rc_persist = coo_json_extract_bool(cmd->payload, "persistent", &persist); - if (parse_rc_persist == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid persistent\"}"); + if (has_lastcommand && + coo_json_append(payload, sizeof(payload), &off, + ",\"lastcommand\":{\"name\":\"%s\",\"source\":\"%s\"," + "\"time\":%lld}}", + lastcommand.request.key, + coo_cmd_source_name(lastcommand.request.source), + (long long)lastcommand.time_ms) != 0) { + return coo_cmd_error(cmd, "status response too large"); } - app_settings_set_serial_holdoff_s(holdoff_s, persist); - if (cmd->source == CMD_SRC_SERIAL) { - command_serial_note_activity(); + if (!has_lastcommand && + coo_json_append(payload, sizeof(payload), &off, + ",\"lastcommand\":{\"name\":\"\",\"source\":\"unknown\"," + "\"time\":0}}") != 0) { + return coo_cmd_error(cmd, "status response too large"); } - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\"}"); -} - - -static bool memsroute_output_seen(const char *const *outputs, uint8_t count, - const char *output_name) -{ - for (uint8_t i = 0U; i < count; ++i) { - if (strcmp(outputs[i], output_name) == 0) { - return true; - } - } - - return false; -} - -static int memsroute_append_json(char *buf, size_t buf_len, size_t *offset, - const char *fmt, ...) -{ - va_list args; - int written; - - if (buf == NULL || offset == NULL || *offset >= buf_len) { - return -ENOSPC; - } - - va_start(args, fmt); - written = vsnprintk(buf + *offset, buf_len - *offset, fmt, args); - va_end(args); - - if (written < 0 || written >= (int)(buf_len - *offset)) { - return -ENOSPC; - } - - *offset += (size_t)written; - return 0; -} - -static int memsroute_append_sources_for_output(char *buf, size_t buf_len, size_t *offset, - const struct mems_route_key *active, - uint8_t active_count, - const char *output_name) -{ - uint8_t n_sources = 0U; - - if (memsroute_append_json(buf, buf_len, offset, "\"%s\":[", output_name) != 0) { - return -ENOSPC; - } - - for (uint8_t i = 0U; i < active_count; ++i) { - if (strcmp(active[i].output_name, output_name) != 0) { - continue; - } - - if (n_sources > 0U && memsroute_append_json(buf, buf_len, offset, ",") != 0) { - return -ENOSPC; - } - if (memsroute_append_json(buf, buf_len, offset, "\"%s\"", - active[i].input_name) != 0) { - return -ENOSPC; - } - n_sources++; - } - - if (n_sources == 0U && - memsroute_append_json(buf, buf_len, offset, "\"no source\"") != 0) { - return -ENOSPC; - } - - return memsroute_append_json(buf, buf_len, offset, "]"); -} - -struct OutMsg memsroute_get(const struct Command *cmd) -{ - struct mems_route_key active[MEMS_ROUTER_MAX_ROUTES]; - const char *outputs[MEMS_ROUTER_MAX_ROUTES]; - uint8_t n_active = mems_router_active_routes(&router, active, MEMS_ROUTER_MAX_ROUTES); - uint8_t n_outputs = 0U; - char buf[MAX_PAYLOAD_LEN] = {0}; - size_t offset = 0; - - if (memsroute_append_json(buf, sizeof(buf), &offset, "{\"active_routes\":{") != 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"response too large\"}"); - } - - for (uint8_t i = 0U; router.routes != NULL && i < router.num_routes; ++i) { - const char *output_name = router.routes[i].key.output_name; - - if (memsroute_output_seen(outputs, n_outputs, output_name)) { - continue; - } - - outputs[n_outputs++] = output_name; - if (n_outputs > 1U && - memsroute_append_json(buf, sizeof(buf), &offset, ",") != 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"response too large\"}"); - } - if (memsroute_append_sources_for_output(buf, sizeof(buf), &offset, - active, n_active, output_name) != 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"response too large\"}"); - } - } - - if (memsroute_append_json(buf, sizeof(buf), &offset, "}}") != 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"response too large\"}"); - } - - return _msg_builder(cmd, RESP_OK, buf); -} - -enum { - SPLIT_CHANNEL_COUNT = 2, - SPLIT_OUTPUT_COUNT = 3, - SPLIT_ROUTE_SWITCH_COUNT = 3, -}; - -struct split_switch_duty { - char name[MEMS_SWITCH_NAME_LEN]; - char state; - float duty_cycle; - uint32_t numerator; - uint32_t denominator; - uint32_t tick_ms; -}; - -struct split_state { - float requested[SPLIT_OUTPUT_COUNT]; - float actual[SPLIT_OUTPUT_COUNT]; - struct split_switch_duty switches[SPLIT_ROUTE_SWITCH_COUNT]; - uint32_t stopsin_s; -}; - -static const char *split_channel_names[SPLIT_CHANNEL_COUNT] = {"yj", "hk"}; -static const char *split_route_inputs[SPLIT_CHANNEL_COUNT] = {"yj_calin", "hk_calin"}; -static const char *split_route_outputs[SPLIT_CHANNEL_COUNT] = {"yj_split", "hk_split"}; - -/* Command-level cache of each channel's last requested and measured split. - * The MEMS router lock protects switch hardware state; this mutex only keeps - * command responses coherent across serial/MQTT callers. - */ -static struct split_state g_split_state[SPLIT_CHANNEL_COUNT]; -static K_MUTEX_DEFINE(split_state_lock); - -static int split_channel_index(const char *channel, uint8_t *index) -{ - if (channel == NULL || index == NULL) { - return -EINVAL; - } - - for (uint8_t i = 0; i < SPLIT_CHANNEL_COUNT; ++i) { - if (strcmp(channel, split_channel_names[i]) == 0) { - *index = i; - return 0; - } - } - - return -ENOENT; -} - -static int split_channel_index_from_key(const char *key, uint8_t *index) -{ - const char *slash = strchr(key, '/'); - - if (slash == NULL || slash[1] == '\0') { - return -ENOENT; - } - - return split_channel_index(slash + 1, index); -} - -static const struct mems_route *split_route_for_channel(uint8_t channel_index) -{ - return mems_router_get_route(&router, - split_route_inputs[channel_index], - split_route_outputs[channel_index]); -} - -static uint32_t split_period_ticks(void) -{ - const float ticks = 1000.0f / - (MEMS_SWITCH_MAX_TOGGLE_HZ * - (float)MEMS_SWITCH_ELECTRICAL_PULSE_MS); - uint32_t period_ticks = (uint32_t)ticks; - - if ((float)period_ticks < ticks) { - period_ticks += 1U; - } - if (period_ticks < 2U) { - period_ticks = 2U; - } - - return period_ticks; -} - -static uint32_t split_ratio_to_ticks(float ratio, uint32_t period_ticks) -{ - uint32_t ticks = (uint32_t)(ratio * (float)period_ticks + 0.5f); - - return MIN(ticks, period_ticks); -} - -static float split_abs_float(float value) -{ - return value < 0.0f ? -value : value; -} - -static uint32_t split_selected_numerator(const struct mems_switch_status *status, - char state) -{ - if (state == 'A') { - return status->duty_numerator; - } - - return status->duty_denominator - status->duty_numerator; -} - -static void split_clamp_actual(float actual[SPLIT_OUTPUT_COUNT]) -{ - float used; - - for (uint8_t i = 0; i < SPLIT_OUTPUT_COUNT; ++i) { - if (actual[i] < 0.0f) { - actual[i] = 0.0f; - } - if (actual[i] > 1.0f) { - actual[i] = 1.0f; - } - } - - used = actual[0] + actual[1]; - if (used > 1.0f) { - actual[1] = 1.0f - actual[0]; - used = 1.0f; - } - actual[2] = 1.0f - used; -} - -static int split_read_channel_state(uint8_t channel_index, - const struct mems_route *route, - const float requested[SPLIT_OUTPUT_COUNT]) -{ - struct split_state next = {0}; - float sw1_duty; - float sw3_duty; - - if (route == NULL || route->num_steps != SPLIT_ROUTE_SWITCH_COUNT) { - return -EINVAL; - } - - if (requested != NULL) { - memcpy(next.requested, requested, sizeof(next.requested)); - } else { - k_mutex_lock(&split_state_lock, K_FOREVER); - next = g_split_state[channel_index]; - k_mutex_unlock(&split_state_lock); - } - - for (uint8_t i = 0; i < SPLIT_ROUTE_SWITCH_COUNT; ++i) { - const struct mems_route_step *step = &route->steps[i]; - struct mems_switch *sw = mems_router_find_switch(&router, step->switch_name); - struct mems_switch_status status = {0}; - uint32_t selected_ticks; - - if (sw == NULL) { - LOG_ERR("Split route %s->%s references missing switch %s", - route->key.input_name, route->key.output_name, - step->switch_name); - return -EINVAL; - } - - mems_switch_get_status(sw, &status); - selected_ticks = split_selected_numerator(&status, step->state); - - snprintk(next.switches[i].name, sizeof(next.switches[i].name), - "%s", step->switch_name); - next.switches[i].state = step->state; - next.switches[i].numerator = selected_ticks; - next.switches[i].denominator = status.duty_denominator; - next.switches[i].tick_ms = status.tick_duration_ms; - next.switches[i].duty_cycle = - status.duty_denominator == 0U ? 0.0f : - (float)selected_ticks / (float)status.duty_denominator; - next.stopsin_s = MAX(next.stopsin_s, status.stopafter_s); - } - - sw1_duty = next.switches[0].duty_cycle; - sw3_duty = next.switches[2].duty_cycle; - next.actual[0] = sw1_duty; - next.actual[1] = sw3_duty > sw1_duty ? sw3_duty - sw1_duty : 0.0f; - split_clamp_actual(next.actual); - - k_mutex_lock(&split_state_lock, K_FOREVER); - g_split_state[channel_index] = next; - k_mutex_unlock(&split_state_lock); - - LOG_INF("Split %s actual %.4f %.4f %.4f", - split_channel_names[channel_index], - (double)next.actual[0], - (double)next.actual[1], - (double)next.actual[2]); - - return 0; -} - -static struct OutMsg split_channel_response(const struct Command *cmd, - uint8_t channel_index) -{ - struct split_state state; - char payload[MAX_PAYLOAD_LEN]; - int written; - - k_mutex_lock(&split_state_lock, K_FOREVER); - state = g_split_state[channel_index]; - k_mutex_unlock(&split_state_lock); - - written = snprintk(payload, sizeof(payload), - "{\"status\":\"success\",\"channel\":\"%s\"," - "\"requested_ratio\":[%.4f,%.4f,%.4f]," - "\"actual_ratio\":[%.4f,%.4f,%.4f]," - "\"switches\":[" - "{\"name\":\"%s\",\"state\":\"%c\",\"duty_cycle\":%.4f," - "\"numerator\":%u,\"denominator\":%u,\"tick_ms\":%u}," - "{\"name\":\"%s\",\"state\":\"%c\",\"duty_cycle\":%.4f," - "\"numerator\":%u,\"denominator\":%u,\"tick_ms\":%u}," - "{\"name\":\"%s\",\"state\":\"%c\",\"duty_cycle\":%.4f," - "\"numerator\":%u,\"denominator\":%u,\"tick_ms\":%u}]," - "\"stopsin_s\":%u}", - split_channel_names[channel_index], - (double)state.requested[0], - (double)state.requested[1], - (double)state.requested[2], - (double)state.actual[0], - (double)state.actual[1], - (double)state.actual[2], - state.switches[0].name, - state.switches[0].state, - (double)state.switches[0].duty_cycle, - state.switches[0].numerator, - state.switches[0].denominator, - state.switches[0].tick_ms, - state.switches[1].name, - state.switches[1].state, - (double)state.switches[1].duty_cycle, - state.switches[1].numerator, - state.switches[1].denominator, - state.switches[1].tick_ms, - state.switches[2].name, - state.switches[2].state, - (double)state.switches[2].duty_cycle, - state.switches[2].numerator, - state.switches[2].denominator, - state.switches[2].tick_ms, - state.stopsin_s); - - if (written < 0 || written >= (int)sizeof(payload)) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"split response too large\"}"); - } - - return _msg_builder(cmd, RESP_OK, payload); -} - -static void split_emit_quantization_warning(uint8_t channel_index, - const struct split_state *state) -{ - char context[144]; - - if (split_abs_float(state->actual[0] - state->requested[0]) <= 0.0005f && - split_abs_float(state->actual[1] - state->requested[1]) <= 0.0005f && - split_abs_float(state->actual[2] - state->requested[2]) <= 0.0005f) { - return; - } - - snprintk(context, sizeof(context), - "channel=%s requested=%.4f/%.4f/%.4f actual=%.4f/%.4f/%.4f", - split_channel_names[channel_index], - (double)state->requested[0], - (double)state->requested[1], - (double)state->requested[2], - (double)state->actual[0], - (double)state->actual[1], - (double)state->actual[2]); - app_warning_emit("split_ratio_quantized", - "requested split ratio was quantized to MEMS ticks", - context); -} - -static int split_parse_channel(const struct Command *cmd, uint8_t *channel_index) -{ - char channel[8] = {0}; - int parse_rc; - - if (split_channel_index_from_key(cmd->key, channel_index) == 0) { - return 0; - } - - parse_rc = coo_json_extract_string(cmd->payload, "channel", - channel, sizeof(channel)); - if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return -ENOENT; - } - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return -EINVAL; - } - - return split_channel_index(channel, channel_index); -} - -struct OutMsg splitting_get(const struct Command *cmd) -{ - uint8_t channel_index; - const struct mems_route *route; - int rc; - - rc = split_parse_channel(cmd, &channel_index); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"channel required: yj or hk\"}"); - } - - route = split_route_for_channel(channel_index); - if (route == NULL) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"split route unavailable\"}"); - } - - rc = split_read_channel_state(channel_index, route, NULL); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"split route invalid\"}"); - } - - return split_channel_response(cmd, channel_index); -} - -/** Apply one AS-PCB split route. - * - * The user provides ratio1 and ratio2 for one channel. ratio3 is the remaining - * fraction. SW3 is intentionally held through SW1's output-1 interval, so its - * selected-state numerator is ratio1 + ratio2 rather than ratio2 alone. - */ -struct OutMsg splitting_set(const struct Command *cmd) -{ - uint8_t channel_index; - const struct mems_route *route; - struct split_state stored; - float requested[SPLIT_OUTPUT_COUNT] = {0}; - float ratio3_probe = 0.0f; - uint32_t stopafter_s = 0U; - uint32_t period_ticks; - uint32_t output_ticks[SPLIT_OUTPUT_COUNT]; - uint32_t switch_ticks[SPLIT_ROUTE_SWITCH_COUNT]; - int parse_rc; - int rc; - - rc = split_parse_channel(cmd, &channel_index); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"channel must be yj or hk\"}"); - } - - parse_rc = coo_json_extract_float(cmd->payload, "ratio1", &requested[0]); - if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"missing ratio1\"}"); - } - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid ratio1\"}"); - } - - parse_rc = coo_json_extract_float(cmd->payload, "ratio2", &requested[1]); - if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"missing ratio2\"}"); - } - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid ratio2\"}"); - } - - parse_rc = coo_json_extract_float(cmd->payload, "ratio3", &ratio3_probe); - if (parse_rc != COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"ratio3 is computed internally\"}"); - } - - if (requested[0] < 0.0f || requested[0] > 1.0f || - requested[1] < 0.0f || requested[1] > 1.0f || - requested[0] + requested[1] > 1.000001f) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"ratios must be 0.0-1.0 and sum <= 1.0\"}"); - } - requested[2] = 1.0f - requested[0] - requested[1]; - - parse_rc = coo_json_extract_u32(cmd->payload, "stopafter_s", &stopafter_s); - if (parse_rc == COO_JSON_EXTRACT_ERR || - stopafter_s > MEMS_SWITCH_MAX_TOGGLE_DURATION_S) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid stopafter_s\"}"); - } - - parse_rc = coo_json_extract_float(cmd->payload, "toggle_rate_hz", &ratio3_probe); - if (parse_rc != COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"toggle_rate_hz is automatic\"}"); - } - - route = split_route_for_channel(channel_index); - if (route == NULL || route->num_steps != SPLIT_ROUTE_SWITCH_COUNT) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"split route unavailable\"}"); - } - - period_ticks = split_period_ticks(); - output_ticks[0] = split_ratio_to_ticks(requested[0], period_ticks); - output_ticks[1] = split_ratio_to_ticks(requested[1], period_ticks); - if (output_ticks[0] + output_ticks[1] > period_ticks) { - output_ticks[1] = period_ticks - output_ticks[0]; - } - output_ticks[2] = period_ticks - output_ticks[0] - output_ticks[1]; - - switch_ticks[0] = output_ticks[0]; - switch_ticks[1] = period_ticks; - switch_ticks[2] = output_ticks[0] + output_ticks[1]; - - for (uint8_t i = 0; i < SPLIT_ROUTE_SWITCH_COUNT; ++i) { - const struct mems_route_step *step = &route->steps[i]; - struct mems_switch *sw = mems_router_find_switch(&router, step->switch_name); - - if (sw == NULL) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"split route references missing switch\"}"); - } - - rc = mems_switch_set_state_ticks(sw, step->state, switch_ticks[i], - period_ticks, - i == 1U ? 0U : stopafter_s); - if (rc != 0) { - char payload[MAX_PAYLOAD_LEN]; - - snprintk(payload, sizeof(payload), - "{\"status\":\"error\",\"msg\":\"failed setting %s\"}", - step->switch_name); - return _msg_builder(cmd, RESP_ERROR, payload); - } - } - - rc = split_read_channel_state(channel_index, route, requested); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"split readback failed\"}"); - } - - k_mutex_lock(&split_state_lock, K_FOREVER); - stored = g_split_state[channel_index]; - k_mutex_unlock(&split_state_lock); - split_emit_quantization_warning(channel_index, &stored); - - return split_channel_response(cmd, channel_index); -} - -struct OutMsg memsroute_set(const struct Command *cmd) { - - // Parse {"input":"...","output":"..."} - struct mems_route_id route_id = {0}; - struct json_obj_descr d[] = { - JSON_OBJ_DESCR_PRIM(struct mems_route_id, input, JSON_TOK_STRING), - JSON_OBJ_DESCR_PRIM(struct mems_route_id, output, JSON_TOK_STRING), - }; - if (json_obj_parse((char *) cmd->payload, cmd->payload_len, d, ARRAY_SIZE(d), &route_id) < 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Failed to parse JSON input or output\"}"); - } - - const struct mems_route *route = mems_router_get_route(&router, route_id.input, route_id.output); - if (!route) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Invalid Route\"}"); - } - - for (uint8_t i = 0; i < route->num_steps; ++i) { - const struct mems_route_step *step = &route->steps[i]; - struct mems_switch *sw = mems_router_find_switch(&router, step->switch_name); - int rc; - - if (sw==NULL) { - //TODO this should be an impossible error if compiled code is correct - LOG_ERR("Internal route error: Switch %s not found\n", step->switch_name); - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Internal route error\"}"); - } - rc = mems_switch_set_state(sw, step->state, 1, 0, 0.0f); - - if (rc != 0) { - char payload[MAX_PAYLOAD_LEN]={0}; - snprintf(payload, MAX_PAYLOAD_LEN, "{\"error\":\"Setting switch %s to %c failed\"}", - step->switch_name, step->state); - return _msg_builder(cmd, RESP_ERROR, payload); - } - LOG_INF("Set switch %s to %c\n", step->switch_name, step->state); - } - - return _msg_builder(cmd, RESP_OK, "{\"status\":\"OK\"}"); -} - - -static void mems_format_state(const struct mems_switch_status *status, char *out, size_t out_len) -{ - if (status->state == 'A' || status->state == 'B') { - snprintk(out, out_len, status->state_known_this_boot ? "%c": "%c?", status->state); - return; - } - - snprintk(out, out_len, "?"); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); } - -static struct OutMsg mems_response_for_switch(const struct Command *cmd, const struct mems_switch *sw) +struct coo_cmd_response temp_get(const struct coo_cmd_request *cmd) { - struct mems_switch_status status = {0}; - char state_buf[4] = {0}; + struct housekeeping_temperature_status ts = {0}; + struct hispec_laser_channel_temperature channel_temp[HISPEC_LASER_COUNT] = {0}; char payload[MAX_PAYLOAD_LEN] = {0}; - - mems_switch_get_status(sw, &status); - mems_format_state(&status, state_buf, sizeof(state_buf)); - - snprintk(payload, sizeof(payload), - "{\"state\":\"%s\",\"duty_cycle\":%.3f," - "\"requested_toggle_rate_hz\":%.3f,\"toggle_rate_hz\":%.3f," - "\"stopafter_s\":%u}", - state_buf, - (double)status.duty_cycle, - (double)status.requested_toggle_rate_hz, - (double)status.toggle_rate_hz, - status.stopafter_s); - - return _msg_builder(cmd, RESP_OK, payload); -} - - -struct OutMsg mems_get(const struct Command *cmd) { - - - if (strcmp(cmd->key, "mems") == 0) { - char payload[MAX_PAYLOAD_LEN] = {0}; - size_t off = 0; - int written; - struct mems_switch_status status = {0}; - char state_buf[4] = {0}; - - written = snprintk(payload + off, sizeof(payload) - off, "{"); - off += (size_t)written; - - for (uint8_t i = 0; i < router.num_switches; ++i) { - if (i > 0U) { - written = snprintk(payload + off, sizeof(payload) - off, ","); - if (written < 0 || written >= (int)(sizeof(payload) - off)) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"mems response too large; query mems/\"}"); + size_t off = 0U; + double bank_sum = 0.0; + uint8_t bank_count = 0U; + int laser_rc = 0; + + housekeeping_get_temperature_status(&ts); + if (devices_board_type() == HISPEC_BOARD_TIB) { + laser_rc = hispec_laser_bank_read_temperatures(channel_temp); + if (laser_rc == 0) { + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + if (channel_temp[i].valid) { + bank_sum += (double)channel_temp[i].tec_temperature_c; + bank_count++; } - off += (size_t)written; - } - - mems_switch_get_status(router.switches[i], &status); - mems_format_state(&status, state_buf, sizeof(state_buf)); - written = snprintk(payload + off, sizeof(payload) - off, - "\"%s\":{\"state\":\"%s\",\"duty_cycle\":%.3f," - "\"requested_toggle_rate_hz\":%.3f,\"toggle_rate_hz\":%.3f," - "\"stopafter_s\":%u}", - router.switches[i]->name, - state_buf, - (double)status.duty_cycle, - (double)status.requested_toggle_rate_hz, - (double)status.toggle_rate_hz, - status.stopafter_s); - if (written < 0 || written >= (int)(sizeof(payload) - off)) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"mems response too large; query mems/\"}"); } - off += (size_t)written; - } - - written = snprintk(payload + off, sizeof(payload) - off, "}"); - if (written < 0 || written >= (int)(sizeof(payload) - off)) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"mems response too large\"}"); } - return _msg_builder(cmd, RESP_OK, payload); } - char name[16], mems_switch[16]; - if (parse_key_pair(cmd->key, name, 15, mems_switch, 15)!=0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Failed to parse mems switch name\"}"); + if (coo_json_append(payload, sizeof(payload), &off, + "{\"ambient_c\":") != 0 || + coo_json_append_float_or_null(payload, sizeof(payload), &off, + ts.valid ? ts.ambient_c : NAN, 3) != 0) { + return coo_cmd_error(cmd, "temp response too large"); } - struct mems_switch *sw = mems_router_find_switch(&router, mems_switch); - - if (sw==NULL) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid switch name\"}"); + if (coo_json_append(payload, sizeof(payload), &off, + ",\"laserbank_c\":") != 0 || + coo_json_append_float_or_null(payload, sizeof(payload), &off, + bank_count > 0U ? bank_sum / (double)bank_count : (double)NAN, + 3) != 0 || + coo_json_append(payload, sizeof(payload), &off, + ",\"laser\":{") != 0) { + return coo_cmd_error(cmd, "temp response too large"); } - - return mems_response_for_switch(cmd, sw); -} - - -struct OutMsg mems_set(const struct Command *cmd) { - char requested_state[8] = {0}; - float duty_cycle = 0.0f; - float stopafter_s = 0.0f; - float toggle_rate_hz = 0.0f; - uint32_t stopafter_s_u32 = 0U; - bool has_duty_cycle = false; - bool has_stopafter_s = false; - bool has_toggle_rate_hz = false; - int parse_rc; - int rc; - - char name[16], mems_switch[16]; - if (parse_key_pair(cmd->key, name, 15, mems_switch, 15)!=0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Failed to parse mems switch name\"}"); - } - - parse_rc = coo_json_extract_string(cmd->payload, "state", requested_state, sizeof(requested_state)); - if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Missing state\"}"); - } - if (parse_rc == COO_JSON_EXTRACT_ERR || requested_state[0] == '\0' || requested_state[1] != '\0') { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Failed to parse switch state\"}"); - } - - requested_state[0] = (char)toupper((unsigned char)requested_state[0]); - if (requested_state[0] != 'A' && requested_state[0] != 'B') { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Invalid switch state\"}"); - } - - parse_rc = coo_json_extract_float(cmd->payload, "duty_cycle", &duty_cycle); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Invalid duty_cycle\"}"); - } - has_duty_cycle = (parse_rc == COO_JSON_EXTRACT_OK); - - parse_rc = coo_json_extract_float(cmd->payload, "stopafter_s", &stopafter_s); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Invalid stopafter_s\"}"); - } - has_stopafter_s = (parse_rc == COO_JSON_EXTRACT_OK); - - parse_rc = coo_json_extract_float(cmd->payload, "toggle_rate_hz", &toggle_rate_hz); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Invalid toggle_rate_hz\"}"); - } - has_toggle_rate_hz = (parse_rc == COO_JSON_EXTRACT_OK); - if (has_toggle_rate_hz && toggle_rate_hz <= 0.0f) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"toggle_rate_hz must be > 0\"}"); - } - - if (has_duty_cycle && requested_state[0] == 'B') { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"duty_cycle only valid with state A\"}"); - } - if (has_stopafter_s) { - if (stopafter_s < 0.0f || stopafter_s > (float)MEMS_SWITCH_MAX_TOGGLE_DURATION_S) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"stopafter_s out of range\"}"); - } - stopafter_s_u32 = (uint32_t)(stopafter_s + 0.5f); - if (stopafter_s_u32 == 0U && duty_cycle > 0.0f && duty_cycle < 1.0f) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"stopafter_s must be > 0 for toggling\"}"); - } - } - - struct mems_switch *sw = mems_router_find_switch(&router, mems_switch); - - if (sw==NULL) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid switch name\"}"); - } - - if (has_duty_cycle) { - rc = mems_switch_set_state(sw, requested_state[0], duty_cycle, - stopafter_s_u32, - has_toggle_rate_hz ? toggle_rate_hz : 0.0f); - } else { - rc = mems_switch_set_state(sw, requested_state[0], 1, 0, - has_toggle_rate_hz ? toggle_rate_hz : 0.0f); - } - - if (rc == -ERANGE) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"MEMS setting out of range\"}"); - } - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, "{\"error\":\"Invalid MEMS setting\"}"); - } - - if (has_toggle_rate_hz) { - struct mems_switch_status status = {0}; - char context[96]; - float diff; - - mems_switch_get_status(sw, &status); - diff = status.toggle_rate_hz - toggle_rate_hz; - if (diff < 0.0f) { - diff = -diff; - } - if (diff > 0.001f) { - snprintk(context, sizeof(context), - "switch=%s requested=%.3f actual=%.3f", - sw->name, (double)toggle_rate_hz, - (double)status.toggle_rate_hz); - app_warning_emit("mems_rate_quantized", - "requested MEMS toggle rate was quantized", - context); - } - } - - return mems_response_for_switch(cmd, sw); -} - -struct OutMsg laser_setting_get(const struct Command *cmd) { - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Laser bank unavailable on this board\"}"); - } - - // Extract laser### and from key - char laser_name[16], setting[16]; - if (parse_key_pair(cmd->key, laser_name, 15, setting, 15)!=0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Failed to parse laser/setting\"}"); - } - - maiman_driver_t driver; - driver.node_id=get_laser_channel(laser_name+5); - if (driver.node_id==LASER_UNKNOWN) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid laser\"}"); - } - - laser_address_t addr;; - if (!maiman_get_register_address(setting, &addr)) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid laser setting\"}"); - } - - if (enable_power()) { - wait_laser_boot(); - } - - uint16_t value = 0; - if (!maiman_read_u16(&driver, addr, &value)) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"get_driver_setting failed\"}"); - } - - char payload[MAX_PAYLOAD_LEN]={0}; - snprintf(payload, MAX_PAYLOAD_LEN, "{\"%s\":%hd}", setting, value); - return _msg_builder(cmd, RESP_OK, payload); -} - -struct OutMsg laser_setting_set(const struct Command *cmd) { - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Laser bank unavailable on this board\"}"); - } - - // Extract laser### and - char laser_name[16], setting[16]; - if (parse_key_pair(cmd->key, laser_name, 15, setting, 15)!=0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Failed to parse laser/setting\"}"); - } - - // Parse value - struct json_value_uint16 in_data = {0}; - struct json_obj_descr d[] = { - JSON_OBJ_DESCR_PRIM(struct json_value_uint16, value, JSON_TOK_NUMBER) - }; - if (json_obj_parse((char *) cmd->payload, cmd->payload_len, d, 1, &in_data) < 0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Missing setting value\"}"); - } - - maiman_driver_t driver; - driver.node_id=get_laser_channel(laser_name+5); - if (driver.node_id==LASER_UNKNOWN) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid laser\"}"); - } - - laser_address_t addr;; - if (!maiman_get_register_address(setting, &addr)) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid laser setting\"}"); - } - - if (enable_power()) { - wait_laser_boot(); - } - - if (!maiman_write_u16(&driver, addr, in_data.value) ) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"set_driver_setting failed\"}"); - } - - return _msg_builder(cmd, RESP_OK, "{\"status\":\"OK\"}"); -} - - -struct OutMsg atten_setting_get(const struct Command *cmd) { - - char laser_name[16], setting[16]; - if (parse_atten_key(cmd->key, laser_name, sizeof(laser_name), - setting, sizeof(setting)) != 0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Failed to parse atten/setting\"}"); - } - - laser_t laser_id = get_laser_channel(laser_name); - - if (laser_id==LASER_UNKNOWN) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid attenuator\"}"); - } - if (!attenuator_channel_available(laser_id)) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Attenuator unavailable on this board\"}"); - } - - char payload[MAX_PAYLOAD_LEN]={0}; - if (strcasecmp(setting, "coeff") == 0) { - snprintf(payload, MAX_PAYLOAD_LEN, - "{\"db2volt\":[%.4f,%.4f,%.4f],\"volt2db\":[%.4f,%.4f,%.4f]}", - attenuators[laser_id].coeff_db_to_volt[0], - attenuators[laser_id].coeff_db_to_volt[1], - attenuators[laser_id].coeff_db_to_volt[2], - attenuators[laser_id].coeff_volt_to_db[0], - attenuators[laser_id].coeff_volt_to_db[1], - attenuators[laser_id].coeff_volt_to_db[2]); - } else if (strcasecmp(setting, "value") == 0 || strcasecmp(setting, "valuedb") == 0) { - double db, voltage; - attenuator_get(&attenuators[laser_id], &db, false); - attenuator_get(&attenuators[laser_id], &voltage, true); - snprintf(payload, MAX_PAYLOAD_LEN, "{\"voltage\":%.4f,\"db\":%.4f}", voltage, db); - } else { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid setting\"}"); - } - - return _msg_builder(cmd, RESP_OK, payload); -} - -struct OutMsg atten_setting_set(const struct Command *cmd) { - - char laser_name[16], setting[16]; - char payload[64] = "{\"status\":\"OK\"}"; - if (parse_atten_key(cmd->key, laser_name, sizeof(laser_name), - setting, sizeof(setting)) != 0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Failed to parse laser/setting\"}"); - } - - laser_t laser_id = get_laser_channel(laser_name); - - if (laser_id==LASER_UNKNOWN) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid attenuator\"}"); - } - if (!attenuator_channel_available(laser_id)) { - return _msg_builder(cmd, RESP_ERROR, - "{\"error\":\"Attenuator unavailable on this board\"}"); - } - - // Parse value - struct coeffs { - float db2volt[ATTENUATOR_COEFF_COUNT]; - size_t db2volt_len; - float volt2db[ATTENUATOR_COEFF_COUNT]; - size_t volt2db_len; - }; - - // Parse value - struct json_value_float { - float value; - }; - - if (strcasecmp(setting, "coeff") == 0) { - - struct coeffs parsed_coeffs = {0}; - struct app_attenuator_channel_settings stored_coeffs = {0}; - bool persist = false; - int parse_rc; - - const struct json_obj_descr coeff_descr[] = { - JSON_OBJ_DESCR_ARRAY(struct coeffs, db2volt, ATTENUATOR_COEFF_COUNT, - db2volt_len, JSON_TOK_FLOAT), - JSON_OBJ_DESCR_ARRAY(struct coeffs, volt2db, ATTENUATOR_COEFF_COUNT, - volt2db_len, JSON_TOK_FLOAT), - }; - - if (json_obj_parse((char *) cmd->payload, cmd->payload_len, coeff_descr, - ARRAY_SIZE(coeff_descr), &parsed_coeffs) < 0 || - parsed_coeffs.db2volt_len != ATTENUATOR_COEFF_COUNT || - parsed_coeffs.volt2db_len != ATTENUATOR_COEFF_COUNT) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Improper arguments\"}"); - } - parse_rc = coo_json_extract_bool(cmd->payload, "persistent", &persist); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid persistent flag\"}"); - } - - double db; - attenuator_get(&attenuators[laser_id], &db, false); - - for (uint8_t i=0; ipayload, cmd->payload_len, d, 1, &in_data) < 0) { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Missing setting value\"}"); - } - - bool raw_voltage = (strcasecmp(setting, "value") == 0); - attenuator_set(&attenuators[laser_id], in_data.value, raw_voltage); - - } else { - return _msg_builder(cmd, RESP_ERROR,"{\"error\":\"Invalid setting\"}"); - } - - return _msg_builder(cmd, RESP_OK, payload); -} - -static int pd_parse_channel_name(const char *name, enum photodiode_channel *channel) -{ - if (name == NULL || channel == NULL) { - return -EINVAL; - } - if (strcasecmp(name, "yj") == 0) { - *channel = PHOTODIODE_CHANNEL_YJ; - return 0; - } - if (strcasecmp(name, "hk") == 0) { - *channel = PHOTODIODE_CHANNEL_HK; - return 0; - } - - return -ENOENT; -} - -static int pd_parse_channel_from_key(const struct Command *cmd, - enum photodiode_channel *channel) -{ - const char *slash = strchr(cmd->key, '/'); - - if (slash == NULL || slash[1] == '\0') { - return -ENOENT; - } - - return pd_parse_channel_name(slash + 1, channel); -} - -static int pd_parse_channel_from_payload_or_key(const struct Command *cmd, - enum photodiode_channel *channel) -{ - char channel_name[8] = {0}; - int parse_rc; - - parse_rc = pd_parse_channel_from_key(cmd, channel); - if (parse_rc == 0) { - return 0; - } - - parse_rc = coo_json_extract_string(cmd->payload, "channel", - channel_name, sizeof(channel_name)); - if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return -ENOENT; - } - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return -EINVAL; - } - - return pd_parse_channel_name(channel_name, channel); -} - -struct OutMsg pd_get(const struct Command *cmd) -{ - struct photodiode_status status; - char unit[12] = "power"; - char payload[MAX_PAYLOAD_LEN] = {0}; - float yj_value; - float hk_value; - float yj_err; - float hk_err; - int parse_rc; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"photodiodes unavailable on this board\"}"); - } - - parse_rc = coo_json_extract_string(cmd->payload, "unit", unit, sizeof(unit)); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid unit\"}"); - } - if (strcasecmp(unit, "power") != 0 && strcasecmp(unit, "volts") != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"unit must be power or volts\"}"); - } - - photodiode_get_status(&status); - - if (strcasecmp(unit, "volts") == 0) { - yj_value = status.channel[PHOTODIODE_CHANNEL_YJ].mv / 1000.0f; - hk_value = status.channel[PHOTODIODE_CHANNEL_HK].mv / 1000.0f; - yj_err = status.channel[PHOTODIODE_CHANNEL_YJ].noise_rms_mv / 1000.0f; - hk_err = status.channel[PHOTODIODE_CHANNEL_HK].noise_rms_mv / 1000.0f; - snprintk(unit, sizeof(unit), "volts"); - } else { - yj_value = status.channel[PHOTODIODE_CHANNEL_YJ].power_uw; - hk_value = status.channel[PHOTODIODE_CHANNEL_HK].power_uw; - - struct app_photodiode_settings settings; - float yj_gain; - float hk_gain; - - app_settings_get_photodiode(&settings); - yj_gain = settings.channel[PHOTODIODE_CHANNEL_YJ].gain_v_per_uw; - hk_gain = settings.channel[PHOTODIODE_CHANNEL_HK].gain_v_per_uw; - - yj_err = (yj_gain > 0.0f) ? - status.channel[PHOTODIODE_CHANNEL_YJ].noise_rms_mv / (yj_gain * 1000.0f) : - 0.0f; - - hk_err = (hk_gain > 0.0f) ? - status.channel[PHOTODIODE_CHANNEL_HK].noise_rms_mv / (hk_gain * 1000.0f) : - 0.0f; - - snprintk(unit, sizeof(unit), "power"); - } - - snprintk(payload, sizeof(payload), - "{\"unit\":\"%s\",\"yjvalue\":%.6f,\"yjvalue_err\":%.6f," - "\"hkvalue\":%.6f,\"hkvalue_err\":%.6f," - "\"yj_raw\":%d,\"hk_raw\":%d,\"yj_mv\":%.3f,\"hk_mv\":%.3f," - "\"yj_noise_rms_mv\":%.3f,\"hk_noise_rms_mv\":%.3f," - "\"uptime\":%lld}", - unit, - (double)yj_value, - (double)yj_err, - (double)hk_value, - (double)hk_err, - status.channel[PHOTODIODE_CHANNEL_YJ].raw, - status.channel[PHOTODIODE_CHANNEL_HK].raw, - (double)status.channel[PHOTODIODE_CHANNEL_YJ].mv, - (double)status.channel[PHOTODIODE_CHANNEL_HK].mv, - (double)status.channel[PHOTODIODE_CHANNEL_YJ].noise_rms_mv, - (double)status.channel[PHOTODIODE_CHANNEL_HK].noise_rms_mv, - status.uptime_ms); - return _msg_builder(cmd, RESP_OK, payload); -} - -static struct OutMsg pd_dark_status_response(const struct Command *cmd, - const struct photodiode_dark_status *status) -{ - char payload[MAX_PAYLOAD_LEN] = {0}; - const struct photodiode_dark_result *result = &status->result; - const char *state_name = photodiode_dark_state_name(status->state); - - if (status->state == PHOTODIODE_DARK_COMPLETE) { - snprintk(payload, sizeof(payload), - "{\"status\":\"%s\",\"channel\":\"%s\",\"stored\":%s," - "\"duration_ms\":%u,\"samples\":%u,\"target_samples\":%u," - "\"mean_dark_mv\":%.3f,\"rms_mv\":%.3f," - "\"min_mv\":%.3f,\"max_mv\":%.3f," - "\"previous_dark_mv\":%.3f,\"configured_dark_mv\":%.3f," - "\"lowest_dark_mv\":%.3f,\"lowest_dark_valid\":%s}", - state_name, - photodiode_channel_names[status->channel], - result->stored ? "true" : "false", - status->duration_ms, - status->samples, - status->target_samples, - (double)result->mean_mv, - (double)result->rms_mv, - (double)result->min_mv, - (double)result->max_mv, - (double)result->previous_dark_mv, - (double)result->configured_dark_mv, - (double)result->lowest_dark_mv, - result->lowest_dark_valid ? "true" : "false"); - return _msg_builder(cmd, RESP_OK, payload); - } - - if (status->state == PHOTODIODE_DARK_ERROR) { - snprintk(payload, sizeof(payload), - "{\"status\":\"error\",\"channel\":\"%s\",\"rc\":%d," - "\"duration_ms\":%u,\"samples\":%u,\"target_samples\":%u}", - photodiode_channel_names[status->channel], - status->last_error, - status->duration_ms, - status->samples, - status->target_samples); - return _msg_builder(cmd, RESP_ERROR, payload); - } - - snprintk(payload, sizeof(payload), - "{\"status\":\"%s\",\"channel\":\"%s\",\"stored_on_complete\":%s," - "\"duration_ms\":%u,\"samples\":%u,\"target_samples\":%u}", - state_name, - photodiode_channel_names[status->channel], - status->store ? "true" : "false", - status->duration_ms, - status->samples, - status->target_samples); - return _msg_builder(cmd, RESP_OK, payload); -} - -struct OutMsg pd_set(const struct Command *cmd) -{ - char action[32] = {0}; - enum photodiode_channel channel; - uint32_t duration_ms = 0U; - bool store = false; - bool persist = true; - int parse_rc; - int rc; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"photodiodes unavailable on this board\"}"); - } - - parse_rc = coo_json_extract_string(cmd->payload, "action", action, sizeof(action)); - if (parse_rc == COO_JSON_EXTRACT_MISSING) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"missing action\"}"); - } - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"invalid action\"}"); - } - - rc = pd_parse_channel_from_payload_or_key(cmd, &channel); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"channel must be yj or hk\"}"); - } - - if (strcasecmp(action, "measure_dark") == 0) { - struct photodiode_dark_status status; - - parse_rc = coo_json_extract_u32(cmd->payload, "duration_ms", &duration_ms); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"invalid duration_ms\"}"); + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + if (coo_json_append(payload, sizeof(payload), &off, + "%s\"%s\":", + i == 0U ? "" : ",", + hispec_laser_name((enum hispec_laser_id)i)) != 0 || + coo_json_append_float_or_null(payload, sizeof(payload), &off, + laser_rc == 0 && channel_temp[i].valid ? + channel_temp[i].tec_temperature_c : NAN, + 3) != 0) { + return coo_cmd_error(cmd, "temp response too large"); } - - parse_rc = coo_json_extract_bool(cmd->payload, "store", &store); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"invalid store\"}"); - } - - rc = photodiode_start_dark_measurement(channel, duration_ms, store, &status); - if (rc != 0) { - char payload[MAX_PAYLOAD_LEN]; - - snprintk(payload, sizeof(payload), - "{\"status\":\"error\",\"msg\":\"dark measurement failed\",\"rc\":%d}", - rc); - return _msg_builder(cmd, RESP_ERROR, payload); - } - return pd_dark_status_response(cmd, &status); } - - if (strcasecmp(action, "dark_status") == 0) { - struct photodiode_dark_status status; - - rc = photodiode_get_dark_status(channel, &status); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"dark status unavailable\"}"); - } - - return pd_dark_status_response(cmd, &status); - } - - if (strcasecmp(action, "reset_lowest_dark") == 0) { - parse_rc = coo_json_extract_bool(cmd->payload, "persistent", &persist); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"invalid persistent\"}"); - } - - rc = photodiode_reset_lowest_dark(channel, persist); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"reset failed\"}"); - } - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\"}"); - } - - return _msg_builder(cmd, RESP_ERROR, "{\"status\":\"error\",\"msg\":\"unknown action\"}"); -} - -static int pd_settings_channel_json(char *payload, size_t payload_len, - enum photodiode_channel channel, - const struct app_pd_channel_settings *ch) -{ - struct photodiode_dark_status dark = {0}; - int written; - - (void)photodiode_get_dark_status(channel, &dark); - - written = snprintk(payload, payload_len, - "{\"channel\":\"%s\",\"dark_mv\":%.3f," - "\"lowest_dark_mv\":%.3f," - "\"lowest_dark_valid\":%s," - "\"dark_measurement\":\"%s\"," - "\"dark_measurement_duration_ms\":%u," - "\"dark_measurement_samples\":%u," - "\"dark_measurement_target_samples\":%u," - "\"noise_rms_mV\":%.3f," - "\"gain_v_p_uw\":%.6f}", - photodiode_channel_names[channel], - (double)ch->dark_mv, - (double)ch->lowest_dark_mv, - ch->lowest_dark_valid ? "true" : "false", - photodiode_dark_state_name(dark.state), - dark.duration_ms, - dark.samples, - dark.target_samples, - (double)ch->noise_warn_rms_mv, - (double)ch->gain_v_per_uw); - - return (written >= 0 && written < (int)payload_len) ? 0 : -ENOSPC; -} - -struct OutMsg pd_settings_get(const struct Command *cmd) -{ - struct app_photodiode_settings settings; - char payload[MAX_PAYLOAD_LEN] = {0}; - enum photodiode_channel channel; - int rc; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"photodiodes unavailable on this board\"}"); - } - - rc = pd_parse_channel_from_key(cmd, &channel); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"pdsettings key must be pdsettings/yj or pdsettings/hk\"}"); - } - - app_settings_get_photodiode(&settings); - rc = pd_settings_channel_json(payload, sizeof(payload), channel, - &settings.channel[channel]); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"pdsettings response too large\"}"); - } - - return _msg_builder(cmd, RESP_OK, payload); -} - -struct OutMsg pd_settings_set(const struct Command *cmd) -{ - struct app_photodiode_settings settings; - struct app_pd_channel_settings channel_settings; - enum photodiode_channel channel; - bool persist = false; - bool changed = false; - int parse_rc; - int rc; - - if (devices_board_type() != HISPEC_BOARD_TIB) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"photodiodes unavailable on this board\"}"); - } - - rc = pd_parse_channel_from_key(cmd, &channel); - if (rc != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"pdsettings key must be pdsettings/yj or pdsettings/hk\"}"); - } - - app_settings_get_photodiode(&settings); - channel_settings = settings.channel[channel]; - - parse_rc = coo_json_extract_bool(cmd->payload, "persistent", &persist); - if (parse_rc == COO_JSON_EXTRACT_ERR) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"invalid persistent\"}"); - } - - if (coo_json_extract_optional_float_range(cmd->payload, "dark_mv", - &channel_settings.dark_mv, - &changed, -5000.0f, 5000.0f) != 0 || - coo_json_extract_optional_float_range(cmd->payload, "noise_rms_mV", - &channel_settings.noise_warn_rms_mv, - &changed, 0.0f, 5000.0f) != 0 || - coo_json_extract_optional_float_range(cmd->payload, "gain_v_p_uw", - &channel_settings.gain_v_per_uw, - &changed, 0.000001f, 1000000000.0f) != 0) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"invalid pdsettings value\"}"); - } - - if (!changed) { - return _msg_builder(cmd, RESP_ERROR, - "{\"status\":\"error\",\"msg\":\"no pdsettings fields supplied\"}"); - } - - app_settings_update_photodiode_channel((uint8_t)channel, - &channel_settings, - persist); - return _msg_builder(cmd, RESP_OK, "{\"status\":\"success\"}"); -} - - -struct OutMsg status_get(const struct Command *cmd) { - struct network_ipv4_info net = {0}; - char payload[MAX_PAYLOAD_LEN]={0}; - - (void)network_get_ipv4_info(&net); - snprintf(payload, MAX_PAYLOAD_LEN, - "{\"fwversion\":\"%s\",\"bootcount\":%u,\"uptime\":%lld," - "\"board_type\":\"%s\",\"board_valid\":%s,\"mems_switches\":%u," - "\"network_ready\":%s,\"ip\":\"%s\",\"laser_power\":%s}", - APP_VERSION_STRING, - app_settings_get_boot_count(), - (long long)k_uptime_get(), - devices_board_type_name(), - devices_board_type() != HISPEC_BOARD_UNKNOWN ? "true" : "false", - router.num_switches, - net.link_ready ? "true" : "false", - net.ip, - power_enabled() ? "true" : "false"); - return _msg_builder(cmd, RESP_OK, payload); -} - -struct OutMsg temp_get(const struct Command *cmd) -{ - struct tempsense_status ts = {0}; - char payload[MAX_PAYLOAD_LEN] = {0}; - - tempsense_get_status(&ts); - if (ts.valid) { - snprintf(payload, sizeof(payload), - "{\"ambient_c\":%.3f,\"ambient_age_ms\":%u,\"laserbankavg_c\":null}", - (double)ts.ambient_c, - ts.age_ms); - } else { - snprintf(payload, sizeof(payload), - "{\"ambient_c\":null,\"ambient_age_ms\":null,\"laserbankavg_c\":null," - "\"status\":\"error\",\"msg\":\"ambient temperature unavailable\",\"last_error\":%d}", - ts.last_error); + if (coo_json_append(payload, sizeof(payload), &off, "}}") != 0) { + return coo_cmd_error(cmd, "temp response too large"); } - return _msg_builder(cmd, ts.valid ? RESP_OK : RESP_ERROR, payload); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); } diff --git a/app/src/command.h b/app/src/command.h index 30f542e..f163f45 100644 --- a/app/src/command.h +++ b/app/src/command.h @@ -1,11 +1,11 @@ /** * @file command.h - * @brief MQTT and serial command ingress, dispatch, and response queues. + * @brief HISPEC command table and app-specific command runtime hooks. * - * Commands from MQTT and the line-oriented serial console are normalized into - * `struct Command` and executed by the common command executor thread. Handler - * functions may touch hardware, sleep on Zephyr or bus I/O, enqueue warnings, - * and return one `struct OutMsg` response for later serial/MQTT publication. + * The common command runtime owns MQTT/serial ingress, topic formatting, + * warning emission, executor threads, and outbound drain mechanics. This app + * layer supplies command handlers, help metadata, and the static queues used by + * the runtime. */ #ifndef COMMAND_H @@ -16,170 +16,36 @@ #include #include #include - -#define MAX_TOPIC_LEN 96 -#define MAX_KEY_LEN 48 -#define MAX_REQID_LEN 32 -#define MAX_SESSION_ID_LEN 48 -#define MAX_PAYLOAD_LEN CONFIG_COO_MQTT_PAYLOAD_SIZE -/* MQTT 5 correlation_data is opaque requester state. Keep a fixed static - * buffer large enough for any correlation property that can fit in the MQTT - * wrapper's configured packet buffer so accepted requests can be matched. - */ -#define MAX_CORRELATION_DATA CONFIG_COO_MQTT_PAYLOAD_SIZE +#include + +#define MAX_TOPIC_LEN COO_CMD_TOPIC_MAX +#define MAX_KEY_LEN COO_CMD_KEY_MAX +#define MAX_REQID_LEN COO_CMD_REQID_MAX +#define MAX_SESSION_ID_LEN COO_CMD_SESSION_ID_MAX +#define MAX_PAYLOAD_LEN COO_CMD_PAYLOAD_MAX +#define MAX_CORRELATION_DATA COO_CMD_CORRELATION_MAX #define MAX_PENDING_COMMANDS 2 +/* Handler prototypes for command.c-owned commands (get/set where defined). */ +struct coo_cmd_response ip_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response ip_set(const struct coo_cmd_request *cmd); +struct coo_cmd_response mqtt_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response mqtt_set(const struct coo_cmd_request *cmd); +struct coo_cmd_response time_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response time_set(const struct coo_cmd_request *cmd); -/** Command request/response type understood by the dispatcher and builders. */ -enum MsgType { MSG_GET, MSG_SET, ACK, RESP_OK, RESP_ERROR }; - -/** Ingress path used for response routing and serial-guard policy. */ -enum CommandSource { CMD_SRC_MQTT = 0, CMD_SRC_SERIAL = 1 }; - -/** Publication target for responses, warnings, and telemetry. */ -enum OutMsgTarget { - OUT_TARGET_MQTT = 0, - OUT_TARGET_SERIAL = 1, - /* Fire-and-forget MQTT publication, used for warnings/telemetry that - * must not block command responses when MQTT is unavailable. - */ - OUT_TARGET_MQTT_BEST_EFFORT = 2, -}; - -struct Command { - enum MsgType msg_type; - enum CommandSource source; - - char key[MAX_KEY_LEN]; //topic instead - char session_id[MAX_SESSION_ID_LEN]; //maybe or part of Mqtt? - char response_topic[MAX_TOPIC_LEN]; - size_t payload_len; - char payload[MAX_PAYLOAD_LEN]; - uint8_t correlation_data[MAX_CORRELATION_DATA]; - uint32_t corr_len; -}; - -/** Fully formatted outbound response or publication. */ -struct OutMsg { - enum MsgType msg_type; // RES, ACK, ERROR - enum OutMsgTarget target; - char topic[MAX_TOPIC_LEN]; - uint8_t qos; - size_t payload_len; - char payload[MAX_PAYLOAD_LEN]; - uint8_t correlation_data[MAX_CORRELATION_DATA]; - size_t corr_len; -}; - -/** Work wrapper retained for possible Zephyr workqueue dispatch use. */ -struct CommandWork { - struct k_work work; - struct Command cmd; -}; - - -typedef struct OutMsg (*DispatchFunc)(const struct Command *cmd) ; - -struct DispatchEntry { - const char *key; /* e.g. "memsroute", "laser1/flux", etc. */ - DispatchFunc get_handler; // may be none - DispatchFunc set_handler; // may be none -}; - - -/* Handler prototypes for all commands (get/set where defined) */ -struct OutMsg memsroute_get(const struct Command *cmd); -struct OutMsg memsroute_set(const struct Command *cmd); -/** Query one AS splitter channel, usually with command key split/yj or split/hk. */ -struct OutMsg splitting_get(const struct Command *cmd); -/** Apply one AS-PCB splitter channel using channel, ratio1, and ratio2. */ -struct OutMsg splitting_set(const struct Command *cmd); -struct OutMsg help_get(const struct Command *cmd); -struct OutMsg ip_get(const struct Command *cmd); -struct OutMsg ip_set(const struct Command *cmd); -struct OutMsg mqtt_get(const struct Command *cmd); -struct OutMsg mqtt_set(const struct Command *cmd); -struct OutMsg time_get(const struct Command *cmd); -struct OutMsg time_set(const struct Command *cmd); -struct OutMsg reboot_set(const struct Command *cmd); -struct OutMsg serial_guard_get(const struct Command *cmd); -struct OutMsg serial_guard_set(const struct Command *cmd); - -struct OutMsg mems_get(const struct Command *cmd); -struct OutMsg mems_set(const struct Command *cmd); - -struct OutMsg laser_setting_get(const struct Command *cmd); -struct OutMsg laser_setting_set(const struct Command *cmd); -/** Power on the TIB laser bank using the board power GPIO. */ -struct OutMsg laserbank_poweron(const struct Command *cmd); -/** Power off the TIB laser bank using the board power GPIO. */ -struct OutMsg laserbank_poweroff(const struct Command *cmd); -/** Clear laser-bank faults with a bounded laser-bank power cycle. */ -struct OutMsg laserbank_clearfaults(const struct Command *cmd); - - -struct OutMsg atten_setting_get(const struct Command *cmd); -struct OutMsg atten_setting_set(const struct Command *cmd); -struct OutMsg pd_get(const struct Command *cmd); -struct OutMsg pd_set(const struct Command *cmd); -struct OutMsg pd_settings_get(const struct Command *cmd); -struct OutMsg pd_settings_set(const struct Command *cmd); - -struct OutMsg status_get(const struct Command *cmd); -struct OutMsg temp_get(const struct Command *cmd); - - -/** Parse optional MQTT-style `msg_type` from JSON; missing/unknown returns false. */ -bool parse_msg_type_from_payload(const char *payload, enum MsgType *msg_type_out); -struct OutMsg invalid_command_response(const struct Command *cmd); -struct OutMsg unknown_response(const struct Command *cmd); -struct OutMsg unsupported_response(const struct Command *cmd); -struct OutMsg busy_response(const struct Command *cmd); -struct OutMsg serial_active_response(const struct Command *cmd); -/** Dispatch one normalized command to the longest matching command-table entry. */ -struct OutMsg dispatch_command(const struct Command *cmd); - -/** Executor task: blocks on inbound_queue, runs a handler, and enqueues one response. */ -void command_executor_thread(void *p1, void *p2, void *p3); +struct coo_cmd_response status_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response temp_get(const struct coo_cmd_request *cmd); /** - * @brief Initialize command-layer delayed actions. + * @brief Initialize command runtime identity, queues, hooks, and reboot work. * - * Registers the callbacks used by serial-override expiration and delayed - * reboot. Call once before starting command ingress threads. + * Call once before starting command ingress threads. */ int command_runtime_init(void); -/** Serial task: blocks on Zephyr console lines and queues normalized commands. */ -void command_serial_thread(void *p1, void *p2, void *p3); - -/** - * @brief MQTT receive callback. - * - * Copies the MQTT topic, payload, response-topic property, and correlation data - * before returning. Empty payload means GET; non-empty payload defaults to SET - * unless JSON `msg_type:"get"` is present. Enqueues or publishes an immediate - * error when serial guard or queue capacity rejects the command. - */ -void command_handle_mqtt_publish(const struct mqtt_publish_param *pub); - -/** Extend the serial-command holdoff window that rejects MQTT command execution. */ -void command_serial_note_activity(void); - -/** Return false while serial override is active. */ -bool command_network_mqtt_allowed(void); - -/** Parse " [payload]" into a queued Command; serial has no get/set words. */ -void command_parse_serial_line(char *line); - -/** - * @brief Drain queued serial/MQTT responses. - * - * MQTT publishing happens only here from the main loop. Non-best-effort MQTT - * messages are retried by requeueing when MQTT is down or publish fails. - */ -void command_drain_outbound_queue(struct mqtt_client *client, bool mqtt_available); - +/** Return the app's configured command runtime for main-loop and warning use. */ +struct coo_cmd_runtime *command_runtime_get(void); extern struct k_msgq inbound_queue; extern struct k_msgq outbound_queue; diff --git a/app/src/devices.c b/app/src/devices.c index e0e7093..09dfad1 100644 --- a/app/src/devices.c +++ b/app/src/devices.c @@ -12,21 +12,29 @@ #define __DEVICE_C__ #include "devices.h" +#include "app_identity.h" #include "app_settings.h" +#include "command.h" +#include "maiman.h" #include "mems_switching.h" #include +#include #include +#include #include LOG_MODULE_REGISTER(devices, LOG_LEVEL_INF); #define USER_NODE DT_PATH(zephyr_user) #define MAX_NUM_MEMS_SWITCHES 8U -#define CAL_ATTENUATOR_INDEX 4U BUILD_ASSERT(APP_ATTENUATOR_CHANNEL_COUNT == NUM_ATTENUATORS, "Persistent attenuator settings must match logical attenuator count"); +BUILD_ASSERT(NUM_ATTENUATORS == HISPEC_LASER_COUNT, + "Logical attenuator mapping assumes one TIB attenuator per laser"); +BUILD_ASSERT(HISPEC_ATTENUATOR_LFC_INDEX < NUM_ATTENUATORS, + "LFC/CAL attenuator index must fit logical attenuator table"); /* Devices */ const struct gpio_dt_spec laser_power_gpio = GPIO_DT_SPEC_GET(USER_NODE, laser_power_gpios); @@ -35,56 +43,16 @@ const struct gpio_dt_spec yj_power_gpio = GPIO_DT_SPEC_GET(USER_NODE, yj_power_g const struct gpio_dt_spec hk_power_gpio = GPIO_DT_SPEC_GET(USER_NODE, hk_power_gpios); -#if DT_NODE_HAS_PROP(USER_NODE, board_type_tib_gpios) -static const struct gpio_dt_spec board_type_tib_gpio = - GPIO_DT_SPEC_GET(USER_NODE, board_type_tib_gpios); -#else -static const struct gpio_dt_spec board_type_tib_gpio = {0}; -#endif - -#if DT_NODE_HAS_PROP(USER_NODE, board_type_cal_yj_gpios) -static const struct gpio_dt_spec board_type_cal_yj_gpio = - GPIO_DT_SPEC_GET(USER_NODE, board_type_cal_yj_gpios); -#else -static const struct gpio_dt_spec board_type_cal_yj_gpio = {0}; -#endif - -#if DT_NODE_HAS_PROP(USER_NODE, board_type_cal_hk_gpios) -static const struct gpio_dt_spec board_type_cal_hk_gpio = - GPIO_DT_SPEC_GET(USER_NODE, board_type_cal_hk_gpios); -#else -static const struct gpio_dt_spec board_type_cal_hk_gpio = {0}; -#endif - -#if DT_NODE_HAS_PROP(USER_NODE, board_type_as_gpios) -static const struct gpio_dt_spec board_type_as_gpio = - GPIO_DT_SPEC_GET(USER_NODE, board_type_as_gpios); -#else -static const struct gpio_dt_spec board_type_as_gpio = {0}; -#endif - -#if DT_HAS_COMPAT_STATUS_OKAY(zephyr_modbus_serial) +static const struct gpio_dt_spec board_type_tib_gpio = GPIO_DT_SPEC_GET(USER_NODE, board_type_tib_gpios); +static const struct gpio_dt_spec board_type_cal_yj_gpio = GPIO_DT_SPEC_GET(USER_NODE, board_type_cal_yj_gpios); +static const struct gpio_dt_spec board_type_cal_hk_gpio = GPIO_DT_SPEC_GET(USER_NODE, board_type_cal_hk_gpios); +static const struct gpio_dt_spec board_type_as_gpio = GPIO_DT_SPEC_GET(USER_NODE, board_type_as_gpios); + #define MODBUS_NODE DT_COMPAT_GET_ANY_STATUS_OKAY(zephyr_modbus_serial) static const char modbus_name[] = DEVICE_DT_NAME(MODBUS_NODE); -#endif - -#if DT_NODE_EXISTS(DT_NODELABEL(adc1115)) const struct device *adc_dev = DEVICE_DT_GET(DT_NODELABEL(adc1115)); -#else -const struct device *adc_dev = NULL; -#endif - -#if DT_NODE_EXISTS(DT_NODELABEL(dac7578)) -const struct device *dac_dev = DEVICE_DT_GET(DT_NODELABEL(dac7578)); -#else -const struct device *dac_dev = NULL; -#endif -#if DT_NODE_EXISTS(DT_NODELABEL(pcal6416a)) const struct device *gpio_dev = DEVICE_DT_GET(DT_NODELABEL(pcal6416a)); -#else -const struct device *gpio_dev = NULL; -#endif struct attenuator attenuators[NUM_ATTENUATORS]; struct mems_switch mems_switches[MEMS_ROUTER_MAX_SWITCHES]; @@ -102,7 +70,7 @@ static const char *const as_switch_names[6] = { "hk_as1", "hk_as2", "hk_as3", }; -/* TODO decide on final CAL route/switch names once the fiber path names are finalized. */ + static const char *const cal_switch_names[7] = { "cal1", "cal2", "cal3", "cal4", "cal5", "cal6", "cal7", }; @@ -110,15 +78,69 @@ static const char *const cal_switch_names[7] = { /* The GPIO expander wiring is the same 2-pin sequence on each populated board. * Board profiles below limit how many of these pairs are instantiated. */ -static const gpio_pin_t mems_switch_pin_pairs[MAX_NUM_MEMS_SWITCHES][2] = { - {0, 1}, {2, 3}, {4, 5}, {6, 7}, - {8, 9}, {10, 11}, {12, 13}, {14, 15}, -}; - -/* Per-switch compile-time nominal toggle rates (Hz), quantized in mems_switch_init(). */ -static const float mems_switch_toggle_rate_hz[MAX_NUM_MEMS_SWITCHES] = { - 5.0f, 5.0f, 5.0f, 5.0f, 5.0f, 5.0f, 5.0f, 5.0f, -}; +static const struct gpio_dt_spec mems_switch_gpio_pairs[MAX_NUM_MEMS_SWITCHES][2] = { + {GPIO_DT_SPEC_GET(USER_NODE, mems3_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems3_b_gpios)}, /* sw1 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems4_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems4_b_gpios)}, /* sw2 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems2_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems2_b_gpios)}, /* sw3 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems5_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems5_b_gpios)}, /* sw4 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems6_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems6_b_gpios)}, /* sw5 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems7_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems7_b_gpios)}, /* sw6 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems1_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems1_b_gpios)}, /* sw7 */ + {GPIO_DT_SPEC_GET(USER_NODE, mems0_a_gpios), + GPIO_DT_SPEC_GET(USER_NODE, mems0_b_gpios)}, /* sw8 */ +}; + + +struct attenuator_dac_pair { + const struct device *dev; + uint8_t channel1; + uint8_t channel2; +}; + +/* Logical attenuator to physical DAC wiring from doc/hardware.md. + * DAC channels are zero-based here: A=0, C=2, D=3, E=4, F=5, G=6. + */ +static const struct attenuator_dac_pair attenuator_dac_pairs[NUM_ATTENUATORS] = { + [HISPEC_LASER_1028_Y] = { + .dev = DEVICE_DT_GET(DT_NODELABEL(dac7678_b)), + .channel1 = 0U, + .channel2 = 2U, + }, + [HISPEC_LASER_1270_J] = { + .dev = DEVICE_DT_GET(DT_NODELABEL(dac7678_b)), + .channel1 = 4U, + .channel2 = 6U, + }, + [HISPEC_LASER_1430_YJ] = { + .dev = DEVICE_DT_GET(DT_NODELABEL(dac7678_b)), + .channel1 = 3U, + .channel2 = 5U, + }, + [HISPEC_LASER_1430_HK] = { + .dev = DEVICE_DT_GET(DT_NODELABEL(dac7678)), + .channel1 = 0U, + .channel2 = 2U, + }, + [HISPEC_LASER_1510_H] = { + .dev = DEVICE_DT_GET(DT_NODELABEL(dac7678)), + .channel1 = 4U, + .channel2 = 6U, + }, + [HISPEC_LASER_2330_K] = { + .dev = DEVICE_DT_GET(DT_NODELABEL(dac7678)), + .channel1 = 3U, + .channel2 = 5U, + }, +}; +BUILD_ASSERT(ARRAY_SIZE(attenuator_dac_pairs) == NUM_ATTENUATORS, + "DAC pair table must match logical attenuator count"); struct board_profile { enum hispec_board_type board; @@ -149,7 +171,7 @@ static const struct board_profile cal_yj_profile = { .name = "cal_yj", .mems_switch_count = 7, .switch_names = cal_switch_names, - .attenuator_first = CAL_ATTENUATOR_INDEX, + .attenuator_first = HISPEC_ATTENUATOR_LFC_INDEX, .attenuator_count = 1, }; @@ -158,7 +180,7 @@ static const struct board_profile cal_hk_profile = { .name = "cal_hk", .mems_switch_count = 7, .switch_names = cal_switch_names, - .attenuator_first = CAL_ATTENUATOR_INDEX, + .attenuator_first = HISPEC_ATTENUATOR_LFC_INDEX, .attenuator_count = 1, }; @@ -172,6 +194,12 @@ static const struct board_profile as_profile = { static K_MUTEX_DEFINE(board_profile_lock); static const struct board_profile *active_profile = &unknown_profile; static bool board_type_checked; +static K_MUTEX_DEFINE(relay_gpio_lock); +static bool relay_gpio_online; +static int relay_gpio_last_error = -ENODEV; +static bool relay_gpio_warning_emitted; +static uint32_t boot_reset_cause; +static bool boot_reset_cause_valid; struct board_strap { const struct gpio_dt_spec *gpio; @@ -186,6 +214,151 @@ static const struct board_strap board_straps[] = { {&board_type_as_gpio, HISPEC_BOARD_AS, "as"}, }; +static const char *reset_cause_name(uint32_t bit) +{ + switch (bit) { + case RESET_PIN: + return "pin"; + case RESET_SOFTWARE: + return "software"; + case RESET_BROWNOUT: + return "brownout"; + case RESET_POR: + return "power_on"; + case RESET_WATCHDOG: + return "watchdog"; + case RESET_DEBUG: + return "debug"; + case RESET_SECURITY: + return "security"; + case RESET_LOW_POWER_WAKE: + return "low_power_wake"; + case RESET_CPU_LOCKUP: + return "cpu_lockup"; + case RESET_PARITY: + return "parity"; + case RESET_PLL: + return "pll"; + case RESET_CLOCK: + return "clock"; + case RESET_HARDWARE: + return "hardware"; + case RESET_USER: + return "user"; + case RESET_TEMPERATURE: + return "temperature"; + case RESET_BOOTLOADER: + return "bootloader"; + case RESET_FLASH: + return "flash"; + default: + return NULL; + } +} + +static void format_reset_cause_list(uint32_t cause, char *buf, size_t buf_len) +{ + size_t off = 0U; + bool first = true; + + if (buf == NULL || buf_len == 0U) { + return; + } + + buf[0] = '\0'; + if (cause == 0U) { + (void)snprintk(buf, buf_len, "unknown"); + return; + } + + for (uint32_t bit = BIT(0); bit != 0U; bit <<= 1) { + const char *name; + + if ((cause & bit) == 0U) { + continue; + } + + name = reset_cause_name(bit); + if (name == NULL) { + continue; + } + + off += snprintk(&buf[off], buf_len - off, "%s%s", + first ? "" : ",", name); + if (off >= buf_len) { + buf[buf_len - 1U] = '\0'; + return; + } + first = false; + } + + if (first) { + (void)snprintk(buf, buf_len, "unknown"); + } +} + +void devices_capture_boot_reset_cause(void) +{ + uint32_t cause = 0U; + char cause_text[128]; + int rc; + + rc = hwinfo_get_reset_cause(&cause); + if (rc != 0) { + LOG_WRN("Reset cause unavailable (%d)", rc); + return; + } + + boot_reset_cause = cause; + boot_reset_cause_valid = true; + format_reset_cause_list(cause, cause_text, sizeof(cause_text)); + + if ((cause & RESET_WATCHDOG) != 0U) { + LOG_WRN("Previous boot ended in watchdog reset; reset_cause=%s", cause_text); + } else { + LOG_INF("Reset cause: %s", cause_text); + } + + rc = hwinfo_clear_reset_cause(); + if (rc != 0) { + LOG_WRN("Failed to clear reset cause flags (%d)", rc); + } +} + +void devices_queue_boot_reset_telemetry(void) +{ + struct coo_cmd_response msg = {0}; + char cause_text[128]; + int rc; + + if (!boot_reset_cause_valid || (boot_reset_cause & RESET_WATCHDOG) == 0U) { + return; + } + + format_reset_cause_list(boot_reset_cause, cause_text, sizeof(cause_text)); + msg.target = COO_CMD_OUT_MQTT; + msg.qos = 0; + rc = coo_cmd_format_data_topic(app_mqtt_device_id(), "boot", + msg.topic, sizeof(msg.topic)); + if (rc != 0) { + LOG_WRN("Failed to format boot telemetry topic (%d)", rc); + return; + } + + msg.payload_len = snprintk(msg.payload, sizeof(msg.payload), + "{\"event\":\"boot\",\"reset_cause\":\"%s\"," + "\"watchdog\":true,\"raw_reset_cause\":%u}", + cause_text, boot_reset_cause); + if (msg.payload_len >= sizeof(msg.payload)) { + LOG_WRN("Boot telemetry payload too large"); + return; + } + + if (k_msgq_put(&outbound_queue, &msg, K_FOREVER) != 0) { + LOG_WRN("Outbound queue full; boot watchdog telemetry not queued"); + } +} + #define ROUTE_DEF(input_, output_, steps_) \ { .key = { .input_name = (input_), .output_name = (output_) }, \ .steps = (steps_), .num_steps = ARRAY_SIZE(steps_) } @@ -454,86 +627,33 @@ static void set_current_profile(const struct board_profile *profile, bool checke k_mutex_unlock(&board_profile_lock); } -static bool all_board_straps_mapped(void) -{ - for (uint8_t i = 0; i < ARRAY_SIZE(board_straps); ++i) { - if (board_straps[i].gpio->port == NULL) { - return false; - } - } - - return true; -} - -static int board_strap_read_active(const struct board_strap *strap, bool *active) -{ - int value; - int rc; - - if (strap == NULL || active == NULL || strap->gpio->port == NULL) { - return -ENODEV; - } - if (!gpio_is_ready_dt(strap->gpio)) { - return -ENODEV; - } - - /* gpio_pin_configure_dt() applies the GPIO_ACTIVE_LOW and GPIO_PULL_UP - * flags from the overlay. gpio_pin_get_dt() then returns logical active - * state, so an active-low jumper shorted to ground reads as true. - */ - rc = gpio_pin_configure_dt(strap->gpio, GPIO_INPUT); - if (rc != 0) { - return rc; - } - - value = gpio_pin_get_dt(strap->gpio); - if (value < 0) { - return value; - } - - *active = (value != 0); - return 0; -} int devices_detect_board_type(void) { enum hispec_board_type detected = HISPEC_BOARD_UNKNOWN; uint8_t active_count = 0U; - int first_error = 0; - if (!all_board_straps_mapped()) { - LOG_ERR("Board type strap GPIOs are not mapped in devicetree"); - set_current_profile(&unknown_profile, true); - return -ENODEV; - } + /* Strap direction and pull-ups are GPIO hogs in the board overlay. */ + k_busy_wait(100); for (uint8_t i = 0; i < ARRAY_SIZE(board_straps); ++i) { - bool active = false; - int rc = board_strap_read_active(&board_straps[i], &active); + const struct board_strap *strap = &board_straps[i]; + int logical; - if (rc != 0) { - LOG_ERR("Failed to read board strap %s (%d)", board_straps[i].name, rc); - if (first_error == 0) { - first_error = rc; - } - continue; - } + logical = gpio_pin_get_dt(strap->gpio); - LOG_DBG("Board strap %s active=%d", board_straps[i].name, active ? 1 : 0); - if (active) { + LOG_INF("Board strap %s flags=0x%x active=%d", + strap->name, + strap->gpio->dt_flags, + logical != 0 ? 1 : 0); + if (logical != 0) { active_count++; - detected = board_straps[i].board; + detected = strap->board; } } - if (first_error != 0) { - set_current_profile(&unknown_profile, true); - return first_error; - } - if (active_count == 1U) { const struct board_profile *profile = profile_for_type(detected); - set_current_profile(profile, true); LOG_INF("Detected PCB board type: %s", profile->name); return 0; @@ -545,9 +665,14 @@ int devices_detect_board_type(void) return -EIO; } - LOG_ERR("No board type strap is active; refusing board-specific setup"); - set_current_profile(&unknown_profile, true); - return -ENODEV; + //TODO remove this bringup patch when finished. + // LOG_ERR("No board type strap is active; refusing board-specific setup"); + // set_current_profile(&unknown_profile, true); + // return -ENODEV; + LOG_ERR("No board type strap is active; defaulting to TIB"); + set_current_profile(&tib_profile, true); + return 0; + } bool devices_board_type_checked(void) @@ -571,6 +696,17 @@ const char *devices_board_type_name(void) return current_profile()->name; } +bool devices_attenuator_channel_available(uint8_t attenuator_index) +{ + const struct board_profile *profile = current_profile(); + uint8_t first = profile->attenuator_first; + uint8_t count = profile->attenuator_count; + + return attenuator_index < NUM_ATTENUATORS && + attenuator_index >= first && + attenuator_index < first + count; +} + static bool device_ready_or_log(const struct device *dev, const char *label) { if (dev == NULL) { @@ -591,8 +727,8 @@ static bool configure_gpio_output_inactive_or_log(const struct gpio_dt_spec *gpi { int rc; - if (gpio == NULL || !gpio_is_ready_dt(gpio)) { - LOG_ERR("%s GPIO is not ready", label); + if (gpio == NULL) { + LOG_ERR("%s GPIO is not mapped in devicetree", label); return false; } @@ -608,9 +744,84 @@ static bool configure_gpio_output_inactive_or_log(const struct gpio_dt_spec *gpi return true; } +static void set_relay_gpio_status(bool online, int error) +{ + k_mutex_lock(&relay_gpio_lock, K_FOREVER); + relay_gpio_online = online; + relay_gpio_last_error = online ? 0 : error; + k_mutex_unlock(&relay_gpio_lock); +} + +bool devices_relay_gpio_online(void) +{ + bool online; + + k_mutex_lock(&relay_gpio_lock, K_FOREVER); + online = relay_gpio_online; + k_mutex_unlock(&relay_gpio_lock); + + return online; +} + +int devices_relay_gpio_last_error(void) +{ + int error; + + k_mutex_lock(&relay_gpio_lock, K_FOREVER); + error = relay_gpio_last_error; + k_mutex_unlock(&relay_gpio_lock); + + return error; +} + +//TODO change coo_cmd_runtime_warning_emit so can emit as required not best effort +static void emit_relay_gpio_offline_warning_once(int error) +{ + char context[24]; + + k_mutex_lock(&relay_gpio_lock, K_FOREVER); + if (relay_gpio_warning_emitted) { + k_mutex_unlock(&relay_gpio_lock); + return; + } + relay_gpio_warning_emitted = true; + k_mutex_unlock(&relay_gpio_lock); + + snprintf(context, sizeof(context), "rc=%d", error); + coo_cmd_runtime_warning_emit(command_runtime_get(), "relay_gpio_offline", + "off-board relay GPIO expander is offline; photodiode relay commands are ignored and laser bank heater is unavailable", + context); +} + +static bool configure_relay_gpio_outputs(void) +{ + const struct device *relay_port = yj_power_gpio.port; + int error = -ENODEV; + + if (relay_port == NULL || !device_is_ready(relay_port)) { + LOG_WRN("Relay GPIO expander is offline at boot"); + set_relay_gpio_status(false, error); + emit_relay_gpio_offline_warning_once(error); + return false; + } + + if (!configure_gpio_output_inactive_or_log(&yj_power_gpio, "YJ photodiode power") || + !configure_gpio_output_inactive_or_log(&hk_power_gpio, "HK photodiode power") || + !configure_gpio_output_inactive_or_log(&heater_power_gpio, "laser bank heater")) { + error = -EIO; + LOG_WRN("Relay GPIO expander setup failed"); + set_relay_gpio_status(false, error); + emit_relay_gpio_offline_warning_once(error); + return false; + } + + set_relay_gpio_status(true, 0); + LOG_INF("Relay-box GPIO outputs configured inactive"); + return true; +} + static bool setup_modbus_client(void) { -#if DT_HAS_COMPAT_STATUS_OKAY(zephyr_modbus_serial) struct modbus_iface_param modbus_cfg = { .mode = MODBUS_MODE_RTU, .serial = { @@ -618,7 +829,7 @@ static bool setup_modbus_client(void) .parity = MODBUS_PARITY, .stop_bits = MODBUS_STOPBITS, }, - .rx_timeout = MODBUS_RX_TIMEOUT_MS, + .rx_timeout = MODBUS_RX_TIMEOUT_US, }; int client_iface = modbus_iface_get_by_name(modbus_name); @@ -627,17 +838,22 @@ static bool setup_modbus_client(void) LOG_ERR("Modbus interface %s not found", modbus_name); return false; } - if (modbus_init_client(client_iface, modbus_cfg) == 0) { - LOG_INF("Modbus client initialized on %s", modbus_name); + if (modbus_init_client(client_iface, modbus_cfg) == 0 && + maiman_set_client_iface(client_iface) == 0) { + LOG_INF("Modbus client initialized on %s iface=%d", modbus_name, client_iface); return true; } LOG_ERR("Modbus init failed"); return false; -#else - LOG_ERR("Modbus serial device is not configured"); - return false; -#endif +} + +static const struct attenuator_dac_pair * +attenuator_dac_pair_for_index(uint8_t attenuator_index) +{ + __ASSERT_NO_MSG(attenuator_index < NUM_ATTENUATORS); + + return &attenuator_dac_pairs[attenuator_index]; } void setup_attenuators(void) @@ -649,31 +865,25 @@ void setup_attenuators(void) LOG_INF("Board %s has no attenuator channels", profile->name); return; } - if (!device_ready_or_log(dac_dev, "DAC")) { - return; - } app_settings_get_attenuator(&atten_settings); for (uint8_t i = 0; i < profile->attenuator_count; ++i) { uint8_t attenuator_index = profile->attenuator_first + i; + const struct attenuator_dac_pair *dac_pair = + attenuator_dac_pair_for_index(attenuator_index); - if (attenuator_index >= NUM_ATTENUATORS) { - LOG_ERR("Profile %s attenuator index %u is out of range", - profile->name, attenuator_index); + if (!attenuator_init(&attenuators[attenuator_index], + dac_pair->dev, dac_pair->channel1, + dac_pair->dev, dac_pair->channel2)) { continue; } - if (!attenuator_init(&attenuators[attenuator_index], attenuator_index)) { - continue; - } + attenuators[attenuator_index].coeff1.slope = atten_settings.channel[attenuator_index].physical[0].slope; + attenuators[attenuator_index].coeff1.offset = atten_settings.channel[attenuator_index].physical[0].offset; - for (uint8_t coeff = 0U; coeff < ATTENUATOR_COEFF_COUNT; ++coeff) { - attenuators[attenuator_index].coeff_db_to_volt[coeff] = - atten_settings.channel[attenuator_index].db_to_volt[coeff]; - attenuators[attenuator_index].coeff_volt_to_db[coeff] = - atten_settings.channel[attenuator_index].volt_to_db[coeff]; - } + attenuators[attenuator_index].coeff2.slope = atten_settings.channel[attenuator_index].physical[1].slope; + attenuators[attenuator_index].coeff2.offset = atten_settings.channel[attenuator_index].physical[1].offset; } } @@ -696,12 +906,19 @@ void setup_mems_switches_and_routes(void) } for (uint8_t i = 0; i < profile->mems_switch_count; ++i) { + enum mems_switch_type switch_type = MEMS_SWITCH_TYPE_FFSW; + + if (profile->board == HISPEC_BOARD_TIB && + i >= profile->mems_switch_count - 2U) { + switch_type = MEMS_SWITCH_TYPE_FFLS; + } + mems_switch_init(&mems_switches[i], - gpio_dev, - mems_switch_pin_pairs[i][0], - mems_switch_pin_pairs[i][1], + &mems_switch_gpio_pairs[i][0], + &mems_switch_gpio_pairs[i][1], profile->switch_names[i], - mems_switch_toggle_rate_hz[i], + switch_type, + MEMS_SWITCH_MAX_TOGGLE_HZ, 'A'); mems_switch_ptrs[i] = &mems_switches[i]; } @@ -762,36 +979,29 @@ bool devices_ready(void) "laser bank power")) { rc = false; } - if (!configure_gpio_output_inactive_or_log(&yj_power_gpio, - "YJ photodiode power")) { - rc = false; - } - if (!configure_gpio_output_inactive_or_log(&hk_power_gpio, - "HK photodiode power")) { - rc = false; - } - if (!configure_gpio_output_inactive_or_log(&heater_power_gpio, - "laser bank heater")) { - rc = false; - } + (void)configure_relay_gpio_outputs(); } if (profile->board == HISPEC_BOARD_TIB && !setup_modbus_client()) { rc = false; } - if (profile->attenuator_count > 0U && !device_ready_or_log(dac_dev, "DAC")) { - rc = false; + if (profile->attenuator_count > 0U) { + for (uint8_t i = 0; i < profile->attenuator_count; ++i) { + uint8_t attenuator_index = profile->attenuator_first + i; + const struct attenuator_dac_pair *dac_pair = + attenuator_dac_pair_for_index(attenuator_index); + + if (!device_ready_or_log(dac_pair->dev, "DAC")) { + rc = false; + } + } } if (profile->board == HISPEC_BOARD_TIB && !device_ready_or_log(adc_dev, "ADC")) { rc = false; } - if (profile->board == HISPEC_BOARD_TIB) { - LOG_INF("Relay-box GPIO outputs configured"); - } - return rc; } diff --git a/app/src/devices.h b/app/src/devices.h index 986fae5..d397fd7 100644 --- a/app/src/devices.h +++ b/app/src/devices.h @@ -27,11 +27,17 @@ #define MODBUS_BAUD 115200 #define MODBUS_PARITY UART_CFG_PARITY_NONE -#define MODBUS_STOPBITS UART_CFG_STOP_BITS_2 -#define MODBUS_RX_TIMEOUT_MS 10 +#define MODBUS_STOPBITS UART_CFG_STOP_BITS_1 +/* Zephyr Modbus passes rx_timeout to K_USEC(); keep this value in microseconds. + * A measured single-register NH8 round trip is about 3.2 ms from DE assertion + * through the last Nucleo RX transition. Zephyr still waits about 1 ms for RTU + * frame completion, so 10 ms leaves scheduling margin without masking faults. + */ +#define MODBUS_RX_TIMEOUT_US 10000U #define DAC_RESOLUTION 12 #define NUM_ATTENUATORS 6 +#define HISPEC_ATTENUATOR_LFC_INDEX 4U enum hispec_board_type { HISPEC_BOARD_UNKNOWN = 0, @@ -44,7 +50,6 @@ enum hispec_board_type { // extern const struct device *modbus; extern const struct device *adc_dev; -extern const struct device *dac_dev; extern const struct device *gpio_dev; extern const struct gpio_dt_spec laser_power_gpio; @@ -74,9 +79,39 @@ enum hispec_board_type devices_board_type(void); /** @brief Return the short stable board type name used in logs/settings. */ const char *devices_board_type_name(void); +/** + * @brief Return true when a logical attenuator belongs to the active profile. + * + * This is a profile/board-presence check only. It does not probe DAC readiness + * or perform I2C; callers still need to handle transient DAC failures. + */ +bool devices_attenuator_channel_available(uint8_t attenuator_index); + /** @brief Check/configure devices required by the detected board profile. */ bool devices_ready(void); +/** @brief Return true when the off-board DS2408 relay GPIO expander is usable. */ +bool devices_relay_gpio_online(void); + +/** @brief Last DS2408 relay GPIO setup error, or 0 when online. */ +int devices_relay_gpio_last_error(void); + +/** + * @brief Capture and clear hardware reset-cause flags for this boot. + * + * On STM32 this uses Zephyr hwinfo to read RCC reset flags, including + * watchdog reset. Call once early in boot before later code can clear them. + */ +void devices_capture_boot_reset_cause(void); + +/** + * @brief Queue retained watchdog boot telemetry for MQTT retry. + * + * Uses the captured reset cause and enqueues a non-best-effort MQTT telemetry + * message if the prior reset included watchdog expiration. + */ +void devices_queue_boot_reset_telemetry(void); + /** @brief Build MEMS switch objects and select the board-specific route table. */ void setup_mems_switches_and_routes(void); diff --git a/app/src/housekeeping.c b/app/src/housekeeping.c new file mode 100644 index 0000000..84cf383 --- /dev/null +++ b/app/src/housekeeping.c @@ -0,0 +1,278 @@ +/** + * @file housekeeping.c + * @brief Slow background polling for ambient sensing and power policy. + * + * This module owns slow relay-box power state and ambient temperature sampling. + * Ambient sampling runs as delayable work because it is cache refresh, not a + * timing-critical loop. + */ + +#include "housekeeping.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "command.h" +#include "devices.h" + +LOG_MODULE_REGISTER(housekeeping, LOG_LEVEL_INF); + +#define HOUSEKEEPING_TEMP_INTERVAL_MS 1000U +#define HOUSEKEEPING_POWER_OUTPUT_COUNT (HOUSEKEEPING_POWER_BANK_HEATER + 1) + +struct power_on_time_runtime { + bool active; + int64_t started_ms; + int64_t accumulated_ms; +}; + +static K_MUTEX_DEFINE(housekeeping_state_lock); +static struct housekeeping_temperature_status temperature_status; +static int64_t last_sample_ms; +static const struct device *temperature_dev; +static bool temperature_initialized; +static struct power_on_time_runtime power_on_time[HOUSEKEEPING_POWER_OUTPUT_COUNT]; + +static void temperature_work_handler(struct k_work *work); +static K_WORK_DELAYABLE_DEFINE(temperature_work, temperature_work_handler); + +static void temperature_update(float ambient_c, int error, bool valid) +{ + k_mutex_lock(&housekeeping_state_lock, K_FOREVER); + temperature_status.ambient_c = ambient_c; + temperature_status.last_error = error; + temperature_status.valid = valid; + last_sample_ms = valid ? k_uptime_get() : 0; + temperature_status.age_ms = 0; + k_mutex_unlock(&housekeeping_state_lock); +} + +static void temperature_mark_error(int error) +{ + k_mutex_lock(&housekeeping_state_lock, K_FOREVER); + temperature_status.last_error = error; + temperature_status.valid = false; + last_sample_ms = 0; + temperature_status.age_ms = UINT32_MAX; + k_mutex_unlock(&housekeeping_state_lock); +} + +void housekeeping_get_temperature_status(struct housekeeping_temperature_status *out) +{ + int64_t now; + + if (out == NULL) { + return; + } + + k_mutex_lock(&housekeeping_state_lock, K_FOREVER); + *out = temperature_status; + now = k_uptime_get(); + out->age_ms = (out->valid && last_sample_ms > 0) ? + (uint32_t)(now - last_sample_ms) : UINT32_MAX; + k_mutex_unlock(&housekeeping_state_lock); +} + +static const struct device *get_ds18b20_device(void) +{ + const struct device *const dev = DEVICE_DT_GET_ANY(maxim_ds18b20); + + if (dev == NULL) { + LOG_ERR("No DS18B20 devicetree node with status okay"); + return NULL; + } + + if (!device_is_ready(dev)) { + LOG_ERR("DS18B20 device %s is not ready", dev->name); + return NULL; + } + + LOG_INF("Using DS18B20 device %s", dev->name); + return dev; +} + +static int temperature_init_once(void) +{ + int rc = 0; + + if (temperature_initialized) { + return temperature_dev == NULL ? -ENODEV : 0; + } + + temperature_dev = get_ds18b20_device(); + if (temperature_dev == NULL) { + rc = -ENODEV; + } + temperature_update(0.0f, rc, false); + temperature_initialized = true; + return rc; +} + +static int temperature_sample_once(void) +{ + struct sensor_value temp; + float ambient_c; + int rc; + + rc = temperature_init_once(); + if (rc != 0) { + return rc; + } + + /* Refresh the Zephyr sensor sample before reading the temperature channel. */ + rc = sensor_sample_fetch(temperature_dev); + if (rc != 0) { + LOG_ERR("DS18B20 sample fetch failed: %d", rc); + temperature_mark_error(rc); + return rc; + } + + /* SENSOR_CHAN_AMBIENT_TEMP returns Celsius in integer + micro unit parts. */ + rc = sensor_channel_get(temperature_dev, SENSOR_CHAN_AMBIENT_TEMP, &temp); + if (rc != 0) { + LOG_ERR("DS18B20 channel read failed: %d", rc); + temperature_mark_error(rc); + return rc; + } + + ambient_c = sensor_value_to_double(&temp); + temperature_update(ambient_c, 0, true); + return 0; +} + +static const struct gpio_dt_spec *power_gpio(enum housekeeping_power_output output) +{ + switch (output) { + case HOUSEKEEPING_POWER_YJ_PHOTODIODE: + return &yj_power_gpio; + case HOUSEKEEPING_POWER_HK_PHOTODIODE: + return &hk_power_gpio; + case HOUSEKEEPING_POWER_BANK_HEATER: + return &heater_power_gpio; + default: + return NULL; + } +} + +static bool power_output_is_photodiode(enum housekeeping_power_output output) +{ + return output == HOUSEKEEPING_POWER_YJ_PHOTODIODE || + output == HOUSEKEEPING_POWER_HK_PHOTODIODE; +} + +static void power_on_time_update_locked(enum housekeeping_power_output output, + bool active) +{ + struct power_on_time_runtime *runtime; + int64_t now = k_uptime_get(); + + if (output < 0 || output >= HOUSEKEEPING_POWER_OUTPUT_COUNT) { + return; + } + + runtime = &power_on_time[output]; + if (active && !runtime->active) { + runtime->active = true; + runtime->started_ms = now; + return; + } + if (!active && runtime->active) { + runtime->accumulated_ms += now - runtime->started_ms; + runtime->active = false; + runtime->started_ms = 0; + } +} + +int housekeeping_power_set(enum housekeeping_power_output output, bool enabled) +{ + const struct gpio_dt_spec *gpio = power_gpio(output); + int rc; + + if (gpio == NULL) { + return -EINVAL; + } + if (!devices_relay_gpio_online()) { + if (power_output_is_photodiode(output)) { + coo_cmd_runtime_warning_emit(command_runtime_get(), "relay_gpio_offline", + "photodiode relay command ignored because relay GPIO expander is offline", + enabled ? "enable" : "disable"); + return 0; + } + return -EIO; + } + + k_mutex_lock(&housekeeping_state_lock, K_FOREVER); + /* Logical GPIO value; devicetree flags own DS2408 relay polarity. */ + rc = gpio_pin_set_dt(gpio, enabled ? 1 : 0); + if (rc == 0) { + power_on_time_update_locked(output, enabled); + } + k_mutex_unlock(&housekeeping_state_lock); + return rc; +} + +int housekeeping_power_get(enum housekeeping_power_output output, bool *enabled) +{ + const struct gpio_dt_spec *gpio = power_gpio(output); + int rc = 0; + int val; + + if (gpio == NULL || enabled == NULL) { + return -EINVAL; + } + if (!devices_relay_gpio_online()) { + return -EIO; + } + + k_mutex_lock(&housekeeping_state_lock, K_FOREVER); + val = gpio_pin_get_dt(gpio); + if (val < 0) { + rc = val; + goto out; + } + *enabled = val > 0; + +out: + k_mutex_unlock(&housekeeping_state_lock); + return rc; +} + +float housekeeping_power_on_time_s(enum housekeeping_power_output output) +{ + struct power_on_time_runtime runtime; + int64_t ms; + + if (output < 0 || output >= HOUSEKEEPING_POWER_OUTPUT_COUNT) { + return NAN; + } + + k_mutex_lock(&housekeeping_state_lock, K_FOREVER); + runtime = power_on_time[output]; + k_mutex_unlock(&housekeeping_state_lock); + + ms = runtime.accumulated_ms; + if (runtime.active) { + ms += k_uptime_get() - runtime.started_ms; + } + return (float)ms / 1000.0f; +} + +static void temperature_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + + (void)temperature_sample_once(); + (void)k_work_reschedule(&temperature_work, K_MSEC(HOUSEKEEPING_TEMP_INTERVAL_MS)); +} + +void housekeeping_start(void) +{ + (void)k_work_reschedule(&temperature_work, K_NO_WAIT); +} diff --git a/app/src/housekeeping.h b/app/src/housekeeping.h new file mode 100644 index 0000000..6738de9 --- /dev/null +++ b/app/src/housekeeping.h @@ -0,0 +1,55 @@ +/** + * @file housekeeping.h + * @brief Slow background polling for ambient sensing and power policy. + */ + +#ifndef HISPEC_HOUSEKEEPING_H +#define HISPEC_HOUSEKEEPING_H + +#include +#include + +struct housekeeping_temperature_status { + float ambient_c; + uint32_t age_ms; + int last_error; + bool valid; +}; + +enum housekeeping_power_output { + HOUSEKEEPING_POWER_YJ_PHOTODIODE = 0, + HOUSEKEEPING_POWER_HK_PHOTODIODE, + HOUSEKEEPING_POWER_BANK_HEATER, +}; + +/** Copy the ambient-temperature cache and compute age from Zephyr uptime. */ +void housekeeping_get_temperature_status(struct housekeeping_temperature_status *out); + +/** + * Set one slow relay-box output: YJ PD power, HK PD power, or bank heater. + * + * This may sleep on the housekeeping mutex and writes a logical Zephyr GPIO + * value; devicetree active flags own the electrical polarity. + */ +int housekeeping_power_set(enum housekeeping_power_output output, bool enabled); + +/** Read one slow relay-box output's logical GPIO state. */ +int housekeeping_power_get(enum housekeeping_power_output output, bool *enabled); + +/** + * Return relay-output on-time tracked since boot. + * + * Housekeeping owns this runtime estimate because it owns slow relay writes. + * It does not read back or infer pre-boot relay state. + */ +float housekeeping_power_on_time_s(enum housekeeping_power_output output); + +/** + * Start ambient-temperature cache refresh work. + * + * The delayable work runs in Zephyr system workqueue context and may block + * briefly on DS18B20 sensor I/O. Relay power helpers remain direct calls. + */ +void housekeeping_start(void); + +#endif /* HISPEC_HOUSEKEEPING_H */ diff --git a/app/src/laser_command.c b/app/src/laser_command.c new file mode 100644 index 0000000..da0a877 --- /dev/null +++ b/app/src/laser_command.c @@ -0,0 +1,629 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "laser_command.h" + +#include +#include +#include +#include + +#include + +#include "app_settings.h" +#include "laserbank_tempcontrol.h" +#include "lasers.h" +#include "throughput_monitor.h" + +#include + +#define LASERBANK_FAULT_CLEAR_OFF_MS 250U + +static const struct coo_json_string_choice laserbank_power_mode_choices[] = { + { "auto", HISPEC_LASER_BANK_POWER_AUTO }, + { "override_on", HISPEC_LASER_BANK_POWER_OVERRIDE_ON }, + { "override_off", HISPEC_LASER_BANK_POWER_OVERRIDE_OFF }, +}; + +static const struct coo_json_string_choice heater_mode_choices[] = { + { "auto", LASERBANK_HEATER_MODE_AUTO }, + { "override_on", LASERBANK_HEATER_MODE_OVERRIDE_ON }, + { "override_off", LASERBANK_HEATER_MODE_OVERRIDE_OFF }, +}; + +static bool parse_laserbank_override_request(const struct coo_cmd_request *cmd, + const char *key, + const struct coo_json_string_choice *choices, + size_t choice_count, + int *mode_value) +{ + const char *suffix; + + if (mode_value == NULL) { + return false; + } + + suffix = coo_cmd_key_suffix_after(cmd != NULL ? cmd->key : NULL, key); + if (coo_json_match_string_choice(suffix, choices, choice_count, + mode_value) == 0) { + return true; + } + if (coo_cmd_payload_empty(cmd)) { + return false; + } + return coo_json_extract_string_choice(cmd->payload, "override", + choices, choice_count, + mode_value) == COO_JSON_EXTRACT_OK; +} + +struct coo_cmd_response laserbank_power(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_bank_power_mode mode; + char payload[128] = {0}; + int mode_value; + int rc; + + if (cmd != NULL && + (cmd->msg_type == COO_CMD_EFFECT || + coo_cmd_key_suffix_after(cmd->key, "laserbank/power")[0] != '\0')) { + if (!parse_laserbank_override_request(cmd, "laserbank/power", + laserbank_power_mode_choices, + ARRAY_SIZE(laserbank_power_mode_choices), + &mode_value)) { + return coo_cmd_error(cmd, "override must be auto, override_on, or override_off"); + } + mode = (enum hispec_laser_bank_power_mode)mode_value; + rc = hispec_laser_bank_power_mode_set(mode); + if (rc != 0) { + mode = hispec_laser_bank_power_mode_get(); + snprintk(payload, sizeof(payload), + "{\"error\":\"laser bank power mode failed\"," + "\"rc\":%d,\"mode\":\"%s\",\"powered\":%s}", + rc, + hispec_laser_bank_power_mode_name(mode), + hispec_laser_bank_power_is_enabled() ? "true" : "false"); + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, payload); + } + } + + mode = hispec_laser_bank_power_mode_get(); + snprintk(payload, sizeof(payload), + "{\"mode\":\"%s\",\"powered\":%s}", + hispec_laser_bank_power_mode_name(mode), + hispec_laser_bank_power_is_enabled() ? "true" : "false"); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +struct coo_cmd_response laserbank_clearfaults(const struct coo_cmd_request *cmd) +{ + uint32_t off_ms = 0U; + char payload[MAX_PAYLOAD_LEN] = {0}; + int rc; + + rc = hispec_laser_bank_clear_faults(LASERBANK_FAULT_CLEAR_OFF_MS, &off_ms); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser bank fault clear failed", rc); + } + + snprintf(payload, sizeof(payload), "{\"off_ms\":%u}", off_ms); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static void laserbank_tempcontrol_status_payload(char *payload, size_t payload_len) +{ + struct laserbank_tempcontrol_status status = {0}; + + laserbank_tempcontrol_get_status(&status); + snprintk(payload, payload_len, + "{\"heater_mode\":\"%s\"," + "\"heater_on\":%s,\"bank_power\":%s," + "\"ambient_valid\":%s,\"ambient_c\":%.2f," + "\"valid_temps\":%u,\"stale_temps\":%u," + "\"any_disabled_below_15c\":%s," + "\"any_disabled_above_off_threshold\":%s," + "\"all_tecs_enabled\":%s,\"all_tecs_enabled_ms\":%u," + "\"last_error\":%d,\"last_poll_age_ms\":%u}", + laserbank_heater_mode_name(status.heater_mode), + status.heater_on ? "true" : "false", + status.bank_powered ? "true" : "false", + status.ambient_valid ? "true" : "false", + (double)status.ambient_c, + status.valid_temp_count, + status.stale_temp_count, + status.any_disabled_below_15c ? "true" : "false", + status.any_disabled_above_off_threshold ? "true" : "false", + status.all_tecs_enabled ? "true" : "false", + status.all_tecs_enabled_ms, + status.last_error, + status.last_poll_age_ms); +} + +struct coo_cmd_response laserbank_heater(const struct coo_cmd_request *cmd) +{ + enum laserbank_heater_mode mode; + char payload[MAX_PAYLOAD_LEN] = {0}; + int mode_value; + + if (cmd != NULL && + (cmd->msg_type == COO_CMD_EFFECT || + coo_cmd_key_suffix_after(cmd->key, "laserbank/heater")[0] != '\0')) { + if (!parse_laserbank_override_request(cmd, "laserbank/heater", + heater_mode_choices, + ARRAY_SIZE(heater_mode_choices), + &mode_value)) { + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Use laserbank/heater auto|override_on|override_off\"}"); + } + mode = (enum laserbank_heater_mode)mode_value; + int rc = laserbank_tempcontrol_set_heater_mode(mode, true); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser bank heater relay unavailable", rc); + } + } + + laserbank_tempcontrol_status_payload(payload, sizeof(payload)); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static int command_laser_id_from_payload(const struct coo_cmd_request *cmd, + enum hispec_laser_id *id, + char *name, + size_t name_len) +{ + int parse_rc; + + if (cmd == NULL || id == NULL || name == NULL || name_len == 0U) { + return -EINVAL; + } + + parse_rc = coo_json_extract_string(cmd->payload, "name", name, name_len); + if (parse_rc != COO_JSON_EXTRACT_OK) { + return -EINVAL; + } + return hispec_laser_id_from_name(name, id); +} + +static int laser_append_compact_status(char *payload, size_t payload_len, + const struct hispec_laser_status *status) +{ + size_t off = 0U; + const laserprops_t *props = status->properties; + + if (coo_json_append(payload, payload_len, &off, + "{\"name\":\"%s\",\"powered\":%s," + "\"tec_on_s\":%.1f,\"emit_on_s\":%.1f," + "\"emit_total_s\":%.1f,\"temp_c\":", + status->name, + status->bank_powered ? "true" : "false", + (double)status->tec_on_time_s, + (double)status->current_on_time_s, + status->total_emitting_s) != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->tec_temperature_measured_c, 2) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"current_ma\":") != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->current_set_ma, 2) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"level\":") != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->level_percent, 2) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"power_mw\":") != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->estimated_power_mw, 3) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"nominal_nm\":%.3f,\"tuned_nm\":", + props != NULL ? (double)props->wavelength_nm : 0.0) != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->estimated_wavelength_nm, 3) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"tune_nm\":%.3f,\"tec_ma\":", + (double)status->tune_delta_nm) != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + (double)status->tec_current_measured_a * 1000.0, 2) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"diode_v\":") != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->voltage_v, 3) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"tec_v\":") != 0 || + coo_json_append_float_or_null(payload, payload_len, &off, + status->tec_voltage_v, 3) != 0 || + coo_json_append(payload, payload_len, &off, + ",\"offin_s\":%lld,\"oc_fault\":%s}", + (long long)status->off_in_s, + status->lock_ld_overcurrent ? "true" : "false") != 0) { + return -ENOSPC; + } + + return 0; +} + +struct coo_cmd_response laser_get(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + struct hispec_laser_status status = {0}; + char name[16] = {0}; + char payload[MAX_PAYLOAD_LEN] = {0}; + int rc; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + + rc = hispec_laser_get_status(id, &status); + if (rc != 0 && !status.bank_powered) { + return coo_cmd_error_rc(cmd, "laser status failed", rc); + } + if (laser_append_compact_status(payload, sizeof(payload), &status) != 0) { + return coo_cmd_error(cmd, "laser response too large"); + } + return rc == 0 ? coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload) : + coo_cmd_error_rc(cmd, "laser status failed", rc); +} + +struct coo_cmd_response laser_set(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + struct app_laser_channel_settings settings; + char name[16] = {0}; + float level = 0.0f; + uint32_t autooff_s; + int parse_rc; + int rc; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + parse_rc = coo_json_extract_float(cmd->payload, "level", &level); + if (parse_rc != COO_JSON_EXTRACT_OK || level < 0.0f || level > 100.0f) { + return coo_cmd_error(cmd, "level must be 0..100"); + } + rc = hispec_laser_get_channel_settings(id, &settings); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser settings unavailable", rc); + } + autooff_s = settings.autooff_s; + if (coo_json_extract_optional_u32(cmd->payload, "autooff_s", + &autooff_s, NULL) != 0) { + return coo_cmd_error(cmd, "invalid autooff_s"); + } + + throughput_monitor_note_laser_changed(id); + rc = hispec_laser_set_output_percent_autooff(id, level, autooff_s); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser level failed", rc); + } + return coo_cmd_ok(cmd); +} + +struct coo_cmd_response laser_tune_get(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + char name[16] = {0}; + char payload[MAX_PAYLOAD_LEN]; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + snprintk(payload, sizeof(payload), + "{\"name\":\"%s\",\"tune_nm\":%.4f}", + hispec_laser_name(id), + (double)hispec_laser_get_tune_delta_nm(id)); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +struct coo_cmd_response laser_tune_set(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + char name[16] = {0}; + float delta_nm = 0.0f; + int parse_rc; + int rc; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + parse_rc = coo_json_extract_float(cmd->payload, "tune_nm", &delta_nm); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + parse_rc = coo_json_extract_float(cmd->payload, "delta_nm", &delta_nm); + } + if (parse_rc != COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "missing tune_nm"); + } + throughput_monitor_note_laser_changed(id); + rc = hispec_laser_set_tune_delta_nm(id, delta_nm, true); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser tune failed", rc); + } + return coo_cmd_ok(cmd); +} + +static int laser_settings_payload(char *payload, size_t payload_len, + enum hispec_laser_id id, + const struct app_laser_channel_settings *settings) +{ + const laserprops_t *p = &settings->properties; + int written; + + written = snprintk(payload, payload_len, + "{\"name\":\"%s\",\"settings\":{" + "\"model\":\"%s\",\"nominal_current_ma\":%.3f," + "\"max_current_ma\":%.3f,\"current_set_calibration_pct\":%.3f," + "\"threshold_current_ma\":%.3f,\"efficiency_mw_per_ma\":%.6f," + "\"wavelength_nm\":%.3f,\"operating_temp_range_c\":[%.2f,%.2f]," + "\"default_operating_temp_c\":%.2f,\"thermistor_kohm\":%.2f," + "\"isolation_db\":%.2f,\"tec_max_current_a\":%.3f," + "\"tec_pid\":{\"p\":%u,\"i\":%u,\"d\":%u}," + "\"disable_tec_at_autooff\":%s," + "\"ntc_t_coefficient_per_c\":%.6f," + "\"dlambda_dT_nm_per_k\":%.6f," + "\"dlambda_dA_nm_per_ma\":%.6f," + "\"autooff_s\":%u,\"tune_nm\":%.4f," + "\"emit_total_s\":%.1f}}", + hispec_laser_name(id), p->model_number, + (double)p->nominal_current_ma, + (double)p->max_current_ma, + (double)settings->current_set_calibration_pct, + (double)p->threshold_current_ma, + (double)p->efficiency_mw_per_ma, + (double)p->wavelength_nm, + (double)p->operating_temp_range_c.min_c, + (double)p->operating_temp_range_c.max_c, + (double)p->operating_temp_c, + (double)p->thermistor_kohm, + (double)p->isolation_db, + (double)p->tec_max_current_a, + p->tec_pid.kp, p->tec_pid.ki, p->tec_pid.kd, + settings->disable_tec_at_autooff ? "true" : "false", + (double)p->ntc_t_coefficient_per_c, + (double)p->dlambda_dT_nm_per_k, + (double)p->dlambda_dA_nm_per_ma, + settings->autooff_s, + (double)settings->tune_delta_nm, + settings->total_emitting_s); + + return written >= 0 && written < (int)payload_len ? 0 : -ENOSPC; +} + +struct coo_cmd_response laser_settings_get(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + struct app_laser_channel_settings settings; + char name[16] = {0}; + char payload[MAX_PAYLOAD_LEN] = {0}; + int rc; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + rc = hispec_laser_get_channel_settings(id, &settings); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser settings unavailable", rc); + } + if (laser_settings_payload(payload, sizeof(payload), id, &settings) != 0) { + return coo_cmd_error(cmd, "laser settings response too large"); + } + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static int laser_parse_settings_update(const char *json, + struct app_laser_channel_settings *settings, + bool *changed) +{ + double range[2] = {0}; + size_t range_len = 0U; + char pid_json[128] = {0}; + int rc; + + if (json == NULL || settings == NULL || changed == NULL) { + return -EINVAL; + } + +#define LASER_PARSE_FLOAT(key, field) do { \ + if (coo_json_extract_optional_float_range(json, key, &(field), \ + changed, -FLT_MAX, \ + FLT_MAX) != 0) \ + return -EINVAL; \ + } while (0) + + LASER_PARSE_FLOAT("nominal_current_ma", settings->properties.nominal_current_ma); + LASER_PARSE_FLOAT("max_current_ma", settings->properties.max_current_ma); + LASER_PARSE_FLOAT("threshold_current_ma", settings->properties.threshold_current_ma); + LASER_PARSE_FLOAT("efficiency_mw_per_ma", settings->properties.efficiency_mw_per_ma); + LASER_PARSE_FLOAT("wavelength_nm", settings->properties.wavelength_nm); + LASER_PARSE_FLOAT("current_set_calibration_pct", + settings->current_set_calibration_pct); + LASER_PARSE_FLOAT("current_set_calibration_%", + settings->current_set_calibration_pct); + LASER_PARSE_FLOAT("default_operating_temp_c", settings->properties.operating_temp_c); + LASER_PARSE_FLOAT("tec_max_current_a", settings->properties.tec_max_current_a); + LASER_PARSE_FLOAT("dlambda_dT_nm_per_k", settings->properties.dlambda_dT_nm_per_k); + LASER_PARSE_FLOAT("dlambda_dA_nm_per_ma", settings->properties.dlambda_dA_nm_per_ma); + +#undef LASER_PARSE_FLOAT + + rc = coo_json_extract_double_array(json, "operating_temp_range_c", + range, ARRAY_SIZE(range), &range_len); + if (rc == COO_JSON_EXTRACT_ERR) { + return -EINVAL; + } + if (rc == COO_JSON_EXTRACT_OK) { + if (range_len != 2U) { + return -ERANGE; + } + settings->properties.operating_temp_range_c.min_c = (float)range[0]; + settings->properties.operating_temp_range_c.max_c = (float)range[1]; + *changed = true; + } + + rc = coo_json_extract_object(json, "tec_pid", pid_json, sizeof(pid_json)); + if (rc == COO_JSON_EXTRACT_ERR) { + return -EINVAL; + } + if (rc == COO_JSON_EXTRACT_OK) { + if (coo_json_extract_optional_u16(pid_json, "p", + &settings->properties.tec_pid.kp, + changed) != 0 || + coo_json_extract_optional_u16(pid_json, "i", + &settings->properties.tec_pid.ki, + changed) != 0 || + coo_json_extract_optional_u16(pid_json, "d", + &settings->properties.tec_pid.kd, + changed) != 0) { + return -EINVAL; + } + } + + if (coo_json_extract_optional_bool(json, "disable_tec_at_autooff", + &settings->disable_tec_at_autooff, + changed) != 0) { + return -EINVAL; + } + + if (coo_json_extract_optional_u32(json, "autooff_s", + &settings->autooff_s, changed) != 0) { + return -EINVAL; + } + + return 0; +} + +struct coo_cmd_response laser_settings_set(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + struct app_laser_channel_settings settings; + char name[16] = {0}; + char settings_json[MAX_PAYLOAD_LEN] = {0}; + const char *json; + bool changed = false; + int rc; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + rc = hispec_laser_get_channel_settings(id, &settings); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser settings unavailable", rc); + } + + rc = coo_json_extract_object(cmd->payload, "settings", settings_json, sizeof(settings_json)); + if (rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid settings object"); + } + json = rc == COO_JSON_EXTRACT_OK ? settings_json : cmd->payload; + + rc = laser_parse_settings_update(json, &settings, &changed); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "invalid laser settings", rc); + } + if (!changed) { + return coo_cmd_error(cmd, "no laser settings fields supplied"); + } + + throughput_monitor_note_laser_changed(id); + rc = hispec_laser_update_channel_settings(id, &settings, true); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "laser settings update failed", rc); + } + return coo_cmd_ok(cmd); +} + +static int json_append_named_float(char *payload, size_t payload_len, + size_t *off, const char *name, + double value, int precision) +{ + if (coo_json_append(payload, payload_len, off, ",\"%s\":", name) != 0) { + return -ENOSPC; + } + return coo_json_append_float_or_null(payload, payload_len, off, + value, precision); +} + +struct coo_cmd_response laser_engstatus_get(const struct coo_cmd_request *cmd) +{ + enum hispec_laser_id id; + struct hispec_laser_status s = {0}; + char name[16] = {0}; + char payload[MAX_PAYLOAD_LEN] = {0}; + size_t off = 0U; + int rc; + + if (command_laser_id_from_payload(cmd, &id, name, sizeof(name)) != 0) { + return coo_cmd_error(cmd, "missing or invalid laser name"); + } + + rc = hispec_laser_get_status(id, &s); + if (coo_json_append(payload, sizeof(payload), &off, + "{\"name\":\"%s\",\"read_rc\":%d,\"powered\":%s," + "\"dev_id\":%u,\"serial\":%u,\"serial_ok\":%s," + "\"raw_state\":%u,\"raw_lock\":%u,\"raw_tec\":%u," + "\"op_started\":%s,\"ready\":%s," + "\"curr_set_internal\":%s,\"enable_internal\":%s," + "\"ext_ntc_denied\":%s,\"interlock_denied\":%s," + "\"interlock\":%s,\"ext_ntc_interlock\":%s," + "\"ld_overcurrent\":%s,\"ld_overheat\":%s," + "\"tec_started\":%s,\"tec_set_internal\":%s," + "\"tec_enable_internal\":%s,\"tec_error\":%s," + "\"tec_selfheat\":%s", + s.name, rc, s.bank_powered ? "true" : "false", + s.device_id, s.serial_number, + s.serial_matches ? "true" : "false", + s.device_state, s.lock_status, s.tec_state, + s.operation_started ? "true" : "false", + s.ready_to_operate ? "true" : "false", + s.current_set_internal ? "true" : "false", + s.enable_internal ? "true" : "false", + s.external_ntc_denied ? "true" : "false", + s.interlock_denied ? "true" : "false", + s.lock_interlock ? "true" : "false", + s.lock_external_ntc_interlock ? "true" : "false", + s.lock_ld_overcurrent ? "true" : "false", + s.lock_ld_overheat ? "true" : "false", + s.tec_started ? "true" : "false", + s.tec_set_internal ? "true" : "false", + s.tec_enable_internal ? "true" : "false", + s.lock_tec_error ? "true" : "false", + s.lock_tec_selfheat ? "true" : "false") != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "curr_ma", s.current_set_ma, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "curr_meas_ma", s.current_measured_ma, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "curr_min_ma", s.current_min_ma, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "curr_max_ma", s.current_max_ma, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "drv_max_ma", s.current_max_limit_ma, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "ocp_ma", s.current_protection_threshold_ma, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "curr_cal_pct", s.current_set_calibration_pct, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "diode_v", s.voltage_v, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "tec_temp_set_c", s.tec_temperature_set_c, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "tec_temp_c", s.tec_temperature_measured_c, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "pcb_temp_c", s.pcb_temperature_c, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "tec_curr_a", s.tec_current_measured_a, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "tec_curr_lim_a", s.tec_current_limit_a, 3) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "tec_v", s.tec_voltage_v, 3) != 0 || + coo_json_append(payload, sizeof(payload), &off, + ",\"pid\":[%u,%u,%u]", + s.tec_pid.kp, s.tec_pid.ki, s.tec_pid.kd) != 0 || + json_append_named_float(payload, sizeof(payload), &off, + "ntc_t_coeff", s.ntc_t_coefficient_per_c, 6) != 0 || + coo_json_append(payload, sizeof(payload), &off, "}") != 0) { + return coo_cmd_error(cmd, "laser engineering status response too large"); + } + return rc == 0 ? coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload) : + coo_cmd_error_rc(cmd, "laser engineering status failed", rc); +} diff --git a/app/src/laser_command.h b/app/src/laser_command.h new file mode 100644 index 0000000..2e86816 --- /dev/null +++ b/app/src/laser_command.h @@ -0,0 +1,34 @@ +/** + * @file laser_command.h + * @brief Command adapters for laser and laser-bank requests. + * + * These handlers parse documented MQTT/serial command payloads, validate + * command-schema fields, shape responses, and delegate hardware behavior to + * lasers.c and laserbank_tempcontrol.c. Handlers may sleep or block indirectly + * through those domain modules and may notify throughput monitoring when laser + * output state changes. + */ + +#ifndef HISPEC_LASER_COMMAND_H +#define HISPEC_LASER_COMMAND_H + +#include "command.h" + +struct coo_cmd_response laser_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response laser_set(const struct coo_cmd_request *cmd); +struct coo_cmd_response laser_tune_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response laser_tune_set(const struct coo_cmd_request *cmd); +struct coo_cmd_response laser_settings_get(const struct coo_cmd_request *cmd); +struct coo_cmd_response laser_settings_set(const struct coo_cmd_request *cmd); +struct coo_cmd_response laser_engstatus_get(const struct coo_cmd_request *cmd); + +/** Query or set laser-bank power auto/override mode. */ +struct coo_cmd_response laserbank_power(const struct coo_cmd_request *cmd); + +/** Clear laser-bank faults with a bounded laser-bank power cycle. */ +struct coo_cmd_response laserbank_clearfaults(const struct coo_cmd_request *cmd); + +/** Query or set laser-bank heater auto/override mode. */ +struct coo_cmd_response laserbank_heater(const struct coo_cmd_request *cmd); + +#endif /* HISPEC_LASER_COMMAND_H */ diff --git a/app/src/laserbank_tempcontrol.c b/app/src/laserbank_tempcontrol.c new file mode 100644 index 0000000..637f258 --- /dev/null +++ b/app/src/laserbank_tempcontrol.c @@ -0,0 +1,321 @@ +/** + * @file laserbank_tempcontrol.c + * @brief Laser-bank temperature-control policy loop. + * + * The delayable work owns automatic bank pre-warm decisions for TIB. It never + * publishes MQTT directly; warnings are best-effort messages queued through the + * command runtime. + */ + +#include "laserbank_tempcontrol.h" + +#include +#include + +#include +#include + +#include "app_settings.h" +#include "command.h" +#include "devices.h" +#include "housekeeping.h" +#include "lasers.h" + +LOG_MODULE_REGISTER(laserbank_tempcontrol, LOG_LEVEL_INF); + +#define LASERBANK_WARM_MIN_C 15.0f +#define LASERBANK_COLD_OFF_C 20.0f + +struct laserbank_cached_channel { + bool valid; + bool tec_enabled; + float tec_temperature_c; + int64_t last_valid_ms; +}; + +struct laserbank_tempcontrol_runtime { + struct laserbank_cached_channel channel[HISPEC_LASER_COUNT]; + struct laserbank_tempcontrol_status status; + int64_t last_poll_ms; + int64_t all_tecs_enabled_since_ms; + int64_t last_override_warning_ms; + enum laserbank_heater_mode previous_mode; + bool have_previous_mode; +}; + +static struct laserbank_tempcontrol_runtime control; +static K_MUTEX_DEFINE(control_lock); + +static void tempcontrol_work_handler(struct k_work *work); +static K_WORK_DELAYABLE_DEFINE(tempcontrol_work, tempcontrol_work_handler); + +const char *laserbank_heater_mode_name(enum laserbank_heater_mode mode) +{ + switch (mode) { + case LASERBANK_HEATER_MODE_AUTO: + return "auto"; + case LASERBANK_HEATER_MODE_OVERRIDE_ON: + return "override_on"; + case LASERBANK_HEATER_MODE_OVERRIDE_OFF: + return "override_off"; + default: + return "unknown"; + } +} + +static bool heater_mode_is_valid(enum laserbank_heater_mode mode) +{ + return mode == LASERBANK_HEATER_MODE_AUTO || + mode == LASERBANK_HEATER_MODE_OVERRIDE_ON || + mode == LASERBANK_HEATER_MODE_OVERRIDE_OFF; +} + +static bool channel_is_stale(const struct laserbank_cached_channel *channel, + int64_t now_ms) +{ + return channel == NULL || !channel->valid || + channel->last_valid_ms <= 0 || + now_ms - channel->last_valid_ms > LASERBANK_TEMPCONTROL_TEMP_STALE_MS; +} + +static void copy_status_locked(struct laserbank_tempcontrol_status *out) +{ + if (out == NULL) { + return; + } + + *out = control.status; + if (control.last_poll_ms > 0) { + out->last_poll_age_ms = (uint32_t)(k_uptime_get() - control.last_poll_ms); + } else { + out->last_poll_age_ms = UINT32_MAX; + } +} + +void laserbank_tempcontrol_get_status(struct laserbank_tempcontrol_status *out) +{ + k_mutex_lock(&control_lock, K_FOREVER); + control.status.bank_powered = hispec_laser_bank_power_is_enabled(); + copy_status_locked(out); + k_mutex_unlock(&control_lock); +} + +int laserbank_tempcontrol_set_heater_mode(enum laserbank_heater_mode mode, + bool persist) +{ + struct app_laserbank_settings settings; + + if (!heater_mode_is_valid(mode)) { + return -EINVAL; + } + if (!devices_relay_gpio_online()) { + return -EIO; + } + + app_settings_get_laserbank(&settings); + settings.heater_mode = mode; + app_settings_update_laserbank(&settings, persist); + (void)k_work_reschedule(&tempcontrol_work, K_NO_WAIT); + return 0; +} + +static void summarize_temperature_state(const struct housekeeping_temperature_status *ambient, + int64_t now_ms) +{ + bool all_enabled = true; + uint8_t valid_count = 0U; + uint8_t stale_count = 0U; + float off_threshold = LASERBANK_COLD_OFF_C; + + control.status.any_disabled_below_15c = false; + control.status.any_disabled_above_off_threshold = false; + + if (ambient != NULL && ambient->valid && ambient->ambient_c > LASERBANK_WARM_MIN_C) { + off_threshold = LASERBANK_WARM_MIN_C; + } + + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + struct laserbank_cached_channel *channel = &control.channel[i]; + + if (channel_is_stale(channel, now_ms)) { + stale_count++; + all_enabled = false; + continue; + } + + valid_count++; + if (!channel->tec_enabled) { + all_enabled = false; + if (channel->tec_temperature_c < LASERBANK_WARM_MIN_C) { + control.status.any_disabled_below_15c = true; + } + if (channel->tec_temperature_c > off_threshold) { + control.status.any_disabled_above_off_threshold = true; + } + } + } + + if (all_enabled && valid_count == HISPEC_LASER_COUNT) { + if (control.all_tecs_enabled_since_ms <= 0) { + control.all_tecs_enabled_since_ms = now_ms; + } + control.status.all_tecs_enabled = true; + control.status.all_tecs_enabled_ms = + (uint32_t)(now_ms - control.all_tecs_enabled_since_ms); + } else { + control.all_tecs_enabled_since_ms = 0; + control.status.all_tecs_enabled = false; + control.status.all_tecs_enabled_ms = 0U; + } + + control.status.valid_temp_count = valid_count; + control.status.stale_temp_count = stale_count; +} + +static void maybe_emit_override_warning(enum laserbank_heater_mode mode, + int64_t now_ms) +{ + if (mode == LASERBANK_HEATER_MODE_AUTO) { + return; + } + + if (control.last_override_warning_ms > 0 && + now_ms - control.last_override_warning_ms < + LASERBANK_TEMPCONTROL_OVERRIDE_WARNING_MS) { + return; + } + + coo_cmd_runtime_warning_emit(command_runtime_get(), "laserbank_heater_override", + "laser bank heater override is active; automatic warmup is disabled", + laserbank_heater_mode_name(mode)); + control.last_override_warning_ms = now_ms; +} + +static void apply_heater(bool enable) +{ + int rc; + + rc = housekeeping_power_set(HOUSEKEEPING_POWER_BANK_HEATER, enable); + if (rc != 0) { + k_mutex_lock(&control_lock, K_FOREVER); + control.status.last_error = rc; + k_mutex_unlock(&control_lock); + LOG_WRN("Failed to set laser bank heater %s (%d)", + enable ? "on" : "off", rc); + return; + } + + k_mutex_lock(&control_lock, K_FOREVER); + control.status.heater_on = enable; + k_mutex_unlock(&control_lock); +} + +static void run_heater_control_cycle(void) +{ + struct app_laserbank_settings settings; + struct hispec_laser_channel_temperature poll[HISPEC_LASER_COUNT] = {0}; + struct housekeeping_temperature_status ambient = {0}; + bool entered_auto; + bool all_stale; + int64_t now_ms = k_uptime_get(); + int rc; + + app_settings_get_laserbank(&settings); + housekeeping_get_temperature_status(&ambient); + + k_mutex_lock(&control_lock, K_FOREVER); + control.status.available = true; + control.status.heater_mode = settings.heater_mode; + control.status.bank_powered = hispec_laser_bank_power_is_enabled(); + control.status.ambient_valid = ambient.valid; + control.status.ambient_c = ambient.ambient_c; + control.status.ambient_age_ms = ambient.age_ms; + control.status.last_error = 0; + entered_auto = settings.heater_mode == LASERBANK_HEATER_MODE_AUTO && + (!control.have_previous_mode || + control.previous_mode != LASERBANK_HEATER_MODE_AUTO); + control.previous_mode = settings.heater_mode; + control.have_previous_mode = true; + k_mutex_unlock(&control_lock); + + if (settings.heater_mode != LASERBANK_HEATER_MODE_AUTO) { + bool force_on = settings.heater_mode == LASERBANK_HEATER_MODE_OVERRIDE_ON; + + apply_heater(force_on); + k_mutex_lock(&control_lock, K_FOREVER); + maybe_emit_override_warning(settings.heater_mode, now_ms); + k_mutex_unlock(&control_lock); + return; + } + + if (entered_auto && !hispec_laser_bank_power_is_enabled()) { + bool transitioned; + + rc = hispec_laser_bank_power_set(true, &transitioned); + if (rc != 0) { + k_mutex_lock(&control_lock, K_FOREVER); + control.status.last_error = rc; + k_mutex_unlock(&control_lock); + return; + } + } + + rc = hispec_laser_bank_read_temperatures(poll); + now_ms = k_uptime_get(); + + k_mutex_lock(&control_lock, K_FOREVER); + control.last_poll_ms = now_ms; + control.status.bank_powered = hispec_laser_bank_power_is_enabled(); + if (rc == 0) { + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + if (!poll[i].valid) { + continue; + } + + control.channel[i].valid = true; + control.channel[i].tec_enabled = poll[i].tec_enabled; + control.channel[i].tec_temperature_c = poll[i].tec_temperature_c; + control.channel[i].last_valid_ms = now_ms; + } + } else { + control.status.last_error = rc; + } + (void)housekeeping_power_get(HOUSEKEEPING_POWER_BANK_HEATER, + &control.status.heater_on); + + summarize_temperature_state(&ambient, now_ms); + all_stale = control.status.stale_temp_count == HISPEC_LASER_COUNT; + k_mutex_unlock(&control_lock); + + if (all_stale && ambient.valid && ambient.ambient_c < LASERBANK_WARM_MIN_C) { + (void)hispec_laser_bank_power_set(true, NULL); + } + + k_mutex_lock(&control_lock, K_FOREVER); + if (control.status.any_disabled_below_15c) { + k_mutex_unlock(&control_lock); + apply_heater(true); + } else if (control.status.any_disabled_above_off_threshold || + (control.status.all_tecs_enabled && + control.status.all_tecs_enabled_ms >= + LASERBANK_TEMPCONTROL_POLL_INTERVAL_MS)) { + k_mutex_unlock(&control_lock); + apply_heater(false); + } else { + k_mutex_unlock(&control_lock); + } +} + +static void tempcontrol_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + + run_heater_control_cycle(); + (void)k_work_reschedule(&tempcontrol_work, + K_MSEC(LASERBANK_TEMPCONTROL_POLL_INTERVAL_MS)); +} + +void laserbank_tempcontrol_start(void) +{ + (void)k_work_reschedule(&tempcontrol_work, K_NO_WAIT); +} diff --git a/app/src/laserbank_tempcontrol.h b/app/src/laserbank_tempcontrol.h new file mode 100644 index 0000000..4809ecd --- /dev/null +++ b/app/src/laserbank_tempcontrol.h @@ -0,0 +1,58 @@ +/** + * @file laserbank_tempcontrol.h + * @brief TIB laser-bank temperature-control loop. + */ + +#ifndef HISPEC_LASERBANK_TEMPCONTROL_H +#define HISPEC_LASERBANK_TEMPCONTROL_H + +#include +#include + +#define LASERBANK_TEMPCONTROL_POLL_INTERVAL_MS 10000U +#define LASERBANK_TEMPCONTROL_TEMP_STALE_MS (2U * LASERBANK_TEMPCONTROL_POLL_INTERVAL_MS) +#define LASERBANK_TEMPCONTROL_OVERRIDE_WARNING_MS (20U * 60U * 1000U) + +enum laserbank_heater_mode { + LASERBANK_HEATER_MODE_AUTO = 0, + LASERBANK_HEATER_MODE_OVERRIDE_ON, + LASERBANK_HEATER_MODE_OVERRIDE_OFF, +}; + +struct laserbank_tempcontrol_status { + bool available; + enum laserbank_heater_mode heater_mode; + bool bank_powered; + bool heater_on; + bool ambient_valid; + float ambient_c; + uint32_t ambient_age_ms; + uint8_t valid_temp_count; + uint8_t stale_temp_count; + bool any_disabled_below_15c; + bool any_disabled_above_off_threshold; + bool all_tecs_enabled; + uint32_t all_tecs_enabled_ms; + int last_error; + uint32_t last_poll_age_ms; +}; + +/** + * Start the TIB heater-policy delayable work. + * + * The work runs in Zephyr system workqueue context and may block on Modbus and + * relay GPIO I/O. Heater-mode commands reschedule the same work for immediate + * policy application. + */ +void laserbank_tempcontrol_start(void); + +/** Copy the latest temperature-control loop state. */ +void laserbank_tempcontrol_get_status(struct laserbank_tempcontrol_status *out); + +/** Set heater mode. Auto runs the warmup policy; override modes force the relay. */ +int laserbank_tempcontrol_set_heater_mode(enum laserbank_heater_mode mode, + bool persist); + +const char *laserbank_heater_mode_name(enum laserbank_heater_mode mode); + +#endif /* HISPEC_LASERBANK_TEMPCONTROL_H */ diff --git a/app/src/lasers.c b/app/src/lasers.c index 998a575..23cd00d 100644 --- a/app/src/lasers.c +++ b/app/src/lasers.c @@ -1,6 +1,6 @@ /** * @file lasers.c - * @brief Laser-bank power, relay outputs, Maiman status, and tuning helpers. + * @brief Laser-bank power, Maiman status, and tuning helpers. * * A module mutex serializes shared RS-485/GPIO operations. Functions can block * on Modbus RTU, sleep for bank boot/fault-clear delays, and modify driver @@ -12,9 +12,11 @@ #include "lasers.h" +#include "app_settings.h" #include "devices.h" #include +#include #include #include @@ -25,12 +27,40 @@ LOG_MODULE_REGISTER(lasers, LOG_LEVEL_INF); -/* One mutex protects the shared RS-485 bus sequencing and the bank/relay GPIOs. +#define PLANCK_J_S 6.62607015e-34 +#define LIGHT_M_PER_S 299792458.0 + +#define LASER_AUTOFF_NO_DEADLINE 0LL + +/* One mutex protects the shared RS-485 bus sequencing and bank-power GPIO. * k_mutex_lock() sleeps the calling thread instead of busy-waiting while another * command is talking to the Maiman modules. */ static K_MUTEX_DEFINE(laser_lock); +struct on_time_runtime { + bool active; + int64_t started_ms; + int64_t accumulated_ms; +}; + +struct laser_output_estimate_state { + float current_ma; + float tec_temperature_c; + bool valid; +}; + +static struct on_time_runtime laser_current_runtime[HISPEC_LASER_COUNT]; +static struct on_time_runtime laser_tec_runtime[HISPEC_LASER_COUNT]; +static struct app_laser_channel_settings laser_settings[HISPEC_LASER_COUNT]; +static struct laser_output_estimate_state laser_output_estimate[HISPEC_LASER_COUNT]; +static int64_t laser_autooff_deadline_ms[HISPEC_LASER_COUNT]; +static int64_t bank_power_started_ms; +static bool bank_power_requested_enabled; +static bool laser_runtime_initialized; +static enum hispec_laser_bank_power_mode bank_power_mode = + HISPEC_LASER_BANK_POWER_OVERRIDE_OFF; + static const struct hispec_laser_driver_profile laser_profiles[] = { [HISPEC_LASER_1028_Y] = { .id = HISPEC_LASER_1028_Y, @@ -91,6 +121,63 @@ static const struct hispec_laser_driver_profile laser_profiles[] = { BUILD_ASSERT(ARRAY_SIZE(laser_profiles) == HISPEC_LASER_COUNT, "Laser profile table must match hispec_laser_id"); +static int profile_for_id(enum hispec_laser_id id, + const struct hispec_laser_driver_profile **profile); +static int64_t next_autooff_deadline_locked(void); +static void hispec_laser_service_autooff(void); +static void laser_autooff_reschedule(void); +static void laser_autooff_work_handler(struct k_work *work); + +static K_WORK_DELAYABLE_DEFINE(laser_autooff_work, laser_autooff_work_handler); + +static void ensure_laser_runtime_settings_locked(void) +{ + struct app_laser_settings stored = {0}; + + if (laser_runtime_initialized) { + return; + } + + /* app_settings_init() owns loading defaults plus persisted values. The + * laser module pulls the completed snapshot on first use so boot code does + * not need a laser-specific post-settings hook and app_settings.c stays a + * storage layer instead of calling into hardware/domain modules. + */ + app_settings_get_laser(&stored); + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + laser_settings[i] = stored.channel[i]; + laser_output_estimate[i].current_ma = 0.0f; + laser_output_estimate[i].tec_temperature_c = + stored.channel[i].properties.operating_temp_c; + laser_output_estimate[i].valid = true; + laser_autooff_deadline_ms[i] = LASER_AUTOFF_NO_DEADLINE; + } + + laser_runtime_initialized = true; +} + +static void output_estimate_set_locked(enum hispec_laser_id id, + float current_ma, + float tec_temperature_c) +{ + if (id < 0 || id >= HISPEC_LASER_COUNT) { + return; + } + + laser_output_estimate[id].current_ma = current_ma; + laser_output_estimate[id].tec_temperature_c = tec_temperature_c; + laser_output_estimate[id].valid = true; +} + +static const laserprops_t *runtime_props_locked(enum hispec_laser_id id) +{ + ensure_laser_runtime_settings_locked(); + if (id < 0 || id >= HISPEC_LASER_COUNT) { + return NULL; + } + return &laser_settings[id].properties; +} + static bool float_is_valid(float value) { return value == value; @@ -106,6 +193,70 @@ static bool float_is_nonzero(float value) return float_is_valid(value) && value != 0.0f; } +static void on_time_runtime_update_locked(struct on_time_runtime *runtimes, + size_t count, + int index, + bool active) +{ + struct on_time_runtime *runtime; + int64_t now = k_uptime_get(); + + if (index < 0 || (size_t)index >= count) { + return; + } + + runtime = &runtimes[index]; + if (active && !runtime->active) { + runtime->active = true; + runtime->started_ms = now; + return; + } + if (!active && runtime->active) { + runtime->accumulated_ms += now - runtime->started_ms; + runtime->active = false; + runtime->started_ms = 0; + } +} + +static float on_time_runtime_seconds_locked(const struct on_time_runtime *runtimes, + size_t count, + int index) +{ + const struct on_time_runtime *runtime; + int64_t ms; + + if (index < 0 || (size_t)index >= count) { + return LASERPROP_NA; + } + + runtime = &runtimes[index]; + ms = runtime->accumulated_ms; + if (runtime->active) { + ms += k_uptime_get() - runtime->started_ms; + } + + return (float)ms / 1000.0f; +} + +static void commit_current_runtime_locked(enum hispec_laser_id id, bool persist) +{ + double total; + + if (id < 0 || id >= HISPEC_LASER_COUNT) { + return; + } + + total = laser_settings[id].total_emitting_s + + (double)on_time_runtime_seconds_locked(laser_current_runtime, + ARRAY_SIZE(laser_current_runtime), + id); + laser_settings[id].total_emitting_s = total; + laser_current_runtime[id].active = false; + laser_current_runtime[id].started_ms = 0; + laser_current_runtime[id].accumulated_ms = 0; + (void)app_settings_update_laser_total_emitting((uint8_t)id, total, persist); +} + static float clampf_with_flag(float value, float min_value, float max_value, bool *clamped) { float out = value; @@ -162,11 +313,6 @@ static int profile_for_id(enum hispec_laser_id id, return 0; } -static int require_tib(void) -{ - return (devices_board_type() == HISPEC_BOARD_TIB) ? 0 : -ENODEV; -} - int hispec_laser_id_from_name(const char *name, enum hispec_laser_id *out) { if (name == NULL || out == NULL) { @@ -200,12 +346,12 @@ const char *hispec_laser_name(enum hispec_laser_id id) const laserprops_t *hispec_laser_properties(enum hispec_laser_id id) { - const struct hispec_laser_driver_profile *profile; + const laserprops_t *props; - if (profile_for_id(id, &profile) != 0) { - return NULL; - } - return profile->properties; + k_mutex_lock(&laser_lock, K_FOREVER); + props = runtime_props_locked(id); + k_mutex_unlock(&laser_lock); + return props; } int hispec_laser_get_driver_profile(enum hispec_laser_id id, @@ -232,16 +378,37 @@ int hispec_laser_make_driver(enum hispec_laser_id id, maiman_driver_t *drv) return 0; } -static bool bank_power_is_enabled_locked(void) +static int zero_all_driver_currents_locked(bool stop_tecs) { - int val; + int first_rc = 0; - if (devices_board_type() != HISPEC_BOARD_TIB || !gpio_is_ready_dt(&laser_power_gpio)) { - return false; + ensure_laser_runtime_settings_locked(); + if (!bank_power_requested_enabled) { + return 0; + } + + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + maiman_driver_t drv; + + maiman_init(&drv, laser_profiles[i].node_id); + if (!maiman_set_current(&drv, 0.0f) || !maiman_stop_device(&drv)) { + first_rc = first_rc == 0 ? -EIO : first_rc; + } + if (stop_tecs && !maiman_stop_tec(&drv)) { + first_rc = first_rc == 0 ? -EIO : first_rc; + } + commit_current_runtime_locked((enum hispec_laser_id)i, true); + output_estimate_set_locked((enum hispec_laser_id)i, 0.0f, + laser_output_estimate[i].tec_temperature_c); + laser_autooff_deadline_ms[i] = LASER_AUTOFF_NO_DEADLINE; + if (stop_tecs) { + on_time_runtime_update_locked(laser_tec_runtime, + ARRAY_SIZE(laser_tec_runtime), + (enum hispec_laser_id)i, false); + } } - val = gpio_pin_get_dt(&laser_power_gpio); - return val > 0; + return first_rc; } bool hispec_laser_bank_power_is_enabled(void) @@ -249,33 +416,53 @@ bool hispec_laser_bank_power_is_enabled(void) bool enabled; k_mutex_lock(&laser_lock, K_FOREVER); - enabled = bank_power_is_enabled_locked(); + enabled = bank_power_requested_enabled; k_mutex_unlock(&laser_lock); return enabled; } -static int bank_power_set_locked(bool enabled, bool *transitioned) +enum hispec_laser_bank_power_mode hispec_laser_bank_power_mode_get(void) +{ + enum hispec_laser_bank_power_mode mode; + + k_mutex_lock(&laser_lock, K_FOREVER); + mode = bank_power_mode; + k_mutex_unlock(&laser_lock); + return mode; +} + +const char *hispec_laser_bank_power_mode_name(enum hispec_laser_bank_power_mode mode) +{ + switch (mode) { + case HISPEC_LASER_BANK_POWER_AUTO: + return "auto"; + case HISPEC_LASER_BANK_POWER_OVERRIDE_ON: + return "override_on"; + case HISPEC_LASER_BANK_POWER_OVERRIDE_OFF: + return "override_off"; + default: + return "unknown"; + } +} + +static int bank_power_set_locked(bool enabled, bool *transitioned, bool force_write) { bool was_enabled; int rc; + int zero_rc = 0; if (transitioned != NULL) { *transitioned = false; } - rc = require_tib(); - if (rc != 0) { - return rc; - } - - if (!gpio_is_ready_dt(&laser_power_gpio)) { - return -ENODEV; + was_enabled = bank_power_requested_enabled; + if (was_enabled == enabled && !force_write) { + return 0; } - was_enabled = bank_power_is_enabled_locked(); - if (was_enabled == enabled) { - return 0; + if (!enabled && was_enabled) { + zero_rc = zero_all_driver_currents_locked(true); } /* gpio_pin_set_dt() writes the logical active state described by @@ -285,16 +472,28 @@ static int bank_power_set_locked(bool enabled, bool *transitioned) if (rc != 0) { return rc; } + bank_power_requested_enabled = enabled; + bank_power_started_ms = enabled ? k_uptime_get() : 0; + LOG_INF("Laser bank power %s; GPIO %s", + enabled ? "turned on" : "turned off", + enabled ? "released" : "sinking low"); if (transitioned != NULL) { - *transitioned = true; + *transitioned = was_enabled != enabled; } - if (enabled) { + if (enabled && !was_enabled) { /* Let the Maiman modules boot before the caller starts Modbus IO. */ - k_sleep(K_MSEC(HISPEC_LASER_BANK_BOOT_DELAY_MS)); + k_msleep(HISPEC_LASER_BANK_BOOT_DELAY_MS); + } else if (!enabled && was_enabled) { + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + commit_current_runtime_locked((enum hispec_laser_id)i, true); + on_time_runtime_update_locked(laser_tec_runtime, + ARRAY_SIZE(laser_tec_runtime), + (enum hispec_laser_id)i, false); + } } - return 0; + return zero_rc; } int hispec_laser_bank_power_set(bool enabled, bool *transitioned) @@ -302,111 +501,176 @@ int hispec_laser_bank_power_set(bool enabled, bool *transitioned) int rc; k_mutex_lock(&laser_lock, K_FOREVER); - rc = bank_power_set_locked(enabled, transitioned); + if (enabled && bank_power_mode == HISPEC_LASER_BANK_POWER_OVERRIDE_OFF) { + rc = -EPERM; + } else { + rc = bank_power_set_locked(enabled, transitioned, false); + } k_mutex_unlock(&laser_lock); + if (rc == 0 && !enabled) { + laser_autooff_reschedule(); + } return rc; } -static int ensure_bank_powered_locked(void) -{ - return bank_power_set_locked(true, NULL); -} - -int hispec_laser_bank_clear_faults(uint32_t off_ms) +uint32_t hispec_laser_bank_power_on_duration_s(void) { - uint32_t delay_ms = (off_ms == 0U) ? HISPEC_LASER_BANK_FAULT_CLEAR_OFF_MS : off_ms; - int rc; + uint32_t duration_s = 0U; + int64_t now_ms; k_mutex_lock(&laser_lock, K_FOREVER); + now_ms = k_uptime_get(); + if (bank_power_requested_enabled && bank_power_started_ms > 0) { + int64_t elapsed_s = (now_ms - bank_power_started_ms) / 1000; - rc = bank_power_set_locked(false, NULL); - if (rc != 0) { - goto out; + duration_s = elapsed_s > UINT32_MAX ? UINT32_MAX : (uint32_t)elapsed_s; } + k_mutex_unlock(&laser_lock); - /* k_sleep() yields during the supply-off interval needed to clear the - * SF8025 overcurrent latch. - */ - k_sleep(K_MSEC(delay_ms)); - rc = bank_power_set_locked(true, NULL); + return duration_s; +} -out: +int hispec_laser_bank_power_mode_set(enum hispec_laser_bank_power_mode mode) +{ + int rc = 0; + + if (mode < HISPEC_LASER_BANK_POWER_AUTO || + mode > HISPEC_LASER_BANK_POWER_OVERRIDE_OFF) { + return -EINVAL; + } + + k_mutex_lock(&laser_lock, K_FOREVER); + bank_power_mode = mode; + if (mode == HISPEC_LASER_BANK_POWER_OVERRIDE_ON) { + rc = bank_power_set_locked(true, NULL, true); + } else if (mode == HISPEC_LASER_BANK_POWER_OVERRIDE_OFF) { + rc = bank_power_set_locked(false, NULL, true); + } k_mutex_unlock(&laser_lock); + return rc; } -static const struct gpio_dt_spec *aux_gpio(enum hispec_laser_aux_output output) +static int ensure_bank_powered_locked(void) { - switch (output) { - case HISPEC_LASER_AUX_YJ_PHOTODIODE: - return &yj_power_gpio; - case HISPEC_LASER_AUX_HK_PHOTODIODE: - return &hk_power_gpio; - case HISPEC_LASER_AUX_BANK_HEATER: - return &heater_power_gpio; - default: - return NULL; + if (bank_power_mode == HISPEC_LASER_BANK_POWER_OVERRIDE_OFF) { + return -EPERM; } + return bank_power_set_locked(true, NULL, false); } -int hispec_laser_aux_power_set(enum hispec_laser_aux_output output, bool enabled) +int hispec_laser_bank_clear_faults(uint32_t off_ms, uint32_t *actual_off_ms) { - const struct gpio_dt_spec *gpio = aux_gpio(output); + uint32_t delay_ms = (off_ms == 0U) ? HISPEC_LASER_BANK_FAULT_CLEAR_OFF_MS : off_ms; + bool fault = false; int rc; - if (gpio == NULL) { - return -EINVAL; + if (actual_off_ms != NULL) { + *actual_off_ms = 0U; } k_mutex_lock(&laser_lock, K_FOREVER); - rc = require_tib(); - if (rc != 0) { + + if (!bank_power_requested_enabled) { + rc = 0; goto out; } - if (!gpio_is_ready_dt(gpio)) { - rc = -ENODEV; + + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + maiman_driver_t drv; + uint16_t lock_status; + + maiman_init(&drv, laser_profiles[i].node_id); + lock_status = maiman_get_raw_lock_status(&drv); + if ((lock_status & LOCK_STATE_LD_OVERCURRENT) != 0U) { + fault = true; + break; + } + } + + if (!fault) { + rc = 0; goto out; } - /* Relay GPIOs are logical Zephyr GPIOs; devicetree active flags handle - * the DS2408's open-drain electrical behavior. + rc = bank_power_set_locked(false, NULL, false); + if (rc != 0) { + goto out; + } + + /* k_sleep() yields during the supply-off interval needed to clear the + * SF8025 overcurrent latch. */ - rc = gpio_pin_set_dt(gpio, enabled ? 1 : 0); + k_sleep(K_MSEC(delay_ms)); + rc = bank_power_set_locked(true, NULL, false); + if (rc == 0 && actual_off_ms != NULL) { + *actual_off_ms = delay_ms; + } out: k_mutex_unlock(&laser_lock); return rc; } -int hispec_laser_aux_power_get(enum hispec_laser_aux_output output, bool *enabled) +static float level_percent_for_current(const laserprops_t *props, float current_ma) { - const struct gpio_dt_spec *gpio = aux_gpio(output); - int rc = 0; - int val; + float range; + + if (props == NULL || !float_is_valid(current_ma)) { + return LASERPROP_NA; + } + range = props->nominal_current_ma - props->threshold_current_ma; + if (range <= 0.0f) { + return LASERPROP_NA; + } + if (current_ma <= 0.0f) { + return 0.0f; + } + return 100.0f * (current_ma - props->threshold_current_ma) / range; +} - if (gpio == NULL || enabled == NULL) { +int hispec_laser_bank_read_temperatures( + struct hispec_laser_channel_temperature channels[HISPEC_LASER_COUNT]) +{ + int rc; + + if (channels == NULL) { return -EINVAL; } - k_mutex_lock(&laser_lock, K_FOREVER); - rc = require_tib(); - if (rc != 0) { - goto out; + memset(channels, 0, + sizeof(struct hispec_laser_channel_temperature) * HISPEC_LASER_COUNT); + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + channels[i].id = laser_profiles[i].id; + channels[i].tec_temperature_c = LASERPROP_NA; } - if (!gpio_is_ready_dt(gpio)) { - rc = -ENODEV; - goto out; + + k_mutex_lock(&laser_lock, K_FOREVER); + if (!bank_power_requested_enabled) { + rc = 0; + goto out_unlock; } - val = gpio_pin_get_dt(gpio); - if (val < 0) { - rc = val; - goto out; + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + maiman_driver_t drv; + float tec_temp = LASERPROP_NA; + bool tec_started = false; + bool temp_ok; + bool state_ok; + + maiman_init(&drv, laser_profiles[i].node_id); + temp_ok = maiman_read_tec_temperature_measured(&drv, &tec_temp); + state_ok = maiman_read_tec_started(&drv, &tec_started); + + channels[i].valid = temp_ok && state_ok; + channels[i].tec_temperature_c = tec_temp; + channels[i].tec_enabled = state_ok && tec_started; } - *enabled = val > 0; -out: + rc = 0; + +out_unlock: k_mutex_unlock(&laser_lock); return rc; } @@ -476,7 +740,7 @@ static int check_ocp_limit_locked(const struct hispec_laser_driver_profile *prof maiman_driver_t *drv) { float ocp_ma; - const laserprops_t *props = profile->properties; + const laserprops_t *props = runtime_props_locked(profile->id); ocp_ma = maiman_get_current_protection_threshold(drv); if (!float_is_valid(ocp_ma) || ocp_ma < 0.0f) { @@ -495,10 +759,61 @@ static int check_ocp_limit_locked(const struct hispec_laser_driver_profile *prof return 0; } +static int64_t next_autooff_deadline_locked(void) +{ + int64_t next = LASER_AUTOFF_NO_DEADLINE; + + ensure_laser_runtime_settings_locked(); + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + const int64_t deadline = laser_autooff_deadline_ms[i]; + + if (deadline <= LASER_AUTOFF_NO_DEADLINE) { + continue; + } + if (next == LASER_AUTOFF_NO_DEADLINE || deadline < next) { + next = deadline; + } + } + + return next; +} + +static k_timeout_t laser_autooff_wait_timeout(void) +{ + int64_t next_deadline; + int64_t wait_ms; + + k_mutex_lock(&laser_lock, K_FOREVER); + next_deadline = next_autooff_deadline_locked(); + k_mutex_unlock(&laser_lock); + + if (next_deadline == LASER_AUTOFF_NO_DEADLINE) { + return K_FOREVER; + } + + wait_ms = next_deadline - k_uptime_get(); + return wait_ms <= 0 ? K_NO_WAIT : K_MSEC(wait_ms); +} + +static void laser_autooff_reschedule(void) +{ + const k_timeout_t timeout = laser_autooff_wait_timeout(); + + /* This delayable work runs on Zephyr's system workqueue. Auto-off is slow + * best-effort cleanup, not a timing path, and the only blocking work here is + * the Modbus stop sequence for an expired output. + */ + if (K_TIMEOUT_EQ(timeout, K_FOREVER)) { + (void)k_work_cancel_delayable(&laser_autooff_work); + } else { + (void)k_work_reschedule(&laser_autooff_work, timeout); + } +} + static int apply_runtime_profile_locked(const struct hispec_laser_driver_profile *profile, maiman_driver_t *drv) { - const laserprops_t *props = profile->properties; + const laserprops_t *props = runtime_props_locked(profile->id); int rc; rc = check_ocp_limit_locked(profile, drv); @@ -509,13 +824,22 @@ static int apply_runtime_profile_locked(const struct hispec_laser_driver_profile if (!maiman_set_current_max(drv, props->max_current_ma)) { return -EIO; } + if (!maiman_set_current_set_calibration(drv, + laser_settings[profile->id].current_set_calibration_pct)) { + return -EIO; + } if (!maiman_set_tec_current_limit(drv, props->tec_max_current_a)) { return -EIO; } if (!maiman_set_tec_pid(drv, props->tec_pid)) { return -EIO; } - if (!maiman_set_frequency(drv, 0.0f)) { + /* All HISPEC laser operation is continuous-wave. Normalize installed + * drivers during profile programming so stale pulse-mode register values + * cannot survive a module replacement or manual reconfiguration. + */ + if (!maiman_set_frequency(drv, 0.0f) || + !maiman_set_duration(drv, 0.0f)) { return -EIO; } @@ -616,6 +940,7 @@ int hispec_laser_reset_driver_settings(enum hispec_laser_id id) static int prepare_to_operate_locked(const struct hispec_laser_driver_profile *profile, maiman_driver_t *drv) { + const laserprops_t *props = runtime_props_locked(profile->id); float current_temp; uint16_t lock_status; int rc; @@ -647,7 +972,7 @@ static int prepare_to_operate_locked(const struct hispec_laser_driver_profile *p if (!maiman_is_tec_started(drv)) { current_temp = maiman_get_tec_temperature_measured(drv); if (!float_is_valid(current_temp) || current_temp < -100.0f) { - current_temp = profile->properties->operating_temp_c; + current_temp = props->operating_temp_c; } if (!maiman_set_tec_temperature(drv, current_temp) || !maiman_start_tec(drv)) { @@ -673,18 +998,27 @@ static void status_defaults(const struct hispec_laser_driver_profile *profile, out->expected_device_id = profile->expected_device_id; out->expected_serial = profile->expected_serial; out->current_set_ma = LASERPROP_NA; + out->level_percent = LASERPROP_NA; out->current_measured_ma = LASERPROP_NA; out->current_min_ma = LASERPROP_NA; out->current_max_ma = LASERPROP_NA; out->current_max_limit_ma = LASERPROP_NA; out->current_protection_threshold_ma = LASERPROP_NA; out->voltage_v = LASERPROP_NA; + out->current_on_time_s = LASERPROP_NA; + out->tec_on_time_s = LASERPROP_NA; + out->total_emitting_s = 0.0; + out->tune_delta_nm = 0.0f; + out->autooff_s = 0U; + out->off_in_s = 0; out->tec_temperature_set_c = LASERPROP_NA; out->tec_temperature_measured_c = LASERPROP_NA; out->pcb_temperature_c = LASERPROP_NA; out->tec_current_measured_a = LASERPROP_NA; out->tec_current_limit_a = LASERPROP_NA; out->tec_voltage_v = LASERPROP_NA; + out->current_set_calibration_pct = LASERPROP_NA; + out->ntc_t_coefficient_per_c = LASERPROP_NA; out->estimated_power_mw = LASERPROP_NA; out->estimated_wavelength_nm = LASERPROP_NA; } @@ -708,12 +1042,25 @@ int hispec_laser_get_status(enum hispec_laser_id id, struct hispec_laser_status status_defaults(profile, out); k_mutex_lock(&laser_lock, K_FOREVER); - rc = require_tib(); - if (rc != 0) { - goto out_unlock; - } - - out->bank_powered = bank_power_is_enabled_locked(); + out->properties = runtime_props_locked(id); + out->current_on_time_s = + on_time_runtime_seconds_locked(laser_current_runtime, + ARRAY_SIZE(laser_current_runtime), id); + out->tec_on_time_s = + on_time_runtime_seconds_locked(laser_tec_runtime, + ARRAY_SIZE(laser_tec_runtime), id); + out->total_emitting_s = laser_settings[id].total_emitting_s + + (double)out->current_on_time_s; + out->tune_delta_nm = laser_settings[id].tune_delta_nm; + out->autooff_s = laser_settings[id].autooff_s; + out->current_set_calibration_pct = laser_settings[id].current_set_calibration_pct; + out->ntc_t_coefficient_per_c = out->properties->ntc_t_coefficient_per_c; + if (laser_autooff_deadline_ms[id] > 0) { + int64_t remaining_ms = laser_autooff_deadline_ms[id] - k_uptime_get(); + + out->off_in_s = remaining_ms > 0 ? (remaining_ms + 999) / 1000 : 0; + } + out->bank_powered = bank_power_requested_enabled; if (!out->bank_powered) { rc = 0; goto out_unlock; @@ -753,11 +1100,13 @@ int hispec_laser_get_status(enum hispec_laser_id id, struct hispec_laser_status if (!maiman_get_current(&drv, &out->current_set_ma)) { read_ok = false; } + out->level_percent = level_percent_for_current(out->properties, out->current_set_ma); out->current_measured_ma = maiman_get_current_measured(&drv); out->current_min_ma = maiman_get_current_min(&drv); out->current_max_ma = maiman_get_current_max(&drv); out->current_max_limit_ma = maiman_get_current_max_limit(&drv); out->current_protection_threshold_ma = maiman_get_current_protection_threshold(&drv); + out->current_set_calibration_pct = maiman_get_current_set_calibration(&drv); out->voltage_v = maiman_get_voltage_measured(&drv); out->tec_temperature_set_c = maiman_get_tec_temperature_value(&drv); out->tec_temperature_measured_c = maiman_get_tec_temperature_measured(&drv); @@ -765,14 +1114,15 @@ int hispec_laser_get_status(enum hispec_laser_id id, struct hispec_laser_status out->tec_current_measured_a = maiman_get_tec_current_measured(&drv); out->tec_current_limit_a = maiman_get_tec_current_limit(&drv); out->tec_voltage_v = maiman_get_tec_voltage(&drv); + out->ntc_t_coefficient_per_c = maiman_get_ntc_b25_100_coefficient(&drv); if (!maiman_get_tec_pid(&drv, &out->tec_pid)) { read_ok = false; } out->estimated_power_mw = - hispec_laser_estimate_power_mw(profile->properties, out->current_set_ma); + hispec_laser_estimate_power_mw(out->properties, out->current_set_ma); out->estimated_wavelength_nm = - hispec_laser_estimate_wavelength_nm(profile->properties, + hispec_laser_estimate_wavelength_nm(out->properties, out->tec_temperature_measured_c, out->current_set_ma); rc = read_ok ? 0 : -EIO; @@ -782,12 +1132,13 @@ int hispec_laser_get_status(enum hispec_laser_id id, struct hispec_laser_status return rc; } -static int stop_output_locked(const struct hispec_laser_driver_profile *profile) +static int stop_output_locked(const struct hispec_laser_driver_profile *profile, bool stop_tec) { maiman_driver_t drv; int rc; - if (!bank_power_is_enabled_locked()) { + ensure_laser_runtime_settings_locked(); + if (!bank_power_requested_enabled) { return 0; } @@ -800,10 +1151,53 @@ static int stop_output_locked(const struct hispec_laser_driver_profile *profile) if (!maiman_set_current(&drv, 0.0f) || !maiman_stop_device(&drv)) { return -EIO; } + if (stop_tec && !maiman_stop_tec(&drv)) { + return -EIO; + } + commit_current_runtime_locked(profile->id, true); + output_estimate_set_locked(profile->id, 0.0f, + laser_output_estimate[profile->id].tec_temperature_c); + laser_autooff_deadline_ms[profile->id] = LASER_AUTOFF_NO_DEADLINE; + if (stop_tec) { + on_time_runtime_update_locked(laser_tec_runtime, ARRAY_SIZE(laser_tec_runtime), + profile->id, false); + } return 0; } +int hispec_laser_stop_output(enum hispec_laser_id id, bool stop_tec) +{ + const struct hispec_laser_driver_profile *profile; + int rc; + + rc = profile_for_id(id, &profile); + if (rc != 0) { + return rc; + } + + k_mutex_lock(&laser_lock, K_FOREVER); + rc = stop_output_locked(profile, stop_tec); + k_mutex_unlock(&laser_lock); + if (rc == 0) { + laser_autooff_reschedule(); + } + return rc; +} + +int hispec_laser_stop_all_outputs(bool stop_tecs) +{ + int rc; + + k_mutex_lock(&laser_lock, K_FOREVER); + rc = zero_all_driver_currents_locked(stop_tecs); + k_mutex_unlock(&laser_lock); + if (rc == 0) { + laser_autooff_reschedule(); + } + return rc; +} + int hispec_laser_set_current_ma(enum hispec_laser_id id, float current_ma) { const struct hispec_laser_driver_profile *profile; @@ -815,7 +1209,9 @@ int hispec_laser_set_current_ma(enum hispec_laser_id id, float current_ma) if (rc != 0) { return rc; } - props = profile->properties; + k_mutex_lock(&laser_lock, K_FOREVER); + props = runtime_props_locked(id); + k_mutex_unlock(&laser_lock); if (!float_is_valid(current_ma) || current_ma < 0.0f || current_ma > props->max_current_ma) { @@ -824,7 +1220,7 @@ int hispec_laser_set_current_ma(enum hispec_laser_id id, float current_ma) k_mutex_lock(&laser_lock, K_FOREVER); if (current_ma == 0.0f) { - rc = stop_output_locked(profile); + rc = stop_output_locked(profile, false); goto out; } @@ -835,6 +1231,12 @@ int hispec_laser_set_current_ma(enum hispec_laser_id id, float current_ma) if (!maiman_set_current(&drv, current_ma) || !maiman_start_device(&drv)) { rc = -EIO; + } else { + on_time_runtime_update_locked(laser_current_runtime, + ARRAY_SIZE(laser_current_runtime), id, true); + on_time_runtime_update_locked(laser_tec_runtime, + ARRAY_SIZE(laser_tec_runtime), id, true); + output_estimate_set_locked(id, current_ma, props->operating_temp_c); } out: @@ -893,6 +1295,56 @@ int hispec_laser_set_output_percent(enum hispec_laser_id id, float percent) return hispec_laser_set_current_ma(id, current_ma); } +int hispec_laser_set_output_percent_autooff(enum hispec_laser_id id, + float percent, + uint32_t autooff_s) +{ + struct app_laser_channel_settings settings; + const laserprops_t *props; + int rc; + + if (id < 0 || id >= HISPEC_LASER_COUNT) { + return -EINVAL; + } + + k_mutex_lock(&laser_lock, K_FOREVER); + ensure_laser_runtime_settings_locked(); + settings = laser_settings[id]; + props = &laser_settings[id].properties; + k_mutex_unlock(&laser_lock); + + if (percent > 0.0f && settings.tune_delta_nm != 0.0f) { + struct hispec_laser_tune_request request = { + .desired_power_percent = percent, + .wavelength_nm = props->wavelength_nm + settings.tune_delta_nm, + .use_current = true, + .use_temperature = true, + .maximum_power_shift_percent = 100.0f, + .apply = true, + }; + struct hispec_laser_tune_result result = {0}; + + rc = hispec_laser_tune_wavelength(id, &request, &result); + } else { + rc = hispec_laser_set_output_percent(id, percent); + } + + if (rc == 0) { + k_mutex_lock(&laser_lock, K_FOREVER); + if (percent > 0.0f) { + laser_autooff_deadline_ms[id] = + autooff_s == 0U ? LASER_AUTOFF_NO_DEADLINE : + k_uptime_get() + (int64_t)autooff_s * 1000LL; + } else { + laser_autooff_deadline_ms[id] = LASER_AUTOFF_NO_DEADLINE; + } + k_mutex_unlock(&laser_lock); + laser_autooff_reschedule(); + } + + return rc; +} + int hispec_laser_set_tec_temperature_c(enum hispec_laser_id id, float temperature_c) { const struct hispec_laser_driver_profile *profile; @@ -904,7 +1356,9 @@ int hispec_laser_set_tec_temperature_c(enum hispec_laser_id id, float temperatur if (rc != 0) { return rc; } - props = profile->properties; + k_mutex_lock(&laser_lock, K_FOREVER); + props = runtime_props_locked(id); + k_mutex_unlock(&laser_lock); if (!float_is_valid(temperature_c) || temperature_c < props->operating_temp_range_c.min_c || @@ -916,13 +1370,16 @@ int hispec_laser_set_tec_temperature_c(enum hispec_laser_id id, float temperatur rc = prepare_to_operate_locked(profile, &drv); if (rc == 0 && !maiman_set_tec_temperature(&drv, temperature_c)) { rc = -EIO; + } else if (rc == 0) { + output_estimate_set_locked(id, laser_output_estimate[id].current_ma, + temperature_c); } k_mutex_unlock(&laser_lock); return rc; } -int hispec_laser_set_pulse(enum hispec_laser_id id, float frequency_hz, float duration_ms) +int hispec_laser_set_tec_pid(enum hispec_laser_id id, tec_pid_t pid) { const struct hispec_laser_driver_profile *profile; maiman_driver_t drv; @@ -932,10 +1389,6 @@ int hispec_laser_set_pulse(enum hispec_laser_id id, float frequency_hz, float du if (rc != 0) { return rc; } - if (!float_is_valid(frequency_hz) || !float_is_valid(duration_ms) || - frequency_hz < 0.0f || duration_ms < 0.0f) { - return -ERANGE; - } k_mutex_lock(&laser_lock, K_FOREVER); rc = ensure_bank_powered_locked(); @@ -943,9 +1396,7 @@ int hispec_laser_set_pulse(enum hispec_laser_id id, float frequency_hz, float du maiman_init(&drv, profile->node_id); rc = verify_driver_locked(profile, &drv, NULL); } - if (rc == 0 && - (!maiman_set_frequency(&drv, frequency_hz) || - !maiman_set_duration(&drv, duration_ms))) { + if (rc == 0 && !maiman_set_tec_pid(&drv, pid)) { rc = -EIO; } k_mutex_unlock(&laser_lock); @@ -953,31 +1404,218 @@ int hispec_laser_set_pulse(enum hispec_laser_id id, float frequency_hz, float du return rc; } -int hispec_laser_set_tec_pid(enum hispec_laser_id id, tec_pid_t pid) +static int validate_laser_settings(const struct hispec_laser_driver_profile *profile, + const struct app_laser_channel_settings *settings) +{ + const laserprops_t *props; + + if (profile == NULL || profile->properties == NULL || settings == NULL) { + return -EINVAL; + } + + props = &settings->properties; + if (!float_is_valid(props->nominal_current_ma) || + !float_is_valid(props->max_current_ma) || + !float_is_valid(props->threshold_current_ma) || + !float_is_valid(props->efficiency_mw_per_ma) || + !float_is_valid(props->wavelength_nm) || + !float_is_valid(settings->current_set_calibration_pct) || + !float_is_valid(props->tec_max_current_a) || + !float_is_valid(props->dlambda_dT_nm_per_k) || + !float_is_valid(props->dlambda_dA_nm_per_ma) || + props->threshold_current_ma < 0.0f || + props->threshold_current_ma > 1000.0f || + props->nominal_current_ma <= props->threshold_current_ma || + props->nominal_current_ma > 1000.0f || + props->max_current_ma < props->nominal_current_ma || + props->max_current_ma > 1000.0f || + props->efficiency_mw_per_ma < 0.0f || + props->efficiency_mw_per_ma > 100.0f || + props->wavelength_nm < 1.0f || + props->wavelength_nm > 10000.0f || + settings->current_set_calibration_pct < 95.0f || + settings->current_set_calibration_pct > 105.0f || + props->tec_max_current_a <= 0.0f || + props->tec_max_current_a > profile->properties->tec_max_current_a || + props->dlambda_dT_nm_per_k < -10.0f || + props->dlambda_dT_nm_per_k > 10.0f || + props->dlambda_dA_nm_per_ma < -10.0f || + props->dlambda_dA_nm_per_ma > 10.0f) { + return -ERANGE; + } + + if (!float_is_valid(props->operating_temp_range_c.min_c) || + !float_is_valid(props->operating_temp_range_c.max_c) || + !float_is_valid(props->operating_temp_c) || + props->operating_temp_range_c.min_c < 15.0f || + props->operating_temp_range_c.max_c > 40.0f || + props->operating_temp_range_c.min_c > props->operating_temp_range_c.max_c || + props->operating_temp_c < props->operating_temp_range_c.min_c || + props->operating_temp_c > props->operating_temp_range_c.max_c) { + return -ERANGE; + } + + return 0; +} + +static bool laser_driver_settings_differ(const struct app_laser_channel_settings *a, + const struct app_laser_channel_settings *b) +{ + return a->properties.max_current_ma != b->properties.max_current_ma || + a->current_set_calibration_pct != b->current_set_calibration_pct || + a->properties.tec_max_current_a != b->properties.tec_max_current_a || + a->properties.tec_pid.kp != b->properties.tec_pid.kp || + a->properties.tec_pid.ki != b->properties.tec_pid.ki || + a->properties.tec_pid.kd != b->properties.tec_pid.kd; +} + +int hispec_laser_get_channel_settings(enum hispec_laser_id id, + struct app_laser_channel_settings *out) +{ + if (out == NULL || id < 0 || id >= HISPEC_LASER_COUNT) { + return -EINVAL; + } + + k_mutex_lock(&laser_lock, K_FOREVER); + ensure_laser_runtime_settings_locked(); + *out = laser_settings[id]; + k_mutex_unlock(&laser_lock); + return 0; +} + +int hispec_laser_update_channel_settings(enum hispec_laser_id id, + const struct app_laser_channel_settings *settings, + bool persist) { const struct hispec_laser_driver_profile *profile; + struct app_laser_channel_settings previous; maiman_driver_t drv; + bool apply_driver; + bool was_powered = false; int rc; + int power_restore_rc = 0; rc = profile_for_id(id, &profile); if (rc != 0) { return rc; } + rc = validate_laser_settings(profile, settings); + if (rc != 0) { + return rc; + } + k_mutex_lock(&laser_lock, K_FOREVER); - rc = ensure_bank_powered_locked(); - if (rc == 0) { + ensure_laser_runtime_settings_locked(); + previous = laser_settings[id]; + apply_driver = laser_driver_settings_differ(&previous, settings); + + if (apply_driver) { + if (bank_power_mode == HISPEC_LASER_BANK_POWER_OVERRIDE_OFF) { + rc = -EPERM; + goto out_unlock; + } + + was_powered = bank_power_requested_enabled; + rc = bank_power_set_locked(true, NULL, false); + if (rc != 0) { + goto out_unlock; + } + + rc = stop_output_locked(profile, settings->disable_tec_at_autooff); + if (rc != 0) { + goto restore_power; + } + + laser_settings[id] = *settings; maiman_init(&drv, profile->node_id); rc = verify_driver_locked(profile, &drv, NULL); + if (rc == 0) { + rc = apply_runtime_profile_locked(profile, &drv); + } + if (rc != 0) { + laser_settings[id] = previous; + } + +restore_power: + if (!was_powered) { + power_restore_rc = bank_power_set_locked(false, NULL, false); + if (rc == 0) { + rc = power_restore_rc; + } + } + } else { + laser_settings[id] = *settings; } - if (rc == 0 && !maiman_set_tec_pid(&drv, pid)) { - rc = -EIO; - } + +out_unlock: k_mutex_unlock(&laser_lock); + if (rc == 0) { + (void)app_settings_update_laser_channel((uint8_t)id, settings, persist); + } return rc; } +int hispec_laser_set_tune_delta_nm(enum hispec_laser_id id, float delta_nm, + bool persist) +{ + struct app_laser_channel_settings settings; + int rc; + + if (!float_is_valid(delta_nm)) { + return -EINVAL; + } + rc = hispec_laser_get_channel_settings(id, &settings); + if (rc != 0) { + return rc; + } + settings.tune_delta_nm = delta_nm; + return hispec_laser_update_channel_settings(id, &settings, persist); +} + +float hispec_laser_get_tune_delta_nm(enum hispec_laser_id id) +{ + float value = LASERPROP_NA; + + k_mutex_lock(&laser_lock, K_FOREVER); + ensure_laser_runtime_settings_locked(); + if (id >= 0 && id < HISPEC_LASER_COUNT) { + value = laser_settings[id].tune_delta_nm; + } + k_mutex_unlock(&laser_lock); + return value; +} + +static void hispec_laser_service_autooff(void) +{ + int64_t now = k_uptime_get(); + + for (uint8_t i = 0U; i < HISPEC_LASER_COUNT; ++i) { + bool expired = false; + bool stop_tec = false; + + k_mutex_lock(&laser_lock, K_FOREVER); + ensure_laser_runtime_settings_locked(); + expired = laser_autooff_deadline_ms[i] > 0 && + now >= laser_autooff_deadline_ms[i]; + stop_tec = laser_settings[i].disable_tec_at_autooff; + k_mutex_unlock(&laser_lock); + + if (expired) { + (void)hispec_laser_stop_output((enum hispec_laser_id)i, stop_tec); + } + } +} + +static void laser_autooff_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + + hispec_laser_service_autooff(); + laser_autooff_reschedule(); +} + float hispec_laser_estimate_power_mw(const laserprops_t *properties, float current_ma) { float power_mw; @@ -992,6 +1630,81 @@ float hispec_laser_estimate_power_mw(const laserprops_t *properties, float curre return (power_mw > 0.0f) ? power_mw : 0.0f; } +int laser_estimate_flux(enum hispec_laser_id id, + float fractional_noise, + float constant_noise_mw, + struct hispec_laser_flux_estimate *out) +{ + laserprops_t properties; + float current_ma; + float tec_temperature_c; + double power_mw; + double power_w; + double photon_j; + double wavelength_m; + double power_err_mw; + + if (out == NULL || id < 0 || id >= HISPEC_LASER_COUNT) { + return -EINVAL; + } + + k_mutex_lock(&laser_lock, K_FOREVER); + ensure_laser_runtime_settings_locked(); + if (!laser_output_estimate[id].valid) { + k_mutex_unlock(&laser_lock); + return -EAGAIN; + } + properties = laser_settings[id].properties; + current_ma = laser_output_estimate[id].current_ma; + tec_temperature_c = laser_output_estimate[id].tec_temperature_c; + k_mutex_unlock(&laser_lock); + + if (!float_is_valid(current_ma) || + !float_is_valid(tec_temperature_c) || + !float_is_valid(fractional_noise) || + !float_is_valid(constant_noise_mw) || + fractional_noise < 0.0f || constant_noise_mw < 0.0f || + !float_is_valid(properties.wavelength_nm) || + properties.wavelength_nm <= 0.0f) { + return -EINVAL; + } + + memset(out, 0, sizeof(*out)); + power_mw = hispec_laser_estimate_power_mw(&properties, current_ma); + out->current_ma = current_ma; + out->tec_temperature_c = tec_temperature_c; + out->wavelength_nm = hispec_laser_estimate_wavelength_nm(&properties, + tec_temperature_c, + current_ma); + if (!(out->wavelength_nm > 0.0)) { + return -EINVAL; + } + wavelength_m = out->wavelength_nm * 1.0e-9; + photon_j = PLANCK_J_S * LIGHT_M_PER_S / wavelength_m; + power_w = power_mw * 1.0e-3; + power_err_mw = sqrt((power_mw * (double)fractional_noise) * + (power_mw * (double)fractional_noise) + + (double)constant_noise_mw * (double)constant_noise_mw); + + out->power_mw = power_mw; + out->power_err_mw = power_err_mw; + out->flux_ph_s = power_w / photon_j; + out->flux_err_ph_s = (power_err_mw * 1.0e-3) / photon_j; + return 0; +} + +float hispec_laser_current_on_time_s(enum hispec_laser_id id) +{ + float value; + + k_mutex_lock(&laser_lock, K_FOREVER); + value = on_time_runtime_seconds_locked(laser_current_runtime, + ARRAY_SIZE(laser_current_runtime), id); + k_mutex_unlock(&laser_lock); + + return value; +} + float hispec_laser_estimate_wavelength_nm(const laserprops_t *properties, float tec_temperature_c, float current_ma) @@ -1049,7 +1762,9 @@ int hispec_laser_tune_wavelength(enum hispec_laser_id id, if (rc != 0) { return rc; } - props = profile->properties; + k_mutex_lock(&laser_lock, K_FOREVER); + props = runtime_props_locked(id); + k_mutex_unlock(&laser_lock); memset(result, 0, sizeof(*result)); result->requested_wavelength_nm = request->wavelength_nm; @@ -1164,6 +1879,15 @@ int hispec_laser_tune_wavelength(enum hispec_laser_id id, !maiman_start_device(&drv))) { rc = -EIO; } + if (rc == 0) { + on_time_runtime_update_locked(laser_current_runtime, + ARRAY_SIZE(laser_current_runtime), + id, true); + on_time_runtime_update_locked(laser_tec_runtime, + ARRAY_SIZE(laser_tec_runtime), + id, true); + output_estimate_set_locked(id, target_current_ma, target_temp_c); + } k_mutex_unlock(&laser_lock); return rc; } diff --git a/app/src/lasers.h b/app/src/lasers.h index e53bf14..5a73de7 100644 --- a/app/src/lasers.h +++ b/app/src/lasers.h @@ -1,10 +1,10 @@ /** * @file lasers.h - * @brief Higher-level TIB laser-bank GPIO, relay, and Maiman helper APIs. + * @brief Higher-level TIB laser-bank power and Maiman helper APIs. * - * These helpers may sleep, block on Modbus, change laser-bank power/relay - * state, and write Maiman EEPROM-backed parameters. Most are not yet exposed by - * the current command dispatch table. + * These helpers may sleep, block on Modbus, change laser-bank power, and write + * Maiman EEPROM-backed parameters. Most are not yet exposed by the current + * command dispatch table. * * Copyright (c) 2026 Caltech Optical Observatories * SPDX-License-Identifier: Apache-2.0 @@ -19,6 +19,8 @@ #include "laser_properties.h" #include "maiman.h" +struct app_laser_channel_settings; + #define HISPEC_LASER_BANK_BOOT_DELAY_MS 1000U #define HISPEC_LASER_BANK_FAULT_CLEAR_OFF_MS 250U @@ -33,10 +35,10 @@ enum hispec_laser_id { HISPEC_LASER_UNKNOWN = -1, }; -enum hispec_laser_aux_output { - HISPEC_LASER_AUX_YJ_PHOTODIODE = 0, - HISPEC_LASER_AUX_HK_PHOTODIODE, - HISPEC_LASER_AUX_BANK_HEATER, +enum hispec_laser_bank_power_mode { + HISPEC_LASER_BANK_POWER_AUTO = 0, + HISPEC_LASER_BANK_POWER_OVERRIDE_ON, + HISPEC_LASER_BANK_POWER_OVERRIDE_OFF, }; struct hispec_laser_driver_profile { @@ -78,18 +80,27 @@ struct hispec_laser_status { bool lock_tec_selfheat; bool ready_to_operate; float current_set_ma; + float level_percent; float current_measured_ma; float current_min_ma; float current_max_ma; float current_max_limit_ma; float current_protection_threshold_ma; float voltage_v; + float current_on_time_s; + float tec_on_time_s; + double total_emitting_s; + float tune_delta_nm; + uint32_t autooff_s; + int64_t off_in_s; float tec_temperature_set_c; float tec_temperature_measured_c; float pcb_temperature_c; float tec_current_measured_a; float tec_current_limit_a; float tec_voltage_v; + float current_set_calibration_pct; + float ntc_t_coefficient_per_c; tec_pid_t tec_pid; float estimated_power_mw; float estimated_wavelength_nm; @@ -116,6 +127,23 @@ struct hispec_laser_tune_result { bool current_clamped; }; +struct hispec_laser_flux_estimate { + double current_ma; + double tec_temperature_c; + double power_mw; + double power_err_mw; + double wavelength_nm; + double flux_ph_s; + double flux_err_ph_s; +}; + +struct hispec_laser_channel_temperature { + enum hispec_laser_id id; + bool valid; + bool tec_enabled; + float tec_temperature_c; +}; + /** * @brief Parse a command/API laser name into a laser-bank channel. * @@ -138,40 +166,62 @@ int hispec_laser_get_driver_profile(enum hispec_laser_id id, int hispec_laser_make_driver(enum hispec_laser_id id, maiman_driver_t *drv); /** - * @brief Read the TIB laser-bank supply GPIO. + * @brief Return the firmware-requested TIB laser-bank supply state. * - * Returns false when this board is not the TIB or the GPIO is unavailable. + * The Nucleo overlay drives the regulator inhibit line with an open-drain + * GPIO. A released pin may not read high, so command/control flow uses the + * firmware-requested state as its source of truth. */ bool hispec_laser_bank_power_is_enabled(void); +/** + * @brief Return the current laser-bank supply on-duration in seconds. + * + * The laser-bank power owner latches this runtime-only value when the bank + * supply is observed or driven on. It returns 0 when the bank is off. + */ +uint32_t hispec_laser_bank_power_on_duration_s(void); + +/** @brief Get or set the runtime laser-bank power override mode. */ +enum hispec_laser_bank_power_mode hispec_laser_bank_power_mode_get(void); +const char *hispec_laser_bank_power_mode_name(enum hispec_laser_bank_power_mode mode); +int hispec_laser_bank_power_mode_set(enum hispec_laser_bank_power_mode mode); + /** * @brief Set the TIB laser-bank supply GPIO. * - * Side effect: drives the board power switch and, when enabling, sleeps for - * HISPEC_LASER_BANK_BOOT_DELAY_MS so the Maiman controllers can boot before a - * following Modbus transaction. + * Side effect: drives the board power switch. With the Nucleo open-drain test + * configuration, enabling releases the GPIO and disabling sinks it low. When + * enabling, sleeps for HISPEC_LASER_BANK_BOOT_DELAY_MS so the Maiman + * controllers can boot before a following Modbus transaction. When disabling a + * powered bank, first writes driver currents to zero and stops TECs as + * practical; a Modbus shutdown failure is returned even if the GPIO transition + * itself succeeds. */ int hispec_laser_bank_power_set(bool enabled, bool *transitioned); /** * @brief Power-cycle the laser bank to clear latched Maiman overcurrent faults. * - * Side effect: disables and re-enables the whole bank supply. This is not a - * per-laser reset and should not be used while another channel is intentionally - * emitting. + * If the bank is off or no powered driver reports an overcurrent fault, this + * is a no-op and @p actual_off_ms is set to 0. Otherwise this disables and + * re-enables the whole bank supply, sleeps for the requested off interval, and + * reports the interval through @p actual_off_ms. This is not a per-laser reset + * and should not be used while another channel is intentionally emitting. */ -int hispec_laser_bank_clear_faults(uint32_t off_ms); +int hispec_laser_bank_clear_faults(uint32_t off_ms, + uint32_t *actual_off_ms); /** - * @brief Set one relay-box output: YJ PD power, HK PD power, or bank heater. + * @brief Poll TEC temperatures and TEC-running state for each laser channel. * - * Side effect: writes a logical GPIO value through Zephyr's gpio_pin_set_dt(); - * active-low/high board flags are handled by the GPIO API. + * This call blocks on Modbus RTU transactions while holding the laser-bank + * mutex so command handlers and the heater control loop do not interleave RS-485 + * traffic. If the bank is off, channel readings are marked invalid but the + * caller still receives one initialized entry per known laser channel. */ -int hispec_laser_aux_power_set(enum hispec_laser_aux_output output, bool enabled); - -/** @brief Read one relay-box output's logical state. */ -int hispec_laser_aux_power_get(enum hispec_laser_aux_output output, bool *enabled); +int hispec_laser_bank_read_temperatures( + struct hispec_laser_channel_temperature channels[HISPEC_LASER_COUNT]); /** * @brief Verify that the expected Maiman driver is at a channel's Modbus address. @@ -185,8 +235,9 @@ int hispec_laser_verify_driver(enum hispec_laser_id id, uint16_t *serial_out); * @brief Program diode-specific limits into the Maiman driver. * * Writes settings that must be restored if a Maiman module is replaced: - * current max, TEC current limit, TEC PID, and CW mode. If @p save_to_eeprom is - * true, the module's EEPROM save command is sent afterward. + * current max, current calibration, TEC current limit, TEC PID, and CW + * modulation registers. If @p save_to_eeprom is true, the module's EEPROM + * save command is sent afterward. */ int hispec_laser_program_driver_profile(enum hispec_laser_id id, bool save_to_eeprom); @@ -197,6 +248,12 @@ int hispec_laser_reset_driver_settings(enum hispec_laser_id id); /** @brief Read a best-effort snapshot of one laser channel. */ int hispec_laser_get_status(enum hispec_laser_id id, struct hispec_laser_status *out); +/** @brief Stop one channel's emission and write current 0 when possible. */ +int hispec_laser_stop_output(enum hispec_laser_id id, bool stop_tec); + +/** @brief Stop every channel's emission and write driver current 0 when possible. */ +int hispec_laser_stop_all_outputs(bool stop_tecs); + /** * @brief Set raw diode current in mA. * @@ -206,6 +263,11 @@ int hispec_laser_get_status(enum hispec_laser_id id, struct hispec_laser_status */ int hispec_laser_set_current_ma(enum hispec_laser_id id, float current_ma); +/** @brief Set output percent and configure auto-off deadline. */ +int hispec_laser_set_output_percent_autooff(enum hispec_laser_id id, + float percent, + uint32_t autooff_s); + /** @brief Set estimated output power in mW using the diode efficiency model. */ int hispec_laser_set_output_mw(enum hispec_laser_id id, float power_mw); @@ -215,15 +277,44 @@ int hispec_laser_set_output_percent(enum hispec_laser_id id, float percent); /** @brief Set the TEC temperature setpoint and start the TEC if needed. */ int hispec_laser_set_tec_temperature_c(enum hispec_laser_id id, float temperature_c); -/** @brief Configure pulse frequency and duration without starting emission. */ -int hispec_laser_set_pulse(enum hispec_laser_id id, float frequency_hz, float duration_ms); - /** @brief Configure TEC PID coefficients on the Maiman driver. */ int hispec_laser_set_tec_pid(enum hispec_laser_id id, tec_pid_t pid); +/** + * @brief Get/update app-owned laser channel settings. + * + * The laser module validates the complete settings record and decides whether + * driver-backed values changed enough to require Maiman reprogramming. Updates + * may sleep, power the bank temporarily, stop emission, write Modbus registers, + * and persist app-owned settings when requested. + */ +int hispec_laser_get_channel_settings(enum hispec_laser_id id, + struct app_laser_channel_settings *out); +int hispec_laser_update_channel_settings(enum hispec_laser_id id, + const struct app_laser_channel_settings *settings, + bool persist); +int hispec_laser_set_tune_delta_nm(enum hispec_laser_id id, float delta_nm, + bool persist); +float hispec_laser_get_tune_delta_nm(enum hispec_laser_id id); + /** @brief Estimate optical power from current using the fixed diode properties. */ float hispec_laser_estimate_power_mw(const laserprops_t *properties, float current_ma); +/** + * @brief Estimate emitted photon flux from the laser module's operating state. + * + * The laser module owns the current/TEC setpoints used for this estimate. This + * reads only module state under a mutex; it does not perform Modbus I/O, change + * GPIO state, enqueue, publish, or persist settings. + */ +int laser_estimate_flux(enum hispec_laser_id id, + float fractional_noise, + float constant_noise_mw, + struct hispec_laser_flux_estimate *out); + +/** @brief Return current-emission on-time tracked by this module since boot. */ +float hispec_laser_current_on_time_s(enum hispec_laser_id id); + /** @brief Estimate wavelength from TEC temperature and current. */ float hispec_laser_estimate_wavelength_nm(const laserprops_t *properties, float tec_temperature_c, diff --git a/app/src/maiman.c b/app/src/maiman.c index 9af32a9..15fdd68 100644 --- a/app/src/maiman.c +++ b/app/src/maiman.c @@ -75,6 +75,19 @@ static const MaimanRegister register_table[] = { {"D_COEFFICIENT", REG_TEC_D_COEFFICIENT}, }; +static int maiman_client_iface = -ENODEV; + +int maiman_set_client_iface(int iface) +{ + if (iface < 0) { + return -EINVAL; + } + + maiman_client_iface = iface; + LOG_INF("Maiman Modbus client interface set to %d", iface); + return 0; +} + static bool strcaseeq(const char *a, const char *b) { if (a == NULL || b == NULL) { @@ -125,8 +138,14 @@ bool maiman_read_u16(maiman_driver_t *drv, uint16_t address, uint16_t *value) if (drv == NULL || value == NULL) { return false; } + if (maiman_client_iface < 0) { + LOG_ERR("Modbus read node=%u reg=0x%04x before client init", + drv->node_id, address); + return false; + } - err = modbus_read_holding_regs(CLIENT_IFACE, drv->node_id, address, value, 1); + err = modbus_read_holding_regs(maiman_client_iface, drv->node_id, + address, value, 1); if (err < 0) { LOG_ERR("Modbus read node=%u reg=0x%04x failed: %d", drv->node_id, address, err); @@ -147,8 +166,14 @@ bool maiman_write_u16(maiman_driver_t *drv, uint16_t address, uint16_t value) if (drv == NULL) { return false; } + if (maiman_client_iface < 0) { + LOG_ERR("Modbus write node=%u reg=0x%04x value=0x%04x before client init", + drv->node_id, address, value); + return false; + } - err = modbus_write_holding_regs(CLIENT_IFACE, drv->node_id, address, &value, 1); + err = modbus_write_holding_regs(maiman_client_iface, drv->node_id, + address, &value, 1); if (err < 0) { LOG_ERR("Modbus write node=%u reg=0x%04x value=0x%04x failed: %d", drv->node_id, address, value, err); @@ -224,12 +249,17 @@ bool maiman_write_scaled(maiman_driver_t *drv, uint16_t address, float divider, return maiman_write_u16(drv, address, raw); } +bool maiman_read_tec_temperature_measured(maiman_driver_t *drv, float *value) +{ + return maiman_read_scaled(drv, REG_TEC_TEMPERATURE_MEASURED, + DIVIDER_TEC_TEMPERATURE, true, value); +} + float maiman_get_tec_temperature_measured(maiman_driver_t *drv) { float value; - if (maiman_read_scaled(drv, REG_TEC_TEMPERATURE_MEASURED, - DIVIDER_TEC_TEMPERATURE, true, &value)) { + if (maiman_read_tec_temperature_measured(drv, &value)) { return value; } return -273.15f; @@ -462,16 +492,33 @@ uint16_t maiman_get_raw_lock_status(maiman_driver_t *drv) return 0U; } +bool maiman_read_raw_tec_status(maiman_driver_t *drv, uint16_t *status) +{ + return maiman_read_u16(drv, REG_STATE_OF_TEC_COMMAND, status); +} + uint16_t maiman_get_raw_tec_status(maiman_driver_t *drv) { uint16_t raw; - if (maiman_read_u16(drv, REG_STATE_OF_TEC_COMMAND, &raw)) { + if (maiman_read_raw_tec_status(drv, &raw)) { return raw; } return 0U; } +bool maiman_read_tec_started(maiman_driver_t *drv, bool *started) +{ + uint16_t raw; + + if (started == NULL || !maiman_read_raw_tec_status(drv, &raw)) { + return false; + } + + *started = (raw & TEC_OPERATION_STATE_STARTED) != 0U; + return true; +} + bool maiman_is_bit_set(maiman_driver_t *drv, uint16_t bitmask) { uint16_t status = maiman_get_raw_status(drv); @@ -573,6 +620,13 @@ bool maiman_set_current_max(maiman_driver_t *drv, float current) return maiman_write_scaled(drv, REG_CURRENT_MAX, DIVIDER_CURRENT, false, current); } +bool maiman_set_current_set_calibration(maiman_driver_t *drv, float calibration_percent) +{ + return maiman_write_scaled(drv, REG_CURRENT_SET_CALIBRATION, + DIVIDER_CURRENT_SET_CALIBRATION, false, + calibration_percent); +} + bool maiman_set_frequency(maiman_driver_t *drv, float frequency) { return maiman_write_scaled(drv, REG_FREQUENCY, DIVIDER_FREQUENCY, false, frequency); diff --git a/app/src/maiman.h b/app/src/maiman.h index b353de5..6ab3838 100644 --- a/app/src/maiman.h +++ b/app/src/maiman.h @@ -18,9 +18,6 @@ #include "laser_properties.h" #include -#define CLIENT_IFACE 0 - - /* Maiman MODBUS RTU holding-register addresses. * * These are the register addresses from maiman_modbus_py/config/modbus_config.yaml, @@ -80,6 +77,12 @@ typedef uint16_t laser_address_t; /* Finds the register, returns true and sets *address_out if found. */ bool maiman_get_register_address(const char *name, laser_address_t *address_out); +/* + * Selects the Zephyr Modbus client interface used by subsequent blocking + * Maiman register transactions. Device setup owns the interface lookup. + */ +int maiman_set_client_iface(int iface); + /* Divider constants from the SF8025 v5.4 device metadata used by the * validation notebooks. Values returned by this layer are engineering units: @@ -172,6 +175,7 @@ bool maiman_write_scaled(maiman_driver_t *drv, uint16_t address, float divider, /* ----- Measurement getters ----- */ +bool maiman_read_tec_temperature_measured(maiman_driver_t *drv, float *value); float maiman_get_tec_temperature_measured(maiman_driver_t *drv); float maiman_get_pcb_temperature_measured(maiman_driver_t *drv); float maiman_get_ntc_temperature_measured(maiman_driver_t *drv); @@ -197,7 +201,9 @@ uint16_t maiman_get_device_id(maiman_driver_t *drv); uint16_t maiman_get_serial_number(maiman_driver_t *drv); uint16_t maiman_get_raw_status(maiman_driver_t *drv); uint16_t maiman_get_raw_lock_status(maiman_driver_t *drv); +bool maiman_read_raw_tec_status(maiman_driver_t *drv, uint16_t *status); uint16_t maiman_get_raw_tec_status(maiman_driver_t *drv); +bool maiman_read_tec_started(maiman_driver_t *drv, bool *started); bool maiman_is_bit_set(maiman_driver_t *drv, uint16_t bitmask); bool maiman_is_operation_started(maiman_driver_t *drv); bool maiman_is_current_set_internal(maiman_driver_t *drv); @@ -217,6 +223,7 @@ bool maiman_is_lockstate_tec_selfheat(maiman_driver_t *drv); /* ----- Setpoint and commands ----- */ bool maiman_set_current(maiman_driver_t *drv, float current); bool maiman_set_current_max(maiman_driver_t *drv, float current); +bool maiman_set_current_set_calibration(maiman_driver_t *drv, float calibration_percent); bool maiman_set_frequency(maiman_driver_t *drv, float frequency); bool maiman_set_duration(maiman_driver_t *drv, float duration); bool maiman_set_tec_temperature(maiman_driver_t *drv, float temperature_c); diff --git a/app/src/main.c b/app/src/main.c index f61984b..9d1e1ee 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -16,9 +16,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -27,42 +29,47 @@ #include "app_settings.h" #include "command.h" #include "devices.h" +#include "housekeeping.h" +#include "laserbank_tempcontrol.h" #include "photodiode.h" -#include "tempsense.h" +#include "throughput_monitor.h" +#if defined(CONFIG_SNTP) #include "sntp_sync.h" +#endif LOG_MODULE_REGISTER(main, LOG_LEVEL_DBG); -#define EXECUTOR_STACK_SIZE 1400 -#define EXECUTOR_PRIORITY 5 -#define SERIAL_STACK_SIZE 1400 -#define SERIAL_PRIORITY 6 -#define PHOTODIODE_STACK_SIZE 500 -#define PHOTODIODE_PRIORITY 5 - -#define TEMPSENSOR_STACK_SIZE 500 -#define TEMPSENSOR_PRIORITY 5 //TODO this should be lowest +#define EXECUTOR_STACK_SIZE 8192 +#define EXECUTOR_PRIORITY 6 +#define PHOTODIODE_STACK_SIZE 2048 //1400 +#define PHOTODIODE_PRIORITY 3 +#define THROUGHPUT_MONITOR_STACK_SIZE 4096 +#define THROUGHPUT_MONITOR_PRIORITY 7 +#if defined(CONFIG_NET_CONFIG_INIT_TIMEOUT) +#define BOOT_NETWORK_WAIT_MS ((uint32_t)CONFIG_NET_CONFIG_INIT_TIMEOUT * 1000U) +#else +#define BOOT_NETWORK_WAIT_MS 6000U +#endif -#define WDT_TIMEOUT_MS 6000 +/* Boot can spend CONFIG_NET_CONFIG_INIT_TIMEOUT waiting for DHCP before the + * main network/MQTT loop starts. Keep the watchdog wider than that bounded + * wait so normal no-DHCP bring-up does not reset the board. + */ +#define WDT_TIMEOUT_MS MAX(15000U, BOOT_NETWORK_WAIT_MS + 5000U) +#define MQTT_CONNECT_RETRY_MS 5000 static struct mqtt_client client_ctx; +static char mqtt_cmd_subscription[MAX_TOPIC_LEN]; static K_THREAD_STACK_DEFINE(exec_stack, EXECUTOR_STACK_SIZE); static struct k_thread exec_thread_data; -static K_THREAD_STACK_DEFINE(serial_stack, SERIAL_STACK_SIZE); -static struct k_thread serial_thread_data; - -static struct k_work_delayable photodiode_publish_work; +static K_THREAD_STACK_DEFINE(photodiode_stack, PHOTODIODE_STACK_SIZE); +static struct k_thread photodiode_thread_data; -K_THREAD_DEFINE(photodiode_tid, PHOTODIODE_STACK_SIZE, - photodiode_thread, NULL, NULL, NULL, - PHOTODIODE_PRIORITY, 0, 0); - -K_THREAD_DEFINE(temp_tid, TEMPSENSOR_STACK_SIZE, - tempsensor_thread, NULL, NULL, NULL, - TEMPSENSOR_PRIORITY, 0, 0); +static K_THREAD_STACK_DEFINE(throughput_monitor_stack, THROUGHPUT_MONITOR_STACK_SIZE); +static struct k_thread throughput_monitor_thread_data; static void load_network_config(struct network_config *cfg) { @@ -117,18 +124,45 @@ static void load_mqtt_config(struct coo_mqtt_broker_config *cfg) cfg->port = mqtt_cfg.broker_port; } -static void wdt_callback(const struct device *wdt_dev, int channel_id) +static bool mqtt_config_equal(const struct coo_mqtt_broker_config *a, + const struct coo_mqtt_broker_config *b) +{ + return a != NULL && b != NULL && + a->port == b->port && + strcmp(a->host, b->host) == 0; +} + +static void restore_mqtt_config(const struct coo_mqtt_broker_config *cfg) { - ARG_UNUSED(wdt_dev); - ARG_UNUSED(channel_id); - LOG_ERR("Watchdog callback triggered - resetting"); + struct app_mqtt_settings mqtt_cfg = {0}; + + if (cfg == NULL) { + return; + } + + strncpy(mqtt_cfg.broker_host, cfg->host, sizeof(mqtt_cfg.broker_host) - 1U); + mqtt_cfg.broker_host[sizeof(mqtt_cfg.broker_host) - 1U] = '\0'; + mqtt_cfg.broker_port = cfg->port; + /* Revert persistent settings too so a resolvable but unreachable broker + * does not strand the next boot on the rejected endpoint. + */ + app_settings_update_mqtt(&mqtt_cfg, true); } +/** + * @brief Configure the hardware watchdog used by the main network/MQTT loop. + * + * The STM32 IWDG resets the MCU if the main loop stops feeding it. It does not + * use an application callback in this build, because Zephyr's STM32 IWDG driver + * only supports callbacks when early-wakeup interrupt support is enabled. Setup + * writes hardware watchdog registers and can briefly wait for them to settle. + */ static int watchdog_init(const struct device **wdt_out, int *wdt_channel_out) { const struct device *wdt; struct wdt_timeout_cfg wdt_config; int wdt_channel_id; + int rc; wdt = DEVICE_DT_GET_OR_NULL(DT_ALIAS(watchdog0)); if (!wdt || !device_is_ready(wdt)) { @@ -140,15 +174,16 @@ static int watchdog_init(const struct device **wdt_out, int *wdt_channel_out) wdt_config.flags = WDT_FLAG_RESET_SOC; wdt_config.window.min = 0U; wdt_config.window.max = WDT_TIMEOUT_MS; - wdt_config.callback = wdt_callback; + wdt_config.callback = NULL; wdt_channel_id = wdt_install_timeout(wdt, &wdt_config); if (wdt_channel_id < 0) { return wdt_channel_id; } - if (wdt_setup(wdt, WDT_OPT_PAUSE_HALTED_BY_DBG) < 0) { - return -EIO; + rc = wdt_setup(wdt, WDT_OPT_PAUSE_HALTED_BY_DBG); + if (rc < 0) { + return rc; } *wdt_out = wdt; @@ -156,31 +191,33 @@ static int watchdog_init(const struct device **wdt_out, int *wdt_channel_out) return 0; } -static void photodiode_publish_handler(struct k_work *work) +static void apply_last_known_time(void) { - struct OutMsg out; + struct timespec ts = {0}; + uint64_t utc_ms; + int rc; - ARG_UNUSED(work); + if (!app_settings_get_last_known_utc_ms(&utc_ms)) { + return; + } - /* This work item bridges sampler telemetry to the normal MQTT outbound - * queue. It performs no ADC or MQTT I/O itself. - */ - while (k_msgq_get(&photodiode_queue, &out, K_NO_WAIT) == 0) { - if (k_msgq_put(&outbound_queue, &out, K_NO_WAIT) != 0) { - (void)k_msgq_put(&photodiode_queue, &out, K_NO_WAIT); - break; - } + ts.tv_sec = (time_t)(utc_ms / 1000ULL); + ts.tv_nsec = (long)((utc_ms % 1000ULL) * 1000000ULL); + rc = sys_clock_settime(SYS_CLOCK_REALTIME, &ts); + if (rc != 0) { + LOG_WRN("Failed to restore last known UTC time (%d)", rc); + return; } - k_work_schedule(&photodiode_publish_work, K_MSEC(10)); + LOG_INF("Restored last known UTC time from settings: %llu ms", + (unsigned long long)utc_ms); } static void network_event_handler(bool connected) { LOG_INF("Network event: %s", connected ? "connected" : "disconnected"); -#if defined(CONFIG_SNTP) - if (connected) sntp_sync_schedule_now(); -#endif + if (connected) + sntp_sync_schedule_now(); } int main(void) @@ -190,14 +227,18 @@ int main(void) int wdt_channel = -1; bool mqtt_subscribed = false; uint32_t mqtt_cfg_revision = 0U; + bool mqtt_revert_on_connect_failure = false; struct network_config net_cfg; struct coo_mqtt_broker_config mqtt_cfg; + struct coo_mqtt_broker_config prior_mqtt_cfg = {0}; + struct coo_cmd_runtime *cmd_runtime; + int64_t next_mqtt_connect_ms = 0; + bool board_devices_ready; - LOG_INF("HiSPEC-FIB PCB %s\n", APP_VERSION_STRING); + LOG_INF("HISPEC-FIB PCB %s\n", APP_VERSION_STRING); + + devices_capture_boot_reset_cause(); - /* Watchdog availability is a boot requirement. The main loop feeds it only - * from the MQTT/network path so a wedged main path can still reset the MCU. - */ rc = watchdog_init(&wdt, &wdt_channel); if (rc != 0) { LOG_ERR("Watchdog init failed (%d); refusing to boot", rc); @@ -226,36 +267,53 @@ int main(void) } } + apply_last_known_time(); app_settings_increment_boot_count(); - (void)devices_ready(); - setup_mems_switches_and_routes(); - setup_attenuators(); - rc = command_runtime_init(); if (rc != 0) { LOG_ERR("Command runtime init failed (%d)", rc); return rc; } + cmd_runtime = command_runtime_get(); + devices_queue_boot_reset_telemetry(); + + board_devices_ready = devices_ready(); + setup_mems_switches_and_routes(); + setup_attenuators(); k_thread_create(&exec_thread_data, exec_stack, K_THREAD_STACK_SIZEOF(exec_stack), - command_executor_thread, NULL, NULL, NULL, + coo_cmd_runtime_executor_thread, cmd_runtime, NULL, NULL, EXECUTOR_PRIORITY, 0, K_NO_WAIT); + k_thread_name_set(&exec_thread_data, "command_exec"); - k_thread_create(&serial_thread_data, serial_stack, K_THREAD_STACK_SIZEOF(serial_stack), - command_serial_thread, NULL, NULL, NULL, - SERIAL_PRIORITY, 0, K_NO_WAIT); - - k_work_init_delayable(&photodiode_publish_work, photodiode_publish_handler); + housekeeping_start(); if (devices_board_type() == HISPEC_BOARD_TIB) { - k_work_schedule(&photodiode_publish_work, K_NO_WAIT); + if (board_devices_ready) { + k_thread_create(&photodiode_thread_data, + photodiode_stack, + K_THREAD_STACK_SIZEOF(photodiode_stack), + photodiode_thread, NULL, NULL, NULL, + PHOTODIODE_PRIORITY, 0, K_NO_WAIT); + k_thread_name_set(&photodiode_thread_data, "photodiode"); + k_thread_create(&throughput_monitor_thread_data, + throughput_monitor_stack, + K_THREAD_STACK_SIZEOF(throughput_monitor_stack), + throughput_monitor_thread, NULL, NULL, NULL, + THROUGHPUT_MONITOR_PRIORITY, 0, K_NO_WAIT); + k_thread_name_set(&throughput_monitor_thread_data, "throughput"); + laserbank_tempcontrol_start(); + } else { + LOG_WRN("TIB background workers disabled because board devices are not ready"); + } } sntp_sync_init(); load_network_config(&net_cfg); (void)network_init(&net_cfg, network_event_handler); + // wdt_feed(wdt, wdt_channel); - rc = coo_mqtt_init(&client_ctx, APP_MQTT_DEVICE_ID); + rc = coo_mqtt_init(&client_ctx, app_mqtt_device_id()); if (rc != 0) { LOG_ERR("MQTT init failed (%d)", rc); return rc; @@ -267,42 +325,91 @@ int main(void) return rc; } mqtt_cfg_revision = app_settings_get_mqtt_revision(); - coo_mqtt_set_message_callback(command_handle_mqtt_publish); - (void)coo_mqtt_add_subscription(APP_MQTT_CMD_PREFIX "#", MQTT_QOS_2_EXACTLY_ONCE); + coo_mqtt_set_message_callback(coo_cmd_runtime_mqtt_callback, cmd_runtime); + + //TODO get rid of this test. Software should verify the prefix + command string portion fits in buffer AND that all + // command strings fit in max_command_suffix (and similarly for telemetry/warnings + //also why is this in braces?? + { + int written; + size_t prefix_len; + + strncpy(mqtt_cmd_subscription, cmd_runtime->request_prefix, sizeof(mqtt_cmd_subscription) - 1U); + mqtt_cmd_subscription[sizeof(mqtt_cmd_subscription) - 1U] = '\0'; + prefix_len = strlen(mqtt_cmd_subscription); + written = snprintk(mqtt_cmd_subscription + prefix_len, sizeof(mqtt_cmd_subscription) - prefix_len, "#"); + // if (written < 0 || + // written >= (int)(sizeof(mqtt_cmd_subscription) - prefix_len)) { + // LOG_ERR("MQTT command subscription topic too long"); + // return -ENOSPC; + // } + (void)coo_mqtt_add_subscription(mqtt_cmd_subscription, MQTT_QOS_2_EXACTLY_ONCE); + } + // wdt_feed(wdt, wdt_channel); while (1) { /* MQTT stays connected whenever the network is ready. Serial override - * rejection happens in command_handle_mqtt_publish(), so requesters get + * rejection happens in the command runtime, so requesters get * an explicit response instead of a silent disconnect. */ bool mqtt_can_run = network_is_ready(); uint32_t current_mqtt_revision = app_settings_get_mqtt_revision(); + coo_cmd_runtime_serial_poll(cmd_runtime); + if (current_mqtt_revision != mqtt_cfg_revision) { + struct coo_mqtt_broker_config new_mqtt_cfg; + mqtt_cfg_revision = current_mqtt_revision; - load_mqtt_config(&mqtt_cfg); - rc = coo_mqtt_set_broker_config(&mqtt_cfg); + load_mqtt_config(&new_mqtt_cfg); + rc = coo_mqtt_set_broker_config(&new_mqtt_cfg); if (rc != 0) { LOG_ERR("MQTT broker reconfigure rejected (%d)", rc); - } else if (coo_mqtt_is_connected()) { - (void)mqtt_disconnect(&client_ctx, NULL); - mqtt_subscribed = false; + } else { + if (!mqtt_config_equal(&new_mqtt_cfg, &mqtt_cfg)) { + prior_mqtt_cfg = mqtt_cfg; + mqtt_cfg = new_mqtt_cfg; + mqtt_revert_on_connect_failure = true; + } + if (coo_mqtt_is_connected()) { + (void)mqtt_disconnect(&client_ctx, NULL); + mqtt_subscribed = false; + } } } - if (wdt) { - (void)wdt_feed(wdt, wdt_channel); - } + wdt_feed(wdt, wdt_channel); if (coo_mqtt_is_connected() && !mqtt_can_run) { (void)mqtt_disconnect(&client_ctx, NULL); mqtt_subscribed = false; } - if (!coo_mqtt_is_connected() && mqtt_can_run) { + if (!coo_mqtt_is_connected() && mqtt_can_run && + k_uptime_get() >= next_mqtt_connect_ms) { rc = coo_mqtt_connect(&client_ctx); if (rc == 0) { mqtt_subscribed = false; + mqtt_revert_on_connect_failure = false; + } else if (mqtt_revert_on_connect_failure) { + char context[160]; + + snprintk(context, sizeof(context), + "host=%s port=%u rc=%d", + mqtt_cfg.host, mqtt_cfg.port, rc); + coo_cmd_runtime_warning_emit(command_runtime_get(), "mqtt_broker_revert", + "MQTT broker connection failed; reverting to prior broker", + context); + LOG_WRN("MQTT broker connection failed (%d), reverting to %s:%u", + rc, prior_mqtt_cfg.host, prior_mqtt_cfg.port); + mqtt_cfg = prior_mqtt_cfg; + (void)coo_mqtt_set_broker_config(&mqtt_cfg); + restore_mqtt_config(&mqtt_cfg); + mqtt_cfg_revision = app_settings_get_mqtt_revision(); + mqtt_revert_on_connect_failure = false; + } + if (rc != 0) { + next_mqtt_connect_ms = k_uptime_get() + MQTT_CONNECT_RETRY_MS; } } @@ -313,7 +420,8 @@ int main(void) } } - command_drain_outbound_queue(&client_ctx, coo_mqtt_is_connected() && mqtt_can_run); + coo_cmd_runtime_drain_outbound(cmd_runtime, &client_ctx, + coo_mqtt_is_connected() && mqtt_can_run); if (coo_mqtt_is_connected()) { rc = coo_mqtt_process(&client_ctx); @@ -322,7 +430,7 @@ int main(void) mqtt_subscribed = false; } } else { - k_sleep(K_MSEC(20)); + k_sleep(K_MSEC(50)); } } } diff --git a/app/src/mems_command.c b/app/src/mems_command.c new file mode 100644 index 0000000..81516f5 --- /dev/null +++ b/app/src/mems_command.c @@ -0,0 +1,1037 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "mems_command.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "app_settings.h" +#include "command.h" +#include "mems_switching.h" + +#include +#include + +LOG_MODULE_REGISTER(mems_command, LOG_LEVEL_DBG); + +extern struct mems_router router; + +static bool memsroute_output_seen(const char *const *outputs, uint8_t count, + const char *output_name) +{ + for (uint8_t i = 0U; i < count; ++i) { + if (strcmp(outputs[i], output_name) == 0) { + return true; + } + } + + return false; +} + +static int memsroute_append_sources_for_output(char *buf, size_t buf_len, size_t *offset, + const struct mems_route_key *active, + uint8_t active_count, + const char *output_name) +{ + uint8_t n_sources = 0U; + + if (coo_json_append(buf, buf_len, offset, "\"%s\":[", output_name) != 0) { + return -ENOSPC; + } + + for (uint8_t i = 0U; i < active_count; ++i) { + if (strcmp(active[i].output_name, output_name) != 0) { + continue; + } + + if (n_sources > 0U && coo_json_append(buf, buf_len, offset, ",") != 0) { + return -ENOSPC; + } + if (coo_json_append(buf, buf_len, offset, "\"%s\"", + active[i].input_name) != 0) { + return -ENOSPC; + } + n_sources++; + } + + if (n_sources == 0U && + coo_json_append(buf, buf_len, offset, "\"no source\"") != 0) { + return -ENOSPC; + } + + return coo_json_append(buf, buf_len, offset, "]"); +} + +static const char *const route_loss_laser_names[] = { + "1028y", "1270j", "1430yj", "1430hk", "1510h", "2330k", +}; + +static const struct coo_json_string_choice mems_switch_state_choices[] = { + { "A", 'A' }, + { "B", 'B' }, +}; + +static bool memsroute_is_route_loss_key(const char *key) +{ + return strcmp(key, "memsroute/route_loss") == 0; +} + +static const char *route_loss_json_value_for_key(const char *json, const char *key) +{ + char pattern[40]; + const char *match; + const char *colon; + int written; + + if (json == NULL || key == NULL) { + return NULL; + } + + written = snprintk(pattern, sizeof(pattern), "\"%s\"", key); + if (written < 0 || written >= (int)sizeof(pattern)) { + return NULL; + } + + match = strstr(json, pattern); + if (match == NULL) { + return NULL; + } + + colon = strchr(match + strlen(pattern), ':'); + if (colon == NULL) { + return NULL; + } + + return coo_json_skip_ws(colon + 1); +} + +static int route_loss_parse_db_string(const char *text, double *transmission) +{ + char *end = NULL; + double loss_db; + + if (text == NULL || transmission == NULL) { + return -EINVAL; + } + + errno = 0; + loss_db = strtod(text, &end); + if (errno != 0 || end == text) { + return -EINVAL; + } + + while (*end != '\0' && isspace((unsigned char)*end)) { + end++; + } + if (strcasecmp(end, "db") != 0) { + return -EINVAL; + } + if (loss_db < 0.0) { + return -ERANGE; + } + + *transmission = pow(10.0, -loss_db / 10.0); + return (*transmission > 0.0 && *transmission <= 1.0) ? 0 : -ERANGE; +} + +static int route_loss_parse_scalar_token(const char *start, const char **end, + double *transmission) +{ + char db_text[24] = {0}; + char *parse_end = NULL; + double tx; + size_t len; + + if (start == NULL || end == NULL || transmission == NULL) { + return -EINVAL; + } + + start = coo_json_skip_ws(start); + if (*start == '"') { + start++; + len = strcspn(start, "\""); + if (start[len] != '"' || len == 0U || len >= sizeof(db_text)) { + return -EINVAL; + } + memcpy(db_text, start, len); + db_text[len] = '\0'; + *end = start + len + 1; + return route_loss_parse_db_string(db_text, transmission); + } + + errno = 0; + tx = strtod(start, &parse_end); + if (errno != 0 || parse_end == start || !(tx > 0.0 && tx <= 1.0)) { + return -ERANGE; + } + + *transmission = tx; + *end = parse_end; + return 0; +} + +static int route_loss_extract_field_transmission(const struct coo_cmd_request *cmd, + const char *field, + double *transmission) +{ + char db_text[24] = {0}; + double tx = 0.0; + int rc_num; + int rc_str; + int parse_rc; + + rc_num = coo_json_extract_double(cmd->payload, field, &tx); + if (rc_num == COO_JSON_EXTRACT_OK) { + if (!(tx > 0.0 && tx <= 1.0)) { + return -ERANGE; + } + *transmission = tx; + return 0; + } + + rc_str = coo_json_extract_string(cmd->payload, field, db_text, sizeof(db_text)); + if (rc_str == COO_JSON_EXTRACT_OK) { + parse_rc = route_loss_parse_db_string(db_text, &tx); + if (parse_rc != 0) { + return parse_rc; + } + *transmission = tx; + return 0; + } + + if (rc_num == COO_JSON_EXTRACT_MISSING && rc_str == COO_JSON_EXTRACT_MISSING) { + return -ENOENT; + } + + return -EINVAL; +} + +static int route_loss_extract_value(const struct coo_cmd_request *cmd, + char *laser, size_t laser_len, + double *transmission) +{ + for (uint8_t i = 0U; i < ARRAY_SIZE(route_loss_laser_names); ++i) { + const char *candidate = route_loss_laser_names[i]; + int rc; + + rc = route_loss_extract_field_transmission(cmd, candidate, transmission); + if (rc == 0) { + snprintk(laser, laser_len, "%s", candidate); + return 0; + } + if (rc != -ENOENT) { + return rc; + } + } + + return -ENOENT; +} + +static int route_loss_extract_split_tuple(const struct coo_cmd_request *cmd, + double transmission[MEMS_SPLIT_OUTPUT_COUNT]) +{ + const char *cursor; + int rc; + + cursor = route_loss_json_value_for_key(cmd->payload, "split"); + if (cursor == NULL) { + return -ENOENT; + } + if (*cursor != '[') { + return -EINVAL; + } + cursor++; + + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + cursor = coo_json_skip_ws(cursor); + rc = route_loss_parse_scalar_token(cursor, &cursor, &transmission[i]); + if (rc != 0) { + return rc; + } + + cursor = coo_json_skip_ws(cursor); + if (i + 1U < MEMS_SPLIT_OUTPUT_COUNT) { + if (*cursor != ',') { + return -EINVAL; + } + cursor++; + } + } + + cursor = coo_json_skip_ws(cursor); + return *cursor == ']' ? 0 : -EINVAL; +} + +static bool route_loss_route_is_split(const char *route) +{ + char split_route[APP_ROUTE_LOSS_ROUTE_MAX_LEN] = {0}; + + for (uint8_t i = 0U; i < MEMS_SPLIT_CHANNEL_COUNT; ++i) { + (void)mems_split_route_name(i, split_route, sizeof(split_route)); + if (strcmp(route, split_route) == 0) { + return true; + } + } + + return false; +} + +static int route_loss_append_tx(char *payload, size_t payload_len, size_t *offset, + double tx) +{ + return coo_json_append(payload, payload_len, offset, "%.6f", tx); +} + +static struct coo_cmd_response route_loss_query_response(const struct coo_cmd_request *cmd, + const char *route) +{ + char payload[MAX_PAYLOAD_LEN] = {0}; + size_t offset = 0U; + + if (coo_json_append(payload, sizeof(payload), &offset, + "{\"route\":\"%s\",", route) != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + + if (route_loss_route_is_split(route)) { + if (coo_json_append(payload, sizeof(payload), &offset, "\"split\":[") != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + const char *loss_key = mems_split_output_loss_key(i); + double tx = 1.0; + + (void)app_settings_get_route_loss(route, loss_key, &tx); + if (i > 0U && + coo_json_append(payload, sizeof(payload), &offset, ",") != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + if (route_loss_append_tx(payload, sizeof(payload), &offset, tx) != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + } + if (coo_json_append(payload, sizeof(payload), &offset, "]}") != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); + } + + if (coo_json_append(payload, sizeof(payload), &offset, "\"lasers\":{") != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + for (uint8_t i = 0U; i < ARRAY_SIZE(route_loss_laser_names); ++i) { + double tx = 1.0; + + (void)app_settings_get_route_loss(route, route_loss_laser_names[i], &tx); + if (i > 0U && + coo_json_append(payload, sizeof(payload), &offset, ",") != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + if (coo_json_append(payload, sizeof(payload), &offset, "\"%s\":", + route_loss_laser_names[i]) != 0 || + route_loss_append_tx(payload, sizeof(payload), &offset, tx) != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + } + if (coo_json_append(payload, sizeof(payload), &offset, "}}") != 0) { + return coo_cmd_error(cmd, "route_loss response too large"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static struct coo_cmd_response route_loss_handle(const struct coo_cmd_request *cmd, bool set_request) +{ + char route[APP_ROUTE_LOSS_ROUTE_MAX_LEN] = {0}; + char laser[APP_ROUTE_LOSS_LASER_MAX_LEN] = {0}; + double split_tx[MEMS_SPLIT_OUTPUT_COUNT] = {0}; + double tx = 1.0; + bool persist = false; + int split_rc; + int laser_rc; + int parse_rc; + + parse_rc = coo_json_extract_string(cmd->payload, "route", route, sizeof(route)); + if (parse_rc != COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "missing or invalid route"); + } + + parse_rc = coo_json_extract_bool(cmd->payload, "persistent", &persist); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid persistent"); + } + + split_rc = route_loss_extract_split_tuple(cmd, split_tx); + laser_rc = route_loss_extract_value(cmd, laser, sizeof(laser), &tx); + + if (!set_request) { + if (split_rc != -ENOENT || laser_rc != -ENOENT || + parse_rc == COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "route_loss query uses route only"); + } + return route_loss_query_response(cmd, route); + } + + if (split_rc == 0 && laser_rc == 0) { + return coo_cmd_error(cmd, "route_loss uses split or laser value"); + } + if (split_rc == -ERANGE || laser_rc == -ERANGE) { + return coo_cmd_error(cmd, "route_loss out of range"); + } + if (split_rc != 0 && split_rc != -ENOENT) { + return coo_cmd_error(cmd, "invalid split route_loss"); + } + if (laser_rc != 0 && laser_rc != -ENOENT) { + return coo_cmd_error(cmd, "invalid route_loss value"); + } + + if (split_rc == 0) { + if (!route_loss_route_is_split(route)) { + return coo_cmd_error(cmd, "route_loss split route invalid"); + } + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + const char *loss_key = mems_split_output_loss_key(i); + + parse_rc = app_settings_set_route_loss(route, loss_key, split_tx[i], persist); + if (parse_rc == -ENOSPC) { + return coo_cmd_error(cmd, "route_loss table full"); + } + if (parse_rc != 0) { + return coo_cmd_error(cmd, "invalid route_loss key"); + } + } + return coo_cmd_ok(cmd); + } + + if (laser_rc == -ENOENT) { + return coo_cmd_error(cmd, "missing route_loss value"); + } + parse_rc = app_settings_set_route_loss(route, laser, tx, persist); + if (parse_rc == -ENOSPC) { + return coo_cmd_error(cmd, "route_loss table full"); + } + if (parse_rc != 0) { + return coo_cmd_error(cmd, "invalid route_loss key"); + } + + return coo_cmd_ok(cmd); +} + +struct coo_cmd_response memsroute_get(const struct coo_cmd_request *cmd) +{ + struct mems_route_key active[MEMS_ROUTER_MAX_ROUTES]; + const char *outputs[MEMS_ROUTER_MAX_ROUTES]; + uint8_t n_active = mems_router_active_routes(&router, active, MEMS_ROUTER_MAX_ROUTES); + uint8_t n_outputs = 0U; + char buf[MAX_PAYLOAD_LEN] = {0}; + size_t offset = 0U; + + if (memsroute_is_route_loss_key(cmd->key)) { + return route_loss_handle(cmd, false); + } + + if (coo_json_append(buf, sizeof(buf), &offset, "{\"active_routes\":{") != 0) { + return coo_cmd_error(cmd, "response too large"); + } + + for (uint8_t i = 0U; router.routes != NULL && i < router.num_routes; ++i) { + const char *output_name = router.routes[i].key.output_name; + + if (memsroute_output_seen(outputs, n_outputs, output_name)) { + continue; + } + + outputs[n_outputs++] = output_name; + if (n_outputs > 1U && + coo_json_append(buf, sizeof(buf), &offset, ",") != 0) { + return coo_cmd_error(cmd, "response too large"); + } + if (memsroute_append_sources_for_output(buf, sizeof(buf), &offset, + active, n_active, output_name) != 0) { + return coo_cmd_error(cmd, "response too large"); + } + } + + if (coo_json_append(buf, sizeof(buf), &offset, "}}") != 0) { + return coo_cmd_error(cmd, "response too large"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, buf); +} + +static float split_abs_float(float value) +{ + return value < 0.0f ? -value : value; +} + +static struct coo_cmd_response split_channel_response(const struct coo_cmd_request *cmd, + const struct mems_split_state *state, + uint8_t channel_index) +{ + const char *channel_name = mems_split_channel_name(channel_index); + char payload[MAX_PAYLOAD_LEN]; + int written; + + if (state == NULL || channel_name == NULL) { + return coo_cmd_error(cmd, "split route invalid"); + } + + written = snprintk(payload, sizeof(payload), + "{\"channel\":\"%s\"," + "\"ratio_ask\":[%.4f,%.4f,%.4f]," + "\"ratio_actual\":[%.4f,%.4f,%.4f]," + "\"ratio_out\":[%.4f,%.4f,%.4f]," + "\"split_transmission\":[%.6f,%.6f,%.6f]," + "\"switches\":[" + "{\"name\":\"%s\",\"state\":\"%c\",\"duty_cycle\":%.4f," + "\"numerator\":%u,\"denominator\":%u,\"tick_ms\":%u}," + "{\"name\":\"%s\",\"state\":\"%c\",\"duty_cycle\":%.4f," + "\"numerator\":%u,\"denominator\":%u,\"tick_ms\":%u}," + "{\"name\":\"%s\",\"state\":\"%c\",\"duty_cycle\":%.4f," + "\"numerator\":%u,\"denominator\":%u,\"tick_ms\":%u}]," + "\"stopsin_s\":%u}", + channel_name, + (double)state->requested[0], + (double)state->requested[1], + (double)state->requested[2], + (double)state->actual[0], + (double)state->actual[1], + (double)state->actual[2], + (double)state->output[0], + (double)state->output[1], + (double)state->output[2], + (double)state->transmission[0], + (double)state->transmission[1], + (double)state->transmission[2], + state->switches[0].name, + state->switches[0].state, + (double)state->switches[0].duty_cycle, + state->switches[0].numerator, + state->switches[0].denominator, + state->switches[0].tick_ms, + state->switches[1].name, + state->switches[1].state, + (double)state->switches[1].duty_cycle, + state->switches[1].numerator, + state->switches[1].denominator, + state->switches[1].tick_ms, + state->switches[2].name, + state->switches[2].state, + (double)state->switches[2].duty_cycle, + state->switches[2].numerator, + state->switches[2].denominator, + state->switches[2].tick_ms, + state->stopsin_s); + + if (written < 0 || written >= (int)sizeof(payload)) { + return coo_cmd_error(cmd, "split response too large"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static void split_emit_quantization_warning(uint8_t channel_index, + const struct mems_split_state *state) +{ + const char *channel_name = mems_split_channel_name(channel_index); + char context[144]; + + if (channel_name == NULL || state == NULL) { + return; + } + + if (split_abs_float(state->output[0] - state->requested[0]) <= 0.0005f && + split_abs_float(state->output[1] - state->requested[1]) <= 0.0005f && + split_abs_float(state->output[2] - state->requested[2]) <= 0.0005f) { + return; + } + + snprintk(context, sizeof(context), + "channel=%s ask=%.4f/%.4f/%.4f out=%.4f/%.4f/%.4f", + channel_name, + (double)state->requested[0], + (double)state->requested[1], + (double)state->requested[2], + (double)state->output[0], + (double)state->output[1], + (double)state->output[2]); + coo_cmd_runtime_warning_emit(command_runtime_get(), "split_ratio_quantized", + "requested split ratio was quantized to MEMS ticks", + context); +} + +static int split_channel_index_from_key(const char *key, uint8_t *index) +{ + char channel[8] = {0}; + + if (coo_cmd_key_suffix_segment_copy(key, "split", channel, sizeof(channel)) != 0) { + return -ENOENT; + } + + return mems_split_channel_index(channel, index); +} + +static int split_parse_channel(const struct coo_cmd_request *cmd, uint8_t *channel_index) +{ + char channel[8] = {0}; + int parse_rc; + + if (split_channel_index_from_key(cmd->key, channel_index) == 0) { + return 0; + } + + parse_rc = coo_json_extract_string(cmd->payload, "channel", + channel, sizeof(channel)); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + return -ENOENT; + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return -EINVAL; + } + + return mems_split_channel_index(channel, channel_index); +} + +struct coo_cmd_response splitting_get(const struct coo_cmd_request *cmd) +{ + struct mems_split_state state = {0}; + uint8_t channel_index; + int rc; + + rc = split_parse_channel(cmd, &channel_index); + if (rc != 0) { + return coo_cmd_error(cmd, "channel required: yj or hk"); + } + + rc = mems_split_read_channel_state(&router, channel_index, NULL, &state); + if (rc != 0) { + return coo_cmd_error(cmd, "split route invalid"); + } + + return split_channel_response(cmd, &state, channel_index); +} + +struct coo_cmd_response splitting_set(const struct coo_cmd_request *cmd) +{ + struct mems_split_state state = {0}; + uint8_t channel_index; + float requested[MEMS_SPLIT_OUTPUT_COUNT] = {0}; + float ratio3_probe = 0.0f; + uint32_t stopafter_s = 0U; + const char *failed_switch = NULL; + int parse_rc; + int rc; + + rc = split_parse_channel(cmd, &channel_index); + if (rc != 0) { + return coo_cmd_error(cmd, "channel must be yj or hk"); + } + + parse_rc = coo_json_extract_float(cmd->payload, "ratio1", &requested[0]); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "missing ratio1"); + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid ratio1"); + } + + parse_rc = coo_json_extract_float(cmd->payload, "ratio2", &requested[1]); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "missing ratio2"); + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid ratio2"); + } + + parse_rc = coo_json_extract_float(cmd->payload, "ratio3", &ratio3_probe); + if (parse_rc != COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "ratio3 is computed internally"); + } + + if (requested[0] < 0.0f || requested[0] > 1.0f || + requested[1] < 0.0f || requested[1] > 1.0f || + requested[0] + requested[1] > 1.000001f) { + return coo_cmd_error(cmd, "ratios must be 0.0-1.0 and sum <= 1.0"); + } + requested[2] = 1.0f - requested[0] - requested[1]; + + if (coo_json_extract_optional_u32(cmd->payload, "stopafter_s", + &stopafter_s, NULL) != 0 || + stopafter_s > MEMS_SWITCH_MAX_TOGGLE_DURATION_S) { + return coo_cmd_error(cmd, "invalid stopafter_s"); + } + + parse_rc = coo_json_extract_float(cmd->payload, "toggle_rate_hz", &ratio3_probe); + if (parse_rc != COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "toggle_rate_hz is automatic"); + } + + rc = mems_split_apply_channel(&router, channel_index, requested, stopafter_s, + &state, &failed_switch); + if (rc == -ENOENT) { + return coo_cmd_error(cmd, "split route references missing switch"); + } + if (rc != 0) { + char payload[MAX_PAYLOAD_LEN]; + + if (failed_switch != NULL) { + snprintk(payload, sizeof(payload), + "{\"error\":\"failed setting %s\"}", failed_switch); + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, payload); + } + return coo_cmd_error(cmd, "split route unavailable"); + } + + split_emit_quantization_warning(channel_index, &state); + return split_channel_response(cmd, &state, channel_index); +} + +struct coo_cmd_response memsroute_set(const struct coo_cmd_request *cmd) +{ + struct mems_route_id route_id = {0}; + const struct mems_route *route; + const char *failed_switch = NULL; + char failed_state = '\0'; + struct json_obj_descr d[] = { + JSON_OBJ_DESCR_PRIM(struct mems_route_id, input, JSON_TOK_STRING), + JSON_OBJ_DESCR_PRIM(struct mems_route_id, output, JSON_TOK_STRING), + }; + int rc; + + if (memsroute_is_route_loss_key(cmd->key)) { + return route_loss_handle(cmd, true); + } + + if (json_obj_parse((char *)cmd->payload, cmd->payload_len, + d, ARRAY_SIZE(d), &route_id) < 0) { + return coo_cmd_error(cmd, "Failed to parse JSON input or output"); + } + + route = mems_router_get_route(&router, route_id.input, route_id.output); + if (route == NULL) { + return coo_cmd_error(cmd, "Invalid Route"); + } + + rc = mems_router_apply_route(&router, route, &failed_switch, &failed_state); + if (rc == -ENOENT) { + return coo_cmd_error(cmd, "route references missing switch"); + } + if (rc != 0) { + char payload[MAX_PAYLOAD_LEN] = {0}; + + snprintk(payload, sizeof(payload), + "{\"error\":\"Setting switch %s to %c failed\"}", + failed_switch == NULL ? "unknown" : failed_switch, + failed_state == '\0' ? '?' : failed_state); + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, payload); + } + + LOG_INF("Set route %s -> %s", route_id.input, route_id.output); + return coo_cmd_ok(cmd); +} + +static void mems_format_state(const struct mems_switch_status *status, + char *out, + size_t out_len) +{ + char state = status->state; + + if (status->duty_denominator != 0U) { + if (status->duty_numerator == 0U) { + state = 'B'; + } else if (status->duty_numerator >= status->duty_denominator) { + state = 'A'; + } + } + + if (state == 'A' || state == 'B') { + snprintk(out, out_len, status->state_known_this_boot ? "%c" : "%c?", + state); + return; + } + + snprintk(out, out_len, "?"); +} + +static bool mems_status_has_nonconstant_duty(const struct mems_switch_status *status) +{ + return status->duty_denominator != 0U && + status->duty_numerator > 0U && + status->duty_numerator < status->duty_denominator; +} + +static int mems_append_duty_field(char *buf, size_t buf_len, size_t *offset, + const struct mems_switch_status *status) +{ + int written; + + written = snprintk(buf + *offset, buf_len - *offset, + ",\"duty_cycle\":%.3f", + (double)status->duty_cycle); + if (written < 0 || written >= (int)(buf_len - *offset)) { + return -ENOSPC; + } + + *offset += (size_t)written; + return 0; +} + +/* Command responses include timing fields only while the requested MEMS profile + * is mixed duty. Static A/B replies stay compact and unambiguous. + */ +static int mems_append_timing_fields(char *buf, size_t buf_len, size_t *offset, + const struct mems_switch_status *status) +{ + int written; + + if (mems_append_duty_field(buf, buf_len, offset, status) != 0) { + return -ENOSPC; + } + + written = snprintk(buf + *offset, buf_len - *offset, + ",\"requested_toggle_rate_hz\":%.3f," + "\"toggle_rate_hz\":%.3f,\"stopafter_s\":%u", + (double)status->requested_toggle_rate_hz, + (double)status->toggle_rate_hz, + status->stopafter_s); + if (written < 0 || written >= (int)(buf_len - *offset)) { + return -ENOSPC; + } + + *offset += (size_t)written; + return 0; +} + +static struct coo_cmd_response mems_response_for_switch(const struct coo_cmd_request *cmd, + const struct mems_switch *sw) +{ + struct mems_switch_status status = {0}; + char state_buf[4] = {0}; + char payload[MAX_PAYLOAD_LEN] = {0}; + size_t off = 0U; + int written; + + mems_switch_get_status(sw, &status); + mems_format_state(&status, state_buf, sizeof(state_buf)); + + written = snprintk(payload + off, sizeof(payload) - off, + "{\"state\":\"%s\"", state_buf); + if (written < 0 || written >= (int)(sizeof(payload) - off)) { + return coo_cmd_error(cmd, "mems response too large"); + } + off += (size_t)written; + + if (mems_status_has_nonconstant_duty(&status) && + mems_append_timing_fields(payload, sizeof(payload), &off, &status) != 0) { + return coo_cmd_error(cmd, "mems response too large"); + } + + written = snprintk(payload + off, sizeof(payload) - off, "}"); + if (written < 0 || written >= (int)(sizeof(payload) - off)) { + return coo_cmd_error(cmd, "mems response too large"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +struct coo_cmd_response mems_get(const struct coo_cmd_request *cmd) +{ + if (strcmp(cmd->key, "mems") == 0) { + char payload[MAX_PAYLOAD_LEN] = {0}; + size_t off = 0U; + int written; + struct mems_switch_status status = {0}; + char state_buf[4] = {0}; + + written = snprintk(payload + off, sizeof(payload) - off, "{"); + off += (size_t)written; + + for (uint8_t i = 0U; i < router.num_switches; ++i) { + if (i > 0U) { + written = snprintk(payload + off, sizeof(payload) - off, ","); + if (written < 0 || written >= (int)(sizeof(payload) - off)) { + return coo_cmd_error(cmd, + "mems response too large; query mems/"); + } + off += (size_t)written; + } + + mems_switch_get_status(router.switches[i], &status); + mems_format_state(&status, state_buf, sizeof(state_buf)); + written = snprintk(payload + off, sizeof(payload) - off, + "\"%s\":{\"state\":\"%s\"", + router.switches[i]->name, + state_buf); + if (written < 0 || written >= (int)(sizeof(payload) - off)) { + return coo_cmd_error(cmd, + "mems response too large; query mems/"); + } + off += (size_t)written; + + if (mems_status_has_nonconstant_duty(&status) && + mems_append_duty_field(payload, sizeof(payload), &off, &status) != 0) { + return coo_cmd_error(cmd, + "mems response too large; query mems/"); + } + + written = snprintk(payload + off, sizeof(payload) - off, "}"); + if (written < 0 || written >= (int)(sizeof(payload) - off)) { + return coo_cmd_error(cmd, + "mems response too large; query mems/"); + } + off += (size_t)written; + } + + written = snprintk(payload + off, sizeof(payload) - off, "}"); + if (written < 0 || written >= (int)(sizeof(payload) - off)) { + return coo_cmd_error(cmd, "mems response too large"); + } + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); + } + + char mems_switch[MEMS_SWITCH_NAME_LEN] = {0}; + if (coo_cmd_key_suffix_segment_copy(cmd->key, "mems", mems_switch, + sizeof(mems_switch)) != 0) { + return coo_cmd_error(cmd, "Failed to parse mems switch name"); + } + + struct mems_switch *sw = mems_router_find_switch(&router, mems_switch); + + if (sw == NULL) { + return coo_cmd_error(cmd, "Invalid switch name"); + } + + return mems_response_for_switch(cmd, sw); +} + +struct coo_cmd_response mems_set(const struct coo_cmd_request *cmd) +{ + char requested_state[8] = {0}; + char mems_switch[MEMS_SWITCH_NAME_LEN] = {0}; + float duty_cycle = 0.0f; + float stopafter_s = 0.0f; + float toggle_rate_hz = 0.0f; + uint32_t stopafter_s_u32 = 0U; + bool has_duty_cycle = false; + bool has_stopafter_s = false; + bool has_toggle_rate_hz = false; + int state_value; + int parse_rc; + int rc; + + if (coo_cmd_key_suffix_segment_copy(cmd->key, "mems", mems_switch, + sizeof(mems_switch)) != 0) { + return coo_cmd_error(cmd, "Failed to parse mems switch name"); + } + + parse_rc = coo_json_extract_string(cmd->payload, "state", + requested_state, sizeof(requested_state)); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "Missing state"); + } + if (parse_rc == COO_JSON_EXTRACT_ERR || + requested_state[0] == '\0' || requested_state[1] != '\0') { + return coo_cmd_error(cmd, "state must be A or B"); + } + + if (coo_json_match_string_choice(requested_state, mems_switch_state_choices, + ARRAY_SIZE(mems_switch_state_choices), + &state_value) != 0) { + return coo_cmd_error(cmd, "state must be A or B"); + } + requested_state[0] = (char)state_value; + + parse_rc = coo_json_extract_float(cmd->payload, "duty_cycle", &duty_cycle); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "duty_cycle must be a number from 0.0 to 1.0"); + } + has_duty_cycle = (parse_rc == COO_JSON_EXTRACT_OK); + if (has_duty_cycle && (duty_cycle < 0.0f || duty_cycle > 1.0f)) { + return coo_cmd_error(cmd, "duty_cycle must be a number from 0.0 to 1.0"); + } + + parse_rc = coo_json_extract_float(cmd->payload, "stopafter_s", &stopafter_s); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "Invalid stopafter_s"); + } + has_stopafter_s = (parse_rc == COO_JSON_EXTRACT_OK); + + parse_rc = coo_json_extract_float(cmd->payload, "toggle_rate_hz", &toggle_rate_hz); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "Invalid toggle_rate_hz"); + } + has_toggle_rate_hz = (parse_rc == COO_JSON_EXTRACT_OK); + if (has_toggle_rate_hz && toggle_rate_hz <= 0.0f) { + return coo_cmd_error(cmd, "toggle_rate_hz must be > 0"); + } + + if (has_duty_cycle && requested_state[0] == 'B') { + return coo_cmd_error(cmd, "duty_cycle only valid with state A"); + } + if (has_stopafter_s) { + if (stopafter_s < 0.0f || + stopafter_s > (float)MEMS_SWITCH_MAX_TOGGLE_DURATION_S) { + return coo_cmd_error(cmd, "stopafter_s out of range"); + } + stopafter_s_u32 = (uint32_t)(stopafter_s + 0.5f); + if (stopafter_s_u32 == 0U && duty_cycle > 0.0f && duty_cycle < 1.0f) { + return coo_cmd_error(cmd, "stopafter_s must be > 0 for toggling"); + } + } + + struct mems_switch *sw = mems_router_find_switch(&router, mems_switch); + + if (sw == NULL) { + return coo_cmd_error(cmd, "Invalid switch name"); + } + + if (has_duty_cycle) { + rc = mems_switch_set_state(sw, requested_state[0], duty_cycle, + stopafter_s_u32, + has_toggle_rate_hz ? toggle_rate_hz : 0.0f); + } else { + rc = mems_switch_set_state(sw, requested_state[0], 1.0f, 0U, + has_toggle_rate_hz ? toggle_rate_hz : 0.0f); + } + + if (rc == -ERANGE) { + return coo_cmd_error(cmd, "MEMS setting out of range"); + } + if (rc != 0) { + return coo_cmd_error(cmd, "Invalid MEMS setting"); + } + + if (has_toggle_rate_hz) { + struct mems_switch_status status = {0}; + char context[96]; + float diff; + + mems_switch_get_status(sw, &status); + diff = status.toggle_rate_hz - toggle_rate_hz; + if (diff < 0.0f) { + diff = -diff; + } + if (diff > 0.001f) { + snprintk(context, sizeof(context), + "switch=%s requested=%.3f actual=%.3f", + sw->name, (double)toggle_rate_hz, + (double)status.toggle_rate_hz); + coo_cmd_runtime_warning_emit(command_runtime_get(), "mems_rate_quantized", + "requested MEMS toggle rate was quantized", + context); + } + } + + return mems_response_for_switch(cmd, sw); +} diff --git a/app/src/mems_command.h b/app/src/mems_command.h new file mode 100644 index 0000000..571e257 --- /dev/null +++ b/app/src/mems_command.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef HISPEC_MEMS_COMMAND_H +#define HISPEC_MEMS_COMMAND_H + +#include "command.h" + +/** + * @file mems_command.h + * @brief Command adapters for MEMS switch, route, route-loss, and split control. + * + * The adapters parse MQTT/serial payloads and format command responses. MEMS + * pulse scheduling, static route application, and splitter tick math are owned + * by mems_switching.c. + */ + +/** Query active MEMS routes or route-loss settings. */ +struct coo_cmd_response memsroute_get(const struct coo_cmd_request *cmd); + +/** Apply one MEMS route or route-loss setting. */ +struct coo_cmd_response memsroute_set(const struct coo_cmd_request *cmd); + +/** Query all MEMS switches or one switch. */ +struct coo_cmd_response mems_get(const struct coo_cmd_request *cmd); + +/** Apply one MEMS switch state or toggle profile. */ +struct coo_cmd_response mems_set(const struct coo_cmd_request *cmd); + +/** Query one AS splitter channel, usually with command key split/yj or split/hk. */ +struct coo_cmd_response splitting_get(const struct coo_cmd_request *cmd); + +/** Apply one AS-PCB splitter channel using channel, ratio1, and ratio2. */ +struct coo_cmd_response splitting_set(const struct coo_cmd_request *cmd); + +#endif /* HISPEC_MEMS_COMMAND_H */ diff --git a/app/src/mems_switching.c b/app/src/mems_switching.c index 42e92fe..3afca66 100644 --- a/app/src/mems_switching.c +++ b/app/src/mems_switching.c @@ -4,6 +4,8 @@ */ #include "mems_switching.h" +#include "app_settings.h" + #include #include #include @@ -11,10 +13,57 @@ #include LOG_MODULE_REGISTER(mems_switching, LOG_LEVEL_DBG); -static uint32_t min_toggle_period_cycles(void) +#define MEMS_ROUTER_STACK_SIZE 1024 +#define MEMS_ROUTER_PRIORITY 2 + +BUILD_ASSERT((MEMS_SWITCH_ELECTRICAL_PULSE_MS % MEMS_SWITCH_ROUTER_TICK_MS) == 0U, + "FFSW pulse width must be an integer number of router ticks"); +BUILD_ASSERT((MEMS_SWITCH_ELECTRICAL_PULSE_FFLS_MS % MEMS_SWITCH_ROUTER_TICK_MS) == 0U, + "FFLS pulse width must be an integer number of router ticks"); + +static void mems_router_timer_handler(struct k_timer *timer); +static void mems_router_thread(void *p1, void *p2, void *p3); + +static K_SEM_DEFINE(mems_router_start_sem, 0, 1); +static K_SEM_DEFINE(mems_router_tick_sem, 0, 1); +static K_TIMER_DEFINE(mems_router_timer, mems_router_timer_handler, NULL); + +static struct mems_router *active_router; + +static const char *split_channel_names[MEMS_SPLIT_CHANNEL_COUNT] = {"yj", "hk"}; +static const char *split_route_inputs[MEMS_SPLIT_CHANNEL_COUNT] = {"yj_calin", "hk_calin"}; +static const char *split_route_outputs[MEMS_SPLIT_CHANNEL_COUNT] = {"yj_split", "hk_split"}; +static const char *split_output_loss_keys[MEMS_SPLIT_OUTPUT_COUNT] = { + "split1", "split2", "split3", +}; +static struct mems_split_state g_split_state[MEMS_SPLIT_CHANNEL_COUNT]; +static K_MUTEX_DEFINE(split_state_lock); + +K_THREAD_DEFINE(mems_router_tid, MEMS_ROUTER_STACK_SIZE, + mems_router_thread, NULL, NULL, NULL, + MEMS_ROUTER_PRIORITY, 0, 0); + +static uint32_t mems_switch_pulse_ms(const struct mems_switch *sw) +{ + return sw->switch_type == MEMS_SWITCH_TYPE_FFLS ? + MEMS_SWITCH_ELECTRICAL_PULSE_FFLS_MS : + MEMS_SWITCH_ELECTRICAL_PULSE_MS; +} + +static uint8_t mems_switch_work_ticks(const struct mems_switch *sw) +{ + return (uint8_t)(mems_switch_pulse_ms(sw) / MEMS_SWITCH_ROUTER_TICK_MS); +} + +static bool time_reached_u32(uint32_t now_ms, uint32_t deadline_ms) +{ + return (int32_t)(now_ms - deadline_ms) >= 0; +} + +static uint32_t min_toggle_period_cycles(const struct mems_switch *sw) { const float min_period_ms = 1000.0f / MEMS_SWITCH_MAX_TOGGLE_HZ; - const float cycles = min_period_ms / (float)MEMS_SWITCH_ELECTRICAL_PULSE_MS; + const float cycles = min_period_ms / (float)mems_switch_pulse_ms(sw); uint32_t min_cycles = (uint32_t)cycles; if ((float)min_cycles < cycles) { @@ -26,16 +75,17 @@ static uint32_t min_toggle_period_cycles(void) return min_cycles; } -static uint32_t quantize_toggle_period_cycles(float requested_rate_hz) +static uint32_t quantize_toggle_period_cycles(const struct mems_switch *sw, + float requested_rate_hz) { uint32_t period_cycles; - const uint32_t min_cycles = min_toggle_period_cycles(); + const uint32_t min_cycles = min_toggle_period_cycles(sw); if (requested_rate_hz <= 0.0f) { return min_cycles; } - period_cycles = (uint32_t)((1000.0f / (requested_rate_hz * (float)MEMS_SWITCH_ELECTRICAL_PULSE_MS)) + 0.5f); + period_cycles = (uint32_t)((1000.0f / (requested_rate_hz * (float)mems_switch_pulse_ms(sw))) + 0.5f); if (period_cycles < min_cycles) { period_cycles = min_cycles; } @@ -45,17 +95,18 @@ static uint32_t quantize_toggle_period_cycles(float requested_rate_hz) return period_cycles; } -static float attained_toggle_rate_hz(uint32_t period_cycles) +static float attained_toggle_rate_hz(const struct mems_switch *sw, + uint32_t period_cycles) { if (period_cycles == 0U) { return 0.0f; } - return 1000.0f / ((float)period_cycles * (float)MEMS_SWITCH_ELECTRICAL_PULSE_MS); + return 1000.0f / ((float)period_cycles * (float)mems_switch_pulse_ms(sw)); } -static uint32_t seconds_to_cycles(uint32_t seconds) +static uint32_t seconds_to_cycles(const struct mems_switch *sw, uint32_t seconds) { - uint64_t cycles = ((uint64_t)seconds * 1000ULL) / (uint64_t)MEMS_SWITCH_ELECTRICAL_PULSE_MS; + uint64_t cycles = ((uint64_t)seconds * 1000ULL) / (uint64_t)mems_switch_pulse_ms(sw); if (cycles == 0ULL) { cycles = 1ULL; } @@ -66,7 +117,7 @@ static uint32_t seconds_to_cycles(uint32_t seconds) } -static struct mems_switch *mems_router_find_switch_unlocked(const struct mems_router *router, const char *name) +static struct mems_switch *mems_router_find_switch_locked(const struct mems_router *router, const char *name) { for (uint8_t i = 0; i < router->num_switches; ++i) { if (strncmp(router->switches[i]->name, name, MEMS_SWITCH_NAME_LEN) == 0) { @@ -102,15 +153,15 @@ static void mems_switch_apply_requested_rate_locked(struct mems_switch *sw, } sw->requested_toggle_rate_hz = requested_rate_hz; - sw->switching_period_cycles = quantize_toggle_period_cycles(requested_rate_hz); - sw->actual_toggle_rate_hz = attained_toggle_rate_hz(sw->switching_period_cycles); + sw->switching_period_cycles = quantize_toggle_period_cycles(sw, requested_rate_hz); + sw->actual_toggle_rate_hz = attained_toggle_rate_hz(sw, sw->switching_period_cycles); } static void mems_switch_apply_exact_rate_locked(struct mems_switch *sw, uint32_t period_cycles) { sw->switching_period_cycles = period_cycles; - sw->actual_toggle_rate_hz = attained_toggle_rate_hz(period_cycles); + sw->actual_toggle_rate_hz = attained_toggle_rate_hz(sw, period_cycles); sw->requested_toggle_rate_hz = sw->actual_toggle_rate_hz; } @@ -136,7 +187,7 @@ static int mems_switch_apply_profile_locked(struct mems_switch *sw, return 0; } - /* Mixed duty cycles are applied by the router-owned k_work_delayable tick. + /* Mixed duty cycles are applied by the router-owned MEMS tick thread. * A zero duration means "run until the firmware safety maximum". */ requested_duration_s = stop_after_s; @@ -147,7 +198,7 @@ static int mems_switch_apply_profile_locked(struct mems_switch *sw, (sw->a_state_cycles == a_cycles) && (previous_period_cycles == sw->switching_period_cycles); - sw->remaining_toggle_cycles = seconds_to_cycles(requested_duration_s); + sw->remaining_toggle_cycles = seconds_to_cycles(sw, requested_duration_s); if (!same_profile) { sw->a_state_cycles = a_cycles; sw->target_state = 'A'; @@ -157,9 +208,9 @@ static int mems_switch_apply_profile_locked(struct mems_switch *sw, return 0; } -static int mems_switch_set_state_internal(struct mems_switch *sw, char state, - float duty_cycle, uint32_t stop_after_s, - float requested_toggle_rate_hz) +int mems_switch_set_state(struct mems_switch *sw, char state, + float duty_cycle, uint32_t stop_after_s, + float requested_toggle_rate_hz) { struct mems_router *router; uint32_t previous_period_cycles; @@ -211,17 +262,20 @@ static void mems_switch_tick_locked(struct mems_switch *sw) const bool toggling = (sw->remaining_toggle_cycles > 0U); if ((!sw->state_known_this_boot || sw->state != sw->target_state)) { + const struct gpio_dt_spec *gpio = + (sw->target_state == 'A') ? &sw->gpio_a : &sw->gpio_b; - gpio_pin_t pin = (sw->target_state == 'A') ? sw->pin_a : sw->pin_b; - - /* gpio_pin_set() is raw because the PCAL6416A pins are passed as - * gpio_pin_t values, not gpio_dt_spec. The overlay active flags are - * therefore not applied here. + /* gpio_pin_set_dt(..., 1) emits the board-defined active pulse. The + * Nucleo MEMS drive stage is active-low at the PCAL pin, so the + * external switch-control line pulses high. */ - if (gpio_pin_set(sw->gpio_dev, pin, 1) != 0) { - LOG_ERR("Pulse set failed on %s pin %u", sw->name, (unsigned int)pin); + if (gpio_pin_set_dt(gpio, 1) != 0) { + LOG_ERR("Pulse set failed on %s pin %u", sw->name, + (unsigned int)gpio->pin); } else { + sw->pulse_clear_at_ms = k_uptime_get_32() + mems_switch_pulse_ms(sw); + sw->pulse_active = true; sw->state = sw->target_state; sw->state_known_this_boot = true; } @@ -260,75 +314,212 @@ static void mems_switch_tick_locked(struct mems_switch *sw) } } -static void mems_router_toggler_work_handler(struct k_work *work) +static void mems_switch_clear_finished_pulse_elapsed_locked(struct mems_switch *sw, + uint32_t now_ms) +{ + if (!sw->pulse_active || !time_reached_u32(now_ms, sw->pulse_clear_at_ms)) { + return; + } + + sw->pulse_active = false; + { + const struct gpio_dt_spec *gpio = + (sw->state == 'A') ? &sw->gpio_a : &sw->gpio_b; + + (void)gpio_pin_set_dt(gpio, 0); + } + + if (time_reached_u32(now_ms, sw->pulse_clear_at_ms + MEMS_SWITCH_ROUTER_TICK_MS)) { + LOG_WRN("MEMS %s pulse cleanup was %u ms late", + sw->name, (unsigned int)(now_ms - sw->pulse_clear_at_ms)); + } +} + +static uint32_t mems_switch_target_hold_cycles(const struct mems_switch *sw) +{ + if (sw->switching_period_cycles == 0U) { + return 0U; + } + + if (sw->target_state == 'A') { + return sw->a_state_cycles; + } + return sw->switching_period_cycles - sw->a_state_cycles; +} + +static void mems_switch_drop_fully_missed_pulse_locked(struct mems_switch *sw, + uint32_t late_service_cycles) +{ + if (sw->remaining_toggle_cycles <= late_service_cycles) { + mems_switch_stop_toggling_locked(sw); + return; + } + + sw->remaining_toggle_cycles -= late_service_cycles; + + /* A low-to-high pulse opportunity is real-time. If the full requested high + * window elapsed before this thread ran, do not emit a stale pulse. + */ + sw->target_state = sw->state; + sw->cycles_until_toggle = 1U; +} + +static void mems_switch_service_elapsed_locked(struct mems_switch *sw, + uint32_t elapsed_ticks) +{ + const uint8_t service_period_ticks = mems_switch_work_ticks(sw); + uint32_t late_service_cycles; + bool pulse_due; + + if (elapsed_ticks == 0U) { + return; + } + + if (sw->service_ticks_remaining > elapsed_ticks) { + sw->service_ticks_remaining -= elapsed_ticks; + return; + } + + late_service_cycles = + (elapsed_ticks - sw->service_ticks_remaining) / service_period_ticks; + pulse_due = !sw->state_known_this_boot || sw->state != sw->target_state; + + if (late_service_cycles > 0U && pulse_due) { + uint32_t hold_cycles = mems_switch_target_hold_cycles(sw); + + if (hold_cycles <= late_service_cycles) { + LOG_WRN("MEMS %s skipped stale %c pulse after %u missed service ticks", + sw->name, sw->target_state, late_service_cycles); + mems_switch_drop_fully_missed_pulse_locked(sw, late_service_cycles); + sw->service_ticks_remaining = service_period_ticks; + return; + } + + LOG_WRN_RATELIMIT_RATE(10000, "MEMS %s applying late %c pulse after %u missed service ticks", + sw->name, sw->target_state, late_service_cycles); + } else if (late_service_cycles > 0U) { + LOG_WRN_RATELIMIT_RATE(10000, "MEMS %s service tick was %u cycles late", + sw->name, late_service_cycles); + } + + mems_switch_tick_locked(sw); + if (late_service_cycles > 0U && pulse_due && sw->remaining_toggle_cycles > 0U) { + if (sw->remaining_toggle_cycles > late_service_cycles) { + sw->remaining_toggle_cycles -= late_service_cycles; + } else { + mems_switch_stop_toggling_locked(sw); + } + + if (sw->cycles_until_toggle > late_service_cycles) { + sw->cycles_until_toggle -= late_service_cycles; + } else if (sw->remaining_toggle_cycles > 0U) { + sw->cycles_until_toggle = 1U; + } + } + sw->service_ticks_remaining = service_period_ticks; +} + +static void mems_router_process_ticks(struct mems_router *router, uint32_t elapsed_ticks) { - struct k_work_delayable *dwork = k_work_delayable_from_work(work); - struct mems_router *router = CONTAINER_OF(dwork, struct mems_router, toggler_work); + uint32_t now_ms; + + if (router == NULL || elapsed_ticks == 0U) { + return; + } + + if (elapsed_ticks > 1U) { + LOG_WRN_RATELIMIT_RATE(10000, "MEMS router missed %u base ticks", + (unsigned int)(elapsed_ticks - 1U)); + } k_mutex_lock(&router->lock, K_FOREVER); - //TODO this is quite a bit of unnecessary I2C bus activity, at most half of these will be high + now_ms = k_uptime_get_32(); + for (uint8_t i = 0; i < router->num_switches; ++i) { - struct mems_switch *sw = router->switches[i]; - (void)gpio_pin_set(sw->gpio_dev, sw->pin_a, 0); - (void)gpio_pin_set(sw->gpio_dev, sw->pin_b, 0); + mems_switch_clear_finished_pulse_elapsed_locked(router->switches[i], + now_ms); } for (uint8_t i = 0; i < router->num_switches; ++i) { - mems_switch_tick_locked(router->switches[i]); + mems_switch_service_elapsed_locked(router->switches[i], elapsed_ticks); } k_mutex_unlock(&router->lock); +} + +static void mems_router_timer_handler(struct k_timer *timer) +{ + ARG_UNUSED(timer); - (void)k_work_reschedule(&router->toggler_work, K_MSEC(MEMS_SWITCH_ELECTRICAL_PULSE_MS)); + /* Timer expiry is interrupt context. GPIO-expander writes happen in the + * dedicated MEMS thread so bus I/O never runs in the ISR. + */ + k_sem_give(&mems_router_tick_sem); +} + +static void mems_router_thread(void *p1, void *p2, void *p3) +{ + ARG_UNUSED(p1); + ARG_UNUSED(p2); + ARG_UNUSED(p3); + + k_sem_take(&mems_router_start_sem, K_FOREVER); + + while (1) { + uint32_t elapsed_ticks; + + k_sem_take(&mems_router_tick_sem, K_FOREVER); + elapsed_ticks = k_timer_status_get(&mems_router_timer); + if (elapsed_ticks == 0U) { + elapsed_ticks = 1U; + } + + mems_router_process_ticks(active_router, elapsed_ticks); + } } // ----------------------- // Switch Methods // ----------------------- -void mems_switch_init(struct mems_switch *sw, const struct device *gpio_dev, - gpio_pin_t pin_a, gpio_pin_t pin_b, const char *name, +void mems_switch_init(struct mems_switch *sw, + const struct gpio_dt_spec *gpio_a, + const struct gpio_dt_spec *gpio_b, + const char *name, + enum mems_switch_type switch_type, float switching_frequency_hz, char initial_state) { - sw->gpio_dev = gpio_dev; - sw->pin_a = pin_a; - sw->pin_b = pin_b; + sw->gpio_a = *gpio_a; + sw->gpio_b = *gpio_b; + sw->switch_type = switch_type; sw->state = toupper(initial_state)=='A'? 'A': 'B'; sw->target_state = sw->state; sw->state_known_this_boot = false; sw->owner = NULL; - sw->a_state_cycles = 0U; sw->cycles_until_toggle = 0U; sw->remaining_toggle_cycles = 0U; + sw->pulse_clear_at_ms = 0U; + sw->pulse_active = false; + sw->service_ticks_remaining = 0U; sw->requested_toggle_rate_hz = switching_frequency_hz; - sw->switching_period_cycles = quantize_toggle_period_cycles(switching_frequency_hz); - sw->actual_toggle_rate_hz = attained_toggle_rate_hz(sw->switching_period_cycles); + sw->switching_period_cycles = quantize_toggle_period_cycles(sw, switching_frequency_hz); + sw->actual_toggle_rate_hz = attained_toggle_rate_hz(sw, sw->switching_period_cycles); + /* Keep status readback consistent before the first router pulse. */ + sw->a_state_cycles = (sw->state == 'A') ? sw->switching_period_cycles : 0U; strncpy(sw->name, name, MEMS_SWITCH_NAME_LEN-1); sw->name[MEMS_SWITCH_NAME_LEN-1] = '\0'; - /* Raw gpio_pin_configure() is used because board profiles store only the - * expander pin numbers. These calls do not apply GPIO_ACTIVE_LOW/HIGH from - * devicetree. + /* gpio_pin_configure_dt() applies the board polarity from the overlay. + * For Nucleo MEMS lines, logical inactive is the external idle-low state. */ - (void)gpio_pin_configure(gpio_dev, pin_a, GPIO_OUTPUT_INACTIVE); - (void)gpio_pin_configure(gpio_dev, pin_b, GPIO_OUTPUT_INACTIVE); + (void)gpio_pin_configure_dt(&sw->gpio_a, GPIO_OUTPUT_INACTIVE); + (void)gpio_pin_configure_dt(&sw->gpio_b, GPIO_OUTPUT_INACTIVE); } -int mems_switch_set_state(struct mems_switch *sw, - char state, - float duty_cycle, - uint32_t stop_after_s, - float requested_toggle_rate_hz) -{ - // todo is this static/nonstatic nesting needed? - return mems_switch_set_state_internal(sw, state, duty_cycle, stop_after_s, - requested_toggle_rate_hz); -} - int mems_switch_set_state_ticks(struct mems_switch *sw, char state, uint32_t state_ticks, uint32_t period_ticks, uint32_t stop_after_s) @@ -347,7 +538,7 @@ int mems_switch_set_state_ticks(struct mems_switch *sw, char state, if (state != 'A' && state != 'B') { return -EINVAL; } - if (period_ticks < min_toggle_period_cycles() || + if (period_ticks < min_toggle_period_cycles(sw) || state_ticks > period_ticks || stop_after_s > MEMS_SWITCH_MAX_TOGGLE_DURATION_S) { return -ERANGE; @@ -393,12 +584,12 @@ void mems_switch_get_status(const struct mems_switch *sw, struct mems_switch_sta out->duty_cycle = (float)sw->a_state_cycles / (float)sw->switching_period_cycles; //actual attained duty cycle out->duty_numerator = sw->a_state_cycles; out->duty_denominator = sw->switching_period_cycles; - out->tick_duration_ms = MEMS_SWITCH_ELECTRICAL_PULSE_MS; + out->tick_duration_ms = mems_switch_pulse_ms(sw); out->requested_toggle_rate_hz = sw->requested_toggle_rate_hz; out->toggle_rate_hz = sw->actual_toggle_rate_hz; if (sw->remaining_toggle_cycles!=0) { - out->stopafter_s = (sw->remaining_toggle_cycles * MEMS_SWITCH_ELECTRICAL_PULSE_MS + 999U)/ 1000U; + out->stopafter_s = (sw->remaining_toggle_cycles * mems_switch_pulse_ms(sw) + 999U)/ 1000U; } if (sw->owner != NULL) { @@ -423,21 +614,28 @@ void mems_router_init(struct mems_router *router, struct mems_switch **switches, router->routes = routes; router->num_routes = num_routes; - //It is vital that at this point in the code the switches be initialized such that they WILL NOT toggle. Their - // needed states and potentially restored states must all align. - //TODO Verify this is the case. - - k_work_init_delayable(&router->toggler_work, mems_router_toggler_work_handler); if (router->num_switches > 0U) { - (void)k_work_reschedule(&router->toggler_work, K_MSEC(MEMS_SWITCH_ELECTRICAL_PULSE_MS)); + active_router = router; + k_timer_start(&mems_router_timer, + K_MSEC(MEMS_SWITCH_ROUTER_TICK_MS), + K_MSEC(MEMS_SWITCH_ROUTER_TICK_MS)); + k_sem_give(&mems_router_start_sem); } } struct mems_switch *mems_router_find_switch(const struct mems_router *router, const char *name) { - //todo Why not just make mems_router_find_switch_unlocked a non-static function. This nesting is cryptic and unapproachable to maintainers coming from python - // and should be documented - return mems_router_find_switch_unlocked(router, name); + struct mems_switch *sw; + + if (router == NULL || name == NULL) { + return NULL; + } + + k_mutex_lock((struct k_mutex *)&router->lock, K_FOREVER); + sw = mems_router_find_switch_locked(router, name); + k_mutex_unlock((struct k_mutex *)&router->lock); + + return sw; } // Find route and return pointer/step count, or NULL/-1 if not found @@ -457,6 +655,65 @@ const struct mems_route *mems_router_get_route(const struct mems_router *router, return NULL; } +int mems_router_apply_route(const struct mems_router *router, + const struct mems_route *route, + const char **failed_switch, + char *failed_state) +{ + if (router == NULL || route == NULL) { + return -EINVAL; + } + + for (uint8_t i = 0U; i < route->num_steps; ++i) { + const struct mems_route_step *step = &route->steps[i]; + struct mems_switch *sw = mems_router_find_switch(router, step->switch_name); + int rc; + + if (sw == NULL) { + if (failed_switch != NULL) { + *failed_switch = step->switch_name; + } + if (failed_state != NULL) { + *failed_state = step->state; + } + return -ENOENT; + } + + rc = mems_switch_set_state(sw, step->state, 1.0f, 0U, 0.0f); + if (rc != 0) { + if (failed_switch != NULL) { + *failed_switch = step->switch_name; + } + if (failed_state != NULL) { + *failed_state = step->state; + } + return rc; + } + } + + return 0; +} + +int mems_router_apply_named_route(const struct mems_router *router, + const char *input, + const char *output, + const char **failed_switch, + char *failed_state) +{ + const struct mems_route *route; + + if (router == NULL || input == NULL || output == NULL) { + return -EINVAL; + } + + route = mems_router_get_route(router, input, output); + if (route == NULL) { + return -ENOENT; + } + + return mems_router_apply_route(router, route, failed_switch, failed_state); +} + // List all routes whose switches are ALL in the expected state. // Returns the number of active routes found, up to max_keys. // Each result is an (input, output) pair. @@ -472,7 +729,7 @@ uint8_t mems_router_active_routes(const struct mems_router *router, bool match = true; for (uint8_t j = 0; j < route->num_steps; ++j) { const struct mems_route_step *step = &route->steps[j]; - struct mems_switch *sw = mems_router_find_switch_unlocked(router, step->switch_name); + struct mems_switch *sw = mems_router_find_switch_locked(router, step->switch_name); if (!sw || sw->state != step->state || sw->remaining_toggle_cycles>0) { match = false; break; @@ -488,3 +745,363 @@ uint8_t mems_router_active_routes(const struct mems_router *router, k_mutex_unlock((struct k_mutex *)&router->lock); return n_found; } + +//TODO: TFD or relocation to _command.c +const char *mems_split_channel_name(uint8_t channel_index) +{ + if (channel_index >= MEMS_SPLIT_CHANNEL_COUNT) { + return NULL; + } + + return split_channel_names[channel_index]; +} + +//TODO: TFD or relocation to _command.c and/or combination with mems_split_channel_name +//TODO GLOBALLY: the names that llm is coming up with sometimes make it profoundly hard to reason about where the actual work is done. to with, this is merely channel index getter by name but reads like an action "'Split mems channel-index.' or "Split mems channel (by index)." In the case of splitting using split (action) and splitting (domain/namespace hint) may help +int mems_split_channel_index(const char *channel, uint8_t *index) +{ + if (channel == NULL || index == NULL) { + return -EINVAL; + } + + for (uint8_t i = 0U; i < MEMS_SPLIT_CHANNEL_COUNT; ++i) { + if (strcmp(channel, split_channel_names[i]) == 0) { + *index = i; + return 0; + } + } + + return -ENOENT; +} + +int mems_split_route_name(uint8_t channel_index, char *out, size_t out_len) +{ + int written; + + if (out == NULL || out_len == 0U || channel_index >= MEMS_SPLIT_CHANNEL_COUNT) { + return -EINVAL; + } + + written = snprintk(out, out_len, "%s_to_%s", + split_route_inputs[channel_index], + split_route_outputs[channel_index]); + return (written < 0 || written >= (int)out_len) ? -ENOSPC : 0; +} + +const char *mems_split_output_loss_key(uint8_t output_index) +{ + if (output_index >= MEMS_SPLIT_OUTPUT_COUNT) { + return NULL; + } + + return split_output_loss_keys[output_index]; +} + +static const struct mems_route *split_route_for_channel(const struct mems_router *router, + uint8_t channel_index) +{ + if (channel_index >= MEMS_SPLIT_CHANNEL_COUNT) { + return NULL; + } + + return mems_router_get_route(router, + split_route_inputs[channel_index], + split_route_outputs[channel_index]); +} + +static uint32_t split_period_ticks(void) +{ + const float ticks = 1000.0f / + (MEMS_SWITCH_MAX_TOGGLE_HZ * + (float)MEMS_SWITCH_ELECTRICAL_PULSE_MS); + uint32_t period_ticks = (uint32_t)ticks; + + if ((float)period_ticks < ticks) { + period_ticks += 1U; + } + if (period_ticks < 2U) { + period_ticks = 2U; + } + + return period_ticks; +} + +static uint32_t split_ratio_to_ticks(double ratio, uint32_t period_ticks) +{ + uint32_t ticks = (uint32_t)(ratio * (double)period_ticks + 0.5); + + return MIN(ticks, period_ticks); +} + +static void split_read_transmissions(const char *route_name, + float transmission[MEMS_SPLIT_OUTPUT_COUNT]) +{ + if (route_name == NULL || transmission == NULL) { + return; + } + + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + const char *loss_key = mems_split_output_loss_key(i); + double tx = 1.0; + + (void)app_settings_get_route_loss(route_name, loss_key, &tx); + transmission[i] = (float)tx; + } +} + +static void split_output_ratio_from_actual(const float actual[MEMS_SPLIT_OUTPUT_COUNT], + const float transmission[MEMS_SPLIT_OUTPUT_COUNT], + float output[MEMS_SPLIT_OUTPUT_COUNT]) +{ + double delivered[MEMS_SPLIT_OUTPUT_COUNT]; + double total = 0.0; + + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + delivered[i] = (double)actual[i] * (double)transmission[i]; + total += delivered[i]; + } + + if (!(total > 0.0)) { + memset(output, 0, MEMS_SPLIT_OUTPUT_COUNT * sizeof(output[0])); + return; + } + + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + output[i] = (float)(delivered[i] / total); + } +} + +static int split_correct_for_transmission(const float requested[MEMS_SPLIT_OUTPUT_COUNT], + const float transmission[MEMS_SPLIT_OUTPUT_COUNT], + double corrected[MEMS_SPLIT_OUTPUT_COUNT]) +{ + const double ra = requested[0]; + const double rb = requested[1]; + const double ta = transmission[0]; + const double tb = transmission[1]; + const double tc = transmission[2]; + double denom; + + if (!(ta > 0.0 && tb > 0.0 && tc > 0.0)) { + return -ERANGE; + } + + /* Solve for MEMS duty cycles whose transmitted outputs normalize back to + * the requested ratios after the three split-path transmissions are applied. + */ + denom = ta * tb - ra * ta * tb - rb * ta * tb + + rb * ta * tc + ra * tb * tc; + if (!(denom > 0.0)) { + return -ERANGE; + } + + corrected[0] = (ra * tb * tc) / denom; + corrected[1] = (rb * ta * tc) / denom; + corrected[2] = 1.0 - corrected[0] - corrected[1]; + + if (corrected[0] < -0.000001 || corrected[1] < -0.000001 || + corrected[2] < -0.000001 || corrected[0] > 1.000001 || + corrected[1] > 1.000001 || corrected[2] > 1.000001) { + return -ERANGE; + } + + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + corrected[i] = CLAMP(corrected[i], 0.0, 1.0); + } + + return 0; +} + +static uint32_t split_selected_numerator(const struct mems_switch_status *status, + char state) +{ + if (state == 'A') { + return status->duty_numerator; + } + + return status->duty_denominator - status->duty_numerator; +} + +static void split_clamp_actual(float actual[MEMS_SPLIT_OUTPUT_COUNT]) +{ + float used; + + for (uint8_t i = 0U; i < MEMS_SPLIT_OUTPUT_COUNT; ++i) { + if (actual[i] < 0.0f) { + actual[i] = 0.0f; + } + if (actual[i] > 1.0f) { + actual[i] = 1.0f; + } + } + + used = actual[0] + actual[1]; + if (used > 1.0f) { + actual[1] = 1.0f - actual[0]; + used = 1.0f; + } + actual[2] = 1.0f - used; +} + +int mems_split_read_channel_state(const struct mems_router *router, + uint8_t channel_index, + const float requested[MEMS_SPLIT_OUTPUT_COUNT], + struct mems_split_state *out) +{ + const struct mems_route *route; + struct mems_split_state next = {0}; + char route_name[APP_ROUTE_LOSS_ROUTE_MAX_LEN] = {0}; + float sw1_duty; + float sw3_duty; + + if (router == NULL || channel_index >= MEMS_SPLIT_CHANNEL_COUNT) { + return -EINVAL; + } + + route = split_route_for_channel(router, channel_index); + if (route == NULL || route->num_steps != MEMS_SPLIT_ROUTE_SWITCH_COUNT) { + return -EINVAL; + } + (void)mems_split_route_name(channel_index, route_name, sizeof(route_name)); + + if (requested != NULL) { + memcpy(next.requested, requested, sizeof(next.requested)); + } else { + k_mutex_lock(&split_state_lock, K_FOREVER); + next = g_split_state[channel_index]; + k_mutex_unlock(&split_state_lock); + } + + for (uint8_t i = 0U; i < MEMS_SPLIT_ROUTE_SWITCH_COUNT; ++i) { + const struct mems_route_step *step = &route->steps[i]; + struct mems_switch *sw = mems_router_find_switch(router, step->switch_name); + struct mems_switch_status status = {0}; + uint32_t selected_ticks; + + if (sw == NULL) { + LOG_ERR("Split route %s->%s references missing switch %s", + route->key.input_name, route->key.output_name, + step->switch_name); + return -EINVAL; + } + + mems_switch_get_status(sw, &status); + selected_ticks = split_selected_numerator(&status, step->state); + + snprintk(next.switches[i].name, sizeof(next.switches[i].name), + "%s", step->switch_name); + next.switches[i].state = step->state; + next.switches[i].numerator = selected_ticks; + next.switches[i].denominator = status.duty_denominator; + next.switches[i].tick_ms = status.tick_duration_ms; + next.switches[i].duty_cycle = + status.duty_denominator == 0U ? 0.0f : + (float)selected_ticks / (float)status.duty_denominator; + next.stopsin_s = MAX(next.stopsin_s, status.stopafter_s); + } + + sw1_duty = next.switches[0].duty_cycle; + sw3_duty = next.switches[2].duty_cycle; + next.actual[0] = sw1_duty; + next.actual[1] = sw3_duty > sw1_duty ? sw3_duty - sw1_duty : 0.0f; + split_clamp_actual(next.actual); + split_read_transmissions(route_name, next.transmission); + split_output_ratio_from_actual(next.actual, next.transmission, next.output); + + k_mutex_lock(&split_state_lock, K_FOREVER); + g_split_state[channel_index] = next; + k_mutex_unlock(&split_state_lock); + + if (out != NULL) { + *out = next; + } + + LOG_INF("Split %s actual %.4f %.4f %.4f output %.4f %.4f %.4f", + split_channel_names[channel_index], + (double)next.actual[0], + (double)next.actual[1], + (double)next.actual[2], + (double)next.output[0], + (double)next.output[1], + (double)next.output[2]); + + return 0; +} + +int mems_split_apply_channel(const struct mems_router *router, + uint8_t channel_index, + const float requested[MEMS_SPLIT_OUTPUT_COUNT], + uint32_t stopafter_s, + struct mems_split_state *out, + const char **failed_switch) +{ + const struct mems_route *route; + uint32_t period_ticks; + uint32_t output_ticks[MEMS_SPLIT_OUTPUT_COUNT]; + uint32_t switch_ticks[MEMS_SPLIT_ROUTE_SWITCH_COUNT]; + char route_name[APP_ROUTE_LOSS_ROUTE_MAX_LEN] = {0}; + float transmission[MEMS_SPLIT_OUTPUT_COUNT]; + double corrected[MEMS_SPLIT_OUTPUT_COUNT]; + int rc; + + if (router == NULL || requested == NULL || + channel_index >= MEMS_SPLIT_CHANNEL_COUNT) { + return -EINVAL; + } + if (requested[0] < 0.0f || requested[0] > 1.0f || + requested[1] < 0.0f || requested[1] > 1.0f || + requested[2] < 0.0f || requested[2] > 1.0f || + requested[0] + requested[1] > 1.000001f || + stopafter_s > MEMS_SWITCH_MAX_TOGGLE_DURATION_S) { + return -ERANGE; + } + + route = split_route_for_channel(router, channel_index); + if (route == NULL || route->num_steps != MEMS_SPLIT_ROUTE_SWITCH_COUNT) { + return -EINVAL; + } + + (void)mems_split_route_name(channel_index, route_name, sizeof(route_name)); + split_read_transmissions(route_name, transmission); + rc = split_correct_for_transmission(requested, transmission, corrected); + if (rc != 0) { + return rc; + } + + period_ticks = split_period_ticks(); + output_ticks[0] = split_ratio_to_ticks(corrected[0], period_ticks); + output_ticks[1] = split_ratio_to_ticks(corrected[1], period_ticks); + if (output_ticks[0] + output_ticks[1] > period_ticks) { + output_ticks[1] = period_ticks - output_ticks[0]; + } + output_ticks[2] = period_ticks - output_ticks[0] - output_ticks[1]; + + switch_ticks[0] = output_ticks[0]; + switch_ticks[1] = period_ticks; + switch_ticks[2] = output_ticks[0] + output_ticks[1]; + + for (uint8_t i = 0U; i < MEMS_SPLIT_ROUTE_SWITCH_COUNT; ++i) { + const struct mems_route_step *step = &route->steps[i]; + struct mems_switch *sw = mems_router_find_switch(router, step->switch_name); + int rc; + + if (sw == NULL) { + if (failed_switch != NULL) { + *failed_switch = step->switch_name; + } + return -ENOENT; + } + + rc = mems_switch_set_state_ticks(sw, step->state, switch_ticks[i], + period_ticks, + i == 1U ? 0U : stopafter_s); + if (rc != 0) { + if (failed_switch != NULL) { + *failed_switch = step->switch_name; + } + return rc; + } + } + + return mems_split_read_channel_state(router, channel_index, requested, out); +} diff --git a/app/src/mems_switching.h b/app/src/mems_switching.h index 35f4bbd..87d3241 100644 --- a/app/src/mems_switching.h +++ b/app/src/mems_switching.h @@ -2,9 +2,10 @@ * @file mems_switching.h * @brief MEMS switch pulse scheduling and board-local route tables. * - * The router owns one `k_work_delayable` tick that clears pulse pins, applies - * requested static/toggling switch states, and quantizes requested toggle rates - * into fixed MEMS ticks. Public calls can sleep on the router mutex but do not + * A kernel timer provides the MEMS cadence and a dedicated MEMS thread performs + * GPIO-expander writes. The thread clears pulse pins, applies requested + * static/toggling switch states, and quantizes requested toggle rates into + * fixed MEMS ticks. Public calls can sleep on the router mutex but do not * publish MQTT or persist state. */ @@ -16,10 +17,12 @@ #include #include #include +#include #include #define MEMS_SOURCEDEST_MAX_LEN 24 #define MEMS_SWITCH_ELECTRICAL_PULSE_MS 20U //datasheet says pulse width >=15ms #define MEMS_SWITCH_ELECTRICAL_PULSE_FFLS_MS 50U //datasheet says pulse width of 50ms typical (ch 7&8 on tib) +#define MEMS_SWITCH_ROUTER_TICK_MS 10U #define MEMS_SWITCH_NAME_LEN 24 #define MEMS_ROUTER_MAX_SWITCHES 8 #define MEMS_ROUTER_MAX_ROUTES 18 // TIB=16, CAL=12, AS=4 @@ -27,16 +30,27 @@ #define MEMS_ROUTER_MAX_ACTIVE_ROUTES 6 #define MEMS_SWITCH_MAX_TOGGLE_HZ 5.0f #define MEMS_SWITCH_MAX_TOGGLE_DURATION_S (4U * 60U * 60U) +#define MEMS_SPLIT_CHANNEL_COUNT 2 +#define MEMS_SPLIT_OUTPUT_COUNT 3 +#define MEMS_SPLIT_ROUTE_SWITCH_COUNT 3 struct mems_router; +enum mems_switch_type { + MEMS_SWITCH_TYPE_FFSW, + MEMS_SWITCH_TYPE_FFLS, +}; -/** Runtime state for one dual-coil MEMS switch. */ +/** Runtime state for one dual-coil MEMS switch. + * + * `switch_type` is assigned once by mems_switch_init() from the active board + * profile. It controls the electrical pulse width used by router work ticks. + */ struct mems_switch { - const struct device *gpio_dev; - //todo shouldn't these pins be constant? - gpio_pin_t pin_a; - gpio_pin_t pin_b; + /* Assigned once by mems_switch_init() from the active board profile. */ + struct gpio_dt_spec gpio_a; + struct gpio_dt_spec gpio_b; + enum mems_switch_type switch_type; char state; // 'A', 'B' may report with a ? if ~state_known_this_boot char target_state; // desired state applied by toggler on next tick bool state_known_this_boot; @@ -46,6 +60,9 @@ struct mems_switch { uint32_t a_state_cycles; uint32_t cycles_until_toggle; uint32_t remaining_toggle_cycles; // zero means not toggling + uint32_t pulse_clear_at_ms; + bool pulse_active; + uint8_t service_ticks_remaining; struct mems_router *owner; char name[MEMS_SWITCH_NAME_LEN]; }; @@ -57,7 +74,7 @@ struct mems_switch_status { /* Exact A-state duty numerator and denominator in MEMS tick counts. */ uint32_t duty_numerator; uint32_t duty_denominator; - /* Duration of one MEMS tick, currently the delayable-work period. */ + /* Duration of one MEMS service tick for this switch type. */ uint32_t tick_duration_ms; float requested_toggle_rate_hz; float toggle_rate_hz; @@ -86,6 +103,24 @@ struct mems_route { uint8_t num_steps; }; +struct mems_split_switch_duty { + char name[MEMS_SWITCH_NAME_LEN]; + char state; + float duty_cycle; + uint32_t numerator; + uint32_t denominator; + uint32_t tick_ms; +}; + +struct mems_split_state { + float requested[MEMS_SPLIT_OUTPUT_COUNT]; + float actual[MEMS_SPLIT_OUTPUT_COUNT]; + float output[MEMS_SPLIT_OUTPUT_COUNT]; + float transmission[MEMS_SPLIT_OUTPUT_COUNT]; + struct mems_split_switch_duty switches[MEMS_SPLIT_ROUTE_SWITCH_COUNT]; + uint32_t stopsin_s; +}; + /** Board-selected router state and immutable route table pointer. */ struct mems_router { struct mems_switch *switches[MEMS_ROUTER_MAX_SWITCHES]; @@ -94,23 +129,27 @@ struct mems_router { const struct mems_route *routes; uint8_t num_routes; struct k_mutex lock; - struct k_work_delayable toggler_work; }; /** * @brief Initialize a MEMS switch object and configure its two GPIO outputs inactive. * * The hardware state is not known after boot until the delayable tick sends a - * pulse. The configured toggle rate is stored as the default requested rate and - * quantized to the nearest supported MEMS tick period. + * logical active pulse. Pulse cleanup uses a kernel-uptime deadline from the + * successful GPIO set, so a delayed router tick may extend but not shorten the + * physical pulse. The configured toggle rate is stored as the default + * requested rate and quantized to the nearest supported MEMS tick period. */ -void mems_switch_init(struct mems_switch *sw, const struct device *gpio_dev, - gpio_pin_t pin_a, gpio_pin_t pin_b, const char *name, +void mems_switch_init(struct mems_switch *sw, + const struct gpio_dt_spec *gpio_a, + const struct gpio_dt_spec *gpio_b, + const char *name, + enum mems_switch_type switch_type, float configured_toggle_rate_hz, char initial_state); /** * @brief Queue a static or toggling MEMS switch state change. * - * The router-owned delayable work applies pulses on its next tick. A positive + * The router-owned MEMS thread applies pulses on its next tick. A positive * @p requested_toggle_rate_hz updates the stored requested rate and is * quantized to the nearest firmware tick period before toggling starts. */ @@ -122,8 +161,8 @@ int mems_switch_set_state(struct mems_switch *sw, char state, * * @p state_ticks is the number of ticks spent in @p state during each * @p period_ticks cycle. The function converts that to the internal A-state - * numerator, updates the switch period, and lets the router-owned delayable - * work apply pulses on subsequent ticks. + * numerator, updates the switch period, and lets the router-owned MEMS thread + * apply pulses on subsequent ticks. */ int mems_switch_set_state_ticks(struct mems_switch *sw, char state, uint32_t state_ticks, uint32_t period_ticks, @@ -156,9 +195,77 @@ struct mems_switch *mems_router_find_switch(const struct mems_router *router, co const struct mems_route *mems_router_get_route(const struct mems_router *router, const char *input, const char *output); +/** + * @brief Apply every switch step in one static route. + * + * This queues switch state changes through mems_switch_set_state() and can + * sleep on the router mutex. The caller owns route lookup and command response + * formatting. On a per-switch failure, @p failed_switch and @p failed_state + * identify the route step that failed when non-NULL. + */ +int mems_router_apply_route(const struct mems_router *router, + const struct mems_route *route, + const char **failed_switch, + char *failed_state); + +/** + * @brief Look up and apply a named input/output route. + * + * This can sleep on the router mutex through MEMS switch operations. It keeps + * command modules from duplicating route lookup before setting a simple static + * route. + */ +int mems_router_apply_named_route(const struct mems_router *router, + const char *input, + const char *output, + const char **failed_switch, + char *failed_state); /** @brief List static routes whose switches currently match all required states. */ uint8_t mems_router_active_routes(const struct mems_router *router, struct mems_route_key *out_keys, uint8_t max_keys); +/** @brief Return the API name for one AS split channel, or NULL if invalid. */ +const char *mems_split_channel_name(uint8_t channel_index); + +/** @brief Map an AS split channel name such as "yj" or "hk" to an index. */ +int mems_split_channel_index(const char *channel, uint8_t *index); + +/** @brief Format the app-settings route name used by one AS split channel. */ +int mems_split_route_name(uint8_t channel_index, char *out, size_t out_len); + +/** @brief Return the app-settings key for one split output transmission. */ +const char *mems_split_output_loss_key(uint8_t output_index); + +/** + * @brief Read current AS split route state into @p out. + * + * The route is selected from the board MEMS route table. This can sleep on the + * router mutex while reading switch snapshots and on settings while reading + * split transmissions. If @p requested is non-NULL it becomes the stored + * requested ratio for future responses; otherwise the last requested ratio is + * retained. + */ +int mems_split_read_channel_state(const struct mems_router *router, + uint8_t channel_index, + const float requested[MEMS_SPLIT_OUTPUT_COUNT], + struct mems_split_state *out); + +/** + * @brief Apply one AS split channel as three output ratios. + * + * The user-facing command provides ratio1 and ratio2; this domain helper + * receives all three normalized output ratios, applies route-loss transmission + * correction, and converts the corrected duty targets to exact MEMS ticks. It + * can sleep on the router mutex through MEMS switch operations and on settings + * while reading split transmissions. It does not publish warnings or parse + * command payloads. + */ +int mems_split_apply_channel(const struct mems_router *router, + uint8_t channel_index, + const float requested[MEMS_SPLIT_OUTPUT_COUNT], + uint32_t stopafter_s, + struct mems_split_state *out, + const char **failed_switch); + #endif // MEMS_SWITCHING_H diff --git a/app/src/photodiode.c b/app/src/photodiode.c index 7731987..3f7434f 100644 --- a/app/src/photodiode.c +++ b/app/src/photodiode.c @@ -1,6 +1,6 @@ /** * @file photodiode.c - * @brief ADS1115 photodiode monitor, telemetry queueing, and dark calibration. + * @brief ADS1115 photodiode sampler, rolling status windows, and dark calibration. */ @@ -14,17 +14,13 @@ #include #include "photodiode.h" -#include "app_identity.h" #include "app_settings.h" -#include "app_warning.h" #include "command.h" #include "devices.h" LOG_MODULE_REGISTER(photodiode, LOG_LEVEL_INF); -#define PHOTODIODE_TELEMETRY_TOPIC "dt/" APP_MQTT_DEVICE_ID "/photodiode" - // More ADC info: // https://github.com/zephyrproject-rtos/zephyr/blob/main/samples/drivers/adc/adc_dt/src/main.c // https://docs.zephyrproject.org/apidoc/latest/group__adc__interface.html @@ -41,26 +37,57 @@ static const struct adc_channel_cfg *const pd_adc_cfg[PHOTODIODE_CHANNEL_COUNT] &hk_cfg_dt, }; +#define PD_YJ_ADC_CHANNEL_NODE DT_CHILD(DT_NODELABEL(adc1115), channel_0) +#define PD_HK_ADC_CHANNEL_NODE DT_CHILD(DT_NODELABEL(adc1115), channel_2) +#define ADS1115_DT_RESOLUTION DT_PROP(PD_YJ_ADC_CHANNEL_NODE, zephyr_resolution) + +BUILD_ASSERT(DT_PROP(PD_HK_ADC_CHANNEL_NODE, zephyr_resolution) == ADS1115_DT_RESOLUTION, + "photodiode ADS1115 channels must use the same resolution"); + +/* Zephyr's ADS1115 driver exposes the muxed device as ADC channel 0 only. + * The physical ADS input is selected by input_positive from devicetree. + * For single-ended ADS1115 reads, Zephyr expects the usable positive code + * range: one bit less than the signed conversion register width. + */ +#define ADS1115_ZEPHYR_CHANNEL_ID 0U +#define ADS1115_SINGLE_ENDED_SEQUENCE_RESOLUTION (ADS1115_DT_RESOLUTION - 1U) + const char *const photodiode_channel_names[PHOTODIODE_CHANNEL_COUNT] = { "yj", "hk", }; +static void photodiode_sample_timer_handler(struct k_timer *timer); + +static K_SEM_DEFINE(pd_sample_sem, 0, 1); +static K_TIMER_DEFINE(pd_sample_timer, photodiode_sample_timer_handler, NULL); + /* Hardware docs specify ADS1115 +/-6.144 V full scale at ADC_GAIN_1_3, which * gives 187.5 uV per signed 16-bit count. */ #define PD_ADC_UV_PER_COUNT_NUM 1875 #define PD_ADC_UV_PER_COUNT_DEN 10 #define PD_NOISE_ALPHA 0.02f +#define PD_ADC_ERROR_RETRY_MS 5000U +#define PD_HARDWARE_LOG_RATELIMIT_MS 10000U #define PD_NOISE_WARNING_COOLDOWN_MS 60000U #define PD_DARK_DEFAULT_DURATION_MS (64U * PUBLISH_INTERVAL_MS) -#define PD_DARK_MAX_DURATION_MS (60U * 60U * 1000U) -#define PD_DARK_MAX_SAMPLES (PD_DARK_MAX_DURATION_MS / PUBLISH_INTERVAL_MS) +#define PD_AVERAGE_MAX_DURATION_MS 2000U +#define PD_AVERAGE_MAX_SAMPLES (PD_AVERAGE_MAX_DURATION_MS / PUBLISH_INTERVAL_MS) +#define PD_MEAN_WINDOW_SAMPLES (1000U / PUBLISH_INTERVAL_MS) +#define PD_RMS_WINDOW_SAMPLES (500U / PUBLISH_INTERVAL_MS) +#define PLANCK_J_S 6.62607015e-34 +#define LIGHT_M_PER_S 299792458.0 + +static void photodiode_sample_timer_handler(struct k_timer *timer) +{ + ARG_UNUSED(timer); -/* The sampler publishes into a small queue so ADC timing does not depend on - * MQTT availability. main.c drains this into outbound_queue from delayable work. - */ -K_MSGQ_DEFINE(photodiode_queue, sizeof(struct OutMsg), 4, 4); + /* Timer expiry is interrupt context; ADS1115 I/O stays in the photodiode + * thread so ADC bus transactions never run in the ISR. + */ + k_sem_give(&pd_sample_sem); +} struct photodiode_runtime_channel { bool valid; @@ -72,36 +99,54 @@ struct photodiode_runtime_channel { float smooth_mv; float noise_var_mv2; float noise_rms_mv; + float mean_window_mv[PD_MEAN_WINDOW_SAMPLES]; + float mean_sum_mv; + uint8_t mean_index; + uint8_t mean_count; + float rms_window_mv[PD_RMS_WINDOW_SAMPLES]; + float rms_sum_mv; + float rms_sum_sq_mv2; + uint8_t rms_index; + uint8_t rms_count; + float mean_mv_1s; + float rms_mv_0p5s; uint32_t sample_count; int64_t updated_ms; int64_t next_noise_warning_ms; }; -struct photodiode_dark_request { - enum photodiode_dark_state state; - bool store; - uint32_t target_samples; - uint32_t samples; - uint32_t duration_ms; +enum photodiode_average_owner { + PHOTODIODE_AVERAGE_OWNER_NONE = 0, + PHOTODIODE_AVERAGE_OWNER_USER, + PHOTODIODE_AVERAGE_OWNER_DARK, +}; + +struct photodiode_average_request { + enum photodiode_average_state state; + enum photodiode_average_owner owner; + bool store_dark; float sum_mv; + float sum_net_mv; float sum_sq_mv2; float min_mv; float max_mv; + int16_t max_raw; int last_error; - struct photodiode_dark_result result; + struct photodiode_average_result result; }; static struct photodiode_runtime_channel pd_runtime[PHOTODIODE_CHANNEL_COUNT]; -static struct photodiode_dark_request pd_dark[PHOTODIODE_CHANNEL_COUNT]; +static struct photodiode_average_request pd_average[PHOTODIODE_CHANNEL_COUNT]; static K_MUTEX_DEFINE(pd_runtime_lock); static int pd_read_raw(enum photodiode_channel channel, int16_t *raw) { + struct adc_channel_cfg cfg = *pd_adc_cfg[channel]; struct adc_sequence seq = { - .channels = 0, + .channels = BIT(ADS1115_ZEPHYR_CHANNEL_ID), .buffer = raw, .buffer_size = sizeof(*raw), - .resolution = ADC_RESOLUTION, + .resolution = ADS1115_SINGLE_ENDED_SEQUENCE_RESOLUTION, .oversampling = 0, .calibrate = false, }; @@ -110,12 +155,12 @@ static int pd_read_raw(enum photodiode_channel channel, int16_t *raw) if (raw == NULL || channel < 0 || channel >= PHOTODIODE_CHANNEL_COUNT) { return -EINVAL; } - if (devices_board_type() != HISPEC_BOARD_TIB || - adc_dev == NULL || !device_is_ready(adc_dev)) { + if (adc_dev == NULL || !device_is_ready(adc_dev)) { return -ENODEV; } - rc = adc_channel_setup(adc_dev, pd_adc_cfg[channel]); + cfg.channel_id = ADS1115_ZEPHYR_CHANNEL_ID; + rc = adc_channel_setup(adc_dev, &cfg); if (rc == 0) { rc = adc_read(adc_dev, &seq); } @@ -123,29 +168,66 @@ static int pd_read_raw(enum photodiode_channel channel, int16_t *raw) return rc; } -const char *photodiode_dark_state_name(enum photodiode_dark_state state) +const char *photodiode_average_state_name(enum photodiode_average_state state) { switch (state) { - case PHOTODIODE_DARK_IDLE: - return "idle"; - case PHOTODIODE_DARK_MEASURING: + case PHOTODIODE_AVERAGE_INACTIVE: + return "inactive"; + case PHOTODIODE_AVERAGE_MEASURING: return "measuring"; - case PHOTODIODE_DARK_COMPLETE: + case PHOTODIODE_AVERAGE_COMPLETE: return "complete"; - case PHOTODIODE_DARK_ERROR: + case PHOTODIODE_AVERAGE_ERROR: return "error"; default: return "unknown"; } } -static uint32_t pd_dark_duration_to_samples(uint32_t duration_ms) +double photodiode_power_uw_from_mv(double net_mv, + const struct app_pd_channel_settings *settings) { - uint32_t requested_ms = duration_ms == 0U ? - PD_DARK_DEFAULT_DURATION_MS : duration_ms; + double signal_v; + double power_w; - if (requested_ms >= PD_DARK_MAX_DURATION_MS) { - return PD_DARK_MAX_SAMPLES; + if (settings == NULL || net_mv <= 0.0 || + settings->responsivity_a_per_w <= 0.0 || + settings->transimpedance_v_per_a <= 0.0) { + return 0.0; + } + + signal_v = net_mv / 1000.0; + power_w = signal_v / (settings->transimpedance_v_per_a * + settings->responsivity_a_per_w); + return power_w * 1.0e6; +} + +double photodiode_photon_flux_from_mv(double net_mv, + double wavelength_nm, + const struct app_pd_channel_settings *settings) +{ + double power_w; + double photon_j; + + if (wavelength_nm <= 0.0) { + return 0.0; + } + + power_w = photodiode_power_uw_from_mv(net_mv, settings) * 1.0e-6; + if (power_w <= 0.0) { + return 0.0; + } + + photon_j = PLANCK_J_S * LIGHT_M_PER_S / (wavelength_nm * 1.0e-9); + return power_w / photon_j; +} + +static uint32_t pd_average_duration_to_samples(uint32_t duration_ms) +{ + uint32_t requested_ms = duration_ms == 0U ? PUBLISH_INTERVAL_MS : duration_ms; + + if (requested_ms >= PD_AVERAGE_MAX_DURATION_MS) { + return PD_AVERAGE_MAX_SAMPLES; } requested_ms += PUBLISH_INTERVAL_MS / 2U; @@ -154,10 +236,10 @@ static uint32_t pd_dark_duration_to_samples(uint32_t duration_ms) return requested_ms == 0U ? 1U : requested_ms; } -static void pd_dark_copy_status_locked(enum photodiode_channel channel, - struct photodiode_dark_status *out) +static void pd_average_copy_status_locked(enum photodiode_channel channel, + struct photodiode_average_status *out) { - const struct photodiode_dark_request *dark = &pd_dark[channel]; + const struct photodiode_average_request *avg = &pd_average[channel]; if (out == NULL) { return; @@ -165,98 +247,157 @@ static void pd_dark_copy_status_locked(enum photodiode_channel channel, memset(out, 0, sizeof(*out)); out->channel = channel; - out->state = dark->state; - out->store = dark->store; - out->duration_ms = dark->duration_ms; - out->samples = dark->samples; - out->target_samples = dark->target_samples; - out->last_error = dark->last_error; - out->result = dark->result; + out->state = avg->state; + out->store_dark = avg->store_dark; + out->last_error = avg->last_error; + out->result = avg->result; +} + +static void pd_average_start_locked(enum photodiode_channel channel, + uint32_t sample_count, + enum photodiode_average_owner owner, + bool store_dark, + struct photodiode_average_status *out) +{ + struct photodiode_average_request *avg = &pd_average[channel]; + + memset(avg, 0, sizeof(*avg)); + avg->state = PHOTODIODE_AVERAGE_MEASURING; + avg->owner = owner; + avg->store_dark = store_dark; + avg->result.channel = channel; + avg->result.duration_ms = sample_count * PUBLISH_INTERVAL_MS; + avg->result.target_samples = sample_count; + pd_average_copy_status_locked(channel, out); } -static void pd_dark_finish_store_locked(enum photodiode_channel channel, - struct photodiode_dark_request *dark) +/* Called from the sampler thread when an average tagged as a dark measurement + * completes. It may persist settings and can briefly extend that sampler pass. + */ +static void pd_average_finish_dark_locked(enum photodiode_channel channel, + struct photodiode_average_request *avg) { struct app_photodiode_settings settings; - app_settings_get_photodiode(&settings); - settings.channel[channel].dark_mv = dark->result.mean_mv; - if (!settings.channel[channel].lowest_dark_valid || - dark->result.mean_mv < settings.channel[channel].lowest_dark_mv) { - settings.channel[channel].lowest_dark_mv = dark->result.mean_mv; - settings.channel[channel].lowest_dark_valid = true; + if (avg->owner != PHOTODIODE_AVERAGE_OWNER_DARK) { + return; } - dark->result.configured_dark_mv = settings.channel[channel].dark_mv; - dark->result.lowest_dark_mv = settings.channel[channel].lowest_dark_mv; - dark->result.lowest_dark_valid = settings.channel[channel].lowest_dark_valid; + app_settings_get_photodiode(&settings); - /* This settings write can briefly extend one sampler iteration, but it - * happens only when a user-requested dark window completes. - */ - app_settings_update_photodiode_channel((uint8_t)channel, - &settings.channel[channel], - true); + if (avg->store_dark) { + settings.channel[channel].dark_mv = avg->result.mean_mv; + if (!settings.channel[channel].lowest_dark_valid || + avg->result.mean_mv < settings.channel[channel].lowest_dark_mv) { + settings.channel[channel].lowest_dark_mv = avg->result.mean_mv; + settings.channel[channel].lowest_dark_valid = true; + } + + /* This settings write can briefly extend one sampler iteration, but it + * happens only when a user-requested dark window completes. + */ + app_settings_update_photodiode_channel((uint8_t)channel, + &settings.channel[channel], + true); + } } -static void pd_dark_sample_locked(enum photodiode_channel channel, int rc, float mv) +static void pd_average_sample_locked(enum photodiode_channel channel, + int rc, int16_t raw, float mv, float net_mv) { - struct photodiode_dark_request *dark = &pd_dark[channel]; + struct photodiode_average_request *avg = &pd_average[channel]; float mean; float variance; - if (dark->state != PHOTODIODE_DARK_MEASURING || - dark->samples >= dark->target_samples) { + if (avg->state != PHOTODIODE_AVERAGE_MEASURING || + avg->result.samples >= avg->result.target_samples) { return; } if (rc != 0) { - dark->state = PHOTODIODE_DARK_ERROR; - dark->last_error = rc; - dark->result.samples = dark->samples; + avg->state = PHOTODIODE_AVERAGE_ERROR; + avg->last_error = rc; return; } - if (dark->samples == 0U) { - dark->min_mv = mv; - dark->max_mv = mv; + if (avg->result.samples == 0U) { + avg->min_mv = mv; + avg->max_mv = mv; + avg->max_raw = raw; } else { - if (mv < dark->min_mv) { - dark->min_mv = mv; + if (mv < avg->min_mv) { + avg->min_mv = mv; + } + if (mv > avg->max_mv) { + avg->max_mv = mv; } - if (mv > dark->max_mv) { - dark->max_mv = mv; + if (raw > avg->max_raw) { + avg->max_raw = raw; } } - dark->sum_mv += mv; - dark->sum_sq_mv2 += mv * mv; - dark->samples++; + avg->sum_mv += mv; + avg->sum_net_mv += net_mv; + avg->sum_sq_mv2 += mv * mv; + avg->result.samples++; - if (dark->samples < dark->target_samples) { + if (avg->result.samples < avg->result.target_samples) { return; } - mean = dark->sum_mv / (float)dark->samples; - variance = (dark->sum_sq_mv2 / (float)dark->samples) - (mean * mean); + mean = avg->sum_mv / (float)avg->result.samples; + variance = (avg->sum_sq_mv2 / (float)avg->result.samples) - (mean * mean); if (variance < 0.0f) { variance = 0.0f; } - dark->result.samples = dark->samples; - dark->result.mean_mv = mean; - dark->result.rms_mv = sqrtf(variance); - dark->result.min_mv = dark->min_mv; - dark->result.max_mv = dark->max_mv; - dark->last_error = 0; + avg->result.channel = channel; + avg->result.mean_mv = mean; + avg->result.mean_net_mv = avg->sum_net_mv / (float)avg->result.samples; + avg->result.rms_mv = sqrtf(variance); + avg->result.min_mv = avg->min_mv; + avg->result.max_mv = avg->max_mv; + avg->result.max_raw = avg->max_raw; + avg->last_error = 0; + pd_average_finish_dark_locked(channel, avg); + avg->state = PHOTODIODE_AVERAGE_COMPLETE; +} + +static void pd_window_update(float mv, struct photodiode_runtime_channel *snapshot) +{ + float old_mv; + float mean; + float variance; - if (dark->store) { - pd_dark_finish_store_locked(channel, dark); + if (snapshot->mean_count < PD_MEAN_WINDOW_SAMPLES) { + snapshot->mean_count++; + } else { + snapshot->mean_sum_mv -= snapshot->mean_window_mv[snapshot->mean_index]; } + snapshot->mean_window_mv[snapshot->mean_index] = mv; + snapshot->mean_sum_mv += mv; + snapshot->mean_index = (snapshot->mean_index + 1U) % PD_MEAN_WINDOW_SAMPLES; + snapshot->mean_mv_1s = snapshot->mean_sum_mv / (float)snapshot->mean_count; - dark->state = PHOTODIODE_DARK_COMPLETE; -} + if (snapshot->rms_count < PD_RMS_WINDOW_SAMPLES) { + snapshot->rms_count++; + } else { + old_mv = snapshot->rms_window_mv[snapshot->rms_index]; + snapshot->rms_sum_mv -= old_mv; + snapshot->rms_sum_sq_mv2 -= old_mv * old_mv; + } + snapshot->rms_window_mv[snapshot->rms_index] = mv; + snapshot->rms_sum_mv += mv; + snapshot->rms_sum_sq_mv2 += mv * mv; + snapshot->rms_index = (snapshot->rms_index + 1U) % PD_RMS_WINDOW_SAMPLES; + mean = snapshot->rms_sum_mv / (float)snapshot->rms_count; + variance = (snapshot->rms_sum_sq_mv2 / (float)snapshot->rms_count) - (mean * mean); + if (variance < 0.0f) { + variance = 0.0f; + } + snapshot->rms_mv_0p5s = sqrtf(variance); +} static void pd_update_channel(enum photodiode_channel channel, int rc, int16_t raw, const struct app_pd_channel_settings *settings) @@ -291,10 +432,9 @@ static void pd_update_channel(enum photodiode_channel channel, int rc, int16_t r snapshot.raw = raw; snapshot.mv = mv; snapshot.net_mv = mv - settings->dark_mv; - snapshot.power_uw = (settings->gain_v_per_uw > 0.0f) ? - snapshot.net_mv / (settings->gain_v_per_uw * 1000.0f) : - 0.0f; + snapshot.power_uw = (float)photodiode_power_uw_from_mv(snapshot.net_mv, settings); snapshot.noise_rms_mv = noise_rms; + pd_window_update(mv, &snapshot); snapshot.sample_count++; } else { snapshot.valid = false; @@ -303,7 +443,7 @@ static void pd_update_channel(enum photodiode_channel channel, int rc, int16_t r snapshot.last_error = rc; snapshot.updated_ms = now; pd_runtime[channel] = snapshot; - pd_dark_sample_locked(channel, rc, mv); + pd_average_sample_locked(channel, rc, raw, mv, snapshot.net_mv); k_mutex_unlock(&pd_runtime_lock); if (rc == 0 && settings->noise_warn_rms_mv > 0.0f && @@ -316,7 +456,7 @@ static void pd_update_channel(enum photodiode_channel channel, int rc, int16_t r photodiode_channel_names[channel], (double)noise_rms, (double)settings->noise_warn_rms_mv); - app_warning_emit("photodiode_noise", + coo_cmd_runtime_warning_emit(command_runtime_get(), "photodiode_noise", "photodiode residual noise exceeded warning threshold", context); @@ -343,6 +483,7 @@ void photodiode_get_status(struct photodiode_status *out) for (uint8_t i = 0; i < PHOTODIODE_CHANNEL_COUNT; ++i) { const struct photodiode_runtime_channel *src = &pd_runtime[i]; struct photodiode_channel_status *dst = &out->channel[i]; + struct photodiode_average_status average_status; dst->valid = src->valid; dst->last_error = src->last_error; @@ -351,14 +492,17 @@ void photodiode_get_status(struct photodiode_status *out) dst->net_mv = src->net_mv; dst->power_uw = src->power_uw; dst->noise_rms_mv = src->noise_rms_mv; + dst->mean_mv_1s = src->mean_mv_1s; + dst->rms_mv_0p5s = src->rms_mv_0p5s; dst->dark_mv = settings.channel[i].dark_mv; dst->lowest_dark_mv = settings.channel[i].lowest_dark_mv; dst->lowest_dark_valid = settings.channel[i].lowest_dark_valid; - dst->dark_state = pd_dark[i].state; - dst->dark_duration_ms = pd_dark[i].duration_ms; - dst->dark_samples = pd_dark[i].samples; - dst->dark_target_samples = pd_dark[i].target_samples; - dst->dark_last_error = pd_dark[i].last_error; + pd_average_copy_status_locked((enum photodiode_channel)i, &average_status); + dst->average_state = average_status.state; + dst->average_duration_ms = average_status.result.duration_ms; + dst->average_samples = average_status.result.samples; + dst->average_target_samples = average_status.result.target_samples; + dst->average_last_error = average_status.last_error; dst->sample_count = src->sample_count; dst->age_ms = src->updated_ms > 0 ? (uint32_t)(now - src->updated_ms) : UINT32_MAX; } @@ -367,112 +511,73 @@ void photodiode_get_status(struct photodiode_status *out) out->uptime_ms = now; } -static void pd_format_channel_json(char *payload, size_t payload_len, size_t *off, - enum photodiode_channel channel, - const struct photodiode_channel_status *status) +int photodiode_start_dark_measurement(enum photodiode_channel channel, + uint32_t duration_ms, + bool store, + struct photodiode_average_status *out) { - int written; - - written = snprintk(payload + *off, payload_len - *off, - "\"%s\":{\"valid\":%s,\"raw\":%d,\"mv\":%.3f," - "\"net_mv\":%.3f,\"power_uw\":%.6f," - "\"noise_rms_mv\":%.3f,\"dark_mv\":%.3f," - "\"lowest_dark_mv\":%.3f,\"lowest_dark_valid\":%s," - "\"dark_measurement\":\"%s\"," - "\"age_ms\":%u,\"samples\":%u}", - photodiode_channel_names[channel], - status->valid ? "true" : "false", - status->raw, - (double)status->mv, - (double)status->net_mv, - (double)status->power_uw, - (double)status->noise_rms_mv, - (double)status->dark_mv, - (double)status->lowest_dark_mv, - status->lowest_dark_valid ? "true" : "false", - photodiode_dark_state_name(status->dark_state), - status->age_ms, - status->sample_count); - if (written > 0 && written < (int)(payload_len - *off)) { - *off += (size_t)written; - } -} + uint32_t sample_count; + uint32_t requested_ms; -static void pd_build_telemetry_payload(char *payload, size_t payload_len) -{ - struct photodiode_status status; - size_t off = 0; - int written; + if (channel < 0 || channel >= PHOTODIODE_CHANNEL_COUNT) { + return -EINVAL; + } + if (adc_dev == NULL || !device_is_ready(adc_dev)) { + return -ENODEV; + } - photodiode_get_status(&status); + requested_ms = duration_ms == 0U ? PD_DARK_DEFAULT_DURATION_MS : duration_ms; + sample_count = pd_average_duration_to_samples(requested_ms); - written = snprintk(payload, payload_len, "{"); - if (written < 0 || written >= (int)payload_len) { - return; - } - off = (size_t)written; - pd_format_channel_json(payload, payload_len, &off, PHOTODIODE_CHANNEL_YJ, - &status.channel[PHOTODIODE_CHANNEL_YJ]); - written = snprintk(payload + off, payload_len - off, ","); - if (written < 0 || written >= (int)(payload_len - off)) { - return; + k_mutex_lock(&pd_runtime_lock, K_FOREVER); + if (pd_average[channel].state == PHOTODIODE_AVERAGE_MEASURING && + pd_average[channel].owner != PHOTODIODE_AVERAGE_OWNER_DARK) { + k_mutex_unlock(&pd_runtime_lock); + return -EBUSY; } - off += (size_t)written; - pd_format_channel_json(payload, payload_len, &off, PHOTODIODE_CHANNEL_HK, - &status.channel[PHOTODIODE_CHANNEL_HK]); - (void)snprintk(payload + off, payload_len - off, - ",\"uptime_ms\":%lld}", status.uptime_ms); + pd_average_start_locked(channel, sample_count, PHOTODIODE_AVERAGE_OWNER_DARK, + store, out); + k_mutex_unlock(&pd_runtime_lock); + return 0; } -int photodiode_start_dark_measurement(enum photodiode_channel channel, - uint32_t duration_ms, - bool store, - struct photodiode_dark_status *out) +int photodiode_start_average(enum photodiode_channel channel, + uint32_t duration_ms, + struct photodiode_average_status *out) { - struct app_photodiode_settings settings; uint32_t sample_count; - struct photodiode_dark_request *dark; if (channel < 0 || channel >= PHOTODIODE_CHANNEL_COUNT) { return -EINVAL; } - if (devices_board_type() != HISPEC_BOARD_TIB || - adc_dev == NULL || !device_is_ready(adc_dev)) { + if (adc_dev == NULL || !device_is_ready(adc_dev)) { return -ENODEV; } - sample_count = pd_dark_duration_to_samples(duration_ms); - app_settings_get_photodiode(&settings); + sample_count = pd_average_duration_to_samples(duration_ms); k_mutex_lock(&pd_runtime_lock, K_FOREVER); - dark = &pd_dark[channel]; - memset(dark, 0, sizeof(*dark)); - dark->state = PHOTODIODE_DARK_MEASURING; - dark->store = store; - dark->target_samples = sample_count; - dark->duration_ms = sample_count * PUBLISH_INTERVAL_MS; - dark->result.channel = channel; - dark->result.duration_ms = dark->duration_ms; - dark->result.stored = store; - dark->result.previous_dark_mv = settings.channel[channel].dark_mv; - dark->result.configured_dark_mv = settings.channel[channel].dark_mv; - dark->result.lowest_dark_mv = settings.channel[channel].lowest_dark_mv; - dark->result.lowest_dark_valid = settings.channel[channel].lowest_dark_valid; - pd_dark_copy_status_locked(channel, out); + if (pd_average[channel].state == PHOTODIODE_AVERAGE_MEASURING && + pd_average[channel].owner == PHOTODIODE_AVERAGE_OWNER_DARK) { + k_mutex_unlock(&pd_runtime_lock); + return -EBUSY; + } + pd_average_start_locked(channel, sample_count, PHOTODIODE_AVERAGE_OWNER_USER, + false, out); k_mutex_unlock(&pd_runtime_lock); return 0; } -int photodiode_get_dark_status(enum photodiode_channel channel, - struct photodiode_dark_status *out) +int photodiode_get_average_status(enum photodiode_channel channel, + struct photodiode_average_status *out) { if (channel < 0 || channel >= PHOTODIODE_CHANNEL_COUNT || out == NULL) { return -EINVAL; } k_mutex_lock(&pd_runtime_lock, K_FOREVER); - pd_dark_copy_status_locked(channel, out); + pd_average_copy_status_locked(channel, out); k_mutex_unlock(&pd_runtime_lock); return 0; } @@ -496,68 +601,66 @@ int photodiode_reset_lowest_dark(enum photodiode_channel channel, bool persist) void photodiode_thread(void *p1, void *p2, void *p3) { + int64_t next_adc_attempt_ms[PHOTODIODE_CHANNEL_COUNT] = {0}; + int last_adc_error[PHOTODIODE_CHANNEL_COUNT] = {0}; + ARG_UNUSED(p1); ARG_UNUSED(p2); ARG_UNUSED(p3); k_sleep(K_MSEC(10)); - while (!devices_board_type_checked()) { - k_sleep(K_MSEC(10)); - } - - if (devices_board_type() != HISPEC_BOARD_TIB) { - LOG_INF("Photodiode monitor disabled for board type %s", - devices_board_type_name()); - while (1) { - k_sleep(K_HOURS(1)); - } - } - while (adc_dev == NULL || !device_is_ready(adc_dev)) { - LOG_ERR("ADS1115 not ready"); - k_sleep(K_MSEC(100)); + LOG_ERR_RATELIMIT_RATE(PD_HARDWARE_LOG_RATELIMIT_MS, "ADS1115 not ready"); + k_sleep(K_MSEC(500)); } + k_timer_start(&pd_sample_timer, K_NO_WAIT, K_MSEC(PUBLISH_INTERVAL_MS)); + while (1) { struct app_photodiode_settings settings; - struct OutMsg msg = {0}; int64_t start = k_uptime_get(); + uint32_t elapsed_samples; + + k_sem_take(&pd_sample_sem, K_FOREVER); + elapsed_samples = k_timer_status_get(&pd_sample_timer); + if (elapsed_samples > 1U) { + LOG_WRN_RATELIMIT_RATE(PD_HARDWARE_LOG_RATELIMIT_MS, + "ADC sample timer missed %u intervals", + (unsigned int)(elapsed_samples - 1U)); + } app_settings_get_photodiode(&settings); for (uint8_t i = 0; i < PHOTODIODE_CHANNEL_COUNT; ++i) { int16_t raw = 0; - int rc = pd_read_raw((enum photodiode_channel)i, &raw); + int64_t now = k_uptime_get(); + int rc; + + if (last_adc_error[i] != 0 && now < next_adc_attempt_ms[i]) { + continue; + } + + rc = pd_read_raw((enum photodiode_channel)i, &raw); if (rc != 0) { - LOG_ERR("ADC %s read failed (%d)", photodiode_channel_names[i], rc); + LOG_ERR_RATELIMIT_RATE(PD_HARDWARE_LOG_RATELIMIT_MS, + "ADC %s read failed (%d)", + photodiode_channel_names[i], rc); + next_adc_attempt_ms[i] = now + PD_ADC_ERROR_RETRY_MS; + last_adc_error[i] = rc; + } else { + next_adc_attempt_ms[i] = 0; + last_adc_error[i] = 0; } pd_update_channel((enum photodiode_channel)i, rc, raw, &settings.channel[i]); } - /* Photodiode samples are status telemetry, not command responses. Drop - * stale samples rather than retaining them across MQTT backpressure. - */ - msg.target = OUT_TARGET_MQTT_BEST_EFFORT; - msg.qos = 0; - snprintk(msg.topic, sizeof(msg.topic), PHOTODIODE_TELEMETRY_TOPIC); - pd_build_telemetry_payload(msg.payload, sizeof(msg.payload)); - msg.payload_len = strlen(msg.payload); - - while (k_msgq_put(&photodiode_queue, &msg, K_NO_WAIT) != 0) { - /* photodiode_queue is full: purge old data & try again */ - LOG_WRN("ADC msgq full, purging"); - k_msgq_purge(&photodiode_queue); - } - int64_t elapsed = k_uptime_get() - start; // overflow every 300M years - int64_t remaining = PUBLISH_INTERVAL_MS - elapsed; - - if (remaining > 0) { - k_sleep(K_MSEC(remaining)); - } else { - LOG_WRN("ADC loop overran interval by %lld ms", -remaining); + if (elapsed > PUBLISH_INTERVAL_MS) { + LOG_WRN_RATELIMIT_RATE(PD_HARDWARE_LOG_RATELIMIT_MS, + "ADC loop overran interval by %lld ms", + elapsed - PUBLISH_INTERVAL_MS); } } } diff --git a/app/src/photodiode.h b/app/src/photodiode.h index cacab73..6f6483b 100644 --- a/app/src/photodiode.h +++ b/app/src/photodiode.h @@ -1,10 +1,10 @@ /** * @file photodiode.h - * @brief TIB photodiode sampling, telemetry, and dark-calibration state. + * @brief TIB photodiode sampling, rolling status windows, and dark-calibration state. * - * The sampler thread owns ADC reads and dark-measurement accumulation. Command - * handlers can start/reset/query dark calibration but do not read the ADC or - * wait for an entire measurement interval. + * The sampler thread owns ADC reads and short averaging. Command handlers can + * start/reset/query dark calibration but do not read the ADC or wait for an + * entire measurement interval. */ #ifndef PHOTODIODE_H @@ -14,21 +14,22 @@ #include #include -#define ADC_RESOLUTION 16 //TODO get this from zephyr,resolution = < 16 >; in the DT #define PUBLISH_INTERVAL_MS 20 #define PHOTODIODE_CHANNEL_COUNT 2 +struct app_pd_channel_settings; + enum photodiode_channel { PHOTODIODE_CHANNEL_YJ = 0, PHOTODIODE_CHANNEL_HK = 1 }; -enum photodiode_dark_state { - PHOTODIODE_DARK_IDLE = 0, - PHOTODIODE_DARK_MEASURING, - PHOTODIODE_DARK_COMPLETE, - PHOTODIODE_DARK_ERROR +enum photodiode_average_state { + PHOTODIODE_AVERAGE_INACTIVE = 0, + PHOTODIODE_AVERAGE_MEASURING, + PHOTODIODE_AVERAGE_COMPLETE, + PHOTODIODE_AVERAGE_ERROR }; struct photodiode_channel_status { @@ -39,14 +40,16 @@ struct photodiode_channel_status { float net_mv; float power_uw; float noise_rms_mv; + float mean_mv_1s; + float rms_mv_0p5s; float dark_mv; float lowest_dark_mv; bool lowest_dark_valid; - enum photodiode_dark_state dark_state; - uint32_t dark_duration_ms; - uint32_t dark_samples; - uint32_t dark_target_samples; - int dark_last_error; + enum photodiode_average_state average_state; + uint32_t average_duration_ms; + uint32_t average_samples; + uint32_t average_target_samples; + int average_last_error; uint32_t age_ms; uint32_t sample_count; }; @@ -56,56 +59,72 @@ struct photodiode_status { int64_t uptime_ms; }; -struct photodiode_dark_result { +struct photodiode_average_result { enum photodiode_channel channel; uint32_t duration_ms; uint32_t samples; - bool stored; + uint32_t target_samples; float mean_mv; + float mean_net_mv; float rms_mv; float min_mv; float max_mv; - float previous_dark_mv; - float configured_dark_mv; - float lowest_dark_mv; - bool lowest_dark_valid; + int16_t max_raw; }; -struct photodiode_dark_status { +struct photodiode_average_status { enum photodiode_channel channel; - enum photodiode_dark_state state; - bool store; - uint32_t duration_ms; - uint32_t samples; - uint32_t target_samples; + enum photodiode_average_state state; + bool store_dark; int last_error; - struct photodiode_dark_result result; + struct photodiode_average_result result; }; - -extern struct k_msgq photodiode_queue; /** Channel labels used in command replies and telemetry JSON. */ extern const char *const photodiode_channel_names[PHOTODIODE_CHANNEL_COUNT]; /** @brief Background sampler thread; blocks on ADC reads and periodic sleeps. */ void photodiode_thread(void *p1, void *p2, void *p3); -/** @brief Copy the latest sample, calibration, and dark-measurement status. */ +/** @brief Copy latest sample, calibration, and short-average progress. */ void photodiode_get_status(struct photodiode_status *out); -/** @brief Convert a dark-measurement state enum to command JSON text. */ -const char *photodiode_dark_state_name(enum photodiode_dark_state state); +/** @brief Convert an average-measurement state enum to command JSON text. */ +const char *photodiode_average_state_name(enum photodiode_average_state state); + +/** + * @brief Convert dark-subtracted ADC millivolts to optical power in uW. + * + * Uses the app-owned photodiode responsivity and transimpedance settings. This + * helper performs no I/O and returns zero for non-positive signal or invalid + * response settings. + */ +double photodiode_power_uw_from_mv(double net_mv, + const struct app_pd_channel_settings *settings); + +/** + * @brief Convert dark-subtracted ADC millivolts to photon flux. + * + * Uses app-owned response settings plus the caller-provided wavelength. This + * helper performs no I/O and returns zero for non-positive signal or invalid + * wavelength/response settings. + */ +double photodiode_photon_flux_from_mv(double net_mv, + double wavelength_nm, + const struct app_pd_channel_settings *settings); /** * @brief Start or restart a dark measurement on the sampling thread. * * @param channel Photodiode channel to measure. * @param duration_ms Requested measurement window in milliseconds. Zero uses - * the firmware default. The implementation rounds to the nearest whole sample. + * the firmware default. The implementation rounds to the nearest whole sample + * and clamps to the short-average maximum duration. * @param store If true, update stored dark and lowest-dark when complete. * @param out Optional status populated immediately after the request is armed. * - * A repeated request for the same channel discards the previous in-progress - * accumulator and starts a fresh window. This call does not wait for the - * measurement interval. + * This is a short average tagged to update dark calibration on completion when + * @p store is true. A repeated dark request for the same channel discards the + * previous in-progress accumulator and starts a fresh window. This call does + * not wait for the measurement interval. * * @retval 0 Measurement was started. * @retval -EINVAL Bad channel. @@ -114,10 +133,20 @@ const char *photodiode_dark_state_name(enum photodiode_dark_state state); int photodiode_start_dark_measurement(enum photodiode_channel channel, uint32_t duration_ms, bool store, - struct photodiode_dark_status *out); -/** @brief Copy current or last dark-measurement state for one channel. */ -int photodiode_get_dark_status(enum photodiode_channel channel, - struct photodiode_dark_status *out); + struct photodiode_average_status *out); +/** + * @brief Start a short non-persistent average on the sampling thread. + * + * The accumulator samples the same ADC snapshots as normal photodiode status + * and reports raw and dark-subtracted means. It performs no persistence and + * does not block for the requested window. + */ +int photodiode_start_average(enum photodiode_channel channel, + uint32_t duration_ms, + struct photodiode_average_status *out); +/** @brief Copy current or last short-average state for one channel. */ +int photodiode_get_average_status(enum photodiode_channel channel, + struct photodiode_average_status *out); /** * @brief Clear lowest-dark tracking for one channel. * diff --git a/app/src/photodiode_command.c b/app/src/photodiode_command.c new file mode 100644 index 0000000..f8d4da2 --- /dev/null +++ b/app/src/photodiode_command.c @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "photodiode_command.h" + +#include + +#include + +#include "app_settings.h" +#include "photodiode.h" +#include "throughput_monitor.h" + +#include +#include + +enum pd_action { + PD_ACTION_MEASURE_DARK = 0, + PD_ACTION_DARK_STATUS, + PD_ACTION_RESET_LOWEST_DARK, +}; + +static const struct coo_json_string_choice pd_channel_choices[] = { + { "yj", PHOTODIODE_CHANNEL_YJ }, + { "hk", PHOTODIODE_CHANNEL_HK }, +}; + +static const struct coo_json_string_choice pd_action_choices[] = { + { "measure_dark", PD_ACTION_MEASURE_DARK }, + { "dark_status", PD_ACTION_DARK_STATUS }, + { "reset_lowest_dark", PD_ACTION_RESET_LOWEST_DARK }, +}; + +static struct coo_cmd_response +pd_average_status_response(const struct coo_cmd_request *cmd, + const struct photodiode_average_status *status); + +static int pd_parse_channel_name(const char *name, enum photodiode_channel *channel) +{ + int value; + + if (channel == NULL) { + return -EINVAL; + } + + if (coo_json_match_string_choice(name, pd_channel_choices, + ARRAY_SIZE(pd_channel_choices), + &value) == 0) { + *channel = (enum photodiode_channel)value; + return 0; + } + + return -ENOENT; +} + +static int pd_parse_channel_from_key(const struct coo_cmd_request *cmd, + enum photodiode_channel *channel) +{ + char channel_name[8] = {0}; + + if (cmd == NULL || + (coo_cmd_key_suffix_segment_copy(cmd->key, "pd", channel_name, + sizeof(channel_name)) != 0 && + coo_cmd_key_suffix_segment_copy(cmd->key, "pdsettings", channel_name, + sizeof(channel_name)) != 0)) { + return -ENOENT; + } + + return pd_parse_channel_name(channel_name, channel); +} + +static int pd_parse_channel_from_payload_or_key(const struct coo_cmd_request *cmd, + enum photodiode_channel *channel) +{ + int value; + int parse_rc; + + if (channel == NULL) { + return -EINVAL; + } + + parse_rc = pd_parse_channel_from_key(cmd, channel); + if (parse_rc == 0) { + return 0; + } + + parse_rc = coo_json_extract_string_choice(cmd->payload, "channel", + pd_channel_choices, + ARRAY_SIZE(pd_channel_choices), + &value); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + return -ENOENT; + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return -EINVAL; + } + + *channel = (enum photodiode_channel)value; + return 0; +} + +struct coo_cmd_response pd_get(const struct coo_cmd_request *cmd) +{ + struct photodiode_status status; + char payload[MAX_PAYLOAD_LEN] = {0}; + struct app_photodiode_settings settings; + char action_text[32] = {0}; + int action_value; + float yj_value; + float hk_value; + float yj_err; + float hk_err; + int parse_rc; + enum photodiode_channel channel; + + parse_rc = coo_json_extract_string(cmd->payload, "action", + action_text, sizeof(action_text)); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid action"); + } + if (parse_rc == COO_JSON_EXTRACT_OK) { + struct photodiode_average_status average_status; + int rc; + + if (coo_json_match_string_choice(action_text, pd_action_choices, + ARRAY_SIZE(pd_action_choices), + &action_value) != 0 || + (enum pd_action)action_value != PD_ACTION_DARK_STATUS) { + return coo_cmd_error(cmd, "unsupported query action"); + } + rc = pd_parse_channel_from_payload_or_key(cmd, &channel); + if (rc != 0) { + return coo_cmd_error(cmd, "channel must be yj or hk"); + } + rc = photodiode_get_average_status(channel, &average_status); + if (rc != 0) { + return coo_cmd_error(cmd, "dark status unavailable"); + } + + return pd_average_status_response(cmd, &average_status); + } + + photodiode_get_status(&status); + app_settings_get_photodiode(&settings); + + yj_value = status.channel[PHOTODIODE_CHANNEL_YJ].power_uw; + hk_value = status.channel[PHOTODIODE_CHANNEL_HK].power_uw; + yj_err = (float)photodiode_power_uw_from_mv( + status.channel[PHOTODIODE_CHANNEL_YJ].noise_rms_mv, + &settings.channel[PHOTODIODE_CHANNEL_YJ]); + hk_err = (float)photodiode_power_uw_from_mv( + status.channel[PHOTODIODE_CHANNEL_HK].noise_rms_mv, + &settings.channel[PHOTODIODE_CHANNEL_HK]); + + snprintk(payload, sizeof(payload), + "{\"yjvalue\":%.6f,\"yjvalue_err\":%.6f," + "\"hkvalue\":%.6f,\"hkvalue_err\":%.6f," + "\"yj_raw\":%d,\"hk_raw\":%d,\"yj_mv\":%.3f,\"hk_mv\":%.3f," + "\"yj_noise_rms_mv\":%.3f,\"hk_noise_rms_mv\":%.3f," + "\"yj_mean_mv_1s\":%.3f,\"hk_mean_mv_1s\":%.3f," + "\"yj_rms_mv_0p5s\":%.3f,\"hk_rms_mv_0p5s\":%.3f," + "\"uptime_s\":%lld}", + (double)yj_value, + (double)yj_err, + (double)hk_value, + (double)hk_err, + status.channel[PHOTODIODE_CHANNEL_YJ].raw, + status.channel[PHOTODIODE_CHANNEL_HK].raw, + (double)status.channel[PHOTODIODE_CHANNEL_YJ].mv, + (double)status.channel[PHOTODIODE_CHANNEL_HK].mv, + (double)status.channel[PHOTODIODE_CHANNEL_YJ].noise_rms_mv, + (double)status.channel[PHOTODIODE_CHANNEL_HK].noise_rms_mv, + (double)status.channel[PHOTODIODE_CHANNEL_YJ].mean_mv_1s, + (double)status.channel[PHOTODIODE_CHANNEL_HK].mean_mv_1s, + (double)status.channel[PHOTODIODE_CHANNEL_YJ].rms_mv_0p5s, + (double)status.channel[PHOTODIODE_CHANNEL_HK].rms_mv_0p5s, + status.uptime_ms/1000); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static struct coo_cmd_response pd_average_status_response(const struct coo_cmd_request *cmd, + const struct photodiode_average_status *status) +{ + char payload[MAX_PAYLOAD_LEN] = {0}; + const struct photodiode_average_result *result = &status->result; + const char *state_name = photodiode_average_state_name(status->state); + + if (status->state == PHOTODIODE_AVERAGE_COMPLETE) { + struct app_photodiode_settings settings; + + app_settings_get_photodiode(&settings); + snprintk(payload, sizeof(payload), + "{\"state\":\"%s\",\"channel\":\"%s\",\"stored\":%s," + "\"duration_ms\":%u,\"samples\":%u,\"target_samples\":%u," + "\"mean_dark_mv\":%.3f,\"rms_mv\":%.3f," + "\"min_mv\":%.3f,\"max_mv\":%.3f," + "\"previous_dark_mv\":%.3f,\"configured_dark_mv\":%.3f," + "\"lowest_dark_mv\":%.3f,\"lowest_dark_valid\":%s}", + state_name, + photodiode_channel_names[status->channel], + status->store_dark ? "true" : "false", + result->duration_ms, + result->samples, + result->target_samples, + (double)result->mean_mv, + (double)result->rms_mv, + (double)result->min_mv, + (double)result->max_mv, + (double)(result->mean_mv - result->mean_net_mv), + (double)settings.channel[status->channel].dark_mv, + (double)settings.channel[status->channel].lowest_dark_mv, + settings.channel[status->channel].lowest_dark_valid ? "true" : "false"); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); + } + + if (status->state == PHOTODIODE_AVERAGE_ERROR) { + snprintk(payload, sizeof(payload), + "{\"error\":\"dark measurement failed\",\"channel\":\"%s\",\"rc\":%d," + "\"duration_ms\":%u,\"samples\":%u,\"target_samples\":%u}", + photodiode_channel_names[status->channel], + status->last_error, + result->duration_ms, + result->samples, + result->target_samples); + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, payload); + } + + snprintk(payload, sizeof(payload), + "{\"state\":\"%s\",\"channel\":\"%s\",\"stored_on_complete\":%s," + "\"duration_ms\":%u,\"samples\":%u,\"target_samples\":%u}", + state_name, + photodiode_channel_names[status->channel], + status->store_dark ? "true" : "false", + result->duration_ms, + result->samples, + result->target_samples); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +struct coo_cmd_response pd_set(const struct coo_cmd_request *cmd) +{ + enum pd_action action; + char action_text[32] = {0}; + int action_value; + enum photodiode_channel channel; + uint32_t duration_ms = 0U; + bool store = false; + bool persist = true; + int parse_rc; + int rc; + + parse_rc = coo_json_extract_string(cmd->payload, "action", + action_text, sizeof(action_text)); + if (parse_rc == COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "missing action"); + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid action"); + } + if (coo_json_match_string_choice(action_text, pd_action_choices, + ARRAY_SIZE(pd_action_choices), + &action_value) != 0) { + return coo_cmd_error(cmd, "unknown action"); + } + action = (enum pd_action)action_value; + + rc = pd_parse_channel_from_payload_or_key(cmd, &channel); + if (rc != 0) { + return coo_cmd_error(cmd, "channel must be yj or hk"); + } + + switch (action) { + case PD_ACTION_MEASURE_DARK: { + struct photodiode_average_status status; + + if (throughput_monitor_autolevel_active(channel)) { + return coo_cmd_error(cmd, + "dark measurement blocked by autolevel throughput monitor"); + } + + if (coo_json_extract_optional_u32(cmd->payload, "duration_ms", + &duration_ms, NULL) != 0) { + return coo_cmd_error(cmd, "invalid duration_ms"); + } + + if (coo_json_extract_optional_bool(cmd->payload, "store", + &store, NULL) != 0) { + return coo_cmd_error(cmd, "invalid store"); + } + + rc = photodiode_start_dark_measurement(channel, duration_ms, store, &status); + if (rc != 0) { + return coo_cmd_error_rc(cmd, "dark measurement failed", rc); + } + return pd_average_status_response(cmd, &status); + } + case PD_ACTION_DARK_STATUS: { + struct photodiode_average_status status; + + rc = photodiode_get_average_status(channel, &status); + if (rc != 0) { + return coo_cmd_error(cmd, "dark status unavailable"); + } + + return pd_average_status_response(cmd, &status); + } + case PD_ACTION_RESET_LOWEST_DARK: + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persist, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); + } + + rc = photodiode_reset_lowest_dark(channel, persist); + if (rc != 0) { + return coo_cmd_error(cmd, "reset failed"); + } + return coo_cmd_ok(cmd); + default: + return coo_cmd_error(cmd, "unknown action"); + } +} + +static int pd_settings_channel_json(char *payload, size_t payload_len, + enum photodiode_channel channel, + const struct app_pd_channel_settings *ch) +{ + struct photodiode_average_status average = {0}; + int written; + + (void)photodiode_get_average_status(channel, &average); + + written = snprintk(payload, payload_len, + "{\"channel\":\"%s\",\"dark_mv\":%.3f," + "\"lowest_dark_mv\":%.3f," + "\"lowest_dark_valid\":%s," + "\"average\":\"%s\"," + "\"average_duration_ms\":%u," + "\"average_samples\":%u," + "\"average_target_samples\":%u," + "\"noise_rms_mV\":%.3f," + "\"responsivity_a_per_w\":%.9f," + "\"transimpedance_v_per_a\":%.6e}", + photodiode_channel_names[channel], + (double)ch->dark_mv, + (double)ch->lowest_dark_mv, + ch->lowest_dark_valid ? "true" : "false", + photodiode_average_state_name(average.state), + average.result.duration_ms, + average.result.samples, + average.result.target_samples, + (double)ch->noise_warn_rms_mv, + ch->responsivity_a_per_w, + ch->transimpedance_v_per_a); + + return (written >= 0 && written < (int)payload_len) ? 0 : -ENOSPC; +} + +struct coo_cmd_response pd_settings_get(const struct coo_cmd_request *cmd) +{ + struct app_photodiode_settings settings; + char payload[MAX_PAYLOAD_LEN] = {0}; + enum photodiode_channel channel; + int rc; + + rc = pd_parse_channel_from_key(cmd, &channel); + if (rc != 0) { + return coo_cmd_error(cmd, "pdsettings key must be pdsettings/yj or pdsettings/hk"); + } + + app_settings_get_photodiode(&settings); + rc = pd_settings_channel_json(payload, sizeof(payload), channel, + &settings.channel[channel]); + if (rc != 0) { + return coo_cmd_error(cmd, "pdsettings response too large"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +struct coo_cmd_response pd_settings_set(const struct coo_cmd_request *cmd) +{ + struct app_photodiode_settings settings; + struct app_pd_channel_settings channel_settings; + enum photodiode_channel channel; + bool persist = false; + bool changed = false; + int rc; + + rc = pd_parse_channel_from_key(cmd, &channel); + if (rc != 0) { + return coo_cmd_error(cmd, "pdsettings key must be pdsettings/yj or pdsettings/hk"); + } + + app_settings_get_photodiode(&settings); + channel_settings = settings.channel[channel]; + + if (coo_json_extract_optional_bool(cmd->payload, "persistent", + &persist, NULL) != 0) { + return coo_cmd_error(cmd, "invalid persistent"); + } + + if (coo_json_extract_optional_float_range(cmd->payload, "dark_mv", + &channel_settings.dark_mv, + &changed, -5000.0f, 5000.0f) != 0 || + coo_json_extract_optional_float_range(cmd->payload, "noise_rms_mV", + &channel_settings.noise_warn_rms_mv, + &changed, 0.0f, 5000.0f) != 0 || + coo_json_extract_optional_double_range(cmd->payload, "responsivity_a_per_w", + &channel_settings.responsivity_a_per_w, + &changed, 0.000001, 10.0) != 0 || + coo_json_extract_optional_double_range(cmd->payload, "transimpedance_v_per_a", + &channel_settings.transimpedance_v_per_a, + &changed, 1.0, 1.0e12) != 0) { + return coo_cmd_error(cmd, "invalid pdsettings value"); + } + + if (!changed) { + return coo_cmd_error(cmd, "no pdsettings fields supplied"); + } + + app_settings_update_photodiode_channel((uint8_t)channel, + &channel_settings, + persist); + return coo_cmd_ok(cmd); +} diff --git a/app/src/photodiode_command.h b/app/src/photodiode_command.h new file mode 100644 index 0000000..195b096 --- /dev/null +++ b/app/src/photodiode_command.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef HISPEC_PHOTODIODE_COMMAND_H +#define HISPEC_PHOTODIODE_COMMAND_H + +#include "command.h" + +/** + * @file photodiode_command.h + * @brief Command adapters for photodiode status, calibration, and settings. + */ + +/** + * @brief Return photodiode power, ADC, noise, and timing status. + * + * This command adapter reads cached photodiode state and app-level calibration + * settings. It does not perform ADC I/O or publish telemetry. + */ +struct coo_cmd_response pd_get(const struct coo_cmd_request *cmd); + +/** + * @brief Apply photodiode actions such as dark measurement and reset. + * + * Validates the command payload and calls the photodiode domain API. Starting a + * dark measurement arms work in the sampler thread and returns immediately; it + * does not wait for the full measurement duration. + */ +struct coo_cmd_response pd_set(const struct coo_cmd_request *cmd); + +/** + * @brief Return app-owned photodiode calibration settings for one channel. + * + * Reads app settings and current dark-measurement status. It does not modify + * hardware or persistent storage. + */ +struct coo_cmd_response pd_settings_get(const struct coo_cmd_request *cmd); + +/** + * @brief Update app-owned photodiode calibration settings for one channel. + * + * Validates numeric ranges and updates the app settings cache. Persistence is + * controlled by the command payload's documented `persistent` field. + */ +struct coo_cmd_response pd_settings_set(const struct coo_cmd_request *cmd); + +#endif /* HISPEC_PHOTODIODE_COMMAND_H */ diff --git a/app/src/sntp_sync.c b/app/src/sntp_sync.c index 751a772..dcf4eda 100644 --- a/app/src/sntp_sync.c +++ b/app/src/sntp_sync.c @@ -1,9 +1,10 @@ /** * @file sntp_sync.c - * @brief Delayable-work SNTP sync, retry, and hourly resync logic. + * @brief Low-priority SNTP sync, retry, and hourly resync logic. * - * The work item chooses manual or DHCP NTP source, calls sntp_simple(), updates - * CLOCK_REALTIME on success, and records status for `time` and `ip`. + * The SNTP thread chooses manual or DHCP NTP source, calls sntp_simple(), + * updates Zephyr's realtime clock on success, and records status for `time` + * and `ip`. * * Copyright (c) 2026 Caltech Optical Observatories * SPDX-License-Identifier: Apache-2.0 @@ -18,9 +19,8 @@ #include #include #include -#if defined(CONFIG_SNTP) #include -#endif +#include #include @@ -31,16 +31,27 @@ LOG_MODULE_REGISTER(sntp_sync, LOG_LEVEL_INF); #define SNTP_SYNC_TIMEOUT_MS 3000U #define SNTP_SYNC_RETRY_INTERVAL_MS 30000U #define SNTP_SYNC_RESYNC_INTERVAL_MS 3600000U +#define SNTP_SYNC_INITIAL_DELAY_MS 1000U +#define SNTP_SYNC_STACK_SIZE 1400 +#define SNTP_SYNC_THREAD_PRIORITY 10 struct sntp_sync_runtime { struct k_mutex lock; - struct k_work_delayable sync_work; struct sntp_sync_status status; bool initialized; }; static struct sntp_sync_runtime g_sntp; +static void sntp_sync_thread(void *p1, void *p2, void *p3); + +static K_SEM_DEFINE(sntp_start_sem, 0, 1); +static K_SEM_DEFINE(sntp_wake_sem, 0, 1); + +K_THREAD_DEFINE(sntp_sync_tid, SNTP_SYNC_STACK_SIZE, + sntp_sync_thread, NULL, NULL, NULL, + SNTP_SYNC_THREAD_PRIORITY, 0, 0); + const char *sntp_sync_source_str(enum sntp_sync_source source) { switch (source) { @@ -151,11 +162,11 @@ static enum sntp_sync_source choose_ntp_server(char *out, size_t out_len) return SNTP_SYNC_SOURCE_NONE; } -#if defined(CONFIG_SNTP) static int apply_sntp_time(const struct sntp_time *sntp_time, uint64_t *utc_ms_out) { struct timespec ts = {0}; uint64_t utc_ms; + int rc; if (sntp_time == NULL) { return -EINVAL; @@ -163,14 +174,16 @@ static int apply_sntp_time(const struct sntp_time *sntp_time, uint64_t *utc_ms_o ts.tv_sec = (time_t)sntp_time->seconds; ts.tv_nsec = (long)(((uint64_t)sntp_time->fraction * NSEC_PER_SEC) >> 32); - if (clock_settime(CLOCK_REALTIME, &ts) != 0) { - return -errno; + rc = sys_clock_settime(SYS_CLOCK_REALTIME, &ts); + if (rc != 0) { + return rc; } utc_ms = ((uint64_t)ts.tv_sec * 1000ULL) + ((uint64_t)ts.tv_nsec / 1000000ULL); if (utc_ms_out != NULL) { *utc_ms_out = utc_ms; } + app_settings_note_time_utc_ms(utc_ms); return 0; } @@ -191,14 +204,18 @@ static int sntp_sync_now_internal(void) set_status_server(source, server); if (source == SNTP_SYNC_SOURCE_NONE) { + LOG_WRN("SNTP sync skipped: no manual or DHCP NTP server configured"); return -ENOENT; } - /* sntp_simple() blocks this system-workqueue item until a reply arrives - * or the timeout expires. It never runs in the MQTT or ADC timing path. + /* sntp_simple() blocks this low-priority SNTP thread until a reply + * arrives or the timeout expires. It never runs in the MQTT, command, + * MEMS, or ADC timing paths. */ rc = sntp_simple(server, SNTP_SYNC_TIMEOUT_MS, &sntp_time); if (rc != 0) { + LOG_WRN("SNTP sync failed (%s: %s rc=%d)", + sntp_sync_source_str(source), server, rc); return rc; } @@ -211,56 +228,46 @@ static int sntp_sync_now_internal(void) set_status_result(0, utc_ms); return 0; } -#else -static int sntp_sync_now_internal(void) -{ - return -ENOTSUP; -} -#endif -static void sntp_sync_work_handler(struct k_work *work) +static void sntp_sync_thread(void *p1, void *p2, void *p3) { - int rc; uint32_t next_ms; - ARG_UNUSED(work); + ARG_UNUSED(p1); + ARG_UNUSED(p2); + ARG_UNUSED(p3); - rc = sntp_sync_now_internal(); - if (rc != 0) { - LOG_WRN("SNTP sync failed (%d)", rc); - set_status_result(rc, 0U); - next_ms = SNTP_SYNC_RETRY_INTERVAL_MS; - } else { - next_ms = SNTP_SYNC_RESYNC_INTERVAL_MS; - } + k_sem_take(&sntp_start_sem, K_FOREVER); + next_ms = SNTP_SYNC_INITIAL_DELAY_MS; - /* Rescheduling the same delayable work provides retry/resync behavior - * without creating another thread or user-programmable scheduler. - */ - (void)k_work_reschedule(&g_sntp.sync_work, K_MSEC(next_ms)); + while (1) { + int rc; + + (void)k_sem_take(&sntp_wake_sem, K_MSEC(next_ms)); + + rc = sntp_sync_now_internal(); + if (rc != 0) { + set_status_result(rc, 0U); + next_ms = SNTP_SYNC_RETRY_INTERVAL_MS; + } else { + next_ms = SNTP_SYNC_RESYNC_INTERVAL_MS; + } + } } void sntp_sync_init(void) { memset(&g_sntp, 0, sizeof(g_sntp)); k_mutex_init(&g_sntp.lock); - k_work_init_delayable(&g_sntp.sync_work, sntp_sync_work_handler); k_mutex_lock(&g_sntp.lock, K_FOREVER); -#if defined(CONFIG_SNTP) g_sntp.status.enabled = true; g_sntp.status.last_error = -ENETDOWN; -#else - g_sntp.status.enabled = false; - g_sntp.status.last_error = -ENOTSUP; -#endif g_sntp.status.source = SNTP_SYNC_SOURCE_NONE; g_sntp.initialized = true; k_mutex_unlock(&g_sntp.lock); -#if defined(CONFIG_SNTP) - (void)k_work_schedule(&g_sntp.sync_work, K_SECONDS(1)); -#endif + k_sem_give(&sntp_start_sem); } void sntp_sync_schedule_now(void) @@ -269,9 +276,7 @@ void sntp_sync_schedule_now(void) return; } -#if defined(CONFIG_SNTP) - (void)k_work_reschedule(&g_sntp.sync_work, K_NO_WAIT); -#endif + k_sem_give(&sntp_wake_sem); } void sntp_sync_get_status(struct sntp_sync_status *out) diff --git a/app/src/sntp_sync.h b/app/src/sntp_sync.h index ba4bb2d..42f580e 100644 --- a/app/src/sntp_sync.h +++ b/app/src/sntp_sync.h @@ -2,8 +2,8 @@ * @file sntp_sync.h * @brief SNTP scheduling and clock-status helpers. * - * SNTP state is cached for command responses. Synchronization work runs from a - * delayable work item and can block while waiting for an SNTP reply. + * SNTP state is cached for command responses. Synchronization runs in a + * low-priority SNTP thread and can block while waiting for an SNTP reply. * * Copyright (c) 2026 Caltech Optical Observatories * SPDX-License-Identifier: Apache-2.0 @@ -32,10 +32,10 @@ struct sntp_sync_status { int64_t last_sync_uptime_ms; }; -/** @brief Initialize SNTP delayable work and schedule first sync when enabled. */ +/** @brief Initialize SNTP status and start first sync when enabled. */ void sntp_sync_init(void); -/** @brief Reschedule the SNTP work item for immediate execution. */ +/** @brief Wake the SNTP thread for immediate execution. */ void sntp_sync_schedule_now(void); /** @brief Copy the most recent SNTP status under the module mutex. */ diff --git a/app/src/tempsense.c b/app/src/tempsense.c deleted file mode 100644 index d0e61e4..0000000 --- a/app/src/tempsense.c +++ /dev/null @@ -1,128 +0,0 @@ -/** - * @file tempsense.c - * @brief DS18B20 Zephyr sensor polling thread and status cache. - */ - -#include "tempsense.h" -#include -#include -#include - -#include // k_sleep and uptime helpers. -#include // LOG_ERR and LOG_INF. -#include -#include - -LOG_MODULE_REGISTER(tempsense, LOG_LEVEL_INF); - -struct tempsense_status tempsense; - -static K_MUTEX_DEFINE(tempsense_lock); -static int64_t last_sample_ms; - -static void tempsense_update(float ambient_c, int error, bool valid) -{ - /* One producer updates this cache; unrelated consumers read a snapshot. */ - k_mutex_lock(&tempsense_lock, K_FOREVER); - tempsense.ambient_c = ambient_c; - tempsense.last_error = error; - tempsense.valid = valid; - last_sample_ms = valid ? k_uptime_get() : 0; - tempsense.age_ms = 0; - k_mutex_unlock(&tempsense_lock); -} - -static void tempsense_mark_error(int error) -{ - /* Keep the last good value but mark it unavailable until a fresh read works. */ - k_mutex_lock(&tempsense_lock, K_FOREVER); - tempsense.last_error = error; - tempsense.valid = false; - last_sample_ms = 0; - tempsense.age_ms = UINT32_MAX; - k_mutex_unlock(&tempsense_lock); -} - -void tempsense_get_status(struct tempsense_status *out) -{ - int64_t now; - - if (out == NULL) { - return; - } - - /* Copies a stable status so callers do not read while the sampler writes. */ - k_mutex_lock(&tempsense_lock, K_FOREVER); - *out = tempsense; - now = k_uptime_get(); - out->age_ms = (out->valid && last_sample_ms > 0) ? (uint32_t)(now - last_sample_ms) : UINT32_MAX; - k_mutex_unlock(&tempsense_lock); -} - - -/* - * Get a device structure from a devicetree node with compatible - * "maxim,ds18b20". (If there are multiple, just pick one.) - */ -static const struct device *get_ds18b20_device(void) -{ - const struct device *const dev = DEVICE_DT_GET_ANY(maxim_ds18b20); - - if (dev == NULL) { - LOG_ERR("No DS18B20 devicetree node with status okay"); - return NULL; - } - - if (!device_is_ready(dev)) { - LOG_ERR("DS18B20 device %s is not ready", dev->name); - return NULL; - } - - LOG_INF("Using DS18B20 device %s", dev->name); - return dev; -} - - -void tempsensor_thread(void *p1, void *p2, void *p3) -{ - const struct device *dev = get_ds18b20_device(); - - ARG_UNUSED(p1); - ARG_UNUSED(p2); - ARG_UNUSED(p3); - - tempsense_update(0.0f, dev == NULL ? -ENODEV : 0, false); - - if (dev == NULL) { - return; - } - - while (true) { - struct sensor_value temp; - float ambient_c; - int res; - - /* Refresh the Zephyr sensor sample before reading the temperature channel. */ - res = sensor_sample_fetch(dev); - if (res != 0) { - LOG_ERR("DS18B20 sample fetch failed: %d", res); - tempsense_mark_error(res); - k_sleep(K_SECONDS(1)); - continue; - } - - /* SENSOR_CHAN_AMBIENT_TEMP returns Celsius in integer + micro unit parts. */ - res = sensor_channel_get(dev, SENSOR_CHAN_AMBIENT_TEMP, &temp); - if (res != 0) { - LOG_ERR("DS18B20 channel read failed: %d", res); - tempsense_mark_error(res); - k_sleep(K_SECONDS(1)); - continue; - } - - ambient_c = sensor_value_to_double(&temp); - tempsense_update(ambient_c, 0, true); - - k_sleep(K_MSEC(1000)); - } -} diff --git a/app/src/tempsense.h b/app/src/tempsense.h deleted file mode 100644 index cdbfe6d..0000000 --- a/app/src/tempsense.h +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @file tempsense.h - * @brief DS18B20 ambient temperature sampling cache. - */ - -#ifndef APP_TEMPSENSE_H -#define APP_TEMPSENSE_H - - -#include -#include -#include - - -struct tempsense_status { - float ambient_c; - uint32_t age_ms; - int last_error; - bool valid; -}; - -/* Latest DS18B20 ambient reading cache; use tempsense_get_status() for a stable copy. */ -extern struct tempsense_status tempsense; - -/** Copy the current temperature cache and compute age from Zephyr uptime. */ -void tempsense_get_status(struct tempsense_status *out); - -/** Background sampler thread. Reads the Zephyr sensor API once per second. */ -void tempsensor_thread(void *p1, void *p2, void *p3); - - - - - -#endif //APP_TEMPSENSE_H diff --git a/app/src/throughput_command.c b/app/src/throughput_command.c new file mode 100644 index 0000000..0bdd736 --- /dev/null +++ b/app/src/throughput_command.c @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "throughput_command.h" + +#include +#include + +#include + +#include "devices.h" +#include "mems_switching.h" +#include "throughput_monitor.h" + +#include + +LOG_MODULE_DECLARE(throughput_monitor, LOG_LEVEL_INF); + +enum throughput_format { + THROUGHPUT_FORMAT_JSON = 0, + THROUGHPUT_FORMAT_BINARY, +}; + +static const struct coo_json_string_choice stop_choices[] = { + { "yj", PHOTODIODE_CHANNEL_YJ }, + { "hk", PHOTODIODE_CHANNEL_HK }, + { "all", PHOTODIODE_CHANNEL_COUNT }, +}; + +static const struct coo_json_string_choice fiber_choices[] = { + { "m", 'M' }, + { "s", 'S' }, +}; + +static const struct coo_json_string_choice format_choices[] = { + { "json", THROUGHPUT_FORMAT_JSON }, + { "binary", THROUGHPUT_FORMAT_BINARY }, +}; + +static int throughput_input_for_laser(enum hispec_laser_id laser, + char *out, size_t out_len) +{ + const char *input; + + if (out == NULL || out_len == 0U) { + return -EINVAL; + } + + switch (laser) { + case HISPEC_LASER_1430_YJ: + input = "yj_1430"; + break; + case HISPEC_LASER_1430_HK: + input = "hk_1430"; + break; + case HISPEC_LASER_1028_Y: + case HISPEC_LASER_1270_J: + input = "yj_laser"; + break; + case HISPEC_LASER_1510_H: + case HISPEC_LASER_2330_K: + input = "hk_laser"; + break; + default: + return -EINVAL; + } + + if (snprintk(out, out_len, "%s", input) >= out_len) { + return -ENOSPC; + } + return 0; +} + +static int throughput_channel_from_input(const char *input, + enum photodiode_channel *channel) +{ + if (input == NULL || channel == NULL) { + return -EINVAL; + } + if (strncmp(input, "yj_", 3) == 0 || strcmp(input, "yj") == 0) { + *channel = PHOTODIODE_CHANNEL_YJ; + return 0; + } + if (strncmp(input, "hk_", 3) == 0 || strcmp(input, "hk") == 0) { + *channel = PHOTODIODE_CHANNEL_HK; + return 0; + } + return -EINVAL; +} + +static int throughput_apply_route_if_requested(const char *input, + const char *output) +{ + const char *failed_switch = NULL; + char failed_state = '\0'; + + if (output == NULL || output[0] == '\0') { + return 0; + } + if (input == NULL || input[0] == '\0') { + return -EINVAL; + } + + return mems_router_apply_named_route(&router, input, output, + &failed_switch, &failed_state); +} + +struct coo_cmd_response measure_throughput_set(const struct coo_cmd_request *cmd) +{ + char stop[8] = {0}; + char laser_name[16] = {0}; + char input[MEMS_SOURCEDEST_MAX_LEN] = {0}; + char output[MEMS_SOURCEDEST_MAX_LEN] = {0}; + struct throughput_monitor_request request = {0}; + struct throughput_monitor_status status = {0}; + uint32_t stopafter_s = 0U; + bool autolevel = true; + bool max_flux_present = false; + int choice_value; + int parse_rc; + int rc; + + parse_rc = coo_json_extract_string(cmd->payload, "stop", stop, sizeof(stop)); + if (parse_rc == COO_JSON_EXTRACT_OK) { + if (coo_json_match_string_choice(stop, stop_choices, + ARRAY_SIZE(stop_choices), + &choice_value) != 0) { + return coo_cmd_error(cmd, "stop must be yj, hk, or all"); + } + + rc = throughput_monitor_stop((uint8_t)choice_value, &status); + if (rc != 0) { + return coo_cmd_error(cmd, "stop failed"); + } + + return coo_cmd_ok(cmd); + } + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid stop"); + } + + parse_rc = coo_json_extract_string(cmd->payload, "laser", + laser_name, sizeof(laser_name)); + if (parse_rc != COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "missing or invalid laser"); + } + if (strcmp(laser_name, "none") == 0) { + request.has_laser = false; + request.laser = HISPEC_LASER_UNKNOWN; + } else if (hispec_laser_id_from_name(laser_name, &request.laser) == 0) { + request.has_laser = true; + } else { + return coo_cmd_error(cmd, "missing or invalid laser"); + } + + parse_rc = coo_json_extract_string_choice(cmd->payload, "fiber", + fiber_choices, + ARRAY_SIZE(fiber_choices), + &choice_value); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "fiber must be M or S"); + } + if (parse_rc == COO_JSON_EXTRACT_OK) { + request.fiber = (char)choice_value; + } else { + request.fiber = 'M'; + } + + if (coo_json_extract_optional_bool(cmd->payload, "autolevel", + &autolevel, NULL) != 0) { + return coo_cmd_error(cmd, "invalid autolevel"); + } + + if (coo_json_extract_optional_u32(cmd->payload, "stopafter_s", + &stopafter_s, NULL) != 0) { + return coo_cmd_error(cmd, "invalid stopafter_s"); + } + + if (coo_json_extract_optional_double_range(cmd->payload, "max_flux_ph_s", + &request.max_flux_ph_s, + &max_flux_present, + 0.0, 1.0e30) != 0) { + return coo_cmd_error(cmd, "invalid max_flux_ph_s"); + } + + parse_rc = coo_json_extract_string_choice(cmd->payload, "format", + format_choices, + ARRAY_SIZE(format_choices), + &choice_value); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "format must be json or binary"); + } + request.binary = parse_rc == COO_JSON_EXTRACT_OK && + choice_value == THROUGHPUT_FORMAT_BINARY; + + request.autolevel = autolevel; + request.stopafter_s = stopafter_s; + + parse_rc = coo_json_extract_string(cmd->payload, "input", input, sizeof(input)); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid input"); + } + if (parse_rc == COO_JSON_EXTRACT_MISSING && request.has_laser && + throughput_input_for_laser(request.laser, input, sizeof(input)) != 0) { + return coo_cmd_error(cmd, "invalid laser route"); + } + + parse_rc = coo_json_extract_string(cmd->payload, "output", output, sizeof(output)); + if (parse_rc == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid output"); + } + + if (!request.has_laser) { + if (autolevel || input[0] == '\0' || output[0] == '\0' || + throughput_channel_from_input(input, &request.channel) != 0) { + return coo_cmd_error(cmd, "laser none requires input, output, and autolevel false"); + } + } else if (max_flux_present && !autolevel) { + return coo_cmd_error(cmd, "max_flux_ph_s requires autolevel"); + } + + rc = throughput_apply_route_if_requested(input, output); + if (rc != 0) { + return coo_cmd_error(cmd, "failed to apply output route"); + } + + rc = throughput_monitor_start(&request, &status); + if (rc != 0) { + LOG_ERR("measure_throughput start failed: %d", rc); + return coo_cmd_error(cmd, "measure_throughput start failed"); + } + + return coo_cmd_ok(cmd); +} diff --git a/app/src/throughput_command.h b/app/src/throughput_command.h new file mode 100644 index 0000000..6b1550d --- /dev/null +++ b/app/src/throughput_command.h @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef HISPEC_THROUGHPUT_COMMAND_H +#define HISPEC_THROUGHPUT_COMMAND_H + +#include "command.h" + +/** + * @file throughput_command.h + * @brief Command adapter for throughput-monitor requests. + */ + +/** + * @brief Parse and apply the measure_throughput command. + * + * Validates the MQTT/serial payload, starts or stops the matching monitor, and + * returns one command response. This adapter does not publish telemetry; the + * active monitor thread later enqueues telemetry through outbound_queue. + */ +struct coo_cmd_response measure_throughput_set(const struct coo_cmd_request *cmd); + +#endif /* HISPEC_THROUGHPUT_COMMAND_H */ diff --git a/app/src/throughput_monitor.c b/app/src/throughput_monitor.c new file mode 100644 index 0000000..703838c --- /dev/null +++ b/app/src/throughput_monitor.c @@ -0,0 +1,633 @@ +/** + * @file throughput_monitor.c + * @brief Throughput streaming and autolevel control. + */ + +#include "throughput_monitor.h" + +#include +#include +#include +#include +#include + +#include "app_settings.h" +#include "app_identity.h" +#include "attenuator.h" +#include "attenuator_calibration.h" +#include "command.h" +#include "devices.h" +#include "housekeeping.h" + +#include +#include +#include +#include + +LOG_MODULE_REGISTER(throughput_monitor, LOG_LEVEL_INF); + +#define TP_INTERVAL_MS 100U +#define TP_ADC_USABLE_MV 5000.0 +#define TP_LOW_FRACTION 0.20 +#define TP_HIGH_FRACTION 0.80 +#define TP_INSTANT_BAD_SAMPLES 5U +#define TP_MIN_ATTEN_TX 1.0e-9 + +BUILD_ASSERT((int)HOUSEKEEPING_POWER_YJ_PHOTODIODE == (int)PHOTODIODE_CHANNEL_YJ, + "YJ photodiode relay index must match photodiode channel"); +BUILD_ASSERT((int)HOUSEKEEPING_POWER_HK_PHOTODIODE == (int)PHOTODIODE_CHANNEL_HK, + "HK photodiode relay index must match photodiode channel"); +BUILD_ASSERT(IS_ENABLED(CONFIG_LITTLE_ENDIAN), + "throughput binary telemetry uses little-endian float layout"); + +struct laser_pd_channel { + enum hispec_laser_id laser; + enum photodiode_channel channel; +}; + +struct throughput_state { + bool active; + bool autolevel; + bool binary; + bool has_laser; + enum hispec_laser_id laser; + enum photodiode_channel channel; + uint8_t attenuator_index; + char fiber; + float level_percent; + int64_t started_ms; + uint32_t stopafter_s; + double max_flux_ph_s; + uint8_t high_count; + uint8_t low_count; +}; + +static const struct laser_pd_channel laser_pd_channels[] = { + {HISPEC_LASER_1028_Y, PHOTODIODE_CHANNEL_YJ}, + {HISPEC_LASER_1270_J, PHOTODIODE_CHANNEL_YJ}, + {HISPEC_LASER_1430_YJ, PHOTODIODE_CHANNEL_YJ}, + {HISPEC_LASER_1430_HK, PHOTODIODE_CHANNEL_HK}, + {HISPEC_LASER_1510_H, PHOTODIODE_CHANNEL_HK}, + {HISPEC_LASER_2330_K, PHOTODIODE_CHANNEL_HK}, +}; + +static struct throughput_state monitors[PHOTODIODE_CHANNEL_COUNT]; +static K_MUTEX_DEFINE(monitors_lock); + +static int photodiode_channel_for_laser(enum hispec_laser_id laser, + enum photodiode_channel *channel) +{ + if (channel == NULL) { + return -EINVAL; + } + + for (uint8_t i = 0U; i < ARRAY_SIZE(laser_pd_channels); ++i) { + if (laser_pd_channels[i].laser == laser) { + *channel = laser_pd_channels[i].channel; + return 0; + } + } + + return -ENOENT; +} + +static void route_name_for_pd(char *buf, size_t buf_len, + enum photodiode_channel channel, char fiber) +{ + const char *prefix = channel == PHOTODIODE_CHANNEL_YJ ? "yj" : "hk"; + const char *kind = (fiber == 'M') ? "mm" : "sm"; + + snprintk(buf, buf_len, "%s_%s_to_%s_pd", prefix, kind, prefix); +} + +static void route_name_for_laser(char *buf, size_t buf_len, + const char *laser, char fiber) +{ + snprintk(buf, buf_len, "%s_to_%c", laser, fiber); +} + +static void channel_fiber_name(char *buf, size_t buf_len, + enum photodiode_channel channel, char fiber) +{ + snprintk(buf, buf_len, "%s_%c", photodiode_channel_names[channel], + (char)tolower((unsigned char)fiber)); +} + +static uint64_t realtime_ms(void) +{ + struct timespec ts = {0}; + + (void)sys_clock_gettime(SYS_CLOCK_REALTIME, &ts); + return ((uint64_t)ts.tv_sec * 1000ULL) + ((uint64_t)ts.tv_nsec / 1000000ULL); +} + +static void stop_locked(enum photodiode_channel channel) +{ + memset(&monitors[channel], 0, sizeof(monitors[channel])); +} + +static void put_bytes(uint8_t *payload, size_t payload_len, size_t *offset, + const void *src, size_t src_len) +{ + if (*offset + src_len > payload_len) { + return; + } + + memcpy(payload + *offset, src, src_len); + *offset += src_len; +} + +static void put_u64(uint8_t *payload, size_t payload_len, size_t *offset, uint64_t value) +{ + uint8_t encoded[sizeof(value)]; + + sys_put_le64(value, encoded); + put_bytes(payload, payload_len, offset, encoded, sizeof(encoded)); +} + +static void put_i16(uint8_t *payload, size_t payload_len, size_t *offset, int16_t value) +{ + uint8_t encoded[sizeof(value)]; + + sys_put_le16((uint16_t)value, encoded); + put_bytes(payload, payload_len, offset, encoded, sizeof(encoded)); +} + +static void put_f32(uint8_t *payload, size_t payload_len, size_t *offset, float value) +{ + /* STM32 binary telemetry is specified as little-endian IEEE-754. */ + put_bytes(payload, payload_len, offset, &value, sizeof(value)); +} + +static void put_f64(uint8_t *payload, size_t payload_len, size_t *offset, double value) +{ + /* STM32 binary telemetry is specified as little-endian IEEE-754. */ + put_bytes(payload, payload_len, offset, &value, sizeof(value)); +} + +static int autolevel_adjust(struct throughput_state *state, + const struct photodiode_channel_status *pd, + const struct attenuator_transmission_estimate *atten) +{ + double mean_net_mv = (double)pd->mean_mv_1s - (double)pd->dark_mv; + bool low = mean_net_mv < (TP_ADC_USABLE_MV * TP_LOW_FRACTION); + bool high = mean_net_mv > (TP_ADC_USABLE_MV * TP_HIGH_FRACTION); + struct hispec_laser_flux_estimate laser_flux = {0}; + double emitted_flux = 0.0; + double max_tx = 1.0; + double next_tx; + float next_percent; + int rc; + + if (pd->raw > INT16_MAX - 1024 || mean_net_mv <= 0.0) { + if (high || pd->raw > INT16_MAX - 1024) { + state->high_count++; + } else { + state->low_count++; + } + } else { + state->high_count = high ? state->high_count + 1U : 0U; + state->low_count = low ? state->low_count + 1U : 0U; + } + + if (!low && !high && + state->high_count < TP_INSTANT_BAD_SAMPLES && + state->low_count < TP_INSTANT_BAD_SAMPLES) { + return 0; + } + + if (low || state->low_count >= TP_INSTANT_BAD_SAMPLES) { + if (state->max_flux_ph_s > 0.0 && + laser_estimate_flux(state->laser, 0.0f, 0.0f, &laser_flux) == 0 && + laser_flux.flux_ph_s > 0.0) { + emitted_flux = laser_flux.flux_ph_s * atten->linear; + max_tx = state->max_flux_ph_s / laser_flux.flux_ph_s; + if (max_tx > 1.0) { + max_tx = 1.0; + } + } + if (atten->linear < 0.999) { + next_tx = atten->linear * 3.0; + if (next_tx > 1.0) { + next_tx = 1.0; + } + if (state->max_flux_ph_s > 0.0 && next_tx > max_tx) { + next_tx = max_tx; + } + if (next_tx <= atten->linear) { + state->low_count = 0U; + return 0; + } + (void)attenuator_set_linear(&attenuators[state->attenuator_index], next_tx); + } else if (state->level_percent < 100.0f) { + next_percent = state->level_percent * 3.0f; + if (next_percent > 100.0f) { + next_percent = 100.0f; + } + if (state->max_flux_ph_s > 0.0 && emitted_flux > 0.0) { + float capped = (float)((double)state->level_percent * + state->max_flux_ph_s / emitted_flux); + + if (capped < next_percent) { + next_percent = capped; + } + } + if (next_percent <= state->level_percent) { + state->low_count = 0U; + return 0; + } + rc = hispec_laser_set_output_percent_autooff(state->laser, + next_percent, 0U); + if (rc == 0) { + state->level_percent = next_percent; + } + } + state->low_count = 0U; + return 0; + } + + if (high || state->high_count >= TP_INSTANT_BAD_SAMPLES) { + if (atten->linear > TP_MIN_ATTEN_TX) { + next_tx = atten->linear / 3.0; + if (next_tx < TP_MIN_ATTEN_TX) { + next_tx = TP_MIN_ATTEN_TX; + } + (void)attenuator_set_linear(&attenuators[state->attenuator_index], next_tx); + } else if (state->level_percent > 0.0f) { + next_percent = state->level_percent / 3.0f; + if (next_percent < 0.0f) { + next_percent = 0.0f; + } + rc = hispec_laser_set_output_percent_autooff(state->laser, + next_percent, 0U); + if (rc == 0) { + state->level_percent = next_percent; + } + } + state->high_count = 0U; + } + + return 0; +} + +static void publish_sample(const struct throughput_state *state, + const struct photodiode_channel_status *pd, + uint64_t time_ms) +{ + struct app_photodiode_settings pd_settings; + struct attenuator_transmission_estimate atten = { + .linear = NAN, + .linear_err = NAN, + .attenuation_db = NAN, + }; + struct hispec_laser_flux_estimate laser_flux = {0}; + struct coo_cmd_response msg = {0}; + size_t off = 0U; + char pd_route[APP_ROUTE_LOSS_ROUTE_MAX_LEN] = {0}; + char laser_route[APP_ROUTE_LOSS_ROUTE_MAX_LEN] = {0}; + const char *laser_name = state->has_laser ? hispec_laser_name(state->laser) : "none"; + char channel_fiber[8] = {0}; + double pd_route_tx = 1.0; + double laser_route_tx = 1.0; + double pd_flux = NAN; + double pd_flux_err = NAN; + double emitted_flux = NAN; + double emitted_flux_err = NAN; + double tp = NAN; + double tp_err = NAN; + double tp_rms_err = NAN; + double pd_ontime_s; + double laser_current_ontime_s; + + if (laser_name == NULL) { + return; + } + app_settings_get_photodiode(&pd_settings); + + channel_fiber_name(channel_fiber, sizeof(channel_fiber), state->channel, state->fiber); + pd_ontime_s = housekeeping_power_on_time_s((enum housekeeping_power_output)state->channel); + laser_current_ontime_s = state->has_laser ? + (double)hispec_laser_current_on_time_s(state->laser) : 0.0; + route_name_for_pd(pd_route, sizeof(pd_route), state->channel, state->fiber); + if (state->has_laser && + attenuator_estimate_transmission(&attenuators[state->attenuator_index], + 0.0, 0.0, &atten) && + laser_estimate_flux(state->laser, 0.0f, 0.0f, &laser_flux) == 0) { + route_name_for_laser(laser_route, sizeof(laser_route), laser_name, state->fiber); + (void)app_settings_get_route_loss(pd_route, laser_name, &pd_route_tx); + (void)app_settings_get_route_loss(laser_route, laser_name, &laser_route_tx); + + pd_flux = photodiode_photon_flux_from_mv( + pd->net_mv, laser_flux.wavelength_nm, + &pd_settings.channel[state->channel]) / pd_route_tx; + pd_flux_err = photodiode_photon_flux_from_mv( + pd->rms_mv_0p5s, laser_flux.wavelength_nm, + &pd_settings.channel[state->channel]) / pd_route_tx; + emitted_flux = laser_flux.flux_ph_s * atten.linear * laser_route_tx; + emitted_flux_err = sqrt((laser_flux.flux_err_ph_s * atten.linear * laser_route_tx) * + (laser_flux.flux_err_ph_s * atten.linear * laser_route_tx) + + (laser_flux.flux_ph_s * atten.linear_err * laser_route_tx) * + (laser_flux.flux_ph_s * atten.linear_err * laser_route_tx)); + + if (emitted_flux > 0.0) { + tp = pd_flux / emitted_flux; + tp_rms_err = pd_flux_err / emitted_flux; + if (pd_flux > 0.0) { + tp_err = fabs(tp) * + sqrt((pd_flux_err / pd_flux) * (pd_flux_err / pd_flux) + + (emitted_flux_err / emitted_flux) * + (emitted_flux_err / emitted_flux)); + } + } + } + + msg.target = COO_CMD_OUT_MQTT_BEST_EFFORT; + msg.qos = 0; + (void)coo_cmd_format_data_topic(app_mqtt_device_id(), + state->channel == PHOTODIODE_CHANNEL_YJ ? + "yj_tput" : "hk_tput", + msg.topic, sizeof(msg.topic)); + + if (state->binary) { + put_bytes((uint8_t *)msg.payload, sizeof(msg.payload), &off, + channel_fiber, sizeof(channel_fiber)); + put_u64((uint8_t *)msg.payload, sizeof(msg.payload), &off, time_ms); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, tp); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, tp_err); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, tp_rms_err); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd_flux); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd_flux_err); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, emitted_flux); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, emitted_flux_err); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd_route_tx); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, laser_route_tx); + put_f64((uint8_t *)msg.payload, sizeof(msg.payload), &off, atten.linear); + put_i16((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd->raw); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd->mv); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd->net_mv); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd->mean_mv_1s); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, pd->rms_mv_0p5s); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, + (float)laser_flux.current_ma); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, + (float)atten.attenuation_db); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, + (float)laser_flux.wavelength_nm); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, + (float)pd_ontime_s); + put_f32((uint8_t *)msg.payload, sizeof(msg.payload), &off, + (float)laser_current_ontime_s); + msg.payload_len = off; + (void)k_msgq_put(&outbound_queue, &msg, K_NO_WAIT); + return; + } + + if (coo_json_append(msg.payload, sizeof(msg.payload), &off, + "{\"channel\":\"%s\",\"laser\":\"%s\"," + "\"autolevel\":%s,\"time\":%llu,\"tp\":", + channel_fiber, laser_name, state->autolevel ? "true" : "false", + (unsigned long long)time_ms) != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, tp, 6) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"tp_err\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, tp_err, 6) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"tp_rms_err\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, tp_rms_err, 6) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"pd_flux_ph_s\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, pd_flux, 9) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"pd_flux_err_ph_s\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, pd_flux_err, 9) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"laser_flux_ph_s\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, emitted_flux, 9) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"laser_flux_err_ph_s\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, emitted_flux_err, 9) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, + ",\"pd_route_tx\":%.9g,\"laser_route_tx\":%.9g," + "\"atten_tx\":%.12g,\"pd_raw\":%d", + pd_route_tx, laser_route_tx, atten.linear, pd->raw) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, + ",\"pd_mv\":%.4f,\"pd_net_mv\":%.4f," + "\"pd_mean_mv_1s\":%.4f,\"pd_rms_mv_0p5s\":%.4f", + (double)pd->mv, (double)pd->net_mv, + (double)pd->mean_mv_1s, (double)pd->rms_mv_0p5s) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"laser_current_ma\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, + laser_flux.current_ma, 4) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"atten_db\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, + atten.attenuation_db, 6) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, ",\"wavelength_nm\":") != 0 || + coo_json_append_float_or_null(msg.payload, sizeof(msg.payload), &off, + laser_flux.wavelength_nm, 4) != 0 || + coo_json_append(msg.payload, sizeof(msg.payload), &off, + ",\"pd_ontime_s\":%.3f,\"laser_current_ontime_s\":%.3f," + "\"flags\":[]}", + pd_ontime_s, laser_current_ontime_s) != 0) { + LOG_WRN("throughput telemetry payload too large"); + return; + } + msg.payload_len = strlen(msg.payload); + + (void)k_msgq_put(&outbound_queue, &msg, K_NO_WAIT); +} + +void throughput_monitor_thread(void *p1, void *p2, void *p3) +{ + ARG_UNUSED(p1); + ARG_UNUSED(p2); + ARG_UNUSED(p3); + + while (1) { + struct photodiode_status pd_status; + struct throughput_state local[PHOTODIODE_CHANNEL_COUNT]; + int64_t now = k_uptime_get(); + uint64_t time_ms = realtime_ms(); + + photodiode_get_status(&pd_status); + attenuator_calibration_tick(&pd_status, now); + + k_mutex_lock(&monitors_lock, K_FOREVER); + memcpy(local, monitors, sizeof(local)); + k_mutex_unlock(&monitors_lock); + + for (uint8_t i = 0U; i < PHOTODIODE_CHANNEL_COUNT; ++i) { + bool pd_power = false; + struct attenuator_transmission_estimate atten = {0}; + + if (!local[i].active) { + continue; + } + + if (local[i].stopafter_s > 0U && + now - local[i].started_ms >= (int64_t)local[i].stopafter_s * 1000) { + k_mutex_lock(&monitors_lock, K_FOREVER); + stop_locked((enum photodiode_channel)i); + k_mutex_unlock(&monitors_lock); + continue; + } + + if (housekeeping_power_get((enum housekeeping_power_output)i, + &pd_power) == 0 && !pd_power) { + k_mutex_lock(&monitors_lock, K_FOREVER); + stop_locked((enum photodiode_channel)i); + k_mutex_unlock(&monitors_lock); + continue; + } + + if (local[i].has_laser && local[i].autolevel && + attenuator_estimate_transmission(&attenuators[local[i].attenuator_index], + 0.0, 0.0, &atten)) { + (void)autolevel_adjust(&local[i], &pd_status.channel[i], + &atten); + k_mutex_lock(&monitors_lock, K_FOREVER); + if (monitors[i].active && monitors[i].laser == local[i].laser) { + monitors[i].level_percent = local[i].level_percent; + monitors[i].high_count = local[i].high_count; + monitors[i].low_count = local[i].low_count; + } + k_mutex_unlock(&monitors_lock); + } + + publish_sample(&local[i], &pd_status.channel[i], time_ms); + } + + k_sleep(K_MSEC(TP_INTERVAL_MS)); + } +} + +int throughput_monitor_start(const struct throughput_monitor_request *request, + struct throughput_monitor_status *status) +{ + enum photodiode_channel channel; + uint8_t attenuator_index; + struct throughput_state next = {0}; + int rc; + + if (request == NULL) { + return -EINVAL; + } + + if (request->fiber != 'M' && request->fiber != 'S') { + return -EINVAL; + } + if (request->has_laser) { + if (photodiode_channel_for_laser(request->laser, &channel) != 0) { + return -EINVAL; + } + rc = attenuator_index_from_laser_id(request->laser, &attenuator_index); + if (rc != 0) { + return rc; + } + } else { + if (request->autolevel || + request->channel < 0 || request->channel >= PHOTODIODE_CHANNEL_COUNT) { + return -EINVAL; + } + channel = request->channel; + attenuator_index = 0U; + } + + rc = housekeeping_power_set((enum housekeeping_power_output)channel, true); + if (rc != 0) { + return rc; + } + + next.active = true; + next.autolevel = request->autolevel; + next.binary = request->binary; + next.has_laser = request->has_laser; + next.laser = request->laser; + next.channel = channel; + next.attenuator_index = attenuator_index; + next.fiber = request->fiber; + next.stopafter_s = request->stopafter_s; + next.max_flux_ph_s = request->max_flux_ph_s; + next.started_ms = k_uptime_get(); + + if (request->has_laser && request->autolevel) { + next.level_percent = 100.0f; + (void)attenuator_set_db(&attenuators[attenuator_index], 120.0); + rc = hispec_laser_set_output_percent_autooff(request->laser, + next.level_percent, 0U); + if (rc != 0) { + return rc; + } + } + + k_mutex_lock(&monitors_lock, K_FOREVER); + monitors[channel] = next; + if (status != NULL) { + status->active = true; + status->channel = channel; + status->laser_name = request->has_laser ? hispec_laser_name(request->laser) : "none"; + status->autolevel = request->autolevel; + } + k_mutex_unlock(&monitors_lock); + return 0; +} + +int throughput_monitor_stop(uint8_t channel, struct throughput_monitor_status *status) +{ + if (channel > PHOTODIODE_CHANNEL_COUNT) { + return -EINVAL; + } + + k_mutex_lock(&monitors_lock, K_FOREVER); + if (channel == PHOTODIODE_CHANNEL_COUNT) { + stop_locked(PHOTODIODE_CHANNEL_YJ); + stop_locked(PHOTODIODE_CHANNEL_HK); + } else { + stop_locked((enum photodiode_channel)channel); + } + if (status != NULL) { + memset(status, 0, sizeof(*status)); + } + k_mutex_unlock(&monitors_lock); + return 0; +} + +bool throughput_monitor_any_active(void) +{ + bool active; + + k_mutex_lock(&monitors_lock, K_FOREVER); + active = monitors[PHOTODIODE_CHANNEL_YJ].active || + monitors[PHOTODIODE_CHANNEL_HK].active; + k_mutex_unlock(&monitors_lock); + return active; +} + +bool throughput_monitor_autolevel_active(enum photodiode_channel channel) +{ + bool active = false; + + if (channel < 0 || channel >= PHOTODIODE_CHANNEL_COUNT) { + return false; + } + + k_mutex_lock(&monitors_lock, K_FOREVER); + active = monitors[channel].active && monitors[channel].autolevel; + k_mutex_unlock(&monitors_lock); + return active; +} + +void throughput_monitor_note_attenuator_changed(uint8_t attenuator_index) +{ + k_mutex_lock(&monitors_lock, K_FOREVER); + for (uint8_t i = 0U; i < PHOTODIODE_CHANNEL_COUNT; ++i) { + if (monitors[i].active && monitors[i].attenuator_index == attenuator_index) { + monitors[i].autolevel = false; + } + } + k_mutex_unlock(&monitors_lock); +} + +void throughput_monitor_note_laser_changed(enum hispec_laser_id laser) +{ + k_mutex_lock(&monitors_lock, K_FOREVER); + for (uint8_t i = 0U; i < PHOTODIODE_CHANNEL_COUNT; ++i) { + if (monitors[i].active && monitors[i].has_laser && monitors[i].laser == laser) { + stop_locked((enum photodiode_channel)i); + } + } + k_mutex_unlock(&monitors_lock); +} diff --git a/app/src/throughput_monitor.h b/app/src/throughput_monitor.h new file mode 100644 index 0000000..af732cd --- /dev/null +++ b/app/src/throughput_monitor.h @@ -0,0 +1,59 @@ +/** + * @file throughput_monitor.h + * @brief Throughput monitor command worker and photodiode stream ownership. + * + * The monitor owns streaming publication and optional autolevel decisions. It + * reads photodiode snapshots, attenuator state, route-loss settings, and laser + * estimates, but it does not read the ADC directly or publish MQTT directly. + */ + +#ifndef HISPEC_THROUGHPUT_MONITOR_H +#define HISPEC_THROUGHPUT_MONITOR_H + +#include +#include + +#include "lasers.h" +#include "photodiode.h" + +struct throughput_monitor_request { + enum hispec_laser_id laser; + enum photodiode_channel channel; + bool has_laser; + bool autolevel; + bool binary; + char fiber; + uint32_t stopafter_s; + double max_flux_ph_s; +}; + +struct throughput_monitor_status { + bool active; + enum photodiode_channel channel; + const char *laser_name; + bool autolevel; +}; + +/** Background thread; sleeps between best-effort stream publications. */ +void throughput_monitor_thread(void *p1, void *p2, void *p3); + +/** Start or replace the monitor associated with the request's photodiode. */ +int throughput_monitor_start(const struct throughput_monitor_request *request, + struct throughput_monitor_status *status); + +/** Stop one channel or both channels. Pass PHOTODIODE_CHANNEL_COUNT for all. */ +int throughput_monitor_stop(uint8_t channel, struct throughput_monitor_status *status); + +/** Return true if either photodiode monitor is currently active. */ +bool throughput_monitor_any_active(void); + +/** Return true while autolevel owns the selected photodiode stream. */ +bool throughput_monitor_autolevel_active(enum photodiode_channel channel); + +/** Disable autolevel when another command changes a monitored attenuator. */ +void throughput_monitor_note_attenuator_changed(uint8_t attenuator_index); + +/** Stop any monitor using a laser whose output/settings changed externally. */ +void throughput_monitor_note_laser_changed(enum hispec_laser_id laser); + +#endif /* HISPEC_THROUGHPUT_MONITOR_H */ diff --git a/app_b1_s1/CMakeLists.txt b/app_b1_s1/CMakeLists.txt deleted file mode 100644 index 5128365..0000000 --- a/app_b1_s1/CMakeLists.txt +++ /dev/null @@ -1,19 +0,0 @@ -cmake_minimum_required(VERSION 3.13.1) - -# Point to the shared app root (sources + prj.conf + app.overlay) -set(APP_ROOT ${CMAKE_CURRENT_LIST_DIR}/../app) - -# Make the build use that directory for prj.conf and app.overlay: -set(APPLICATION_CONFIG_DIR ${APP_ROOT} CACHE PATH "" FORCE) # <-- documented mechanism - -# Preselect the board + shield for this variant: -#set(BOARD w5500_evb_pico2/rp2350a/m33 CACHE STRING "" FORCE) -set(BOARD nucleo_h563zi/stm32h563xx CACHE STRING "" FORCE) -#set(SHIELD pcb_a CACHE STRING "" FORCE) - - -find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) -project(app_b1_s1 LANGUAGES C) - -# Pull in common sources/includes -include(${APP_ROOT}/core.cmake) \ No newline at end of file diff --git a/doc/_doxygen/groups.dox b/doc/_doxygen/groups.dox deleted file mode 100644 index edf3a4d..0000000 --- a/doc/_doxygen/groups.dox +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @defgroup command_interface Command interface internals - * @brief MQTT and serial ingress, dispatch, and outbound response flow. - */ - -/** - * @defgroup board_profiles Devices and board profiles - * @brief Devicetree handles, board strap detection, routes, and profile setup. - */ - -/** - * @defgroup mems_switching MEMS switching - * @brief MEMS switch state, route application, and toggled split behavior. - */ - -/** - * @defgroup attenuation Attenuator control - * @brief DAC-backed FVOA logical attenuator control and calibration. - */ - -/** - * @defgroup maiman_laser Maiman laser driver interface - * @brief Blocking Modbus RTU accessors and higher-level laser helpers. - */ - -/** - * @defgroup photodiode_monitoring Photodiode monitoring - * @brief ADS1115 sampling, dark calibration, and photodiode telemetry state. - */ - -/** - * @defgroup settings_persistence Settings - * @brief Zephyr settings-backed application configuration and calibration. - */ - -/** - * @defgroup scheduled_actions Scheduled actions - * @brief Named k_work_delayable actions used by command handlers. - */ - -/** - * @defgroup warnings Warnings - * @brief Local warning logging and best-effort MQTT warning publication. - */ - -/** - * @defgroup time_sync SNTP and time - * @brief SNTP synchronization and clock status helpers. - */ - -/** - * @defgroup temperature Temperature sensing - * @brief DS18B20 ambient temperature polling and cached status. - */ - -/** - * @defgroup network_mqtt Network and MQTT wrappers - * @brief App-local network and MQTT helper wrappers from coo_commons. - */ diff --git a/doc/_doxygen/main.md b/doc/_doxygen/main.md deleted file mode 100644 index 5f83150..0000000 --- a/doc/_doxygen/main.md +++ /dev/null @@ -1,9 +0,0 @@ -# HiSPEC-TIB Firmware API - -This Doxygen corpus is generated from the Zephyr C firmware implementation -under `app/src`, selected local wrappers under `lib/coo_commons`, public -headers under `include/coo_commons`, and the local DS2408 GPIO driver. - -The rendered Sphinx pages use Breathe entry points instead of duplicating every -function signature by hand. Architecture and command intent remain in the -Markdown pages outside this generated API corpus. diff --git a/doc/api/command_interface.md b/doc/api/command_interface.md index 8a3bfcf..3a6177c 100644 --- a/doc/api/command_interface.md +++ b/doc/api/command_interface.md @@ -3,4 +3,16 @@ ```{eval-rst} .. doxygenfile:: app/src/command.h :project: hispec_tib + +.. doxygenfile:: app/src/attenuator_command.h + :project: hispec_tib + +.. doxygenfile:: app/src/laser_command.h + :project: hispec_tib + +.. doxygenfile:: app/src/photodiode_command.h + :project: hispec_tib + +.. doxygenfile:: app/src/throughput_command.h + :project: hispec_tib ``` diff --git a/doc/api/network_mqtt.md b/doc/api/network_mqtt.md index dc19b4f..771a920 100644 --- a/doc/api/network_mqtt.md +++ b/doc/api/network_mqtt.md @@ -1,4 +1,4 @@ -# Network and MQTT Wrappers +# Common Runtime Wrappers ```{eval-rst} .. doxygenfile:: include/coo_commons/mqtt_client.h @@ -7,10 +7,10 @@ .. doxygenfile:: include/coo_commons/network.h :project: hispec_tib -.. doxygenfile:: include/coo_commons/json_utils.h +.. doxygenfile:: include/coo_commons/command_dispatch.h :project: hispec_tib -.. doxygenfile:: include/coo_commons/pid.h +.. doxygenfile:: include/coo_commons/json_utils.h :project: hispec_tib ``` diff --git a/doc/api/scheduled_actions.md b/doc/api/scheduled_actions.md index 6f4e37c..b1d6b6c 100644 --- a/doc/api/scheduled_actions.md +++ b/doc/api/scheduled_actions.md @@ -1,6 +1,6 @@ -# Scheduled Actions +# Scheduled Action Helper ```{eval-rst} -.. doxygenfile:: app/src/app_scheduled_actions.h +.. doxygenfile:: include/coo_commons/scheduled_action.h :project: hispec_tib ``` diff --git a/doc/api/temperature.md b/doc/api/temperature.md index c04a846..bfd4be2 100644 --- a/doc/api/temperature.md +++ b/doc/api/temperature.md @@ -1,6 +1,6 @@ # Temperature Sensing ```{eval-rst} -.. doxygenfile:: app/src/tempsense.h +.. doxygenfile:: app/src/housekeeping.h :project: hispec_tib ``` diff --git a/doc/api/warnings.md b/doc/api/warnings.md index 9288e0b..d0e509c 100644 --- a/doc/api/warnings.md +++ b/doc/api/warnings.md @@ -1,6 +1,6 @@ # Warnings -```{eval-rst} -.. doxygenfile:: app/src/app_warning.h - :project: hispec_tib -``` +Warnings are emitted with +`coo_cmd_runtime_warning_emit(command_runtime_get(), code, msg, context)`. +Reusable warning JSON construction and non-blocking queue emission are +documented under the common command-dispatch helpers. diff --git a/doc/architecture.md b/doc/architecture.md index e917148..86d9113 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -18,22 +18,56 @@ scheduling. Runtime ownership is: - `main.c`: boot order, watchdog, network/MQTT loop, outbound queue draining. -- `command.c`: MQTT/serial command normalization, dispatch, response builders. +- `command.c`: app command queues, static command spec table, support + predicates, custom request classification callbacks, reboot-prepare hook, and + app/cross-domain command handlers. - `devices.c`: board strap detection, profile setup, shared device objects. -- `mems_switching.c`: MEMS switch state, route matching, toggler work. +- `mems_switching.c`: MEMS switch state, route matching, timer-driven router thread. - `attenuator.c`: DAC channel setup/read/write and coefficient application. +- `attenuator_command.c`: command-schema validation for `atten` value and + coefficient requests. - `maiman.c`: raw/scaled Modbus register transactions. -- `lasers.c`: laser-bank power sequencing, driver verification, estimates, and - higher-level Maiman helper APIs. -- `photodiode.c`: ADC sampling, dark calibration, noise tracking, telemetry. -- `tempsense.c`: DS18B20 polling and cache. -- `sntp_sync.c`: SNTP delayable-work sync and status. -- `app_settings.c`: Zephyr settings-backed app configuration and calibration. -- `app_warning.c`: local warning log plus best-effort MQTT warning publication. +- `lasers.c`: laser-bank power sequencing, driver verification, laser settings + validation, output estimates, auto-off delayable work, and higher-level Maiman + helper APIs. +- `laser_command.c`: command-schema validation and response shaping for laser + and laser-bank requests. +- `photodiode.c`: ADC sampling, dark calibration, noise tracking, and rolling + sample windows. +- `photodiode_command.c`: command-schema validation for `pd` and `pdsettings`. +- `throughput_command.c`: command-schema validation for `measure_throughput`. +- `throughput_monitor.c`: measure-throughput streaming, route-loss application, + and optional autolevel control. +- `housekeeping.c`: slow ambient-temperature sampling delayable work, + relay-box power state, and relay-output on-time tracking. +- `laserbank_tempcontrol.c`: laser-bank heater policy delayable work and + related status. +- `sntp_sync.c`: low-priority SNTP sync thread and status. +- `app_settings.c`: direct-NVS app configuration and calibration. +- `app_identity.c`: selected board-profile MQTT device ID. + +Project-local wrappers under `lib/coo_commons` are intentionally small: + +- `network.c`: Ethernet IPv4 bootstrap and runtime reconfiguration with + DHCP/static/fallback profile selection. +- `mqtt_client.c`: MQTT 5 broker parsing, broker resolution, connect/process, + and subscription helpers around Zephyr MQTT. +- `command_dispatch.c`: fixed-buffer MQTT/serial command request, built-in + help/serialguard/reboot commands, static longest-prefix spec lookup, default + request classification/execution, serial guard policy, persisted + lastcommand, topic formatting, response metadata, warning JSON, serial + payload normalization, and serial response printing helpers. +- `json_utils.c`: constrained keyed JSON extraction and fixed-buffer append + helpers used by command code. + +The application otherwise uses Zephyr GPIO, I2C, ADC, DAC, UART, Modbus, +settings/NVS, watchdog, console, networking, MQTT, SNTP, sensor, and 1-Wire +APIs directly. ## Boot Sequence -1. `main()` starts and initializes the watchdog. +1. `main()` reads and clears the hardware reset cause, then initializes the + watchdog. 2. `app_settings_init()` loads defaults and then stored app settings. 3. `devices_detect_board_type()` reads four active-low strap GPIOs. 4. `app_settings_note_board_type()` persists board type and clears other app @@ -42,9 +76,11 @@ Runtime ownership is: 6. `setup_mems_switches_and_routes()` builds the active MEMS router. 7. `setup_attenuators()` initializes profile-available logical attenuators and loads persisted coefficients into runtime attenuator objects. -8. Command runtime registers named scheduled actions. -9. Executor and serial threads are created. Photodiode and temperature threads - were defined statically and self-gate on board/device availability. +8. Command runtime configures static command specs, persisted lastcommand + storage, built-in reboot behavior, and serial console input. +9. Executor and serial threads are created. Ambient-temperature delayable work + is started. On the TIB profile, main also starts the photodiode thread, + throughput monitor thread, and laser-bank heater delayable work. 10. SNTP, network, MQTT client, broker settings, and command subscription are initialized. 11. The main loop feeds the watchdog, keeps MQTT connected when network is @@ -55,7 +91,8 @@ Runtime ownership is: Board identity comes from exactly one active strap: - `tib`: 8 MEMS switches, TIB routes, six logical attenuator channels, laser - bank GPIOs, DS2408 relay outputs, Modbus, ADS1115 photodiodes. + bank GPIO, housekeeping-owned DS2408 relay outputs, Modbus, ADS1115 + photodiodes. - `cal_yj`: 7 MEMS switches, CAL routes, one logical CAL attenuator channel (TIB's H channel). - `cal_hk`: same firmware profile shape as CAL YJ. - `as`: 6 MEMS switches, AS routes, no attenuators. @@ -63,26 +100,104 @@ Board identity comes from exactly one active strap: ## Command Model -MQTT subscribes to `cmd/hsfib-tib/req/#`. The suffix after that prefix is the -command key. An MQTT response topic property is used when present; otherwise -the default response topic is `cmd/hsfib-tib/resp/`. - -Empty MQTT payloads are GET. Non-empty MQTT payloads default to SET unless the -JSON payload has `msg_type:"get"`. Serial commands have no get/set words: -`` is GET and ` ` is SET after serial shorthand/key-value -normalization. - -The command executor runs exactly one command at a time from `inbound_queue`. -Handlers may block on I/O, sleep, enqueue warnings, update settings, and return -one response. +MQTT subscribes to `cmd//req/#`. The suffix after that prefix is the +command key. The shared command-dispatch helper copies the MQTT request into a +fixed request object. An MQTT response topic property is used when present; +otherwise the default response topic is `cmd//resp/`. +The `` namespace is selected from the detected board strap: `tib` maps +to `hsfib-tib`, `cal_hk` to `hsfib-rcal`, `cal_yj` to `hsfib-bcal`, and `as` to +`hsfib-as`. The same name is used for telemetry under `dt//...` and as +the MQTT client ID. + +Requests are classified by schema and topic shape, not by user-visible method +verbs. Command dispatch owns the default query/effect rules and uses the app +command spec table for special cases such as always-query commands, +suffix-triggered actions, and custom payload classifiers. + +Empty or no-payload requests are queries except for dispatcher built-ins such as +`reboot`, app actions such as `laserbank/clearfaults`, and laser-bank topic suffixes such as +`laserbank/power/override_on`. Non-empty payloads normally mean an effect +request, but documented query payloads remain queries: `status`, laser status +endpoints, laser name-only queries, laser tune/settings readbacks, and +`memsroute/route_loss` payloads that contain only `route`, and `pd` dark-status +payloads. + +Serial commands use the same classification after line normalization by the +shared command-dispatch helper: +`` becomes an empty JSON payload, raw JSON is copied, `key=value` tokens +are wrapped as JSON fields, and command-table-selected human shorthands are +translated into the same payload shapes as MQTT. The old payload `msg_type` +convention is not part of current ingress classification. + +The command executor runs exactly one request at a time from `inbound_queue`. +Command dispatch owns default execution unless an app override execute callback +is configured. Handlers may block on I/O, sleep, enqueue warnings, update +settings, and return one response. Pure queries are not recorded as +`lastcommand`; supported effect-capable requests are recorded before handler +execution in command-dispatch-owned runtime state and NVS storage. +App support predicates reject unsupported command families before they reach +laser, photodiode, throughput, or laser-bank command handlers on other board +profiles. Serial help marks those entries as unsupported instead of encoding +board names in the common command-dispatch library. + +When `CONFIG_COO_CMD_REBOOT` is enabled, `reboot` is a dispatcher built-in. +After the response window, command dispatch calls the app reboot-prepare hook +and then `sys_reboot(SYS_REBOOT_COLD)`. + +Command dispatch can persist one lastcommand record through Zephyr NVS when the +app supplies a mounted `struct nvs_fs *` and numeric NVS ID. The record stores a +fixed header plus the full `struct coo_cmd_request`, requiring +`sizeof(struct coo_cmd_request) + COO_CMD_LASTCOMMAND_NVS_OVERHEAD` bytes +(752 bytes with the current 512-byte payload configuration on Nucleo). + +## Network And MQTT Runtime + +Network capability presence follows Zephyr Kconfig: DHCP uses +`CONFIG_NET_DHCPV4`, DNS uses `CONFIG_DNS_RESOLVER`, and SNTP uses +`CONFIG_SNTP`. App code should not add duplicate capability flags. + +The MQTT wrapper accepts one publish callback plus caller-owned user data. This +app registers the command runtime with `coo_cmd_runtime_mqtt_callback()` so MQTT +ingress can enter command dispatch without an app-specific forwarding shim. + +IPv4 configuration precedence is: + +1. Runtime settings from the `ip` command. +2. Compile-time static IPv4 defaults. +3. Fallback service profile for direct laptop recovery. + +At apply time the helper tries DHCP first when configured, otherwise static, +then fallback, then DHCP as the last attempt for static-first mode. The `ip` +command applies network-affecting changes at runtime through +`network_reconfigure()`; it no longer requires reboot for ordinary IPv4 profile +changes. Failed runtime reconfigure attempts restore the prior active profile. + +DNS and NTP addresses are profile/settings data. Unsupported DNS/NTP fields are +reported by command code. Manual DNS is applied to Zephyr's resolver when DNS is +compiled in and a nonzero DNS server is configured; DHCP DNS is used when DHCP +provides it and `preferdhcpdns` is true. + +MQTT broker hostnames are accepted only when they resolve before settings are +updated. Numeric IPv4 brokers do not require DNS. After a broker setting change, +the main loop disconnects and tries the new broker once; if that connection +fails, firmware restores the prior broker setting and emits a best-effort +`mqtt_broker_revert` warning. + +SNTP is independent of manual `time` commands. Manual time setting updates +Zephyr's realtime clock; it does not mark SNTP state as manual. If SNTP is configured +and later succeeds, it will update the clock again, and failures remain visible +through `time`, `ip`, and status paths that report SNTP state. +SNTP network waits run in a low-priority SNTP thread, not on the system +workqueue and not in the command, MQTT, MEMS, or ADC timing paths. ## Hardware Control Commands do not directly publish. For example, MEMS commands update -router-owned switch state that is applied by the MEMS delayable-work tick. -Photodiode sampling does not publish directly; it sends best-effort telemetry -through `photodiode_queue`. Warning publication is non-blocking and -best-effort. +router-owned switch state that is applied by the timer-driven MEMS router +thread. +Photodiode sampling does not publish directly. Throughput monitoring owns +photodiode stream publication through `outbound_queue`. Warning publication is +non-blocking and best-effort. Maiman register calls are blocking Modbus RTU transactions. Laser-bank power commands can sleep while waiting for the Maiman modules to boot or for a @@ -91,15 +206,15 @@ fault-clear power-cycle interval. ## Implemented vs Intended Implemented behavior is detailed in `implemented_commands.md`. Intended command -behavior remains in `commands.md`. Known gaps include the documented -`measure_tput`, `lasersettings`, laser setpoint behavior, `temp` alarm set, and -laserbank autowarm commands, which are not currently implemented as specified. +behavior remains in `commands.md`. Current code-vs-doc gaps and owner-review +items are centralized in `human_review_required.md`. ## Design Constraints - Static memory and bounded queues are preferred. - Domain modules own hardware sequencing; command handlers own command schema. - Timing-sensitive paths should not perform MQTT publish calls. -- Warnings and telemetry are best-effort. +- Warnings and periodic telemetry are best-effort. Watchdog boot telemetry is + queued as non-best-effort so it is retried until MQTT is available. - Broad schedulers, plugin systems, and dynamic command registries are out of scope for current firmware. diff --git a/doc/command_implementation_audit.md b/doc/command_implementation_audit.md index 0d32703..12b70d9 100644 --- a/doc/command_implementation_audit.md +++ b/doc/command_implementation_audit.md @@ -2,117 +2,154 @@ ## Authority -`commands.md` documents intended command/API behavior. Current C source in -`app/src/command.c` is the implementation source of truth. This page compares -the two without silently changing either contract. +`commands.md` documents intended command/API behavior. The dispatcher built-ins +in `lib/coo_commons/command_dispatch.c`, the app command spec table in +`app/src/command.c`, and the current command handlers are the implementation +source of truth. This page compares the two without silently changing either +contract. ## Dispatch Model MQTT requests are accepted under: ```text -cmd/hsfib-tib/req/# +cmd//req/# ``` The suffix after the request prefix is copied into `Command.key`. -`dispatch_command()` chooses the longest dispatch-table key that is either an -exact match or followed by `/`. +`coo_cmd_runtime_find_spec()` chooses the longest command-spec key that is +either an exact match or followed by `/`; command dispatch then applies default +support checks, handler selection, lastcommand recording, and built-in reboot +pending rejection unless an app override execute callback is configured. The +`` component is board-profile dependent: `hsfib-tib`, `hsfib-rcal`, +`hsfib-bcal`, or `hsfib-as`. -Implemented dispatch entries: +Dispatcher built-ins: -| Entry | GET handler | SET handler | +| Entry | Query handler | Effect/action handler | Notes | +| --- | --- | --- | --- | +| `help` | yes | no | Serial prints directly; MQTT returns compact endpoints. | +| `serialguard` | yes | yes | Present when `CONFIG_COO_CMD_SERIAL_GUARD` is enabled. | +| `reboot` | no | yes | Present when `CONFIG_COO_CMD_REBOOT` is enabled. | + +Implemented app dispatch entries. The column names reflect internal C dispatch +slots; the external API is documented as queries, effect requests, and actions. + +| Entry | Query handler | Effect/action handler | | --- | --- | --- | -| `help` | yes | no | | `ip` | yes | yes | | `mqtt` | yes | yes | | `time` | yes | yes | -| `reboot` | no | yes | -| `serialguard` | yes | yes | | `memsroute` | yes | yes | +| `memsroute/route_loss` | yes | yes | | `mems` | yes | yes | | `split` | yes | yes | -| `laserbank/poweron` | yes, side effect | yes | -| `laserbank/poweroff` | yes, side effect | yes | -| `laserbank/clearfaults` | yes, side effect | yes | -| `laser` | yes, currently key-shape mismatch | yes, currently key-shape mismatch | +| `measure_throughput` | no | yes | +| `laserbank/power` | yes | yes | +| `laserbank/clearfaults` | no | yes | +| `laserbank/heater` | yes | yes | +| `laser/engstatus` | yes | no | +| `laser/status` | yes | no | +| `laser/settings` | yes | yes | +| `laser/tune` | yes | yes | +| `laser` | yes | yes | | `atten` | yes | yes | | `pdsettings` | yes | yes | | `pd` | yes | yes | | `temp` | yes | no | | `status` | yes | no | -## GET and SET Selection +## Request Classification + +MQTT and serial are normalized to a shared `Command` and then classified by +command dispatch using the app command spec table. The internal result still +uses `MSG_GET` and `MSG_SET`, but those names are dispatch-slot names, not +user-visible protocol verbs. Serial `help` is the exception: it prints directly +from command dispatch before entering the inbound queue. + +Empty/no-payload requests are queries except: + +- `reboot` +- `laserbank/clearfaults` +- topic-suffix `laserbank/power/` +- topic-suffix `laserbank/heater/` -MQTT behavior: +Non-empty payload requests are effect/action requests except documented +payload-query shapes: -- Empty payload means GET. -- Non-empty payload means SET unless JSON contains `msg_type:"get"`. -- A non-empty GET payload therefore requires `msg_type:"get"`. +- `status` +- `laser/status` +- `laser/engstatus` +- `memsroute/route_loss` when the payload contains only `route` +- `laser` when `level` is absent +- `laser/tune` when `tune_nm` and `delta_nm` are absent +- `laser/settings` when the nested `settings` object is absent +- `pd` when `action` is `dark_status` -Serial behavior: +The old MQTT `msg_type` payload convention is not used by command ingress. -- `` means GET. -- ` ` means SET. -- There are no serial `get` or `set` words. -- Non-JSON shorthand is normalized to JSON before dispatch. +## Response Rules -This creates a mismatch with `commands.md` for commands that document optional -GET payloads, such as `pd` with `{"unit":"volts"}`. In current firmware that -payload is a SET unless `msg_type:"get"` is also present. +Current command responses follow the global `commands.md` contract: + +- Data-less success: `{"status":"ok"}`. +- Data-bearing success: response data only, no top-level transport `status`. +- Failure: an `error` key, with optional diagnostics such as `rc`. + +A source search of `command.c` has no remaining literal old-style command +responses of the forms `{"status":"success"}`, +`{"status":"error","msg":...}`, `{"status":"OK"}`, or partial transport +`status`. ## Response Topics The default MQTT response topic is: ```text -cmd/hsfib-tib/resp/ +cmd//resp/ ``` MQTT 5 `response_topic` overrides this default if it fits the fixed topic -buffer. MQTT 5 `correlation_data` is copied into a fixed static buffer sized to -the configured MQTT packet buffer and echoed exactly in responses. +buffer. MQTT 5 `correlation_data` up to 16 bytes is copied into a fixed static +buffer and echoed exactly in responses. -## Commands Documented but Not Implemented +## Commands Documented but Not Fully Implemented -- `measure_tput` -- `lasersettings` -- `laserbank/autowarm` -- `temp` alarm set behavior -- Full `status` payload including nested IP/temp/PD/laser/atten/last-command - data -- Full intended `laser` command behavior as described in `commands.md` +- None known after this audit pass. ## Commands Implemented but Missing or Stale in `commands.md` -- `pd` dark-measurement actions and `pdsettings` are more detailed in code - than many older notes. -- `laserbank/poweron`, `laserbank/poweroff`, and `laserbank/clearfaults` - have current implementations but no autowarm/driver fault-state integration. - -## High-Risk Implementation Mismatches - -- `laser` command key parsing appears inconsistent with dispatch. The dispatch - entry is `laser`, which accepts `laser/...`, but the handler uses - `parse_key_pair()` and then calls `get_laser_channel(laser_name + 5)`. For a - request like `laser/1028y/current`, `laser_name` is `laser`, so the lookup is - invalid. A key shaped like `laser1028y/current` would make the pointer math - plausible, but it does not match the dispatch table. -- The local `laser_t` enum maps both `LASER_1028_Y` and `LASER_1270_J` to - channel value 1. This affects attenuator and laser mapping review. -- Laser-bank power actions are registered for both GET and SET. Empty MQTT or - serial queries to those exact keys therefore perform power actions. -- `reboot` is SET-only. An empty MQTT payload or bare serial `reboot` is - unsupported; a non-empty payload schedules a reboot. +- None known. + +## High-Risk Implementation Notes + +- `status` optional `lasers` and `attens` sections can perform Modbus and DAC + reads. A large optional response can fail with `{"error":"status response too large"}` + if it exceeds the fixed MQTT payload buffer. +- `lastcommand` records supported effect-capable requests and is persisted by + command dispatch through a fixed NVS record configured by the app. Pure query + requests are not recorded. +- Serial `help` depends on help metadata attached to the app command spec table. + It now reflects the code paths reviewed in this audit and uses generic support + predicates to mark unsupported commands, but future command behavior changes + must update the spec help metadata or help can become stale. +- `laserbank/clearfaults` occupies both internal dispatch slots for legacy + reasons, but ingress classifies no-payload requests as an action. ## Blocking and Queueing Summary -- All command handlers run in the single command executor thread. -- Responses are enqueued to `outbound_queue` and published or printed later. +- Dispatcher built-ins run in command dispatch. Serial `help` prints directly; + MQTT `help`, `serialguard`, and `reboot` enqueue immediate responses. +- App command handlers run in the single command executor thread. +- App responses are enqueued to `outbound_queue` and published or printed later. - Attenuator commands can block on DAC I2C. - Laser and Maiman commands can block on Modbus RTU and laser-bank boot sleeps. -- Settings updates can block on the Zephyr settings backend. +- Persistent settings updates can block on Zephyr NVS writes. +- Lastcommand persistence can block on Zephyr NVS writes before an effect + handler runs. +- `status` optional laser/attenuator sections can block on Modbus/DAC reads. - MEMS and split commands update router state and can enqueue warnings but do not publish directly. - Warning publication is best-effort through `outbound_queue`. -- Photodiode telemetry is best-effort and can be dropped under MQTT or queue +- Throughput telemetry is best-effort and can be dropped under MQTT or queue backpressure. diff --git a/doc/commands.md b/doc/commands.md index 3009fd1..1cb2987 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -9,20 +9,59 @@ Draft 0.1 - **Device publishes (telemetry):** `dt//...` - **Device publishes (warnings):** `dt//warning` +`` is selected from the detected board strap: + +| Board profile | MQTT device name | +| --- | --- | +| `tib` | `hsfib-tib` | +| `cal_hk` | `hsfib-rcal` | +| `cal_yj` | `hsfib-bcal` | +| `as` | `hsfib-as` | + ### Global (applies to all commands) -- Any response may be: `{"status":"error", "msg":}` +- Command request topics use `cmd//req/` and response + topics use `cmd//resp/` unless the endpoint documents a + different topic shape. For example, request `cmd//req/status` + receives its default response on `cmd//resp/status`. + +- Successful requests with no response data return: + ```json + {"status":"ok"} + ``` + +- Successful requests with response data return only the response data. They do + not include a top-level transport `status` key. + +- Failures are indicated by the presence of an `error` key: + ```json + {"error":""} + ``` + Error responses may include extra diagnostic fields such as `rc` or + `context` when the command documents them. + +- Endpoint sections use request-shape wording: + - **Request with no payload** + - **Request with payload** + - **Request with topic suffix** + + Side effects, persistence, and hardware behavior are described in endpoint + notes rather than encoded into a separate request class. - Req/resp also use MQTT5 request/response metadata: - **On requests (publisher → device):** - `response_topic`: where the device should publish the response (this doc assumes it’s under `cmd//resp/...`) - `correlation_data`: opaque bytes that are echoed back exactly in the - response so the requester can match replies to requests + response so the requester can match replies to requests, up to 16 bytes + - retained request messages are ignored and return an error response; command + topics must not replay actions on reconnect - **On responses (device → publisher):** - - `correlation_data`: copied from the request + - `correlation_data`: copied from the request when it is 16 bytes or less - `qos`: response QoS - Commands have serial port duals. Serial commands use a simpler line format for interactive bring-up and debugging. +- Board-specific commands are rejected before their domain handler runs when + the selected board strap does not provide that hardware. ## Serial Command Form @@ -31,13 +70,19 @@ the console. Top-level implementation path: -1. `command_serial_thread()` reads one console line. -2. `command_parse_serial_line()` splits the line into `` and optional payload. -3. `normalize_serial_payload()` turns non-JSON serial payloads into the same JSON shape used by MQTT. -4. `command_executor_thread()` dispatches the command through `dispatch_command()`. -5. `command_drain_outbound_queue()` prints serial responses with `print_serial_response()`. +1. `coo_cmd_runtime_serial_thread()` reads one console line. +2. `coo_cmd_runtime_handle_serial_line()` splits the line into `` and optional payload. +3. `coo_cmd_normalize_serial_payload()` turns non-JSON serial payloads into the same JSON shape used by MQTT. +4. Dispatcher built-ins handle `help` and, when enabled, + `serialguard`. Serial `help` prints directly and does not enter the command + queues. +5. `coo_cmd_runtime_executor_thread()` dispatches app-owned commands through the + app command table. +6. `coo_cmd_runtime_drain_outbound()` prints queued serial responses with + `coo_cmd_print_serial_response_pretty()`. The simpler + `coo_cmd_print_serial_response()` remains available as a fallback renderer. -Query form is just the key: +No-payload serial request form is just the key: ```text status @@ -45,14 +90,14 @@ mems/yj_cal_laser split/yj ``` -Set form is the key followed by a payload. There are no `get` or `set` -keywords in the serial command set. +Requests with payload use the key followed by a payload. There are no `get` or +`set` keywords in the serial command set. ```text serialguard seconds=60 mems/yj_cal_laser state=A duty_cycle=0.5 toggle_rate_hz=17 stopafter_s=30 split channel=yj ratio1=0.33 ratio2=0.33 stopafter_s=300 -laserbank/poweron +laserbank/power/override_on ``` Payload rules: @@ -62,43 +107,50 @@ Payload rules: - Payloads containing `=` use `serial_payload_from_key_values()`, for example `state=A stopafter_s=30`. - Known compact forms use `serial_payload_from_shorthand()`, for example - `serialguard off` or `mems/yj_cal_laser A 0.5 30`. + `serialguard off`, `serialguard 60`, or `mems/yj_cal_laser A 0.5 30`. - Handlers parse and validate the normalized JSON exactly as they do for MQTT. Serial response format: ```text cmd//resp/ - {"same":"payload MQTT would publish, wrapped at print time"} + {"same":"payload MQTT would publish"} ``` The first line is the MQTT response topic. The following lines are the response -payload, tab-indented and wrapped at 80 columns by `print_serial_response()`. - -Any non-empty serial command refreshes serial override. While active, MQTT -commands are rejected before dispatch and receive an error response when MQTT is -available. The override expires after `serialguard_s` seconds without another -serial command; `serialguard off` or `serialguard seconds=0` disables the -override. JSON payloads are accepted for MQTT parity, but should not be needed -for normal serial operation. +payload. The pretty renderer emits CRLF line endings, indents JSON-like +payloads, and wraps long non-JSON payloads at natural break points where +possible. + +Any non-empty serial command refreshes serial override when +`CONFIG_COO_CMD_SERIAL_GUARD` is enabled. While active, MQTT effect commands are +rejected before dispatch and receive an error response when MQTT is available. +The override expires after `serialguard_s` seconds without another serial +command; `serialguard off`, `serialguard 0`, or `serialguard seconds=0` +disables the override. JSON payloads are accepted for MQTT parity, but should +not be needed for normal serial operation. --- ## Command Endpoints - [`help`](#help) - [`memsroute`](#memsroute) +- [`memsroute/route_loss`](#route-loss) - [`mems`](#mems) - [`mems/`](#mems-switchname) -- [`measure_tput`](#measure-tput) +- [`measure_throughput`](#measure-throughput) - [`laser`](#laser) -- [`lasersettings`](#lasersettings) -- [`laserbank/poweron`](#laserbank-poweron) -- [`laserbank/poweroff`](#laserbank-poweroff) +- [`laser/tune`](#laser-tune) +- [`laser/status`](#laser-status) +- [`laser/engstatus`](#laser-engstatus) +- [`laser/settings`](#laser-settings) +- [`laserbank/power`](#laserbank-power) - [`laserbank/clearfaults`](#laserbank-clearfaults) -- [`laserbank/autowarm`](#laserbank-autowarm) -- [`atten//value`](#atten) -- [`atten//valuedb`](#atten) -- [`atten//coeff`](#atten) +- [`laserbank/heater`](#laserbank-heater) +- [`atten//value`](#atten-value) +- [`atten//valuedb`](#atten-valuedb) +- [`atten//coeff`](#atten-coeff) +- [`atten/calibrate`](#atten-calibrate) - [`pd`](#pd) - [`pdsettings/`](#pdsettings) - [`ip`](#ip) @@ -110,20 +162,18 @@ for normal serial operation. - [`reboot`](#reboot) - [`split`](#split) - Telemetry: `yj_tput`, `hk_tput` -- Warnings: `dt//warning` +- Warnings: [`dt//warning`](#warning-publication) +- Boot telemetry: [`dt//boot`](#boot-telemetry) --- -Command items below are **one request topic** (subscribed by the device) and -their **matching response topic** (published by the device). The warning topic -is publish-only. - +(warning-publication)= ### Warning Publication - **Publish topic:** `dt//warning` -- **Top-level helper:** `app_warning_emit()` +- **Top-level helper:** `coo_cmd_runtime_warning_emit(command_runtime_get(), code, msg, context)` - **Queue behavior:** best-effort MQTT through `OUT_TARGET_MQTT_BEST_EFFORT`; logs locally and drops if MQTT is unavailable or the outbound queue is full. -- **Payload:** +- **Warning payload:** ```json { "severity": "warning", @@ -139,427 +189,822 @@ an error. Warning delivery is intentionally lossy and is not mirrored into sticky status fields. Current warning emitters include MQTT command rejection while serial guard is active and attenuator DAC-range clamping. -### `help` -- **Request topic:** `cmd//req/help` - - No payload +(boot-telemetry)= +### Boot Telemetry +- **Publish topic:** `dt//boot` +- **When published:** after a boot where Zephyr hwinfo reports the prior reset + cause included watchdog expiration. +- **Delivery:** non-best-effort MQTT outbound message; the main loop requeues it + until MQTT is available or publish succeeds. +- **Payload:** + ```json + { + "event": "boot", + "reset_cause": "watchdog", + "watchdog": true, + "raw_reset_cause": 16 + } + ``` +- **Notes:** `reset_cause` may contain a comma-separated list if hardware reset + flags report multiple causes for the same boot. -- **Response topic:** `cmd//resp/help` - - Result: `{"help": }` +(help)= +### `help` +- **Serial no payload -> full interactive command help.** + - Prints directly from command dispatch instead of using the inbound or + outbound queues. + - Takes no arguments. `help ` is rejected. + - Enumerates dispatcher built-ins and app command specs with help metadata, + including optional fields in `[]`, accepted enum values, unsupported + commands for the current board profile, and commands allowed as MQTT queries + while serial guard is active. +- **MQTT no payload -> compact endpoint summary:** + ```json + { + "device": "", + "request_prefix": "cmd//req/", + "response_prefix": "cmd//resp/", + "commands": ["", "..."] + } + ``` + MQTT help is intentionally compact so it does not consume the payload budget + with the full serial help text. +(memsroute)= ### `memsroute` -- **Request topic:** `cmd//req/memsroute` - - Set: +- **No payload -> active routes:** ```json - {"input":"", - "output":"" - } + { + "active_routes": { + "": ["", "..."] + } + } ``` - - Query: No payload - -- **Response topic:** `cmd//resp/memsroute` - - Set result: `{"status":"OK"}` - - Query result: - ```json - { - "active_routes": { - "": ["", "..."] - } +- **Payload:** set one route. + ```json + { + "input": "", + "output": "" + } + ``` +- **Notes:** The no-payload response lists every destination present in the active + board route table. Each value is an array of currently connected sources + because one destination may receive multiple sources through combining optics. + A destination with no currently active source reports `["no source"]`. Active + routes are read from current switch state and are not persisted. + +(route-loss)= +### `memsroute/route_loss` +- **Payload:** set one route-loss record. + ```json + { + "route": "yj_sm_to_yj_pd", + "1430yj": 0.93, + "persistent": true + } + ``` + or: + ```json + { + "route": "yj_sm_to_yj_pd", + "1430yj": "0.32 dB", + "persistent": false + } + ``` + or: + ```json + { + "route": "yj_calin_to_yj_split", + "split": ["0.32 dB", "0.32 dB", 0.93], + "persistent": true + } + ``` +- **Payload -> route-loss records for one route:** + ```json + { + "route": "yj_sm_to_yj_pd" + } + ``` + ```json + { + "route": "yj_sm_to_yj_pd", + "lasers": { + "1028y": 1.0, + "1270j": 1.0, + "1430yj": 0.93, + "1430hk": 1.0, + "1510h": 1.0, + "2330k": 1.0 } - ``` - The response lists every destination present in the active board route - table. Each value is an array of currently connected sources because one - destination may receive multiple sources through combining optics. A - destination with no currently active source reports `["no source"]`. - Active routes are read from current switch state and are not persisted. + } + ``` + For a split route: + ```json + { + "route": "yj_calin_to_yj_split", + "split": [0.93, 0.93, 1.0] + } + ``` + +Route-loss records are app settings keyed by route name and laser name or split. Missing route-loss +records are treated as loss-free transmission, `tx = 1.0`. Numeric values are +linear transmission in `(0, 1]`. Strings ending in `dB`, `db`, or `DB` are route +loss in dB and convert to `tx = 10^(-loss_db / 10)`. The split identifier must be a three-tuple, though dB loss and +transmission may be mixed. Route losses are used on the TIB for throughput monitoring +and the AS for splitting fraction correction. + +(mems)= ### `mems` -- **Request topic:** `cmd//req/mems` - - Query: No payload -- **Response topic:** `cmd//resp/mems/` - - Query result: - ```text - { - "":{ - "state":"A|B|A?|B?|?", - "duty_cycle":0.0, - "toggle_rate_hz":0.0, - "stopafter_s":0 - }, - ... +- **No payload -> all MEMS switch states:** + ```json + { + "": { + "state": "A|B|A?|B?|?" } - ``` - Note duty_cycle, toggle_rate_hz, stopafter_s are omitted if not toggling. - If this response exceeds the fixed MQTT payload buffer, the command returns - an error rather than a partial switch listing. Use `mems/` for a - bounded single-switch query. + } + ``` +- **Notes:** The all-switch query is intentionally compact so the TIB + eight-switch response fits the fixed MQTT payload buffer. Static switches + report only `state`. A switch currently configured with a non-constant duty + request also includes `duty_cycle`. Use `mems/` for + requested/actual toggle rate and stop-after details. (mems-switchname)= ### `mems/` -- **Request topic:** `cmd//req/mems/` - - Query: No payload - - Set: - - Static: `{"state":"A"}` or `{"state":"B"}` - - Toggle: `{"state":"A","duty_cycle":[0.0-1.0],"toggle_rate_hz":,"stopafter_s":}` - - `duty_cycle` is only valid with `state:"A"`. - - `toggle_rate_hz` is optional; if omitted the switch uses its current requested toggle rate. - - Requested `toggle_rate_hz` is stored separately from the actual firmware-quantized rate. - - `{"state":"A","duty_cycle":0.0}` is valid and equivalent to static `B`. - - `stopafter_s` max is 4 hours - -- **Response topic:** `cmd//resp/mems/` - - Query/set result: - ```json - { - "state":"A|B|A?|B?", - "duty_cycle":0.0, - "requested_toggle_rate_hz":0.0, - "toggle_rate_hz":0.0, - "stopafter_s":0 - } - ``` - `?` suffix means the state has not yet been pulsed this boot, note that on first boot all switches wil be reported as A? - duty_cycle, toggle_rate_hz, stopafter_s are omitted if not toggling. - `toggle_rate_hz` is the actual quantized rate. If it differs from requested +- **Topic:** `cmd//req/mems/` +- **No payload or payload -> one MEMS switch state.** + + Static payload: + ```json + {"state":"A"} + ``` + or: + ```json + {"state":"B"} + ``` + Toggle: + ```json + { + "state": "A", + "duty_cycle": 0.5, + "toggle_rate_hz": 17, + "stopafter_s": 30 + } + ``` + + Response: + ```json + { + "state": "A|B|A?|B?" + } + ``` + For example, `mems/yj_cal_laser state=B` returns: + ```json + {"state":"B"} + ``` + and `mems/yj_cal_laser state=A` returns: + ```json + {"state":"A"} + ``` + Response while configured with a non-constant duty request: + ```json + { + "state": "A|A?", + "duty_cycle": 0.0, + "requested_toggle_rate_hz": 0.0, + "toggle_rate_hz": 0.0, + "stopafter_s": 0 + } + ``` +- **Notes:** + - `duty_cycle` is only valid with `state:"A"`. + - Static `{"state":"A"}` and `{"state":"B"}` responses report only `state`. + - `toggle_rate_hz` is optional; if omitted the switch uses its current + requested toggle rate. + - Requested `toggle_rate_hz` is stored separately from the actual + firmware-quantized rate. + - `{"state":"A","duty_cycle":0.0}` is valid and equivalent to static `B`. + - `stopafter_s` max is 4 hours. + - `?` suffix means the state has not yet been pulsed this boot; on first boot + all switches will be reported as `A?`. + - `duty_cycle`, `requested_toggle_rate_hz`, `toggle_rate_hz`, and + `stopafter_s` are omitted for constant A or B profiles. + - `toggle_rate_hz` is the actual quantized rate. If it differs from requested by more than rounding noise, the firmware emits `mems_rate_quantized` on `dt//warning`. -(measure-tput)= -### `measure_tput` -- **Request topic:** `cmd//req/measure_tput` - - Payload: - ```text - { - "autolevel": true, - "laser": "", - "fiber": "", - "stopafter_s": 300, - "format": "json"|"binary" - } - ``` - or - ```text - { - "stop": "yj"|"hk"|"all" - } - ``` -- **Response topic:** `cmd//resp/measure_tput` - - Result: `{"status": "success"}` - Causes telemetry to be published on `yj_tput` or `hk_tput` topic (or stops it). +(measure-throughput)= +### `measure_throughput` +- **Payload:** start monitoring. + ```json + { + "autolevel": true, + "laser": "", + "fiber": "M", + "output": "yj_ao", + "max_flux_ph_s": 1.0e12, + "stopafter_s": 300, + "format": "json" + } + ``` +- **Payload:** start monitoring an externally supplied/calibration input. + ```json + { + "autolevel": false, + "laser": "none", + "input": "yj_cal", + "output": "yj_ao", + "fiber": "M", + "format": "json" + } + ``` +- **Payload:** stop monitoring. + ```json + { + "stop": "yj" + } + ``` +`measure_throughput` is the only command that starts or stops photodiode +streaming. It measures throughput by comparing the route-corrected flux at the +selected photodiode with the route- and attenuator-corrected laser flux +estimate. + +`autolevel:true` lets firmware adjust the selected laser output level percent +and logical attenuator to keep the photodiode signal in the useful +ADC/photodiode range. `autolevel:false` streams the selected photodiode level +and derived values without changing laser level or attenuation. + +`output` is optional for normal laser monitoring. When supplied, firmware +selects the outbound MEMS route before starting the monitor. The route input is +inferred from `laser` unless `input` is supplied explicitly. `laser:"none"` is +for monitoring externally supplied light and requires `input`, `output`, and +`autolevel:false`; throughput and emitted-flux fields that require a known +laser are reported as `null` in JSON or NaN in binary. + +`max_flux_ph_s` is optional and valid only with `autolevel:true`. It limits the +estimated emitted photon flux after the calibrated logical attenuator pair, so +the limit uses the current laser flux estimate multiplied by +`attenuator_estimate_transmission()`. + +Firmware uses each photodiode channel's configured `responsivity_a_per_w` and +`transimpedance_v_per_a` from `pdsettings/` with the active laser +wavelength estimate. It does not interpolate wavelength curves at runtime. The +photodiode sampler owns ADC reads and dark tracking. The throughput monitor +owns streaming output, autolevel decisions, and throughput math. **Telemetry topics (published):** - `dt//yj_tput` - `dt//hk_tput` -**Implementation status:** deferred. This capability requires an owner-provided -measurement specification before firmware design or implementation. +**Telemetry payload (`format:"json"`):** +```json +{ + "channel": "yj_m", + "laser": "1430yj", + "autolevel": true, + "time": 0, + "tp": 0.0, + "tp_err": 0.0, + "tp_rms_err": 0.0, + "pd_flux_ph_s": 0.0, + "pd_flux_err_ph_s": 0.0, + "laser_flux_ph_s": 0.0, + "laser_flux_err_ph_s": 0.0, + "pd_route_tx": 1.0, + "laser_route_tx": 1.0, + "atten_tx": 1.0, + "pd_raw": 0, + "pd_mv": 0.0, + "pd_net_mv": 0.0, + "pd_mean_mv_1s": 0.0, + "pd_rms_mv_0p5s": 0.0, + "laser_current_ma": 0.0, + "atten_db": 0.0, + "wavelength_nm": 1430.0, + "pd_ontime_s": 0.0, + "laser_current_ontime_s": 0.0, + "flags": [] +} +``` -**Telemetry payload:** - ```text - { - "tp": 0.0, - "tp_err": 0.0, - "laser": 0.0, - "at1": 0.0, - "at2": 0.0, - "adc": 0.0, - "time": 0, - "waveelngth_nm": 0.0, - "fiber": "M"|"S" - } - ``` - or 35 bytes of binary data +`channel` combines the photodiode channel and fiber class with an underscore, +for example `yj_m`, `yj_s`, `hk_m`, or `hk_s`. `time` is Unix time in +milliseconds from the firmware clock. `pd_ontime_s` is the tracked on-time of +the photodiode power relay for that channel since boot; it does not infer +pre-boot relay state. - ```text - { - float64_t tp, - float64_t tp_err, - uint16_t laser, - uint16_t at1, - uint16_t at2, - uint16_t adc, - uint64_t time, - uint16_t wavelength_nm, - char fiber - } - ``` +**Telemetry payload (`format:"binary"`):** -*(The `yj` vs `hk` stream depends on the requested laser.)* +Binary telemetry is little-endian and contains the fields below in order. The +first field is a zero-padded 8-byte ASCII channel/fiber label such as `yj_m`. -**Notes (behavior):** -- throughput (tp) is in [0-1], though NaN is offscale, greater than unity would indicate a noise artifact -- Units for values are TBD -- Turning on any other laser to that photodiode disables measurement. -- Switching the MEMS route may impact the measurement but will not stop it. -- Changing laser power/attenuation disables autolevel; run the command again to re-enable. -- Powers photodiodes and lasers as needed. -- Auto-off timing: - - PD: updates no sooner than `max(stopafter_s, pd_autooff_s)` - - Laserbank: updates no sooner than `max(stopafter_s, laserbank_autooff_s)` - - Laser: updates no sooner than `max(stopafter_s, laser_autooff_s)` +```text +char[8] channel +uint64 time_ms +float64 tp +float64 tp_err +float64 tp_rms_err +float64 pd_flux_ph_s +float64 pd_flux_err_ph_s +float64 laser_flux_ph_s +float64 laser_flux_err_ph_s +float64 pd_route_tx +float64 laser_route_tx +float64 atten_tx +int16 pd_raw +float32 pd_mv +float32 pd_net_mv +float32 pd_mean_mv_1s +float32 pd_rms_mv_0p5s +float32 laser_current_ma +float32 atten_db +float32 wavelength_nm +float32 pd_ontime_s +float32 laser_current_ontime_s +``` +**Notes:** +- `tp` is unitless. `NaN` means offscale or insufficient information; values + above unity are reported rather than clamped. +- Flux values are photons per second. +- Route transmissions default to `1.0` when no route-loss record is stored. +- Both outbound laser route loss and inbound photodiode route loss are applied + when estimating throughput. +- The current firmware lookup names the inbound photodiode route as + `__to__pd` from `fiber:"M"|"S"` and the outbound laser + route as `_to_`. These names are route-loss record keys, not + MEMS route-table entries. +- The 1 s mean controls autolevel. Below 20% usable range, firmware requests + 3x flux. Above 80%, it requests 1/3 flux. +- Five consecutive saturated samples or five consecutive below-dark samples + trigger immediate autolevel adjustment. +- Flux is raised by decreasing logical attenuation first, then raising laser + output level percent. Flux is decreased by increasing logical attenuation + first, then lowering laser output level percent. +- At start with `autolevel:true`, attenuation is set to maximum before laser + power is raised. +- Starting a monitor powers the required photodiode and laser-bank outputs as + needed. Shutting down the required photodiode power stops that monitor. +- Changing the monitored laser output or its logical attenuator disables + autolevel for the affected monitor; run the command again to re-enable it. +- Dark measurement must not be started while an autolevel throughput monitor is + running on that photodiode. + + +(laser)= ### `laser` -- **Request topic:** `cmd//req/laser` - - Set: - ```json - {"name": "", - "level": 0.0, - "unit": "mw" - } - ``` - - `unit` is `"mw" | "%"`. - - - Query: `{"name": ""}` - -- **Response topic:** `cmd//resp/laser` - - Set result: `{"status": "success"}` - - Query result: - ```json - { +- **Payload -> laser status:** + ```json + {"name":""} + ``` + ```json + { "name": "", "powered": true, - "timeon_s": 0.0, - "totaltimeon_s": 0.0, - "timeemitting_s": 0.0, - "temp": 0.0, + "tec_on_s": 0, + "emit_on_s": 0, + "emit_total_s": 0, + "temp_c": 0.0, "current_ma": 0.0, - "power_%": 0.0, + "level": 0.0, "power_mw": 0.0, - "wavelength_nm": 0.0, - "attenuated_power_mw": 0.0, + "nominal_nm": 0.0, + "tuned_nm": 0.0, + "tune_nm": 0.0, "tec_ma": 0.0, - "voltage": 0.0, + "diode_v": 0.0, "tec_v": 0.0, - "overcurrent_fault": false + "offin_s": 0, + "oc_fault": false + } + ``` +- **Payload:** set one laser output level. + ```json + { + "name": "", + "level": 0.0, + "autooff_s": 0 + } + ``` + +- **Notes:** `level` is 0-100% of the nominal current range above threshold current. Setting a positive level powers + the laser bank as needed, prepares the TEC, sets the laser current, and restarts the auto-off timer. Setting level + 0 stops emission and writes driver current to 0. Laser output current is never persisted by app settings. The Maiman + driver may retain its own current register, so firmware writes 0 whenever emission is disabled or the bank is turned off. + `tec_on_s`, `emit_on_s`, and `emit_total_s` are firmware-owned counters. `emit_total_s` is persisted when emission + stops cleanly. `autooff_s` is optional and non-persistent; if supplied, it overrides the default configured through + `laser/settings` for this start. + +(laser-tune)= +### `laser/tune` +- **Payload -> stored tuning request:** + ```json + {"name":""} + ``` + ```json + { + "name": "", + "tune_nm": 0.0 + } + ``` +- **Payload:** set the stored tuning request. + ```json + { + "name": "", + "tune_nm": 0.0 + } + ``` +- **Notes:** Sets the wavelength tuning request used when running the laser. + The request is stored by firmware and used on future positive `laser` level + commands. It is best-effort: large shifts are clamped by the TEC temperature + range and allowed current adjustment. + + +(laser-status)= +### `laser/status` +- **Payload -> compact operational status:** + ```json + {"name":""} + ``` + Response shape is the same as `laser` query. + +Compact operational status. This is the preferred status payload for normal users and is the source used by the +optional laser section of `status`. + +(laser-engstatus)= +### `laser/engstatus` +- **Payload -> detailed engineering status:** + ```json + {"name":""} + ``` + +Detailed engineering status derived from the Maiman status query used in `refrence_docs_examples/lasers.py`. +Includes raw state, lock, and TEC-state registers, measured diode/TEC voltage and current, driver limits, PID, +serial/device-id verification, and interlock flags. This command may be slower than `laser/status` because it reads +many Modbus registers. + + +(laser-settings)= +### `laser/settings` +- **Payload -> laser settings:** + ```json + {"name":""} + ``` + ```json + { + "name": "", + "settings": { + "model": "", + "nominal_current_ma": 0.0, + "max_current_ma": 0.0, + "current_set_calibration_pct": 0.0, + "threshold_current_ma": 0.0, + "efficiency_mw_per_ma": 0.0, + "wavelength_nm": 0.0, + "operating_temp_range_c": [0.0, 0.0], + "default_operating_temp_c": 0.0, + "thermistor_kohm": 0.0, + "isolation_db": 0.0, + "tec_max_current_a": 0.0, + "tec_pid": { + "p": 0, + "i": 0, + "d": 0 + }, + "disable_tec_at_autooff": true, + "ntc_t_coefficient_per_c": 0.0, + "dlambda_dT_nm_per_k": 0.0, + "dlambda_dA_nm_per_ma": 0.0, + "autooff_s": 0, + "tune_nm": 0.0, + "emit_total_s": 0.0 } - ``` - -- **Notes:** turns on laser (+ laser bank power) as needed; validates range; restarts laser auto-off timer. `timeon_s` is time with TEC running. - - -### `lasersettings` -- **Request topic:** `cmd//req/lasersettings` - - Set: - ```text - { "name": , - "settings": { - "nominal_current_ma": 0.0, - "max_current_ma": 0.0, - "efficiency_mw_per_ma": 0.0, - "wavelength_nm": 0.0, - "operating_temp_c": 0.0, - "tec_pid": { - "p": 0.0, - "i": 0.0, - "d": 0.0 - }, - "dlambda_dT_nm_per_k": 0.0, - "dlambda_dA_nm_per_ma": 0.0, - "autooff_s": 0.0 - } + } + ``` +- **Payload:** update laser settings. + ```json + { + "name": "", + "settings": { + "nominal_current_ma": 0.0, + "max_current_ma": 0.0, + "efficiency_mw_per_ma": 0.0, + "wavelength_nm": 0.0, + "current_set_calibration_pct": 0.0, + "tec_max_current_a": 0.0, + "default_operating_temp_c": 0.0, + "operating_temp_range_c": [0.0, 0.0], + "tec_pid": { + "p": 0, + "i": 0, + "d": 0 + }, + "disable_tec_at_autooff": true, + "dlambda_dT_nm_per_k": 0.0, + "dlambda_dA_nm_per_ma": 0.0, + "autooff_s": 0 } - ``` - -- Query: `{"name": "" }` - -- **Response topic:** `cmd//resp/lasersettings` - - Set result: `{"status": "success"}` - - Query result: - ```json - { - "name": "", - "settings": { - "name": "", - "model": "", - "nominal_current_ma": 0.0, - "max_current_ma": 0.0, - "dne_current_ma": 0.0, - "threshold_current_ma": 0.0, - "efficiency_mw_per_ma": 0.0, - "wavelength_nm": 0.0, - "test_monitor_current_ua": 0.0, - "operating_temp_range_c": { - "min": 0.0, - "max": 0.0 - }, - "operating_temp_c": 0.0, - "thermistor_kohm": 0.0, - "isolation_db": 0.0, - "tec_max_current_a": 0.0, - "tec_pid": { - "p": 0.0, - "i": 0.0, - "d": 0.0 - }, - "ntc_t_coefficient_per_c": 0.0, - "dlambda_dT_nm_per_k": 0.0, - "dlambda_dA_nm_per_ma": 0.0, - "autooff_s": 0.0 - } - } - ``` + } + ``` - **Notes:** - - settings are kept in sync with the driver when powered - - driver will not be powered just to update settings - - some settings require a drive restart (stops emission + any throughput measurement using that laser) - - failures to set will leave all settings unchanged + - It is the user's responsibility to ensure the triple of (nominal_current_ma, default_operating_temp_c, wavelength_nm) are aligned and in sync as these form the basis of wavelength tuning + - Settings are checked when a laser is first talked to at each boot + - A mismatch between those the driver stores in its eeprom and controllers NVRAM will trigger a warning in the log and the driver values will be programmed. + - If the laser bank is off, firmware powers it, applies driver-backed settings, verifies them as practical, and then + restores the previous bank power state. If `laserbank/power` is `override_off`, driver-backed settings changes return + an error. + - it is **encouraged** to send only the settings that requested changed. + - The overcurrent threshold is the maximum current the driver will allow the laser to run at and requires physically + adjusting a potentiometer on the driver. It has a (weak) temperature dependence and is not a fixed value. + - Changes to settings will disable laser emission and may disable the TEC (stops emission + any throughput measurement using that laser) + - failures to set will leave all settings unchanged (rollback is performed or an error emitted) - Unsettable (attempts to set are silently ignored): - - `name` - - `model` - - `dne_current_ma` + - `name`,`model`, `serial` + - `overcurrent_threshold_ma` + - Non-Driver settings: + - `autooff_s` + - `dlambda_dT_nm_per_k` + - `dlambda_dA_nm_per_ma` + - `disable_tec_at_autooff` + - `wavelength_nm` - `threshold_current_ma` - - `test_monitor_current_ua` - - `operating_temp_range_c` - - `thermistor_kohm` + - `efficiency_mw_per_ma` + - Settings that are informational only (included for datasheet posterity): - `isolation_db` - - `tec_max_current_a` - - `ntc_t_coefficient_per_c` + - Ranges: + - Operating temp range: limited to [15,40] strong advice to limit to 17,38 + - current_set_calibration: 95 - 105 in steps of .01 + - TEC max current must be greater than zero and no higher than the compiled-in diode datasheet maximum for that laser. -(laserbank-poweron)= -### `laserbank/poweron` -- **Request topic:** `cmd//req/laserbank/poweron` - - Payload: optional; empty payload is accepted. -- **Top-level handler:** `laserbank_poweron()` -- **Response topic:** `cmd//resp/laserbank/poweron` - - Response: `{"status":"OK","laser_power":true,"transitioned":true|false}` -- **Notes:** powers on the TIB laser-bank power GPIO; does nothing if already - powered. TEC startup and auto-off policy are not implemented yet. - -(laserbank-poweroff)= -### `laserbank/poweroff` -- **Request topic:** `cmd//req/laserbank/poweroff` -- **Top-level handler:** `laserbank_poweroff()` -- **Response topic:** `cmd//resp/laserbank/poweroff` - - Response: `{"status":"OK","laser_power":false,"was_powered":true|false,"transitioned":true|false}` +(laserbank-power)= +### `laserbank/power` +- **No payload -> laser-bank power state:** + ```json + { + "mode": "auto|override_on|override_off", + "powered": false + } + ``` +- **Payload or topic suffix -> laser-bank power state after update:** + ```json + {"override":"auto|override_on|override_off"} + ``` + Suffix requests use + `cmd//req/laserbank/power/auto`, + `cmd//req/laserbank/power/override_on`, or + `cmd//req/laserbank/power/override_off`. +- **Notes:** `override_off` is the compiled boot default. In `auto`, power to the laser bank is handled by the bank + heater and commands interacting with laser drivers. `override_on` forces bank power on. `override_off` stops all laser + emission, writes driver currents to 0 as practical, powers the bank off, and rejects commands that need a live driver + while the override is active. If the pre-off driver-current shutdown reports a Modbus failure, the command returns an + error response that still includes the current `mode` and firmware-requested `powered` state. (laserbank-clearfaults)= ### `laserbank/clearfaults` -- **Request topic:** `cmd//req/laserbank/clearfaults` -- **Top-level handler:** `laserbank_clearfaults()` -- **Response topic:** `cmd//resp/laserbank/clearfaults` - - Response: `{"status":"OK","laser_power":true,"was_powered":true|false,"off_ms":250,"fault_detection":"power_cycle_only"}` - -- **Notes:** currently performs a bounded power cycle of the TIB laser-bank - power GPIO. Overcurrent-specific fault detection is not wired yet because the - `maiman.h` status bit for that condition is not defined. - +- **No payload -> clear result:** + ```json + {"off_ms":250} + ``` -(laserbank-autowarm)= -### `laserbank/autowarm` -- **Request topic:** `cmd//req/laserbank/autowarm/[on,off]` -- - Payload: `{"alloffabove_ambienttemp": 0.0, "bankonbelow_ambienttemp":0.0, - "auxonbelow_tectemp": 0.0, auxoffabove_tectemp": 0.0, auxonoperating_ambienttemp": 0.0}` -- **Response topic:** `cmd//resp/laserbank/autowarm` - - Response: `{"status": "success"}` - - Response: `{settings...}` +This command performs an off-on cycle iff the bank is powered and at least one of the drivers reports an overcurrent +fault. It is a convenience command that has no effect when the bank is not powered or is powered and without fault. +The return indicates if the bank was power cycled. `off_ms` is the time that the bank was turned off (0 if bank was +off or no faults). -- **Notes:** deferred. Autowarm and laser-bank temperature-management behavior - require an owner-provided specification before firmware design or - implementation. +(laserbank-heater)= +### `laserbank/heater` +- **No payload -> laser-bank heater state:** + ```json + { + "heater_mode": "auto|override_on|override_off", + "heater_on": false, + "bank_power": true, + "ambient_valid": true, + "ambient_c": 0.0, + "valid_temps": 6, + "stale_temps": 0, + "any_disabled_below_15c": false, + "any_disabled_above_off_threshold": false, + "all_tecs_enabled": false, + "all_tecs_enabled_ms": 0, + "last_error": 0, + "last_poll_age_ms": 0 + } + ``` +- **Payload or topic suffix -> laser-bank heater state after update:** + ```json + {"override":"auto|override_on|override_off"} + ``` + Suffix requests use + `cmd//req/laserbank/heater/auto`, + `cmd//req/laserbank/heater/override_on`, or + `cmd//req/laserbank/heater/override_off`. + +- **Notes:** `auto` is the default at boot. In `auto`, laser-bank + temperature-control work powers the bank so the Maiman temperature monitors + can initialize, polls TEC temperatures at a fixed interval, and drives the + laser-bank heater through housekeeping relay-power helpers. Any disabled TEC + below 15 C turns the heater on. Any disabled TEC above the ambient-dependent + off threshold turns it off. If all TECs remain enabled for at least one + control interval, the heater is turned off. `override_on` and `override_off` + force the heater state and suspend the + automatic warmup policy. While a heater override is active, firmware emits + `laserbank_heater_override` on `dt//warning` every 20 minutes. + If the off-board DS2408 relay expander is offline, set requests return an I/O + error because the heater relay cannot be driven. + +(atten)= +(atten-value)= +(atten-valuedb)= +(atten-coeff)= ### `atten` - **Top-level handlers:** `atten_setting_get()`, `atten_setting_set()` -- **Request topic:** `cmd//req/atten//value` - - Set raw DAC millivolts: - ```json - {"value": 1234.0} - ``` - - Query: - empty payload - -- **Request topic:** `cmd//req/atten//valuedb` - - Set attenuation using the current `db2volt` calibration coefficients: - ```json - {"value": 12.5} - ``` - - Query: - empty payload - -- **Request topic:** `cmd//req/atten//coeff` - - Set quadratic calibration coefficients: - ```json - { - "db2volt": [0.0, 1.0, 0.0], - "volt2db": [0.0, 1.0, 0.0], - "persistent": true - } - ``` - - Query: - empty payload - -- **Response topic:** `cmd//resp/atten//` - - Value query: `{"voltage":1234.0000,"db":12.5000}` - - Coeff query: `{"db2volt":[...],"volt2db":[...]}` - - Set result: `{"status":"OK"}` or `{"status":"OK","persistent":true}` +- **Topics:** + - `cmd//req/atten//value` + - `cmd//req/atten//valuedb` + - `cmd//req/atten//coeff` + - Responses use the same key under `cmd//resp/...`. +- **No payload to `value` or `valuedb` -> attenuator setting:** + ```json + { + "db": 12.5, + "linear": 0.0562, + "v1_mv": 1234.0, + "v2_mv": 0.0, + "db1": 12.5, + "db2": 0.0 + } + ``` +- **Payload to `value`:** set total linear transmission through the logical + attenuator. + ```json + {"value":0.25} + ``` +- **Payload to `valuedb`:** set total attenuation in dB. + ```json + {"value":12.5} + ``` +- **Serial form for `valuedb`:** the serial shorthand wraps the single numeric + argument as the same `{"value":...}` payload. MQTT commands must still send + the JSON payload above. + ```text + atten/1028y/valuedb 12.5 + ``` +- **No payload to `coeff` -> model coefficients:** + ```json + {"dac1":[0.0016,0.0],"dac2":[0.0016,0.0]} + ``` +- **Payload to `coeff`:** set the linear model coefficients for the two physical + attenuators that make up the logical attenuator. + ```json + { + "dac1": [0.0016, 0.0], + "dac2": [0.0016, 0.0], + "persistent": true + } + ``` +- **Serial form for `coeff`:** send the JSON object after the key. The default + serial shorthand only builds a `value` payload, so it is not useful for + coefficient arrays. The MQTT payload is the same JSON object without the + serial key prefix. + ```text + atten/1028y/coeff {"dac1":[0.0016,0.0],"dac2":[0.0016,0.0],"persistent":true} + ``` - **Notes:** - - `` is one of `1028y`, `1430yj`, `1430hk`, `1510h`, or `2330k`. - - Coefficients are loaded from persistent settings during `setup_attenuators()`. + - On TIB, `` is one of `1028y`, `1270j`, `1430yj`, `1430hk`, `1510h`, + or `2330k`. On calibration boards only, the LFC attenuator is addressed as + `atten/lfc/value`, `atten/lfc/valuedb`, and `atten/lfc/coeff`. + - Laser aliases accepted by the laser profile table, such as `1028`, also + resolve to the matching TIB attenuator channel, but canonical command docs + use the full logical laser names. + - Each logical attenuator is a pair of physical FVOAs. Total set commands use + the full modeled range of the first physical attenuator before using the + second, and override any individual physical set point made through the C + attenuator API. + - `value` is a unitless linear transmission fraction in `(0, 1]`. + - `v1_mv` and `v2_mv` are DAC-backed attenuator drive setpoints in + millivolts. + - Coefficients are loaded from persistent app NVS during + `setup_attenuators()`. They define `b = slope * voltage + offset` for the + attenuation model `transmission = (erf(4) + erf(4 - b)) / (2 * erf(4))`. - `persistent` is optional and defaults to false. A non-persistent coefficient update changes runtime behavior until reboot or a later coefficient command. - There is no separate `attensettings` command; calibration coefficients live on `atten//coeff`. -### `pd` -- **Request topic:** `cmd//req/pd` - - Optional payload: `{"unit": "power"}` - - `unit` is `"power" | "volts"` (case-insensitive), defaults to `"power"`. - - Measure dark without storing: - ```json - { - "action": "measure_dark", - "channel": "yj", - "duration_ms": 1280, - "store": false - } - ``` - - Measure dark and persist it: - ```json - { - "action": "measure_dark", - "channel": "hk", - "duration_ms": 1280, - "store": true - } - ``` - - Retrieve dark measurement progress/result: - ```json - { - "action": "dark_status", - "channel": "yj" - } - ``` - - Reset lowest-ever dark tracking: - ```json - { - "action": "reset_lowest_dark", - "channel": "yj", - "persistent": true - } - ``` - -- **Response topic:** `cmd//resp/pd` - - `measure_dark` start response: - ```json - { - "status": "measuring", - "channel": "yj", - "stored_on_complete": true, - "duration_ms": 60000, - "samples": 0, - "target_samples": 3000 +(atten-calibrate)= +### `atten/calibrate` +- **No payload -> compact calibration state:** + ```json + { + "state": "inactive", + "mode": "none", + "physical": "dac1", + "fit": "none", + "n": 20, + "t_ms": 300, + "complete_pct": 0, + "point": "1/20", + "mv": 5000.0, + "other_mv": 5000.0, + "error": 0, + "dac1": { + "valid": true, + "points": 12, + "slope": 0.0016, + "offset": 0.0, + "corr": 0.999, + "rms_db": 0.1, + "max_abs_db": 0.2, + "min_tx": 1.0e-6, + "max_tx": 0.9, + "voltage_span_mv": 2400.0 + }, + "dac2": {"valid": false} + } + ``` +- **Payload:** start automatic TIB calibration for the logical pair belonging to + a laser. + ```json + { + "laser": "1430yj", + "output": "yj_ao", + "fiber": "M", + "dwell_ms": 300, + "persistent": true + } + ``` +- **Payload:** start manual calibration on a calibration board. + ```json + { + "mode": "manual", + "attenuator": "lfc", + "dwell_ms": 300, + "persistent": true + } + ``` +- **Payload:** advance manual calibration by one voltage point. `other_mv` is + optional and sets the held physical attenuator DAC voltage for the next point. + ```json + {"continue": true, "other_mv": 2800.0} + ``` +- **Payload:** stop/cancel any calibration. + ```json + {"stop": true} + ``` +- **Payload:** fit manual feedback after the operator has measured fluxes. + Flux units are arbitrary but must be positive and consistent within each + physical attenuator sweep. + ```json + { + "mode": "manual", + "attenuator": "lfc", + "persistent": true, + "dac1": { + "voltage_mv": [5000.0, 4750.0, 4500.0, 4250.0, 4000.0, 3750.0], + "flux": [1.0, 2.0, 4.0, 8.0, 16.0, 32.0] + }, + "dac2": { + "voltage_mv": [5000.0, 4750.0, 4500.0, 4250.0, 4000.0, 3750.0], + "flux": [1.0, 2.0, 4.0, 8.0, 16.0, 32.0] } - ``` - - `dark_status` complete response includes the measured mean/RMS/min/max. + } + ``` + +- **Notes:** + - State names are `inactive`, `running`, `waiting`, `complete`, and `error`. + A canceled calibration returns to `inactive`. + - TIB automatic calibration uses `laser`, `output`, and `fiber`; the laser + selects the logical attenuator pair and outbound route input, while `fiber` + selects the photodiode route as in `measure_throughput`. + - TIB automatic calibration powers the selected photodiode, waits 1 s for the + relay/source to settle, sets both physical attenuators to maximum DAC + voltage and the laser to full output, then sweeps about 20 DAC points per + physical attenuator. Each point has a fixed 50 ms step-settle before the + photodiode average begins. + - Automatic calibration uses short photodiode averages from the sampler + thread. It does not start a new calibration thread; the throughput monitor + thread advances the state machine. + - The fit converts normalized flux to the attenuator model coordinate and + uses zscilib simple linear regression for `b = slope * voltage_mv + offset`. + Fit details include point count, correlation, residual RMS/max in dB, fitted + transmission span, and voltage span. + - Manual calibration returns the voltage schedule on completion or stop so an + operator can record fluxes externally and submit the batch later. + +(pd)= +### `pd` +- **No payload -> photodiode values:** ```json { - "unit": "power", "yjvalue": 0.0, "yjvalue_err": 0.0, "hkvalue": 0.0, @@ -570,20 +1015,71 @@ measurement specification before firmware design or implementation. "hk_mv": 0.0, "yj_noise_rms_mv": 0.0, "hk_noise_rms_mv": 0.0, - "uptime": 0 + "yj_mean_mv_1s": 0.0, + "hk_mean_mv_1s": 0.0, + "yj_rms_mv_0p5s": 0.0, + "hk_rms_mv_0p5s": 0.0, + "uptime_s": 0 + } + ``` +- **Payload -> dark measurement state:** measure dark without storing. + ```json + { + "action": "measure_dark", + "channel": "yj", + "duration_ms": 1280, + "store": false + } + ``` + Response: + ```json + { + "state": "measuring", + "channel": "yj", + "stored_on_complete": false, + "duration_ms": 1280, + "samples": 0, + "target_samples": 64 + } + ``` +- **Payload -> dark measurement state:** measure dark and persist it. + ```json + { + "action": "measure_dark", + "channel": "hk", + "duration_ms": 1280, + "store": true + } + ``` +- **Payload -> dark measurement progress/result:** complete results include the + measured mean/RMS/min/max. + ```json + { + "action": "dark_status", + "channel": "yj" + } + ``` +- **Payload:** reset lowest-ever dark tracking. + ```json + { + "action": "reset_lowest_dark", + "channel": "yj", + "persistent": true } ``` - **Notes:** - - `measure_dark` starts or restarts the selected channel's dark measurement - and returns immediately with `status:"measuring"`. + - `measure_dark` starts or restarts the selected channel's short average, + marks it as a dark measurement, and returns immediately with + `state:"measuring"`. - Dark level is updated only after an explicit `measure_dark` with `store:true` completes. - `duration_ms` is rounded to the nearest supported sample count at the - monitor thread cadence. The response reports both actual `duration_ms` and - exact `samples`. - - `dark_status` returns `status:"measuring"`, `status:"complete"`, or - `status:"error"`. Complete results include measured mean/RMS/min/max. + monitor thread cadence and clamps to the same maximum window used by short + photodiode averages. The response reports actual `duration_ms`, + accumulated `samples`, and `target_samples`. + - `dark_status` reports the current or most recent short average for that + channel. Complete dark results include measured mean/RMS/min/max. - `measure_dark` with `store:false` leaves stored calibration unchanged; its completed statistics are available through `dark_status`. - `lowest_dark_mv` is updated only when a stored dark measurement is lower @@ -591,288 +1087,302 @@ measurement specification before firmware design or implementation. - Active monitoring tracks a simple residual RMS after smoothing. If it exceeds the configured warning threshold, the firmware emits `photodiode_noise` on `dt//warning`. - - Power estimates subtract stored dark mV and use `gain_v_p_uw`. + - Power estimates subtract stored dark mV and use `responsivity_a_per_w` and + `transimpedance_v_per_a`. +(pdsettings)= ### `pdsettings` -- **Request topic:** `cmd//req/pdsettings/` - - Set: - ```json - { - "noise_rms_mV": 3.0, - "dark_mv": 0.0, - "gain_v_p_uw": 47500.0, - "persistent": true - } - ``` - - Get: No payload - -- **Response topic:** `cmd//resp/pdsettings/` - - Set result: `{"status": "success"}` - - Get result: - ```json - { - "channel": "yj", - "dark_mv": 0.0, - "lowest_dark_mv": 0.0, - "lowest_dark_valid": false, - "dark_measurement": "idle", - "dark_measurement_duration_ms": 0, - "dark_measurement_samples": 0, - "dark_measurement_target_samples": 0, - "noise_rms_mV": 3.0, - "gain_v_p_uw": 47500.0 - } - ``` +- **Topic:** `cmd//req/pdsettings/` +- **No payload -> one channel's photodiode settings:** + ```json + { + "channel": "yj", + "dark_mv": 0.0, + "lowest_dark_mv": 0.0, + "lowest_dark_valid": false, + "average": "inactive", + "average_duration_ms": 0, + "average_samples": 0, + "average_target_samples": 0, + "noise_rms_mV": 3.0, + "responsivity_a_per_w": 0.93, + "transimpedance_v_per_a": 5.0e10 + } + ``` +- **Payload:** update one channel's photodiode settings. + ```json + { + "noise_rms_mV": 3.0, + "dark_mv": 0.0, + "responsivity_a_per_w": 0.93, + "transimpedance_v_per_a": 5.0e10, + "persistent": true + } + ``` - **Current set fields:** - `dark_mv` - `noise_rms_mV` - - `gain_v_p_uw` + - `responsivity_a_per_w` + - `transimpedance_v_per_a` - `persistent` - **Notes:** not all settings need to be included when setting; failure on any settable setting results in none being set. YJ and HK settings use separate - command keys and separate persistent settings keys. Dark and lowest-dark - values are persisted through the settings subsystem. + command keys and separate app NVS records. Dark and lowest-dark values are + persisted through app settings. +(ip)= ### `ip` -- **Request topic:** `cmd//req/ip` - - Set: - ```json - { "ip": "", - "ntp": "", - "dns": "", - "subnet": "", - "gateway": "", - "trydhcpfirst": true, - "preferdhcpntp": true, - "preferdhcpdns": true, - "persistent": true +- **No payload -> IP configuration:** + ```json + { + "source": "", + "trydhcpfirst": true, + "preferdhcpdns": true, + "preferdhcpntp": true, + "manual": { + "ip": "", + "subnet": "", + "gateway": "", + "dns": "", + "ntp": "" + }, + "active": { + "ready": true, + "ip": "" + }, + "ntp": { + "source": "", + "server": "" } - ``` - - - Query: No payload - -- **Response topic:** `cmd//resp/ip` - - Set result: - ```text - { "status": "success"|"partial", - "ntp": "unsupported", - "dns": "unsupported", - "dhcp": "unsupported" - } - ``` - - Query result: - ```json - { "preferdhcpntp": true, - "preferdhcpdns": true, - "source": "", - "sourceonnextboot": "", - "trydhcpfirst": true, - "source_settings": { - "": { - "ip": "", - "ntp": "", - "dns": "", - "subnet": "", - "gateway": "" - } - } - } - ``` + } + ``` +- **Payload:** update IP configuration. + ```json + { + "ip": "", + "ntp": "", + "dns": "", + "subnet": "", + "gateway": "", + "trydhcpfirst": true, + "preferdhcpntp": true, + "preferdhcpdns": true, + "persistent": true + } + ``` - **Notes:** - - unsupported features don’t error; partial config accepted - - IP precedence: `temporary_override` → `persistent manual setting` → `dhcp` (if enabled) → `compiled`. - - partial comes with keys indicating which settings are not supported. - - unsupported have unsupported in place of an ip - - source names are: `temporary_override`, `persistent_manual`, `dhcp`, `compiled`. - + - Unsupported features don’t error; supported changes are still applied and + partial status reports unsupported fields. + - IP precedence: runtime settings → compiled static defaults → fallback + service profile. + - If `trydhcpfirst` is true and DHCP is compiled in, DHCP is tried before the + runtime static profile. + - Partial responses include keys indicating which settings are not supported. + - network-affecting changes are applied at runtime; ordinary changes do not + require reboot. + - source names are: `unknown`, `compiled`, `static`, `fallback`, `dhcp`. + +(mqtt)= ### `mqtt` -- **Request topic:** `cmd//req/mqtt` - - Set: - ```json - { - "broker": ":", - "persistent": true - } - ``` - - Query: No payload - -- **Response topic:** `cmd//resp/mqtt` - - Set result: `{"status":"success","apply":"reconnect"}` - - Query result: - ```json - {"broker":":", "dns_supported":true} - ``` +- **No payload -> MQTT broker configuration:** + ```json + {"broker":":","dns_supported":true} + ``` +- **Payload:** update MQTT broker configuration. + ```json + { + "broker": ":", + "persistent": true + } + ``` - **Notes:** - Broker value must be one `:` string. - If DNS is not compiled in, hostname values are rejected. - - Successful set updates runtime settings and triggers MQTT reconnect behavior. + - Hostname values must resolve before settings are updated. Numeric IPv4 + broker values do not require DNS. + - Successful set updates runtime settings and triggers MQTT reconnect + behavior. If the new broker cannot connect, firmware restores the prior + broker and emits a best-effort `mqtt_broker_revert` warning. +(serialguard)= ### `serialguard` -- **Request topic:** `cmd//req/serialguard` - - Set: - ```json - { - "seconds": 30, - "persistent": true - } - ``` - `value` is accepted as an alias for `seconds`. - - Query: No payload - -- **Response topic:** `cmd//resp/serialguard` - - Set result: `{"status":"success"}` - - Query result: - ```json - {"serialguard_s":30, "active":true, "remaining_ms":12000} - ``` +- **No payload -> serial guard configuration and current state:** + ```json + {"serialguard_s":30,"active":true,"remaining_ms":12000} + ``` +- **Payload:** update serial guard configuration. + ```json + { + "seconds": 30 + } + ``` + `value` is accepted as an alias for `seconds`. Supplying `persistent` is + rejected; serial guard is runtime-only and is not restored after reboot. - **Notes:** - Any non-empty serial command activates or refreshes the guard. - - Serial shorthand: `serialguard seconds=60` or `serialguard off`. - - While active, MQTT SET/action commands are rejected before dispatch and - logged. Safe read-only MQTT GETs are allowed. Legacy GET handlers with - side effects, including laser-bank power and raw laser register reads, stay - blocked under serial guard until those command shapes are corrected. - - The guard uses the named scheduled action `serial_guard_expire`. + - Serial shorthand: `serialguard seconds=60`, `serialguard 60`, or + `serialguard off`. + - While active, MQTT requests that may change hardware or runtime state are + rejected before dispatch and logged. Safe read-only MQTT requests are + allowed according to the app command table. + - The guard is owned by the command-dispatch library and uses one + dispatcher-owned `k_work_delayable` item. - `seconds:0` disables serial override. +(time)= ### `time` -- **Request topic:** `cmd//req/time` - - Query: No payload - - Set: `{"linuxtime_ms": 0}` - -- **Response topic:** `cmd//resp/time` - - Query result: - ```json - { "utc": 0, - "ticks": 0, - "uptime": 0 - } - ``` - - Set result: `{"status": "success"}` +- **No payload -> firmware time:** + ```json + { + "utc": 0, + "uptime_s": 0 + } + ``` +- **Payload:** set firmware time. + ```json + {"linuxtime_ms":0} + ``` - **Notes:** set time may be overwritten later by NTP if configured and responding. +(temp)= ### `temp` -- **Request topic:** `cmd//req/temp` - - Query: No payload - - Set alarm: `{"alarm_level": 0.0}` - -- **Response topic:** `cmd//resp/temp` - - Query result: `{"ambient_c": 0.0, "laserbankavg_c": NaN| 0.0, "laser[name]_c": NaN| 0.0}` - - Set result: `{"status": "success"}` +- **No payload -> temperature status:** + ```json + { + "ambient_c": 0.0, + "laserbank_c": 0.0, + "laser": { + "": 0.0 + } + } + ``` -- **Notes:** if above alarm level, all commands except this one return an alarm error. Laserbank temperature is not available if power is off or laser TEC is running. +- **Notes:** Laser diode TEC temperatures are included when the laser bank is powered and the relevant driver registers + can be read. Unavailable values are returned as JSON `null`. `laserbank_c` is the average of valid laser TEC + temperatures. +(status)= ### `status` -- **Request topic:** `cmd//req/status` - - Optional payload: - ```json - { "ip": true, - "lasers": true, - "attens": true +- **No payload or payload -> firmware status.** + + Optional payload: + ```json + { + "ip": true, + "lasers": true, + "attens": true + } + ``` + + Response: + ```json + { + "fwversion": "", + "bootcount": 0, + "board_type": "tib|cal_yj|cal_hk|as|unknown", + "board_valid": true, + "mems_switches": 8, + "relay_gpio_error": 0, + "ip": "", + "temp_c": 0.0, + "pd_ontime": 0, + "laserbank_ontime": 0, + "lasers": { + "": { + "power_mw": 0.0, + "tec_on_time_s": 0, + "offin_s": 0 } - ``` - - **Note:** ip lasers and attens are not included unless requested, key is not required - -- **Response topic:** `cmd//resp/status` - ```json - { "fwversion": "", - "bootcount": 0, - "board_type": "tib|cal_yj|cal_hk|as|unknown", - "board_valid": true, - "mems_switches": 8, - "ip": "", - "temp_c": 0.0, - "pd_ontime": 0, - "pd_offin_s": 0, - "laserbank_ontime": 0, - "laserbank_offin_s": 0, - "lasers": { - "": { - "power_mw": 0.0, - "tec_on_time_s": 0, - "offin_s": 0 - } - }, - "attens": { - "": { - "level_%": 0.0 - } - }, - "lastcommand": { - "name": "", - "source": "mqtt", - "time": 0 + }, + "attens": { + "": { + "level_%": 0.0 } + }, + "lastcommand": { + "name": "", + "source": "mqtt", + "time": 0 } + } ``` +- **Notes:** `ip`, `lasers`, and `attens` are omitted unless requested. + `lastcommand` is restored from command-dispatch NVS storage when available. +(reboot)= ### `reboot` -- **Request topic:** `cmd//req/reboot` -- **Response topic:** `cmd//resp/reboot` - - Response: `{"status": "success"}` +- **No payload:** schedule a non-cancelable reboot after the response window. + ```json + {"status":"ok","reboot_ms":3000} + ``` +- **Notes:** command dispatch owns the reboot delayable work item. Immediately + before reboot it calls the app reboot-prepare hook so firmware can put + hardware into a safer state. Once a reboot is pending, later commands are + rejected before app handlers run. +(split)= ### `split` -- **Request topic:** `cmd//req/split` - - Set: - ```json - { - "channel": "yj", - "ratio1": 0.0, - "ratio2": 0.0, - "stopafter_s": 0 - } - ``` - - Query one channel: `cmd//req/split/yj` or - `cmd//req/split/hk` with no payload - - Only available when the AS board strap is selected. The AS board registers - routes `yj_calin -> yj_split` and `hk_calin -> hk_split`. - -- **Response topic:** `cmd//resp/split` for set, or - `cmd//resp/split/` for per-channel query - - Set result: same shape as query result. - - Query result: - ```json - { - "status": "success", - "channel": "yj", - "requested_ratio": [0.33, 0.33, 0.34], - "actual_ratio": [0.33, 0.33, 0.34], - "switches": [ - { - "name": "yj_as1", - "state": "A", - "duty_cycle": 0.33, - "numerator": 33, - "denominator": 100, - "tick_ms": 2 - }, - { - "name": "yj_as2", - "state": "B", - "duty_cycle": 1.0, - "numerator": 100, - "denominator": 100, - "tick_ms": 2 - }, - { - "name": "yj_as3", - "state": "A", - "duty_cycle": 0.66, - "numerator": 66, - "denominator": 100, - "tick_ms": 2 - } - ], - "stopsin_s": 0 - } - ``` +- **Topics:** + - `cmd//req/split` + - `cmd//req/split/yj` or `cmd//req/split/hk` + - Responses use the same key under `cmd//resp/...`. + +- **Payload to `split` -> set splitter state:** + ```json + { + "channel": "yj", + "ratio1": 0.0, + "ratio2": 0.0, + "stopafter_s": 0 + } + ``` +- **No payload to `split/yj` or `split/hk` -> get splitter state.** +- **Availability:** only available when the AS board strap is selected. + + Response: + ```json + { + "channel": "yj", + "ratio_ask": [0.33, 0.33, 0.34], + "ratio_actual": [0.33, 0.33, 0.34], + "ratio_out": [0.33, 0.33, 0.34], + "split_transmission": [1.0, 1.0, 1.0], + "switches": [ + { + "name": "yj_as1", + "state": "A", + "duty_cycle": 0.33, + "numerator": 33, + "denominator": 100, + "tick_ms": 2 + }, + { + "name": "yj_as2", + "state": "B", + "duty_cycle": 1.0, + "numerator": 100, + "denominator": 100, + "tick_ms": 2 + }, + { + "name": "yj_as3", + "state": "A", + "duty_cycle": 0.66, + "numerator": 66, + "denominator": 100, + "tick_ms": 2 + } + ], + "stopsin_s": 0 + } + ``` - **Notes:** - This is intentionally not a general route/switch feature. It is the @@ -893,11 +1403,20 @@ measurement specification before firmware design or implementation. - Users cannot set `toggle_rate_hz` for `split`. The firmware uses the fastest period allowed by `MEMS_SWITCH_MAX_TOGGLE_HZ`, then quantizes the requested ratios to integer MEMS ticks. - - `requested_ratio` and `actual_ratio` are arrays ordered as - `[ratio1, ratio2, ratio3]`. + - Split switch timing may take a few MEMS cycles to settle after a new + request; startup phase is not guaranteed cycle-exact. + - `ratio_ask`, `ratio_actual`, `ratio_out`, and `split_transmission` are + arrays ordered as `[ratio1, ratio2, ratio3]`. + - `ratio_ask` is the requested output split. `ratio_actual` is the MEMS duty + split after transmission correction and integer tick quantization. + `ratio_out` is the estimated optical output split after applying + `split_transmission`. - Each switch report gives the selected route state, the selected-state duty-cycle float, and the exact integer timing as `numerator / denominator` ticks with `tick_ms` milliseconds per tick. - If the attained ratio differs from the requested ratio because MEMS timing is quantized, the firmware emits `split_ratio_quantized` on `dt//warning`. + - The route-loss split tuple sets `split_transmission`. Set all three split + transmissions to the same value, or leave them unset, to disable relative + split correction. diff --git a/doc/diagrams.md b/doc/diagrams.md index 4c6cdae..d4ae483 100644 --- a/doc/diagrams.md +++ b/doc/diagrams.md @@ -16,6 +16,7 @@ flowchart TD Devices --> Atten[attenuators] Devices --> Laser[laser bank and Maiman] Devices --> PD[photodiodes] + Devices --> TP[throughput_monitor] Devices --> Temp[temperature sensor] MQTT[MQTT ingress] --> InQ[inbound_queue] Serial[serial console] --> InQ @@ -24,10 +25,10 @@ flowchart TD Exec --> Atten Exec --> Laser Exec --> PD + Exec --> TP Exec --> OutQ[outbound_queue] - PD --> PDQ[photodiode_queue] - PDQ --> OutQ - Warnings[app_warning_emit] --> OutQ + TP --> OutQ + Warnings[coo_cmd_runtime_warning_emit] --> OutQ OutQ --> MainLoop[main loop] MainLoop --> Broker[MQTT publish] MainLoop --> Console[serial print] @@ -37,7 +38,8 @@ flowchart TD ```mermaid flowchart TD - Start[main] --> Watchdog[configure watchdog] + Static[static MEMS and SNTP threads] --> Start[main] + Start --> Watchdog[configure watchdog] Watchdog --> WdogOK{watchdog ready} WdogOK -- no --> Stop[stop boot] WdogOK -- yes --> Load[load settings] @@ -50,7 +52,11 @@ flowchart TD Router --> Attens[setup profile attenuators] Attens --> Runtime[register scheduled actions] Runtime --> Threads[start executor and serial threads] - Threads --> SNTP[start SNTP work] + Threads --> Work[start ambient delayable work] + Work --> TibActors{TIB profile} + TibActors -- yes --> TibStart[start photodiode, throughput, and laser-bank work] + TibActors -- no --> SNTP[start SNTP runtime] + TibStart --> SNTP SNTP --> Network[start network] Network --> MQTTInit[start MQTT client] MQTTInit --> Loop[main MQTT/outbound loop] @@ -65,8 +71,8 @@ flowchart TD Ready -- no --> Sleep[k_sleep 20 ms] Ready -- yes --> Connected{MQTT connected} Connected -- no --> Connect[coo_mqtt_connect] - Connected -- yes --> Drain[command_drain_outbound_queue] - Connect --> Subscribe[subscribe cmd/hsfib-tib/req/#] + Connected -- yes --> Drain[coo_cmd_runtime_drain_outbound] + Connect --> Subscribe[subscribe cmd//req/#] Subscribe --> Drain Drain --> Process[coo_mqtt_process poll/read] Process --> Loop @@ -77,18 +83,14 @@ flowchart TD ```mermaid flowchart TD - Pub[MQTT publish callback] --> Prefix{topic under cmd/hsfib-tib/req} + Pub[MQTT publish callback] --> Prefix{topic under cmd//req} Prefix -- no --> Drop[ignore] Prefix -- yes --> Copy[copy key payload properties] - Copy --> Type{payload empty} - Type -- yes --> Get[MSG_GET] - Type -- no --> Parse[parse optional msg_type] - Parse --> SetOrGet[default MSG_SET unless msg_type get] - Get --> Guard{serial guard active} - SetOrGet --> Guard - Guard -- yes --> GetAllowed{safe GET} - GetAllowed -- no --> Reject[publish/enqueue serial guard error] - GetAllowed -- yes --> Enq{inbound_queue has space} + Copy --> Classify[dispatch classification by spec and payload shape] + Classify --> Guard{serial guard active} + Guard -- yes --> QueryAllowed{safe query} + QueryAllowed -- no --> Reject[publish/enqueue serial guard error] + QueryAllowed -- yes --> Enq{inbound_queue has space} Guard -- no --> Enq Enq -- yes --> Queue[queue Command] Enq -- no --> Busy[publish/enqueue busy error] @@ -103,14 +105,15 @@ flowchart TD Line -- yes --> Guard[refresh serial guard] Guard --> Split[split key and payload] Split --> Payload{payload form} - Payload -- none --> Get[MSG_GET with empty JSON] + Payload -- none --> Empty[empty JSON payload] Payload -- raw JSON --> Copy[copy payload] Payload -- key=value --> KV[build JSON object] Payload -- shorthand --> Short[translate selected shorthand] - Copy --> Queue - KV --> Queue - Short --> Queue - Get --> Queue{inbound_queue has space} + Copy --> Classify[dispatch classification by spec and payload shape] + KV --> Classify + Short --> Classify + Empty --> Classify + Classify --> Queue{inbound_queue has space} Queue -- yes --> Enqueue[queue Command] Queue -- no --> Error[enqueue serial busy/error] ``` @@ -119,13 +122,25 @@ flowchart TD ```mermaid flowchart TD - Wait[k_msgq_get inbound_queue K_FOREVER] --> Dispatch[find longest dispatch key] - Dispatch --> Found{handler exists for GET/SET} + Wait[k_msgq_get inbound_queue K_FOREVER] --> Override{app execute override} + Override -- yes --> AppExec[app execute handler] + Override -- no --> Reboot{reboot pending} + Reboot -- yes --> Busy[reboot pending response] + Reboot -- no --> Dispatch[find longest command spec] + Dispatch --> Supported{supported on board} + Supported -- no --> Unavailable[unavailable response] + Supported -- yes --> Record{effect-capable request} + Record -- yes --> Last[update persisted lastcommand] + Record -- no --> Found{handler exists for selected path} + Last --> Found Found -- no entry --> Unknown[unknown response] Found -- no handler --> Unsupported[unsupported response] Found -- yes --> Handler[run handler] Handler --> Response[struct OutMsg] + AppExec --> Out[enqueue outbound_queue] + Busy --> Out Unknown --> Out[enqueue outbound_queue] + Unavailable --> Out Unsupported --> Out Response --> Out Out --> Wait @@ -136,11 +151,11 @@ flowchart TD ```mermaid flowchart TD Handler[command handler] --> OutQ[outbound_queue] - Warning[app_warning_emit] --> OutQ - PDWork[photodiode publish work] --> OutQ + Warning[coo_cmd_runtime_warning_emit] --> OutQ + Throughput[throughput_monitor_thread] --> OutQ OutQ --> Drain[main loop drain] Drain --> Target{target} - Target -- serial --> Print[print topic and wrapped payload] + Target -- serial --> Print[print topic and payload] Target -- MQTT best effort --> MQTTBE{MQTT available and publish OK} Target -- MQTT response --> MQTT{MQTT available and publish OK} MQTTBE -- no --> Drop[drop] @@ -153,78 +168,118 @@ flowchart TD ```mermaid flowchart TD - SerialLine[serial command received] --> Note[command_serial_note_activity] + SerialLine[serial command received] --> Note[runtime serial activity hook] Note --> Active[set serial guard active] - Active --> Schedule[schedule serial_guard_expire] + Active --> Schedule[schedule dispatcher k_work_delayable] MQTTCommand[MQTT command] --> Check{guard active} - Check -- yes --> Reject[serial_active_response and warning] - Check -- no --> Queue[queue command] + Check -- yes --> Safe{safe query} + Safe -- no --> Reject[coo_cmd_serial_active_response and warning] + Safe -- yes --> Queue[queue command] + Check -- no --> Queue Schedule --> Expire[system workqueue callback] Expire --> Clear[clear serial guard active] ``` -## 9. Scheduled Actions Architecture +## 9. Delayable Work Ownership ```mermaid flowchart TD - Init[app_scheduled_actions_init] --> Work[k_work_init_delayable per action] - Register[command_runtime_init] --> Handlers[register serial guard and reboot handlers] - Schedule[handler schedules named action] --> Resched[k_work_reschedule] - Resched --> Pending[pending flag set] - Pending --> SysQ[Zephyr system workqueue] - SysQ --> Callback[scheduled_action_work_handler] - Callback --> Domain[registered action handler] - Cancel[optional cancel] --> Clear[pending flag cleared] + Dispatch[command_dispatch.c] --> Guard[serial guard delayable work] + Command[command.c] --> Reboot[reboot delayable work] + Commons[coo_commons scheduled_action helper] --> Future[future fixed-table firmware actions] + Guard --> SysQ[Zephyr system workqueue] + Reboot --> SysQ + Future --> SysQ + SysQ --> Expire[short owner callback] ``` ## 10. Photodiode Sampling and Dark Calibration Flow ```mermaid flowchart TD - Thread[photodiode_thread] --> Board{TIB and ADC ready} - Board -- no --> Sleep[k_sleep retry] - Board -- yes --> Sample[read ADS1115 YJ and HK] + Thread[photodiode_thread started only on TIB] --> Adc{ADC ready} + Adc -- no --> Sleep[k_sleep retry] + Adc -- yes --> Sample[read ADS1115 YJ and HK] Sample --> Convert[counts to mV and power estimate] - Convert --> Dark{dark measurement active} - Dark -- yes --> Accumulate[accumulate dark stats] + Convert --> Average{short average active} + Average -- yes --> Accumulate[accumulate average stats] Accumulate --> Complete{target samples reached} - Complete -- yes --> Store{store requested} + Complete -- yes --> Store{dark store requested} Store -- yes --> Persist[update photodiode settings] - Store -- no --> Status[update dark status] - Dark -- no --> Noise[update residual noise] - Status --> Telemetry[enqueue photodiode_queue] - Persist --> Telemetry + Store -- no --> Status[update average status] + Complete -- no --> Status + Average -- no --> Noise[update residual noise] + Status --> SleepPeriod[sleep to 20 ms period] + Persist --> SleepPeriod Noise --> Warn{noise above threshold} - Warn -- yes --> Emit[app_warning_emit photodiode_noise] - Warn -- no --> Telemetry - Telemetry --> SleepPeriod[sleep to 20 ms period] + Warn -- yes --> Emit[coo_cmd_runtime_warning_emit photodiode_noise] + Warn -- no --> SleepPeriod + Emit --> SleepPeriod ``` -## 11. MEMS Router and Toggler Flow +## 11. Throughput Monitor Flow + +```mermaid +flowchart TD + Command[measure_throughput request] --> Stop{stop field present} + Stop -- yes --> StopReq[clear selected monitor or both monitors] + Stop -- no --> Validate[validate laser, fiber, format, autolevel, stopafter_s] + Validate --> Map[map laser to photodiode channel and attenuator] + Map --> PdPower[enable selected photodiode relay] + PdPower --> AutoStart{autolevel enabled} + AutoStart -- yes --> Seed[set attenuator to high attenuation and laser to 100 percent] + AutoStart -- no --> Arm[store monitor state under lock] + Seed --> Arm + Arm --> Ok[return status ok] + StopReq --> Ok + + Thread[throughput_monitor_thread every 100 ms] --> Snapshot[copy monitor state] + Snapshot --> Active{channel active} + Active -- no --> Sleep[k_sleep 100 ms] + Active -- yes --> Timeout{stopafter expired} + Timeout -- yes --> Clear[clear monitor] + Timeout -- no --> PdOn{photodiode relay still on} + PdOn -- no --> Clear + PdOn -- yes --> Auto{autolevel} + Auto -- yes --> Adjust[adjust attenuator or laser level from PD mean] + Auto -- no --> Publish + Adjust --> Sync[write updated counters and level] + Sync --> Publish[build JSON or binary telemetry] + Publish --> OutQ[enqueue outbound_queue best effort] + OutQ --> Sleep + Clear --> Sleep + + AttenChange[attenuator command changes same attenuator] --> DisableAuto[disable autolevel] + LaserChange[laser command changes same laser] --> StopMonitor[clear monitor] +``` + +## 12. MEMS Router and Toggler Flow ```mermaid flowchart TD Command[mems or memsroute command] --> Lock[lock router/switch] Lock --> Target[store requested state or tick pattern] - Target --> Schedule[schedule router delayable work] - Schedule --> Tick[MEMS work tick] + Target --> Timer[periodic k_timer] + Timer --> Wake[wake MEMS router thread] + Wake --> Tick[MEMS router tick] Tick --> Clear[clear pulse pins] Clear --> Apply[set A/B pulse pins for current tick] Apply --> Advance[advance duty and stop counters] - Advance --> More{active toggles remain} - More -- yes --> Reschedule[reschedule next tick] - More -- no --> Idle[idle with last logical state cached] + Advance --> Missed{missed tick?} + Missed -- no --> Idle[idle until next timer release] + Missed -- yes --> Classify[cleanup late lows, skip stale highs] + Classify --> Idle ``` -## 12. Split Command Flow +## 13. Split Command Flow ```mermaid flowchart TD - Split[split set/get] --> Channel[channel from key or payload] + Split[split request] --> Channel[channel from key or payload] Channel --> Route[lookup yj_calin/hk_calin to split route] - Route --> Set{SET} - Set -- no --> Read[read switch status] - Set -- yes --> Ratios[validate ratio1 ratio2 and compute ratio3] + Route --> Effect{ratio payload present} + Effect -- no --> Read[read switch status] + Effect -- yes --> Ratios[validate ratio1 ratio2 and compute ratio3] Ratios --> Ticks[quantize ratios to MEMS ticks] Ticks --> Apply[set route switches with tick duty] Apply --> Read @@ -235,23 +290,26 @@ flowchart TD Emit --> Response ``` -## 13. Settings Load, Update, and Persist Flow +## 14. Settings Load, Update, and Persist Flow ```mermaid flowchart TD Boot[boot] --> Defaults[initialize defaults] - Defaults --> Subsys[settings_subsys_init] - Subsys --> Load[load app settings subtrees] + Defaults --> Mount[mount app NVS partition] + Mount --> Schema{schema marker valid} + Schema -- no --> Clear[clear old app storage layout] + Schema -- yes --> Load[load app NVS records] + Clear --> Load Load --> Runtime[runtime settings snapshot] - Command[set command] --> Parse[validate JSON fields] + Command[effect request] --> Parse[validate JSON fields] Parse --> Update[update runtime snapshot] Update --> Persist{persistent true} - Persist -- yes --> Save[settings_save_one keys] + Persist -- yes --> Save[write numeric NVS record] Persist -- no --> Volatile[runtime only] - BoardChange[board type changed] --> Clear[delete non-board app settings] + BoardChange[board type changed] --> BoardClear[delete non-board app records] ``` -## 14. Warning Publication Flow +## 15. Warning Publication Flow ```mermaid flowchart TD @@ -261,44 +319,48 @@ flowchart TD Enqueue -- full --> Drop[drop warning] Enqueue -- ok --> Main[main loop drain] Main --> MQTT{MQTT available and publish OK} - MQTT -- yes --> Topic[dt/hsfib-tib/warning] + MQTT -- yes --> Topic[dt//warning] MQTT -- no --> Drop ``` -## 15. Temperature Sensing Flow +## 16. Temperature Sensing Flow ```mermaid flowchart TD - Thread[tempsensor_thread] --> Find[find DS18B20] + Work[ambient temperature delayable work] --> Find[find DS18B20] Find --> Ready{device ready} - Ready -- no --> CacheErr[cache last_error] + Ready -- no --> InitErr[cache unavailable status] + InitErr --> Wait[next ambient sample] Ready -- yes --> Fetch[sensor_sample_fetch] - Fetch --> Get[sensor_channel_get ambient] - Get --> Cache[update mutex-protected status] - CacheErr --> Sleep[k_sleep 1 s] - Cache --> Sleep - Sleep --> Fetch - Command[temp GET] --> Read[tempsense_get_status] + Fetch --> SensorGet[sensor_channel_get ambient] + SensorGet --> Cache[update mutex-protected status] + SensorGet -- error --> CacheErr[mark invalid and keep last value] + Fetch -- error --> CacheErr + Cache --> Wait + CacheErr --> Wait + Wait --> Work + Command[temp query] --> Read[housekeeping_get_temperature_status] Read --> Response[ambient payload or error] ``` -## 16. SNTP and Time Flow +## 17. SNTP and Time Flow ```mermaid flowchart TD - Init[sntp_sync_init] --> Work[delayable SNTP work] - Network[network connected] --> ScheduleNow[schedule sync now] - IPSet[ip ntp change] --> ScheduleNow - Work --> Server{manual or DHCP NTP server} - Server -- none --> Retry[schedule retry] + Init[sntp_sync_init] --> Thread[low-priority SNTP thread] + Network[network connected] --> Wake[wake sync now] + IPSet[ip ntp change] --> Wake + Thread --> Server{manual or DHCP NTP server} + Wake --> Server + Server -- none --> Retry[thread waits retry interval] Server -- present --> SNTP[sntp_simple blocking call] - SNTP -- success --> Clock[clock_settime] + SNTP -- success --> Clock[realtime clock update] Clock --> Status[synced status] - Status --> Resync[schedule hourly resync] + Status --> Resync[thread waits hourly resync] SNTP -- fail --> Error[last_error and retry] ``` -## 17. Watchdog Flow +## 18. Watchdog Flow ```mermaid flowchart TD @@ -313,3 +375,140 @@ flowchart TD Feed --> Loop Feed -- failure --> Log[log watchdog feed failure] ``` + +## 19. Network and MQTT Reconfiguration Flow + +```mermaid +flowchart TD + Loop[main loop] --> NetReady{network ready} + Loop --> Revision{MQTT settings revision changed} + Revision -- yes --> Load[load broker settings] + Load --> Apply[coo_mqtt_set_broker_config] + Apply -- reject --> Log[log rejected broker config] + Apply -- ok --> Changed{broker changed} + Changed -- yes --> SavePrior[save prior broker and arm revert-on-failure] + SavePrior --> DisconnectOld{MQTT connected} + Changed -- no --> NetReady + DisconnectOld -- yes --> Disconnect[mqtt_disconnect and clear subscribed flag] + DisconnectOld -- no --> NetReady + Disconnect --> NetReady + + NetReady -- no --> ConnectedButBlocked{MQTT connected} + ConnectedButBlocked -- yes --> DropConn[mqtt_disconnect] + ConnectedButBlocked -- no --> DrainNoMqtt[drain outbound with MQTT unavailable] + DropConn --> DrainNoMqtt + DrainNoMqtt --> Sleep[k_sleep 20 ms] + + NetReady -- yes --> Connected{MQTT connected} + Connected -- no --> Connect[coo_mqtt_connect] + Connect -- success --> ClearRevert[clear revert flag] + Connect -- failure and revert armed --> Warn[emit mqtt_broker_revert warning] + Warn --> Restore[restore prior broker and persisted settings] + Connect -- failure --> DrainNoMqtt + ClearRevert --> SubscribeNeeded{subscription active} + Connected -- yes --> SubscribeNeeded + SubscribeNeeded -- no --> Subscribe[subscribe cmd//req/#] + SubscribeNeeded -- yes --> Drain[drain outbound with MQTT available] + Subscribe --> Drain + Drain --> Process[coo_mqtt_process] + Process -- ok --> Loop + Process -- failure --> DisconnectProcess[mqtt_disconnect and clear subscribed flag] + DisconnectProcess --> Loop + Sleep --> Loop +``` + +## 20. Laser Bank Control Flow + +```mermaid +flowchart TD + Work[laser-bank temp-control delayable work] --> Cycle[run laserbank temp-control pass] + Cycle --> Settings[read laserbank settings] + Settings --> Ambient[read cached ambient temperature] + Ambient --> Power[read bank power state] + Power --> Mode{heater mode} + + Mode -- override_on --> ForceOn[set heater on] + Mode -- override_off --> ForceOff[set heater off] + ForceOn --> OverrideWarn[rate-limited override warning] + ForceOff --> OverrideWarn + OverrideWarn --> Wait + + Mode -- auto --> EnteredAuto{just entered auto and bank off} + EnteredAuto -- yes --> PowerBank[power bank on] + EnteredAuto -- no --> ReadTemps[read Maiman TEC temperatures] + PowerBank --> ReadTemps + ReadTemps --> Cache[refresh valid per-laser temperature cache] + Cache --> Summarize[summarize valid, stale, disabled, and warm state] + Summarize --> AllStaleCold{all stale and ambient below warm minimum} + AllStaleCold -- yes --> KeepPower[best-effort bank power on] + AllStaleCold -- no --> HeaterPolicy + KeepPower --> HeaterPolicy{heater policy} + HeaterPolicy -- any disabled below 15 C --> HeaterOn[set heater on] + HeaterPolicy -- disabled above threshold --> HeaterOff[set heater off] + HeaterPolicy -- all TECs enabled long enough --> HeaterOff + HeaterPolicy -- otherwise --> Wait + HeaterOn --> Wait + HeaterOff --> Wait + + Wait[reschedule after poll interval] + Wake[heater command changes mode] --> Cycle + Wait --> Cycle +``` + +## 21. Laser Output and Auto-Off Flow + +```mermaid +flowchart TD + Request[laser level effect request] --> Parse[validate laser name, level, autooff_s] + Parse --> Settings[read laser channel settings] + Settings --> StopTP[stop throughput monitor for this laser] + StopTP --> SetOutput[hispec_laser_set_output_percent_autooff] + SetOutput --> Tuned{nonzero tune offset and level > 0} + Tuned -- yes --> Tune[apply wavelength tune using current and TEC] + Tuned -- no --> Percent[convert percent to diode current] + Tune --> Applied{hardware update ok} + Percent --> Applied + Applied -- no --> Error[return command error] + Applied -- yes --> LevelPositive{level > 0} + LevelPositive -- yes --> Deadline[store auto-off deadline or zero for no timeout] + LevelPositive -- no --> Clear[clear auto-off deadline] + Deadline --> ScheduleTimeout[reschedule laser auto-off work] + Clear --> Ok + ScheduleTimeout --> Ok + + TimeoutActor[laser auto-off delayable work] --> Service[service expired auto-off deadlines] + Service --> Expired{deadline expired} + Expired -- no --> Wait[reschedule nearest deadline] + Expired -- yes --> StopOutput[hispec_laser_stop_output] + StopOutput --> DisableTec{disable_tec_at_autooff} + DisableTec -- yes --> TecOff[stop current and TEC] + DisableTec -- no --> CurrentOff[stop current only] + TecOff --> Wait + CurrentOff --> Wait +``` + +## 22. Status Response Assembly Flow + +```mermaid +flowchart TD + Request[status query] --> Parse[parse optional ip, lasers, attens flags] + Parse --> Base[read firmware, boot count, board, MEMS, relay status] + Base --> Temp[read cached ambient temperature] + Temp --> Bank[read laserbank_tempcontrol status and bank on-time] + Bank --> PdOn[read photodiode relay on-time] + PdOn --> Build[append base JSON fields] + + Build --> IncludeIP{ip requested} + IncludeIP -- yes --> IP[ip_get and embed payload] + IncludeIP -- no --> IncludeLasers + IP --> IncludeLasers{lasers requested} + IncludeLasers -- yes --> LaserLoop[read each laser status over Maiman when available] + IncludeLasers -- no --> IncludeAttens + LaserLoop --> IncludeAttens{attens requested} + IncludeAttens -- yes --> AttenLoop[read available attenuator channels over DAC] + IncludeAttens -- no --> Last + AttenLoop --> Last[append lastcommand] + Last --> Size{fixed payload still fits} + Size -- yes --> Response[return status payload] + Size -- no --> Error[return status response too large] +``` diff --git a/doc/hardware.md b/doc/hardware.md index 3a1a9ee..22e8bb8 100644 --- a/doc/hardware.md +++ b/doc/hardware.md @@ -29,14 +29,29 @@ See status.md for software details - Microcontroller reference manual STM32H563ZI.pdf - https://docs.zephyrproject.org/latest/boards/st/nucleo_h563zi/doc/index.html +Must edit default solder bridges to use i2c2: +• HSE not used: PF0/PH0 and PF1/PH1 are used as GPIOs instead of clocks. The configuration must be: +– SB48 and SB50 ON +– SB49 OFF + ## MEMS Switches -Controlled via 3V3 to 5V 16x GPIO expander (PCAL6416AHF) +Controlled via 3V3 to 5V 16x GPIO expander (PCAL6416AHF,128) - 3.3V i2c, 5V gpio, 25mA max drive -- Address 0x33 (ADDR high) or 0x22 (ADDR low), using 0x33 -- Configure as open drain for FFSW as they have 5V pullups -- Configure as push-pull for FFLS -- Require 2 pins per FFSW or FFLS +- Address 0x21 (ADDR high) or 0x20 (ADDR low), using 0x21 (addr is tied to +5V (VDD(P))) 0b0100001 +- FFSW lines have 4.7k external resistors. in open drain each switch channel flows 2mA through PCAL + - FFLS lines do not have a pullup but one may be added at site of unpopulated FFSW drive MOSFET to allow operation in same manner +- Placing ports in push-pull with pull-ups enabled and then idling the external MEMS control lines low should work for all switches. +- The port with FFLS switches also has FFSW switches so I am going with a common selection for both ports for simplicity. +- Initial testing will be with push-pull approach and full drive strength +- Requires 2 pins per FFSW or FFLS +- The Nucleo devicetree configures all 16 PCAL MEMS outputs with GPIO hogs: + push-pull, pull-ups enabled, active-low at the PCAL pin, and logical + output-low at boot so the external switch-control lines idle low. Firmware + pulses the logical line active, which is a high pulse at the switch-control + line. Zephyr's mainline `nxp,pcal6416a` driver resets the PCAL drive strength + registers to full drive and leaves both ports push-pull; firmware no longer + writes the PCAL port-drive register directly. For board files: - Nucleo: @@ -62,17 +77,17 @@ A pair of DAC7578SPW 8 chan DAC driving OPA2991 2 channel OpAmps - Each laser channel uses a pair of physical attenuators: - CAL: 2 DAC channels in use (1 channel x 2 attenuators) - TIB: 12 DAC channels in use (6 channels x 2 attenuators) -- I2C addr: 0x48 (channels 1-3) and 0x4A (chan 4-6) (0x4C floating pin, 0x48 GND, 0x4A VCC) +- I2C addr: 0x48 and 0x4A (DS says: 0x4C floating pin, 0x48 GND, 0x4A VCC) - LDAC is tied to ground. - Channels: - - 0x48 - - 1-2: Y atten 1 & 2 - - 3-4: J atten 1 & 2 - - 5-6: YJATC atten 1 & 2 - 0x4A - - 1-2: HK atten 1 & 2 - - 3-4: H/CAL atten 1 & 2 - - 5-6: K atten 1 & 2 + - Y Attens: A=1, C=2 + - J Attens: E=1, G=2 + - YJATC Attens: D=1 & F=2 + - 0x48 + - HKATC Attens: A=1, C=2 + - H/CAL Attens: E=1, G=2 + - K Attens: D=1, F=2 For board files: - Nucleo: @@ -92,7 +107,8 @@ Uses an ADS1115 16 bit 4 channel muxed ADC - PD coax terminated with 50 Ohm and fed to ADC as singled-ended input (gives 0-5V range from 0-10V PDs) - I2C addr: 0x48 (0x48 ADDR=gnd, 0x49 ADDR=Vcc) - Uses 2-channels of LL shifting for i2c 3.3-5V -- TODO Elec: Consider clamping Ain at MAX Vdd+.3 (say .2V above 5V with a shottkey(?) diode) +- Photodiodes are Femto FWPR-20-IN (YJ) and Thorlabs PDA10DT (HK) +- See photodiode_notes.md for additional details For board files: - Nucleo: @@ -118,18 +134,33 @@ For board files: ## Laser Bank Power Enable - 3.3V, GPIO to enable of power driver, - pull into 1-5v range against a 10k pulldown to ground to enable +- Firmware policy is off after reboot. The Nucleo devicetree hog drives the + on-board laser-bank power enable low before app setup, and app setup repeats + the inactive configuration. For board files: - Nucleo: CN9 13 D72 IO PB2 - +- MB1404 solder bridges SB61, SB66 must be changed to OFF, ON for PB2 to connect to CN9 pin 13 as GPIO. ## Off-board power switch for photodiodes and laser bank aux heater Uses a 1-Wire DS2408 GPIO chip controlling relays on P1-P3 - P1 is the power switch for the YJ photodiode - P2 is the power switch for the HK photodiode - P3 is the power switch for the laser bank aux heater +- The DS2408 driver should set all expander outputs to their overlay-configured defaults during driver init in the same + manner as any system GPIOs when the chip is present. Absent configuration, driver should not configure the chip + (allowing default power-on or current config to persist). Application device startup code will enforce startup logic + state for the relays. +- If the off-board relay expander is missing at boot, firmware emits a (non-droppable) warning, + reports the relay GPIO expander offline in `status`, and ignores relay power commands with a warning. +- The DS2408 is intentionally not configured through a generic GPIO hog because + Zephyr's hog init aborts on a not-ready GPIO controller. The relay board is an + allowed missing-at-boot fault (the mems' PCAL being unavaialble would indicate a much larger, PCB, problem). For board files: - Nucleo: CN9 15 D71 IO PE9 +- MB1404 solder bridges for PE9 must select GPIO on Zio/ST morpho: + SB35 OFF, SB67 ON. ## DS18B20 1Wire Temperature Sensor - 3.3v digital temp sensor for good measure diff --git a/doc/human_review_required.md b/doc/human_review_required.md index 9ced650..999d46c 100644 --- a/doc/human_review_required.md +++ b/doc/human_review_required.md @@ -1,65 +1,43 @@ # Human Review Required -This page is the central list of audit decisions, mismatches, and stale content -that should be reviewed by a project owner. +This is the central owner-review list for current code-vs-doc mismatches, +source TODOs, and behavior decisions. -## Command Spec vs Code +LLMs Agents: Do NOT change heading names in this file. -- `commands.md` documents `measure_tput`, `lasersettings`, and - `laserbank/autowarm`; none are implemented. -- `commands.md` documents `temp` alarm set behavior; code implements GET only. -- `commands.md` documents optional GET payloads. Code treats non-empty MQTT - payloads as SET unless `msg_type:"get"` is present. -- `status_get()` ignores optional `ip`, `lasers`, and `attens` request fields - and returns a compact payload. -- `mqtt` docs and source TODOs disagree about whether broker and port should be - combined as one field. -- `laser` command key parsing appears internally inconsistent and likely does - not reach real Maiman registers through the documented topic shape. -- `reboot` is SET-only in code even though the intended interface reads like a - no-payload action. +## PCB Validation +- [ ] Verify boot MEMS switch state behavior. +- [ ] Verify ADC levels with scope +- [ ] Verify DAC levels with scope pre/post opamp +- [ ] Sort out MEMS loop and ADC loop timing overruns +- [ ] Validate MODBUS comms with NMH & a spare driver -## Hardware vs Code +## Command/API Mismatches -- Hardware docs describe two DAC7578 devices and twelve physical TIB FVOA - channels. Code initializes six logical attenuator channels through one - `dac7578` device handle. -- Hardware docs distinguish FFSW open-drain and FFLS push-pull MEMS drive. - Current code uses raw GPIO expander pins and does not apply per-switch - electrical mode. -- CAL switch names and route names are explicitly provisional in source. +### LLM Resolved; Human Review Requested -## Behavior That May Not Match Intent +- [ ] Confirm serial response pretty-printing is readable in CoolTerm and CLion. +- [ ] Confirm serial `help` content is useful enough for bring-up and matches + expected operator wording. +- [ ] Confirm command options in serial help are complete enough for current + workflow. -- `laserbank/poweron`, `laserbank/poweroff`, and `laserbank/clearfaults` are - registered as both GET and SET handlers, so bare queries perform actions. -- Local `laser_t` values map `LASER_1028_Y` and `LASER_1270_J` to the same - value. -- Photodiode dark calibration ownership is split: settings owns persisted - values, while the sampler thread completes and optionally stores dark - measurements. +## Decisions To Make +- Decide intended persistence for MEMS switch state, AS split requested/actual state and last-command metadata. -## Stale Docs Removed or Rewritten +## TODOs -- `status.md` was rewritten from older platform notes into current Zephyr - firmware status. -- `runtime_architecture.md` was rewritten to remove stale split-route wording - and reflect current thread/queue/work structure. -- `libraries.md` was rewritten around current local wrappers and app modules. -- `nuisances.md` was kept informal and narrowed to current known annoyances. -- The root `README.md` was rewritten from an older W5500/Pico template into a - current Nucleo-oriented overview with links to the audit pages. +- `app/src/maiman.h`: compare Maiman behavior against the referenced validation/test scripts. -## LLM-resolved items requiring human review +## Deferred Owner-Specified Capabilities -- Stale Pico/C++/Zyre status content was removed from the authoritative status - page because current code is a Zephyr C firmware app. -- Stale splitter wording was replaced with current route names: - `yj_calin -> yj_split` and `hk_calin -> hk_split`. -- Old library notes were consolidated into a current module/wrapper inventory. -- Old README build and command examples were replaced by the current Nucleo - build command and links to implementation-derived command docs. +Do not design or implement these without a detailed owner specification: -## Codex Judgment Calls +- Verify boot MEMS switch state behavior. +## Test items + +- Add test coverage around `json_utils.c`, command normalization, and network profile selection. +- Add test coverage for the attenuator model and inverse once calibration expectations are owner-approved. +- Add automated tests for command parsing and non-hardware domain logic. diff --git a/doc/implementation_gaps.md b/doc/implementation_gaps.md deleted file mode 100644 index 2bca268..0000000 --- a/doc/implementation_gaps.md +++ /dev/null @@ -1,62 +0,0 @@ -# Implementation Gaps - -This page collects gaps found during the documentation audit. It is not a -firmware roadmap by itself; items need owner review before being treated as -requirements. - -## Command/API Gaps - -- `measure_tput` is documented but has no dispatch entry. -- `lasersettings` is documented but has no dispatch entry. -- `laserbank/autowarm` is documented but has no dispatch entry. -- `temp` set/alarm behavior is documented but not implemented. -- `status` returns a compact payload and does not implement the larger nested - payload described in `commands.md`. -- `laser` command parsing appears unable to address a real laser with the - currently dispatched key shape. -- `reboot` requires SET semantics in implementation even though docs imply a - no-payload action. - -## Hardware/Profile Gaps - -- TIB hardware uses two DAC7578 devices and twelve physical FVOA channels. - Firmware currently exposes six logical attenuator channels and initializes - one `dac7578` device. -- MEMS electrical mode is not represented per switch in firmware. Hardware - notes distinguish FFSW open-drain and FFLS push-pull. -- CAL switch names have an explicit source TODO pending final fiber path names. -- Temperature sensing only exposes ambient DS18B20 data; laser-bank average and - alarm lockout behavior are not implemented. - -## Persistence Gaps - -The following runtime state is not persisted: - -- MEMS switch state. -- Split requested/actual state. -- Laser current, pulse, temperature, and tuning state. -- Last-command status. - -The following runtime state is intentionally not persisted: - -- Active routes, because they are derived from current MEMS switch state and - route tables. -- Laser-bank power state, because reboot must return laser-related power rails - and heaters to the off state unless a future owner-approved policy says - otherwise. -- DS2408 relay output state, for the same explicit-power-on-after-reboot - policy. - - -## Source TODOs Preserved - -- `app/src/photodiode.h`: ADC resolution should come from devicetree. -- `app/src/mems_switching.c`: MEMS tick may perform excess I2C bus activity. -- `app/src/mems_switching.c`: verify first-boot unknown-state behavior. -- `app/src/main.c`: temperature thread priority should probably be lowest. -- `app/src/devices.c`: final CAL route/switch names need owner decision. -- `app/src/command.c`: MQTT host/port schema TODO tagged `-JIB`. -- `app/src/command.c`: internal route-error TODO. -- `app/src/attenuator.c`: attenuator calibration/nonlinearity TODO. -- `lib/coo_commons/network.c`: DHCP/network helper necessity TODOs. -- `hardware.md`: photodiode ADC clamp TODO. diff --git a/doc/implemented_commands.md b/doc/implemented_commands.md index 5e323ca..af689cd 100644 --- a/doc/implemented_commands.md +++ b/doc/implemented_commands.md @@ -1,271 +1,269 @@ # Implemented Commands -This page is derived from `app/src/command.c`. It is a comparison artifact, not -a replacement for `commands.md`. +This page is derived from the app command spec table in `app/src/command.c`, +the common dispatch helpers, and the current command handlers. It is a +comparison artifact, not a replacement for `commands.md`. ## Global Rules -- MQTT request prefix: `cmd/hsfib-tib/req/`. -- Default MQTT response prefix: `cmd/hsfib-tib/resp/`. +- MQTT request prefix: `cmd//req/`. +- Default MQTT response prefix: `cmd//resp/`. +- `` is selected from the board strap: `tib` uses `hsfib-tib`, + `cal_hk` uses `hsfib-rcal`, `cal_yj` uses `hsfib-bcal`, and `as` uses + `hsfib-as`. - MQTT `response_topic` overrides the default when supplied and fitting the fixed buffer. -- MQTT `correlation_data` is copied into a fixed static buffer sized to the - configured MQTT packet buffer and echoed exactly in responses. -- While serial guard is active, safe MQTT GETs are accepted, but MQTT SET/action - commands and legacy side-effect GET handlers are rejected. -- Empty MQTT payload is GET. -- Non-empty MQTT payload is SET unless it includes `msg_type:"get"`. -- Serial `` is GET. -- Serial ` ` is SET. +- MQTT `correlation_data` up to 16 bytes is copied into a fixed static buffer + and echoed exactly in responses. +- Retained MQTT requests are ignored with an error response to avoid replaying + actions when the device reconnects. +- MQTT and serial share the same schema-based request classification selected + by the app command spec table and applied by command dispatch. The internal + names `MSG_GET` and `MSG_SET` are dispatch-slot names, not user-visible + protocol verbs. +- Empty/no-payload requests are queries except no-payload actions such as `reboot` + and `laserbank/clearfaults`, plus laserbank topic-suffix actions. +- Non-empty payload requests are effect/action requests except documented query + shapes for `status`, laser query endpoints, `memsroute/route_loss`, and + `pd` dark-status. +- The old MQTT `msg_type` payload convention is not used by command ingress. +- Pure queries are not recorded as `lastcommand`; supported effect-capable + requests are recorded by command dispatch before handler execution and + persisted in a fixed NVS record. - Serial supports raw JSON, `key=value` fields, and selected shorthand forms. -- All handlers run in `command_executor_thread()` and enqueue one response to - `outbound_queue`. +- Dispatcher built-ins (`help`, and when enabled, `serialguard` and `reboot`) + are handled in `command_dispatch.c`. Serial `help` prints directly; MQTT + `help`, `serialguard`, and `reboot` enqueue one response to `outbound_queue`. +- App command handlers run in `coo_cmd_runtime_executor_thread()` and enqueue one + response to `outbound_queue`. +- App support predicates reject unsupported command families before their + hardware/domain handlers run. +- Data-less success returns `{"status":"ok"}`. Data-bearing success returns the + data object. Failures include an `error` key. ## Dispatch Table | Command key | MQTT request topic | Default response topic | Serial form | | --- | --- | --- | --- | -| `help` | `cmd/hsfib-tib/req/help` | `cmd/hsfib-tib/resp/help` | `help` | -| `ip` | `cmd/hsfib-tib/req/ip` | `cmd/hsfib-tib/resp/ip` | `ip [payload]` | -| `mqtt` | `cmd/hsfib-tib/req/mqtt` | `cmd/hsfib-tib/resp/mqtt` | `mqtt [payload]` | -| `time` | `cmd/hsfib-tib/req/time` | `cmd/hsfib-tib/resp/time` | `time [payload]` | -| `reboot` | `cmd/hsfib-tib/req/reboot` | `cmd/hsfib-tib/resp/reboot` | `reboot ` | -| `serialguard` | `cmd/hsfib-tib/req/serialguard` | `cmd/hsfib-tib/resp/serialguard` | `serialguard [payload]` | -| `memsroute` | `cmd/hsfib-tib/req/memsroute` | `cmd/hsfib-tib/resp/memsroute` | `memsroute [payload]` | -| `mems` | `cmd/hsfib-tib/req/mems` | `cmd/hsfib-tib/resp/mems` | `mems` | -| `mems/` | `cmd/hsfib-tib/req/mems/` | `cmd/hsfib-tib/resp/mems/` | `mems/ [payload]` | -| `split/` | `cmd/hsfib-tib/req/split/` | `cmd/hsfib-tib/resp/split/` | `split/ [payload]` | -| `laserbank/poweron` | `cmd/hsfib-tib/req/laserbank/poweron` | `cmd/hsfib-tib/resp/laserbank/poweron` | `laserbank/poweron [payload]` | -| `laserbank/poweroff` | `cmd/hsfib-tib/req/laserbank/poweroff` | `cmd/hsfib-tib/resp/laserbank/poweroff` | `laserbank/poweroff [payload]` | -| `laserbank/clearfaults` | `cmd/hsfib-tib/req/laserbank/clearfaults` | `cmd/hsfib-tib/resp/laserbank/clearfaults` | `laserbank/clearfaults [payload]` | -| `laser/...` | `cmd/hsfib-tib/req/laser/...` | `cmd/hsfib-tib/resp/laser/...` | `laser/... [payload]` | -| `atten//` | `cmd/hsfib-tib/req/atten//` | `cmd/hsfib-tib/resp/atten//` | `atten// [payload]` | -| `pd` | `cmd/hsfib-tib/req/pd` | `cmd/hsfib-tib/resp/pd` | `pd [payload]` | -| `pdsettings/` | `cmd/hsfib-tib/req/pdsettings/` | `cmd/hsfib-tib/resp/pdsettings/` | `pdsettings/ [payload]` | -| `temp` | `cmd/hsfib-tib/req/temp` | `cmd/hsfib-tib/resp/temp` | `temp` | -| `status` | `cmd/hsfib-tib/req/status` | `cmd/hsfib-tib/resp/status` | `status` | - -## Command Details +| `help` | `cmd//req/help` | `cmd//resp/help` | `help` (dispatcher built-in) | +| `ip` | `cmd//req/ip` | `cmd//resp/ip` | `ip [payload]` | +| `mqtt` | `cmd//req/mqtt` | `cmd//resp/mqtt` | `mqtt [payload]` | +| `time` | `cmd//req/time` | `cmd//resp/time` | `time [payload]` | +| `reboot` | `cmd//req/reboot` | `cmd//resp/reboot` | `reboot` | +| `serialguard` | `cmd//req/serialguard` | `cmd//resp/serialguard` | `serialguard [payload]` (dispatcher built-in) | +| `memsroute` | `cmd//req/memsroute` | `cmd//resp/memsroute` | `memsroute [payload]` | +| `memsroute/route_loss` | `cmd//req/memsroute/route_loss` | `cmd//resp/memsroute/route_loss` | `memsroute/route_loss ` | +| `mems` | `cmd//req/mems` | `cmd//resp/mems` | `mems` | +| `mems/` | `cmd//req/mems/` | `cmd//resp/mems/` | `mems/ [payload]` | +| `split` | `cmd//req/split` | `cmd//resp/split` | `split ` | +| `split/` | `cmd//req/split/` | `cmd//resp/split/` | `split/` | +| `measure_throughput` | `cmd//req/measure_throughput` | `cmd//resp/measure_throughput` | `measure_throughput ` | +| `laserbank/power` | `cmd//req/laserbank/power` | `cmd//resp/laserbank/power` | `laserbank/power[/mode] [payload]` | +| `laserbank/clearfaults` | `cmd//req/laserbank/clearfaults` | `cmd//resp/laserbank/clearfaults` | `laserbank/clearfaults` | +| `laserbank/heater` | `cmd//req/laserbank/heater` | `cmd//resp/laserbank/heater` | `laserbank/heater[/mode] [payload]` | +| `laser` | `cmd//req/laser` | `cmd//resp/laser` | `laser ` | +| `laser/tune` | `cmd//req/laser/tune` | `cmd//resp/laser/tune` | `laser/tune ` | +| `laser/status` | `cmd//req/laser/status` | `cmd//resp/laser/status` | `laser/status ` | +| `laser/engstatus` | `cmd//req/laser/engstatus` | `cmd//resp/laser/engstatus` | `laser/engstatus ` | +| `laser/settings` | `cmd//req/laser/settings` | `cmd//resp/laser/settings` | `laser/settings ` | +| `atten//` | `cmd//req/atten//` | `cmd//resp/atten//` | `atten// [payload]` | +| `pd` | `cmd//req/pd` | `cmd//resp/pd` | `pd [payload]` | +| `pdsettings/` | `cmd//req/pdsettings/` | `cmd//resp/pdsettings/` | `pdsettings/ [payload]` | +| `temp` | `cmd//req/temp` | `cmd//resp/temp` | `temp` | +| `status` | `cmd//req/status` | `cmd//resp/status` | `status [payload]` | + +## Implementation Map + +This section intentionally avoids restating payload and response schemas. Use +`commands.md` for protocol behavior; this page records where the behavior lives, +which slow resources it can touch, and known implementation-specific caveats. ### `help` -- GET only. Payload ignored. -- Response: `{"help":"help,ip,mqtt,time,temp,status,reboot,serialguard,memsroute,mems,split,laser,laserbank,power,atten,pd,pdsettings"}`. -- No hardware side effects, no settings writes, no direct publish. -- Handler: `help_get()` in `app/src/command.c`. -- Mismatch: help text is implementation-derived, not a full copy of - `commands.md`. +- Owner: command-dispatch built-in in `lib/coo_commons/command_dispatch.c`. +- Notes: no hardware side effects, no NVS writes, no direct publish. +- Serial response prints directly from the dispatcher and bypasses the command + queues so it can exceed the normal MQTT payload budget. +- MQTT response is intentionally compact: device ID, request prefix, response + prefix, and command keys from built-ins and app command specs with help + metadata. +- App-specific help text lives on the static command spec table in + `app/src/command.c`; help entries can report commands as unsupported for the + current board profile. ### `ip` -- GET returns stored/manual IP settings, active IPv4 status, and NTP source. -- SET fields: `trydhcpfirst`, `preferdhcpdns`, `preferdhcpntp`, `ip`, - `subnet`, `gateway`, `dns`, `ntp`, `persistent`. -- Validation: bools must parse as bools; string fields must fit fixed IPv4 - buffers; unsupported DHCP/DNS/NTP fields return partial status. -- Response: success with `apply:"reboot_required"` for network changes, - `apply:"immediate"` for NTP-only changes, or partial support status. -- Side effects: updates runtime settings; optional settings persistence; NTP - changes schedule SNTP sync. -- Blocking: settings writes may block. No direct publish. -- Handler: `ip_get()`, `ip_set()` in `app/src/command.c`. +- Owner: `ip_get()`, `ip_set()` in `app/src/command.c`, with runtime network + apply in `lib/coo_commons/network.c`. +- Side effects: can reconfigure IPv4, update runtime settings, optionally write + NVS, and schedule SNTP sync after NTP setting changes. +- Blocking: DHCP waits, DNS/NTP validation, and NVS writes can block. +- Serial shorthand is described in the command spec and normalized by command + dispatch. ### `mqtt` -- GET returns `broker` as `:` and `dns_supported`. -- SET fields: `broker`, optional `persistent`. -- Validation: broker must be one `:` value; hostname - requires DNS support unless numeric IPv4; port must be 1..65535. -- Response: `{"status":"success","apply":"reconnect"}`. -- Side effects: updates runtime broker settings; optional persistence; main - loop reconnects later. -- Blocking: settings writes may block. No direct publish. -- Serial shorthand: `mqtt : [persistent]`. -- Handler: `mqtt_get()`, `mqtt_set()` in `app/src/command.c`. +- Owner: `mqtt_get()`, `mqtt_set()` in `app/src/command.c`. +- Side effects: updates runtime broker settings and optional NVS persistence; + `main()` reconnects later and can restore the prior broker after a failed + first connection. +- Blocking/enqueue: hostname resolution and NVS writes can block; failed first + connection emits `mqtt_broker_revert`. +- Serial shorthand is described in the command spec and normalized by command + dispatch. ### `time` -- GET returns UTC ms, cycle ticks, uptime, and SNTP status. -- SET field: `linuxtime_ms`. -- Validation: `linuxtime_ms` must parse as unsigned 64-bit milliseconds. -- Side effects: SET calls `clock_settime()`. -- Blocking: no bus I/O; no settings writes; no direct publish. -- Serial shorthand: `time `. -- Handler: `time_get()`, `time_set()` in `app/src/command.c`. +- Owner: `time_get()`, `time_set()` in `app/src/command.c`. +- Side effects: effect requests update Zephyr's realtime clock and persist the + last known UTC time for boot-time restore. +- Blocking: no bus I/O, NVS writes can block, no direct publish. +- Serial shorthand is described in the command spec and normalized by command + dispatch. ### `reboot` -- SET schedules a named delayed reboot action after 250 ms. -- GET is unsupported, so empty MQTT payload or bare serial `reboot` does not - reboot. -- SET payload is not parsed; any non-empty MQTT payload dispatches SET. -- Response: `{"status":"success"}` or schedule error. -- Side effects: calls `sys_reboot(SYS_REBOOT_COLD)` from the scheduled action. -- Handler: `reboot_set()` in `app/src/command.c`. -- Mismatch: intended docs read like a no-payload action. +- Owner: command-dispatch built-in in `lib/coo_commons/command_dispatch.c`. +- Side effects: schedules a dispatcher-owned non-cancelable `k_work_delayable` + item, calls the app reboot-prepare hook, then calls + `sys_reboot(SYS_REBOOT_COLD)` after the response window. +- While reboot is pending, later commands are rejected before app handlers run. ### `serialguard` -- GET returns configured holdoff seconds, active state, and remaining ms. -- SET fields: `seconds` or `value`, optional `persistent`. -- Validation: seconds/value must parse as unsigned 32-bit. -- Side effects: updates serial guard setting; optional persistence; serial SET - refreshes the active guard window. -- While active, serial guard rejects MQTT SET/action commands. Safe read-only - MQTT GETs are allowed; laser-bank power and raw laser register GETs remain - blocked because those legacy GET handlers can have side effects. -- Serial shorthand: `serialguard off`, `serialguard [persistent]`. -- Handler: `serial_guard_get()`, `serial_guard_set()` in `app/src/command.c`. - -### `memsroute` - -- GET returns `{"active_routes": {"":["", "..."]}}`; outputs - with no active source report `["no source"]`. -- SET fields: `input`, `output`. -- Validation: route must exist in current board profile and every route switch - must exist. -- Side effects: sets MEMS switch requested states through the router. -- Blocking/enqueue: can lock router state and schedule MEMS delayable work; no - direct publish. -- Handler: `memsroute_get()`, `memsroute_set()` in `app/src/command.c`. +- Owner: command-dispatch built-in in `lib/coo_commons/command_dispatch.c`, + enabled by `CONFIG_COO_CMD_SERIAL_GUARD`. +- Side effects: updates runtime-only holdoff; serial activity refreshes the + active guard window. No NVS persistence is supported. +- Guard behavior: active guard rejects MQTT effect/action requests. The app + command table marks which MQTT queries may pass through the guard. +- Serial shorthand is implemented in command dispatch: + `serialguard off`, `serialguard 60`, and `serialguard seconds=60`. + +### `memsroute` and `memsroute/route_loss` + +- Owner: `memsroute_get()`, `memsroute_set()` in + `app/src/mems_command.c`. +- Side effects: route changes update router-owned MEMS switch requests applied + by `mems_router_thread()`. +- Route-loss side effects: updates app-owned route-loss records and optional + NVS persistence under `routeloss//`. +- Blocking: can lock router/settings state; no direct publish. ### `mems` and `mems/` -- `mems` GET returns all active profile switches. -- `mems/` GET returns one switch. -- `mems/` SET fields: `state`, optional `duty_cycle`, - `toggle_rate_hz`, `stopafter_s`. -- Validation: state is `A` or `B`; `duty_cycle` only valid with state `A`; - `toggle_rate_hz` must be greater than zero; `stopafter_s` must be in range. -- Response: state, duty cycle, requested and quantized toggle rate, stop-after. -- Side effects: updates router-owned MEMS switch state and schedules toggler. -- Enqueue: can enqueue `mems_rate_quantized` warning. -- Serial shorthand: `mems/ A [duty_cycle] [stopafter_s]`. -- Handler: `mems_get()`, `mems_set()` in `app/src/command.c`. - -### `split/` - -- GET channel can come from key or payload field `channel`. -- SET fields: `ratio1`, `ratio2`, optional `channel`, optional `stopafter_s`. -- Rejected fields: `ratio3`, `toggle_rate_hz`. -- Validation: channel is `yj` or `hk`; ratios are 0.0..1.0 and sum <= 1.0. -- Response: requested ratios, actual quantized ratios, switch tick details, and - `stopsin_s`. -- Side effects: applies three MEMS switches on AS split routes. -- Enqueue: can enqueue `split_ratio_quantized` warning. -- Board restriction: requires routes present in active board profile, normally - the AS profile. -- Handler: `splitting_get()`, `splitting_set()` in `app/src/command.c`. - -### `laserbank/poweron` - -- GET and SET both call the same side-effect handler. -- Payload ignored. -- Board restriction: TIB only. -- Side effects: enables laser-bank power GPIO and sleeps 1000 ms when it - transitions on. -- Response: `status`, `laser_power`, `transitioned`. -- Handler: `laserbank_poweron()` in `app/src/command.c`. +- Owner: `mems_get()`, `mems_set()` in `app/src/mems_command.c`, with switch + timing owned by `app/src/mems_switching.c`. +- Side effects: updates requested switch state applied by `mems_router_thread()`. +- Enqueue: can emit `mems_rate_quantized` warnings. +- Serial shorthand remains implemented in `app/src/command.c`. + +### `split` + +- Owner: `splitting_get()`, `splitting_set()` in `app/src/mems_command.c`. +- Side effects: applies the three MEMS switches that make up an AS splitter + route. +- Enqueue: can emit `split_ratio_quantized` warnings. +- Board restriction: requires routes present in the active board profile, + normally the AS profile. + +### `measure_throughput` + +- Owner: `measure_throughput_set()` in `app/src/throughput_command.c` and + `throughput_monitor_thread()` in `app/src/throughput_monitor.c`. +- Side effects: starts or stops throughput telemetry, can enable photodiode + power, and with autolevel enabled can set attenuation and laser current. +- Enqueue: telemetry is best-effort through `outbound_queue`; command handlers + do not publish directly. -### `laserbank/poweroff` +### `laserbank/power` -- GET and SET both call the same side-effect handler. -- Payload ignored. +- Owner: `laserbank_power()` in `app/src/laser_command.c`; bank power behavior + lives in `app/src/lasers.c`. - Board restriction: TIB only. -- Side effects: disables laser-bank power GPIO. -- Response: `status`, `laser_power`, `was_powered`, `transitioned`. -- Handler: `laserbank_poweroff()` in `app/src/command.c`. +- Side effects: override-on powers the bank and waits for Maiman boot; + override-off best-effort writes currents to 0 before powering the bank off. +- Blocking: Modbus and bank boot/off sleeps can block the command executor. ### `laserbank/clearfaults` -- GET and SET both call the same side-effect handler. -- Payload ignored. +- Owner: `laserbank_clearfaults()` in `app/src/laser_command.c`. - Board restriction: TIB only. -- Side effects: power-cycles the laser bank, sleeps for the fault-clear off - interval, then sleeps 1000 ms if power is re-enabled. -- Response: `status`, `laser_power`, `was_powered`, `off_ms`, - `fault_detection`. -- Handler: `laserbank_clearfaults()` in `app/src/command.c`. - -### `laser/...` - -- Intended handler reads or writes one raw Maiman 16-bit register. -- SET field: `value` as uint16. -- Accepted register names come from `maiman_get_register_address()`, including - `CURRENT`, `FREQUENCY`, `DURATION`, `LOCK_STATUS`, measured current/voltage - and temperature registers, TEC registers, PID coefficients, and aliases in - `maiman.c`. +- Side effects: when the bank is powered and a driver reports overcurrent, the + command power-cycles the bank and waits through the fault-clear and boot + intervals. +- Classification note: the dispatch table points both internal slots at the + action handler; ingress classifies this as a no-payload action. + +### `laserbank/heater` + +- Owner: `laserbank_heater()` in `app/src/laser_command.c`, persisted mode in + `app_settings.c`, policy/cadence in `laserbank_tempcontrol.c`, and relay GPIO + writes through `housekeeping_power_set()`. +- Board restriction: TIB only. +- Side effects: updates persisted heater mode, reschedules heater-policy + delayable work, and can force the auxiliary heater state when override mode + is active. +- Enqueue: heater override mode emits a best-effort warning every 20 minutes. + +### `laser` + +- Owner: request parsing and response shaping in `app/src/laser_command.c`; + hardware sequencing and state live in `app/src/lasers.c`. - Board restriction: TIB only. -- Side effects: powers on the laser bank if needed, sleeps 1000 ms on power - transition, then performs blocking Modbus I/O. -- Handler: `laser_setting_get()`, `laser_setting_set()` in `app/src/command.c`. -- Mismatch: current key parsing likely prevents valid documented laser topics - from resolving to a laser id. - -### `atten//value` and `atten//valuedb` - -- GET returns `voltage` and `db`. -- SET field: `value` float. -- `value` writes raw voltage; `valuedb` writes attenuation dB through - coefficients. -- Board restriction: TIB supports all logical channels below `NUM_ATTENUATORS`; - CAL profiles support only logical channel 4. -- Side effects: blocks on DAC I2C and can clamp DAC range. -- Enqueue: can enqueue `attenuator_clamped` warning. -- Handler: `atten_setting_get()`, `atten_setting_set()` in - `app/src/command.c`. - -### `atten//coeff` - -- GET returns `db2volt` and `volt2db` coefficient arrays. -- SET fields: `db2volt[3]`, `volt2db[3]`, optional `persistent`. -- Validation: both arrays must contain exactly three floats. -- Side effects: updates runtime coefficients, reapplies current attenuation, - and optionally persists coefficients. -- Blocking: DAC I2C and settings writes may block. -- Handler: `atten_setting_get()`, `atten_setting_set()` in - `app/src/command.c`. +- Side effects: effect requests can power the bank, program TEC/current, stop an + active throughput monitor using that laser, and arm or reset firmware + auto-off handled by laser-owned delayable work. +- Blocking: Maiman Modbus and bank boot/off sleeps can block. + +### `laser/tune`, `laser/status`, `laser/engstatus`, `laser/settings` + +- Owner: `laser_command.c` handlers with hardware work in `lasers.c` and + app-owned persisted settings in `app_settings.c`. +- Notes: `laser/status` is the compact `laser` query alias; `laser/engstatus` + reads raw Maiman engineering state; tune/settings can update app-owned values. +- Side effects: driver-backed settings updates temporarily power the bank if + needed unless bank power is `override_off`. + +### `atten//value`, `atten//valuedb`, and `atten//coeff` + +- Owner: `atten_setting_get()`, `atten_setting_set()` in + `app/src/attenuator_command.c`; DAC behavior in `app/src/attenuator.c`. +- Board restriction: TIB supports all configured logical attenuators; CAL + profiles support only their configured logical channel. +- Side effects: value changes block on DAC I2C; coefficient changes update + runtime coefficients, reapply current attenuation, and can persist to NVS. +- Enqueue: value changes can emit `attenuator_clamped`. ### `pd` -- GET field: optional `unit` with `power` or `volts`; for MQTT this requires - `msg_type:"get"` if payload is non-empty. -- GET response includes YJ/HK values, errors, raw counts, mV, noise, and uptime. -- SET fields: `action`, `channel` or key suffix, plus action-specific fields. -- Actions: - - `measure_dark`: optional `duration_ms`, optional `store`. - - `dark_status`: no additional fields. - - `reset_lowest_dark`: optional `persistent`. +- Owner: `pd_get()`, `pd_set()` in `app/src/photodiode_command.c`; sampling, + short averages, and dark persistence in `app/src/photodiode.c`. - Board restriction: TIB only. -- Side effects: starts or reads sampler-owned dark calibration state; optional - persistence is performed by photodiode/settings code. -- Handler: `pd_get()`, `pd_set()` in `app/src/command.c`. +- Side effects: dark measurement starts the sampler-owned short-average + accumulator; persistence is delegated to photodiode/settings code. +- Query note: dark-status action is classified as a pure query and does not + update `lastcommand`. ### `pdsettings/` -- GET returns channel dark settings, lowest dark, dark measurement state, - noise warning threshold, and gain. -- SET fields: optional `persistent` plus at least one of `dark_mv`, - `noise_rms_mV`, `gain_v_p_uw`. -- Validation: dark is -5000..5000 mV; noise is 0..5000 mV; gain is - 0.000001..1000000000. +- Owner: `pd_settings_get()`, `pd_settings_set()` in + `app/src/photodiode_command.c`. - Board restriction: TIB only. -- Side effects: updates runtime photodiode settings and optional persistence. -- Handler: `pd_settings_get()`, `pd_settings_set()` in `app/src/command.c`. +- Side effects: updates runtime photodiode calibration/settings and optional + NVS persistence. ### `temp` -- GET only. -- Response: valid ambient temperature and age, or error with `last_error`. -- Side effects: none; reads cached state from temperature thread. -- Handler: `temp_get()` in `app/src/command.c`. -- Mismatch: documented alarm set behavior is not implemented. +- Owner: `temp_get()` in `app/src/command.c`, cached ambient state in + `housekeeping.c`, and laser-bank temperature reads through `lasers.c`. +- Side effects: reads cached ambient state and can perform Modbus reads for + laser TEC temperatures on TIB. ### `status` -- GET only. -- Response: firmware version, boot count, uptime, board type/validity, MEMS - switch count, network ready/IP, and laser power. -- Side effects: none. -- Handler: `status_get()` in `app/src/command.c`. -- Mismatch: `commands.md` documents a much larger payload. +- Owner: `status_get()` in `app/src/command.c`. +- Notes: base status is cache-oriented; optional sections embed current IP, + laser, and attenuator data. +- Side effects: optional laser and attenuator sections can perform Modbus and + DAC reads; large optional responses can exceed the fixed payload buffer. diff --git a/doc/index.rst b/doc/index.rst index 8f89db9..b44d11f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -35,19 +35,8 @@ Source-of-truth rules: commands.md implemented_commands.md command_implementation_audit.md - implementation_gaps.md human_review_required.md -.. toctree:: - :maxdepth: 2 - :caption: Developer Maintained Docs - - status.md - libraries.md - networking_plan.md - nuisances.md - todo.md - .. toctree:: :maxdepth: 2 :caption: API Reference diff --git a/doc/libraries.md b/doc/libraries.md deleted file mode 100644 index be8a08f..0000000 --- a/doc/libraries.md +++ /dev/null @@ -1,40 +0,0 @@ -# Libraries and Local Wrappers - -## Zephyr Subsystems - -- GPIO, I2C, ADC, DAC, UART, Modbus, settings/NVS, watchdog, console, networking, - MQTT, SNTP, sensor, and 1-Wire APIs are used directly. -- Command parsing uses Zephyr JSON descriptors plus small app/library helpers. -- The DS18B20 ambient sensor uses the Zephyr sensor API. - -## App-Local Domain Modules - -- `devices.c`: board strap detection and board-profile setup. -- `mems_switching.c`: MEMS switch state, route lookup, and duty-cycle tick work. -- `attenuator.c`: DAC-backed logical attenuator channels. -- `maiman.c`: raw/scaled Modbus register wrapper for Maiman drivers. -- `lasers.c`: laser-bank GPIO, Maiman profile, status, and tuning helpers. -- `photodiode.c`: ADS1115 sampling, dark calibration, and telemetry. -- `app_settings.c`: Zephyr settings ownership for app-level persistent state. -- `app_warning.c`: best-effort warning publication. - -## `lib/coo_commons` - -- `network.c`: IPv4 Ethernet bootstrap with DHCP/static/fallback profile logic. -- `mqtt_client.c`: MQTT 5 connect/process/subscription wrapper over Zephyr MQTT. -- `json_utils.c`: keyed primitive extraction helpers for constrained JSON - command payloads. -- `pid.c`: generic PID helper, currently not central to the app runtime. - -## Driver Stubs - -- `drivers/gpio/ds2408`: project-local Zephyr GPIO driver for the DS2408 - 1-Wire open-drain GPIO expander. - -## Open items - -- Decide whether `coo_commons` remains in this repository or is split into a - reusable shared module. -- Add test coverage around `json_utils.c`, command normalization, and network - profile selection. -- Review the DAC7578 integration against the two-DAC hardware description. \ No newline at end of file diff --git a/doc/networking_plan.md b/doc/networking_plan.md deleted file mode 100644 index 5e7cf56..0000000 --- a/doc/networking_plan.md +++ /dev/null @@ -1,52 +0,0 @@ -# Networking Configuration Plan - -## Scope -Simple Zephyr-first networking helper for IPv4 Ethernet that: -- comes up without blocking app startup, -- supports field deployment variance (DHCP or static), -- preserves a known fallback service IP for direct laptop crossover recovery, -- stays separate from command parsing/dispatch logic. -- Docs: https://docs.zephyrproject.org/latest/connectivity/networking/api/net_config.html - -## Capability Model (Kconfig) -Use Zephyr Kconfig as the source of truth for capability presence: -- DHCP capability: `CONFIG_NET_DHCPV4` -- DNS capability: `CONFIG_DNS_RESOLVER` -- NTP capability: `CONFIG_SNTP` - -No duplicate helper Kconfig flags should mirror those capabilities. - -## Configuration Layers and Precedence -1. Runtime persisted profile (settings): primary operator-configured behavior. -2. Compile-time static IPv4 defaults (`CONFIG_NET_CONFIG_MY_IPV4_*`): baseline defaults used when no persisted override exists. -3. Fallback service profile (`192.168.88.2/24`, gw `0.0.0.0` by default): recovery path for direct laptop connection. - -IP selection order at apply-time: -1. DHCP (if enabled and `try_dhcp_first` is true) with bounded timeout. -2. Runtime static profile. -3. Fallback static profile. -4. DHCP retry last (if configured static-first mode is requested). - -## DNS / NTP Handling -- Keep DNS and NTP addresses as configuration data in the profile. -- If DNS/NTP capability is not compiled in, treat incoming DNS/NTP settings as unsupported (not fatal to overall IP apply). -- Command layer reports unsupported fields clearly; network helper remains transport/bootstrap focused. - -## Boot and Reconfigure Behavior -- Boot should not block waiting for network readiness, CONFIG_NET_CONFIG_AUTO_INIT can thus not be used. -- Serial and local command pathways start immediately. -- Network helper brings interface up, applies config, and monitors link events. -- MQTT loop tolerates disconnect/reconnect and resumes when network is available. -- Runtime network reconfigure is helper API-level only; command-trigger wiring remains in app command/control flow. - -## Library Boundary -Network helper responsibilities: -- interface bring-up/reconnect monitoring via Zephyer API -- IPv4 profile apply logic (DHCP/static/fallback), -- feature introspection (`dhcp/dns/ntp` compiled-in status), -- active IPv4 status reporting. - -Out of scope for helper: -- command parsing, -- settings storage policy, -- MQTT command semantics. diff --git a/doc/nuisances.md b/doc/nuisances.md index f5ba3ae..ba62ee2 100644 --- a/doc/nuisances.md +++ b/doc/nuisances.md @@ -2,14 +2,12 @@ This page is informal development context, not firmware source of truth. -## Open items +## Local Setup Notes - CLion users should enable RTOS integration and point GDB at the Zephyr SDK toolchain. - STLink firmware and runner selection still depend on local workstation setup. -## Local Setup Notes - Use the workspace virtual environment: ```bash diff --git a/doc/photodiode_notes.md b/doc/photodiode_notes.md new file mode 100644 index 0000000..95c3bbb --- /dev/null +++ b/doc/photodiode_notes.md @@ -0,0 +1,157 @@ +```python +import astropy.units as u +from astropy import constants as const +# Photodiode responsivity tables (A/W). We'll smooth with PCHIP to continuous curves. +FEMTO_QE_TC = {900.: 0.2*u.A/u.W, 1000.: 0.6*u.A/u.W, 1040.: 0.68*u.A/u.W, 1200.: 0.8*u.A/u.W, + 1270.: 0.85*u.A/u.W, 1430.: 0.93*u.A/u.W, 1500.: 0.95*u.A/u.W, 1600.: 0.93*u.A/u.W, + 1700.: 0.2*u.A/u.W} +FEMTO_QE_TC = tuple(map(u.Quantity, zip(*list(FEMTO_QE_TC.items())))) +THOR_QE_TC = { + 1400.: 0.50199*u.A/u.W, 1410.: 0.51144*u.A/u.W, 1420.: 0.51867*u.A/u.W, 1430.: 0.5264*u.A/u.W, + 1440.: 0.5337*u.A/u.W, 1450.: 0.54358*u.A/u.W, 1460.: 0.55372*u.A/u.W, 1470.: 0.5643*u.A/u.W, + 1480.: 0.5746*u.A/u.W, 1490.: 0.58604*u.A/u.W, 1500.: 0.59885*u.A/u.W, 1510.: 0.60971*u.A/u.W, + 1520.: 0.62102*u.A/u.W, 1530.: 0.63428*u.A/u.W, 1540.: 0.64785*u.A/u.W, 1550.: 0.66118*u.A/u.W, + 1560.: 0.67499*u.A/u.W, 1570.: 0.68843*u.A/u.W, 1580.: 0.70238*u.A/u.W, 1590.: 0.71497*u.A/u.W, + 1600.: 0.7285*u.A/u.W, 1610.: 0.74146*u.A/u.W, 1620.: 0.75481*u.A/u.W, 1630.: 0.76951*u.A/u.W, + 1640.: 0.78517*u.A/u.W, 1650.: 0.79927*u.A/u.W, 1660.: 0.81352*u.A/u.W, 1670.: 0.82736*u.A/u.W, + 1680.: 0.84172*u.A/u.W, 1690.: 0.85701*u.A/u.W, 1700.: 0.87061*u.A/u.W, 1710.: 0.88342*u.A/u.W, + 1720.: 0.89808*u.A/u.W, 1730.: 0.91253*u.A/u.W, 1740.: 0.92943*u.A/u.W, 1750.: 0.94613*u.A/u.W, + 1760.: 0.96487*u.A/u.W, 1770.: 0.98363*u.A/u.W, 1780.: 1.00287*u.A/u.W, 1790.: 1.02496*u.A/u.W, + 1800.: 1.04875*u.A/u.W, 1810.: 1.06712*u.A/u.W, 1820.: 1.08436*u.A/u.W, 1830.: 1.10028*u.A/u.W, + 1840.: 1.11462*u.A/u.W, 1850.: 1.12724*u.A/u.W, 1860.: 1.13955*u.A/u.W, 1870.: 1.14782*u.A/u.W, + 1880.: 1.15491*u.A/u.W, 1890.: 1.16097*u.A/u.W, 1900.: 1.16611*u.A/u.W, 1910.: 1.17542*u.A/u.W, + 1920.: 1.18509*u.A/u.W, 1930.: 1.18763*u.A/u.W, 1940.: 1.19081*u.A/u.W, 1950.: 1.19343*u.A/u.W, + 1960.: 1.19673*u.A/u.W, 1970.: 1.20183*u.A/u.W, 1980.: 1.20729*u.A/u.W, 1990.: 1.21037*u.A/u.W, + 2000.: 1.21345*u.A/u.W, 2010.: 1.21642*u.A/u.W, 2020.: 1.21931*u.A/u.W, 2030.: 1.22209*u.A/u.W, + 2040.: 1.22468*u.A/u.W, 2050.: 1.22843*u.A/u.W, 2060.: 1.23213*u.A/u.W, 2070.: 1.23456*u.A/u.W, + 2080.: 1.23699*u.A/u.W, 2090.: 1.23941*u.A/u.W, 2100.: 1.24186*u.A/u.W, 2110.: 1.24433*u.A/u.W, + 2120.: 1.24566*u.A/u.W, 2130.: 1.24771*u.A/u.W, 2140.: 1.24979*u.A/u.W, 2150.: 1.25045*u.A/u.W, + 2160.: 1.25089*u.A/u.W, 2170.: 1.25113*u.A/u.W, 2180.: 1.25137*u.A/u.W, 2190.: 1.25029*u.A/u.W, + 2200.: 1.24917*u.A/u.W, 2210.: 1.24931*u.A/u.W, 2220.: 1.24955*u.A/u.W, 2230.: 1.24985*u.A/u.W, + 2240.: 1.25024*u.A/u.W, 2250.: 1.25124*u.A/u.W, 2260.: 1.25234*u.A/u.W, 2270.: 1.25041*u.A/u.W, + 2280.: 1.2472*u.A/u.W, 2290.: 1.24611*u.A/u.W, 2300.: 1.24395*u.A/u.W, 2310.: 1.24038*u.A/u.W, + 2320.: 1.23692*u.A/u.W, 2330.: 1.23378*u.A/u.W, 2340.: 1.22827*u.A/u.W, 2350.: 1.22241*u.A/u.W, +} +THOR_QE_TC = tuple(map(u.Quantity, zip(*list(THOR_QE_TC.items())))) + + +class Detection: + def __init__(self, levels, signal, noise, saturation, total_noise=False): + self.levels = levels + self.signal = signal + self.noise = noise + self.saturation = saturation + self.snr = self.signal/(self.noise if total_noise else np.sqrt(self.signal+self.noise**2)) + self.saturation_mask = self.signal >= self.saturation + + def sn(self, saturated=np.nan, collapse=np.max, axis=0): + if isinstance(self.snr, float): + return self.snr if self.signal < self.saturation else np.nan + + snr = self.snr.copy() + snr[self.saturation_mask] = saturated + if snr.ndim ==1: + return snr + if collapse ==np.sum: + return np.sqrt(collapse(snr**2, axis=axis)) + else: + return collapse(snr, axis=axis) + + @property + def has_saturation(self): + return self.saturation_mask if isinstance(self.saturation_mask, bool) else self.saturation_mask.any() + +class Detector(Component): + def __repr__(self): + return f"<{self.__class__.__name__} {self.name}>" + +class Photodiode(Detector): + + def __init__(self, name: str, + noise = 7.5 * u.femtowatt / u.Hz ** 0.5, + gain = 1e11 * u.V/u.A, + saturation = 110 * u.picowatt, + adc_noise=0.187 * u.uV, + saturation_wavelength = 1550 * u.nm, + resp_wavelength_nm: "np.ndarray | None" = None, + noise_bandwidth:float=20*u.Hz, + sample_rate:float = 50 * u.Hz, + adc_gain:float = (2**16-1)/(2*6.144)/u.V, + resp_values: "np.ndarray | None" = None) -> None: + super().__init__(name) + self.in_p = self.add_port("in", PortDirection.IN) + + # Detector noise model (simple, scalar) + self.noise = noise + self.saturation = saturation + self.adc_noise = adc_noise + self.gain = gain + self.resp_wavelength_nm = resp_wavelength_nm + self.resp_values = resp_values.to(u.A/u.W) if resp_values is not None else None + self.saturation_wavelength = saturation_wavelength.value + self.noise_bandwidth = noise_bandwidth + self.sample_rate = sample_rate + self.adc_gain = adc_gain + + # responsivity in A/W + self._resp_a_per_w = lambda grid_nm : np.interp(grid_nm, resp_wavelength_nm, self.resp_values.value).clip(0, np.inf)*u.A/u.W + + def observe(self, fluence: Spectrum, *, grid_nm: np.ndarray, texp_s: float = 1.0) -> "Detection": + """ + Integrate electrons on a caller-supplied wavelength grid. + + Parameters + ---------- + fluence : Spectrum + Source spectrum. Its evaluate_on(grid) should yield photons/s/nm by default. + grid_nm : array-like + Wavelength grid in nm on which to evaluate. + texp_s : float + Exposure time in seconds. + + Returns + ------- + Detection + (levels, photons, noise, saturation_mask) — same structure you use today. + """ + grid_nm = np.asarray(grid_nm, dtype=float) + photons = fluence(grid_nm, photons=True) + photon_energy = photons * (const.h * const.c / (grid_nm*u.nm).to(u.m))/u.s # watts + photon_noise_energy = np.sqrt(photons) * (const.h * const.c / (grid_nm * u.nm).to(u.m)) / u.s + + volts = ((photon_energy * self._resp_a_per_w(grid_nm)).sum() * self.gain).to('V') + shot_noise_volts = ((photon_noise_energy * self._resp_a_per_w(grid_nm)).sum() * self.gain).to('V') + + device_noise_volts = self.noise*np.sqrt(self.noise_bandwidth) * self._resp_a_per_w(self.saturation_wavelength) * self.gain + + # ((7.5e-15 * np.sqrt(20) * .95e11 * 1e3 / 2)) + # (2 * 6.144 / (2 ** 16 - 1) * 1e3) + # adc_noise = ((7.5e-15*sqrt(20)*.95e11*1e3/2))/(2*6.144/(2**16-1)*1e3) + + total_noise = np.sqrt(device_noise_volts**2 + shot_noise_volts**2 + self.adc_noise**2).to(u.V) + + signal = self.adc_gain*volts.to(u.V) + noise = self.adc_gain*total_noise.to(u.V) + + saturation_v = (self.saturation*self.gain*self._resp_a_per_w(self.saturation_wavelength)).to('V') + saturation = np.floor(self.adc_gain*saturation_v) + + return Detection(levels=fluence, signal=signal.value, noise=noise.value, saturation=saturation.value, total_noise=True) + +pd_yj = Photodiode("yj", resp_wavelength_nm=FEMTO_QE_TC[0], resp_values=FEMTO_QE_TC[1], + noise=7.5 * u.femtowatt / u.Hz ** 0.5, # high-impedance termination + gain= 1e11 * u.V/u.A/2, #/2 because 50ohm termination + noise_bandwidth=20 * u.Hz, + saturation=110 * u.picowatt + ) +pd_hk = Photodiode("hk", resp_wavelength_nm=THOR_QE_TC[0], resp_values=THOR_QE_TC[1], + noise=2.11 * u.picowatt / u.Hz ** 0.5 * 3.5, # 50ohm termination, 3.5 is fudge based on plot in datasheet + gain=4750*u.kV/u.A/2, #/2 because 50ohm termination and what we are using + saturation=1.706 * u.microwatt, + noise_bandwidth=500*u.Hz, + saturation_wavelength=2330 * u.nm + # technically saturation will happen about 20 mV sooner because of the bias offset + ) + + +``` \ No newline at end of file diff --git a/doc/queues_and_work.md b/doc/queues_and_work.md index c6d1e31..79d6808 100644 --- a/doc/queues_and_work.md +++ b/doc/queues_and_work.md @@ -1,4 +1,4 @@ -# Queues and Work Items +# Queues, Timers, and Work Items ## `inbound_queue` @@ -14,58 +14,69 @@ command responses, warnings, and telemetry to the main loop. The main loop is the only path that calls `mqtt_publish()`. Non-best-effort MQTT messages are requeued when MQTT is unavailable or publish -fails. Best-effort messages (i.e. warnings and telemetry) are dropped when unavailable or -failed. +fails. Best-effort messages (i.e. warnings and periodic telemetry) are dropped +when unavailable or failed. Boot watchdog telemetry uses the non-best-effort +target so it is retried until MQTT is available. If the main loop observes +`outbound_queue` at capacity while draining, it emits an `outbound_queue_full` +warning directly to serial and to MQTT when connected. -## `photodiode_queue` +Throughput monitoring enqueues photodiode stream telemetry +to `outbound_queue` with `K_NO_WAIT`; if the queue is full, the current sample +is dropped. -Defined in `photodiode.c` as a `k_msgq` of `struct OutMsg` with depth 4. -Photodiode sampling enqueues best-effort telemetry here with `K_NO_WAIT`. If -full, the queue is purged and the current telemetry sample is retried. +Photodiode sampling is released by a `k_timer`; ADC I/O runs in the photodiode +thread, not in the timer ISR. -`main.c` owns `photodiode_publish_work`, which periodically drains this queue -into `outbound_queue`. That keeps ADC sampling decoupled from MQTT availability. -Samples may be dropped under MQTT or queue backpressure; stale ADC telemetry is -preferable to delaying the sampler. +## Command Dispatcher Delayable Work -## Named Scheduled Actions +When `CONFIG_COO_CMD_SERIAL_GUARD` is enabled, `command_dispatch.c` owns one +`k_work_delayable` item for serial guard expiration. It clears the runtime-only +guard flag after the configured holdoff; no serial guard state is persisted. -`app_scheduled_actions.c` wraps a small fixed table of `k_work_delayable` -objects: +## Reboot Delayable Work -- `serial_guard_expire`: clears serial override and re-enables MQTT command - execution. -- `reboot`: calls `sys_reboot(SYS_REBOOT_COLD)` after a short delay so the - response can be queued first. +`command.c` owns the non-cancelable reboot work item used by the `reboot` +command. The command schedules `sys_reboot(SYS_REBOOT_COLD)` after a short +response window and rejects later app commands while reboot is pending. -This is not a general scheduler. New delayed actions should be named firmware -behaviors with fixed enum entries. +## Generic Scheduled Action Helper + +`lib/coo_commons/scheduled_action.c` provides an optional fixed-table wrapper +around Zephyr `k_work_delayable` for future shared firmware actions. It does not +allocate, create threads, or implement user-programmable scheduling. Callbacks +run in Zephyr's system workqueue and must stay short. ## MEMS Router Work -Each initialized `mems_router` owns one `k_work_delayable` tick. Every tick: +The active `mems_router` is driven by a periodic `k_timer` and a dedicated MEMS +thread. The timer callback only gives a semaphore; GPIO-expander writes happen +in the MEMS thread. Every MEMS thread tick: 1. Locks the router. -2. Clears all MEMS pulse pins. -3. Applies any target-state pulses. -4. Advances duty-cycle counters and stop-after counters. -5. Reschedules itself for `MEMS_SWITCH_ELECTRICAL_PULSE_MS`. +2. Clears pulse pins whose per-switch electrical pulse window has expired. +3. Applies any target-state pulses for switches whose service interval is due. +4. Advances duty-cycle counters and stop-after counters on each switch's + FFSW/FFLS pulse-width cadence. The tick uses raw GPIO pin APIs because board profiles store expander pin numbers rather than `gpio_dt_spec` objects. +Missed MEMS ticks are logged. High-to-low pulse cleanup is still applied, late +low-to-high pulses are applied only when their requested high window has not +fully elapsed, and fully stale high pulses are skipped. + ## Network Reconnect Work `lib/coo_commons/network.c` schedules reconnect work when Zephyr reports L4 -disconnect. The handler calls `conn_mgr_all_if_connect(true)`. +disconnect. The handler calls `conn_mgr_all_if_connect(true)`. Runtime IP +changes from the `ip` command call `network_reconfigure()` directly from the +command executor; that command may block while DHCP is tried, but it does not +require reboot. ## SNTP Work -`sntp_sync.c` uses one delayable work item for initial sync, manual reschedule -on network connect, retry after failure, and hourly resync after success. - -## Work/Queue Human Review - -- MEMS toggler work currently performs repeated GPIO bus activity at the tick - rate. Source TODO notes this may be more I/O than necessary. -- SNTP work can block in the system workqueue while waiting for an SNTP reply. +`sntp_sync.c` uses one low-priority thread for initial sync, network-connect +wakeups, retry after failure, and hourly resync after success. Manual `time` +commands do not alter SNTP status; SNTP will update the clock on the next +successful sync. The blocking `sntp_simple()` wait does not run on the system +workqueue. diff --git a/doc/settings.md b/doc/settings.md index 70d5f13..20ef343 100644 --- a/doc/settings.md +++ b/doc/settings.md @@ -2,41 +2,32 @@ ## Ownership -`app_settings.c` owns app-level persistent settings under named top-level -Zephyr settings subtrees. It initializes defaults, loads stored values, and -protects the runtime snapshot with a mutex. +`app_settings.c` owns app-level persistent settings under app-assigned Zephyr +NVS numeric IDs. It initializes defaults, loads stored values, and protects the +runtime snapshot with a mutex. The app does not use the string-keyed Zephyr +settings layer for its own persistence. Maiman modules own their EEPROM-backed driver parameters. Laser diode property tables in `laser_properties.h` are compile-time defaults and estimates. -## Stored Keys - -Current app settings include: - -- `board/type` -- `serial/holdoff_s` -- `boot/count` -- `ip/trydhcpfirst` -- `ip/preferdhcpdns` -- `ip/preferdhcpntp` -- `ip/ip` -- `ip/subnet` -- `ip/gateway` -- `ip/dns` -- `ip/ntp` -- `mqtt/broker` -- `atten//db2volt/` -- `atten//volt2db/` -- `pd/yj/dark_mv` -- `pd/yj/lowest_dark_mv` -- `pd/yj/lowest_dark_valid` -- `pd/yj/noise_warn_rms_mv` -- `pd/yj/gain_v_per_uw` -- `pd/hk/dark_mv` -- `pd/hk/lowest_dark_mv` -- `pd/hk/lowest_dark_valid` -- `pd/hk/noise_warn_rms_mv` -- `pd/hk/gain_v_per_uw` +## Stored NVS Records + +Current app NVS records include: + +- Schema marker. +- Board type. +- Serial guard holdoff. +- Boot count. +- Last known UTC time in milliseconds. +- IP settings as one record. +- MQTT broker host/port as one record. +- One attenuator coefficient record per logical channel. +- One photodiode settings record per photodiode channel. +- Laser-bank heater policy. +- One laser policy record per laser channel. +- One laser total-emitting counter record per laser channel. +- One route-loss table-entry record per configured route/laser output, up to + the fixed route-loss table limit. ## Board-Type Reset Policy @@ -50,34 +41,43 @@ silently reused on another. - IP defaults come from Zephyr network config symbols. - MQTT defaults come from `CONFIG_COO_MQTT_BROKER_HOSTNAME` and - `CONFIG_COO_MQTT_BROKER_PORT`; persistence uses one `mqtt/broker` - `:` value. + `CONFIG_COO_MQTT_BROKER_PORT`; persistence stores host and port directly. - Serial guard defaults to 30 s. -- Attenuator coefficients default to all zeros until calibrated/stored. +- Last known UTC defaults to unset. Once SNTP or a `time` command sets the + realtime clock, the value is persisted and restored on later boots until a + fresher time source updates it. +- Attenuator coefficients default to a linear `b = slope * voltage + offset` + model that maps the 0-5000 mV attenuator drive span onto `b = 0..8` until + calibrated/stored. - Photodiode dark defaults to 0 mV. YJ and HK have different default gain/noise warning values. +- Route-loss records default to absent. Missing route-loss settings are treated + as loss-free transmission, `1.0`. ## Persistence Side Effects -Settings writes happen synchronously through Zephyr settings APIs and may block -the caller. Command handlers that set `persistent:true` can therefore block in -the executor thread. +NVS writes happen synchronously through Zephyr NVS and may block the caller. +Command handlers that set `persistent:true` can therefore block in the executor +thread. Photodiode stored dark updates happen in the sampler thread when a user-started dark measurement completes with `store:true`. -If settings loading fails after a board has already been initialized in the -field, treat it as a human-intervention fault. At minimum, inspect logs and -reinitialize settings before trusting persisted calibration or network intent. -A first boot with no stored settings is normal and should use defaults. +If NVS loading fails after a board has already been initialized in the field, +treat it as a human-intervention fault. At minimum, inspect logs and +reinitialize storage before trusting persisted calibration or network intent. A +first boot with no app schema marker clears the old storage layout, writes the +current schema marker, and uses defaults. ## Intentionally Not Persisted - Active routes are derived from current MEMS switch state and route tables. -- Laser-bank power state is not restored after reboot. Reboot must leave lasers - off, laser-bank power down, the laser-bank heater off, and photodiode power - off until an explicit command or future owner-specified policy changes that. -- DS2408 relay output state is not restored after reboot. +- Laser output state is not restored after reboot. Laser-bank heater mode is a + persisted policy and defaults to `auto`; in auto mode it may power the bank + after boot for temperature monitoring. The heater starts off unless auto mode + or an override turns it on. +- DS2408 relay output state is not restored after reboot; relay outputs default + inactive/off when the off-board expander is online during app setup. ## Not Currently Persisted diff --git a/doc/status.md b/doc/status.md deleted file mode 100644 index 88cbee0..0000000 --- a/doc/status.md +++ /dev/null @@ -1,90 +0,0 @@ -# Development Status - -This page is the maintained current-status note for the Zephyr firmware. It -summarizes current implementation state; `hardware.md` remains the hardware -source of truth, `commands.md` remains the intended command/API source of truth, -and current C source remains the source of truth for what is implemented today. - -## Current Shape - -- Target application: `hispec-tib/app`, built as a Zephyr C application. -- Primary board currently described by overlay: `nucleo_h563zi/stm32h563xx`. -- Boot path: `main.c` initializes watchdog, settings, board strap detection, - profile-specific devices, command runtime, serial/MQTT command paths, - photodiode telemetry bridge, SNTP, network, and MQTT. Watchdog or settings - initialization failure stops boot. -- Command ingress: MQTT and serial both produce `struct Command` records and - enqueue them to `inbound_queue`. -- Command execution: `command_executor_thread()` dispatches commands through the - static table in `command.c`. -- Outbound flow: command responses, warnings, and telemetry are placed on - `outbound_queue`; only the main loop publishes MQTT. -- Hardware profiles: TIB, CAL YJ, CAL HK, AS, and unknown are selected from - active-low board strap GPIOs. -- Persistence: Zephyr settings under the `tib` subtree store board type, boot - count, serial guard, IP, MQTT, attenuator calibration coefficients, and - photodiode calibration/noise/gain settings. - -## Implemented Areas - -- Board strap detection with exactly-one-active validation. -- TIB/CAL/AS MEMS switch profile selection and route tables. -- MEMS static switching, duty-cycle toggling, exact tick duty control, active - route readback, and quantization warnings. -- AS `split` command using fixed YJ/HK AS routes and exact MEMS tick ratios. -- TIB laser-bank GPIO power on/off and clear-faults-by-power-cycle commands. -- Raw Maiman Modbus register get/set helpers and higher-level laser helper APIs. -- Logical attenuator value, dB value, and coefficient command support. -- Photodiode sampling, best-effort telemetry, explicit dark measurement, stored - dark update, lowest-dark tracking, and noise warnings. -- DS18B20 ambient temperature sampling and `temp` query. -- IPv4 network helper with DHCP/static/fallback behavior and link monitoring. -- MQTT broker settings with runtime reconnect trigger. -- Serial command guard with scheduled expiration, MQTT SET/action rejection, - and safe MQTT GET passthrough. -- SNTP sync from manual or DHCP NTP server. -- Watchdog setup/feed in the main loop; watchdog setup failure stops boot. - -## Partially Implemented - -- Laser command coverage exists only as raw register get/set plumbing, and the - current command key parsing appears inconsistent with the dispatch prefix. -- Higher-level laser tuning/status helper APIs exist in `lasers.c`, but the - public command table does not expose most of them. -- Attenuator support covers six logical channels and one DAC device label; the - hardware document describes two DAC7578 devices and twelve physical FVOAs. -- Runtime network reconfiguration is available in the library helper, but the - `ip` command currently reports reboot-required for network-affecting changes. -- SNTP status is exposed through `time` and `ip`; manual time set does not mark - SNTP status as manual. - -## Open items - -- Reconcile implemented command behavior against `commands.md`; see - `command_implementation_audit.md` and `implemented_commands.md`. -- Reconcile hardware/code mismatches for attenuator DAC coverage and MEMS GPIO - electrical mode; see `human_review_required.md`. -- Decide intended persistence for MEMS switch state, AS split requested/actual - state, laser output/tuning state, and last-command metadata. -- Remove stale command help/docs references to unsupported `power` and `sleep` - command names, or reintroduce those commands with an owner-approved spec. -- Add automated tests for command parsing and non-hardware domain logic. -- Decide whether the COO commons network/MQTT helpers should remain app-local - wrappers or become shared library APIs with stricter contracts. - -## LLM-resolved items requiring human review - -- Old status notes said settings persistence was unimplemented. Current code now - persists IP, MQTT, serial guard, boot count, attenuator coefficients, and - photodiode calibration settings. Human review is still needed for missing - operating-state persistence. -- Old status notes said SNTP was unimplemented. Current code has `sntp_sync.c` - and exposes status through `time`; review is still needed for manual/DHCP - policy and failure reporting. -- Old status notes described `main.cpp`, `executor_task.cpp`, and Zyre/Pico - modules from a previous architecture. Those notes are stale for this Zephyr C - app and are replaced by `architecture.md`, `threads.md`, and - `queues_and_work.md`. -- Old notes said MQTT handling lived in `main.c`; current MQTT ingress is in - `command_handle_mqtt_publish()` and MQTT connection/publish pumping remains - in `main.c`. diff --git a/doc/threads.md b/doc/threads.md index 9c0fc30..57d633b 100644 --- a/doc/threads.md +++ b/doc/threads.md @@ -5,61 +5,106 @@ `main()` is the network and MQTT pump. It feeds the watchdog, reconnects MQTT when the network is ready, resubscribes after reconnect, drains `outbound_queue`, and calls `coo_mqtt_process()`. It can block in MQTT connect/process and sleeps -20 ms when MQTT is disconnected. +20 ms when MQTT is disconnected. MQTT connect waits are bounded below the +watchdog interval so a dead broker does not starve the main loop long enough to +reset the device. + +`CONFIG_MAIN_THREAD_PRIORITY` is set below MEMS and photodiode timing work but +above command execution so outbound command responses and watchdog feeding are +not delayed by command handlers. ## Command Executor Thread -`command_executor_thread()` blocks on `k_msgq_get(&inbound_queue, K_FOREVER)`. +`coo_cmd_runtime_executor_thread()` blocks on `k_msgq_get(&inbound_queue, K_FOREVER)`. It dispatches one command and tries one non-blocking enqueue to `outbound_queue`. Handler blocking varies by command: - MEMS commands can sleep on router mutexes but do not perform bus I/O directly. - Attenuator commands can block on DAC I2C. - Laser/Maiman commands can block on Modbus and laser-bank boot/off sleeps. -- Settings commands can block on Zephyr settings backend writes. -- Reboot and serial guard commands schedule delayable work. +- TIB laser-bank heater auto mode runs from laser-bank temperature-control + delayable work and can block on Modbus temperature polling and heater GPIO + writes. +- Persistent settings commands can block on Zephyr NVS writes. +- `reboot` and `serialguard` use command-dispatch-owned delayable work. +- Command-dispatch lastcommand persistence can block on Zephyr NVS writes before + an effect handler runs. ## Serial Thread -`command_serial_thread()` calls `console_getline()` and blocks until a complete +`coo_cmd_runtime_serial_thread()` calls `console_getline()` and blocks until a complete line is available. Non-empty lines refresh serial guard and enqueue a normalized command. Serial output is not printed from this thread; it is printed when the main loop drains `outbound_queue`. ## Photodiode Thread -`photodiode_thread()` is active only for the TIB profile. It waits for board -strap detection and ADS1115 readiness, samples YJ and HK channels, updates dark -and noise state, enqueues best-effort telemetry to `photodiode_queue`, and -sleeps to target the 20 ms sampling period. +`main()` starts `photodiode_thread()` only for the TIB profile after board +detection and device setup. The thread waits for ADS1115 readiness. A `k_timer` +provides the 20 ms sampling cadence and the timer callback only wakes the +photodiode thread; ADS1115 bus I/O remains in thread context. The thread samples +YJ and HK channels and updates dark/noise/window state. + +If the timer reports missed periods, the thread logs the missed count and takes +one current sample. Missed ADC sampling periods are not replayed. + +## Throughput Monitor Thread + +`main()` starts `throughput_monitor_thread()` only for the TIB profile. It owns +`measure_throughput` stream publication and optional autolevel control. It reads +photodiode snapshots, route-loss settings, attenuator state, and laser +estimates, then enqueues best-effort telemetry to `outbound_queue`. + +## MEMS Router Thread + +`mems_router_thread()` is released by a periodic `k_timer` at +`MEMS_SWITCH_ROUTER_TICK_MS`. The timer expiry runs in interrupt context and +only gives a semaphore. The MEMS thread owns GPIO-expander writes, pulse cleanup, +duty-cycle counter advancement, and stop-after countdowns. + +MEMS toggling is not catch-up work. A missed high-to-low cleanup is still +performed because leaving a pulse pin asserted is the worse behavior. A missed +low-to-high pulse is emitted late only when the requested high window still has +enough remaining service time; a fully stale high pulse is skipped and logged. -## Temperature Thread +## SNTP Thread -`tempsensor_thread()` finds the DS18B20 sensor, then once per second calls -`sensor_sample_fetch()` and `sensor_channel_get()` for ambient temperature. It -updates a mutex-protected cache used by the `temp` command. +`sntp_sync_thread()` runs only when `CONFIG_SNTP` is enabled. It handles initial +sync, network-connect wakeups, NTP setting changes, failure retry, and hourly +resync. `sntp_simple()` can block up to the SNTP timeout, but that wait happens +only in the low-priority SNTP thread. ## Zephyr System Workqueue Users The following delayable work items run in Zephyr system workqueue context: -- MEMS router toggler work. -- SNTP sync/retry/resync work. - Network reconnect work. - Serial guard expiration. - Delayed reboot. -- Photodiode telemetry transfer from `photodiode_queue` to `outbound_queue`. +- Laser auto-off expiration. This work is owned by `lasers.c`; it is scheduled + only when a laser command arms a timeout and may block briefly on Modbus while + stopping an expired output. +- Ambient temperature sampling. This work is owned by `housekeeping.c` and may + block briefly on DS18B20 sensor I/O while refreshing the `temp` cache. +- Laser-bank heater policy. This work is owned by `laserbank_tempcontrol.c` and + may block on Maiman Modbus polling and slow relay GPIO I/O. -Work handlers should remain short. The current SNTP handler may block up to the -SNTP timeout, and the MEMS toggler performs GPIO expander writes on each tick. +Physical MEMS and photodiode timing loops and SNTP do not run on the system +workqueue. ## Thread Priorities Current configured priorities: -- Command executor: 5. -- Photodiode thread: 5. -- Temperature thread: 5, with a source TODO that it should be lowest. +- MEMS router thread: 2. +- Photodiode thread: 3. +- Main MQTT/outbound/watchdog thread: 4. +- Command executor: 6. - Serial thread: 6. +- Throughput monitor thread: 7. +- SNTP thread: 10. -These priorities need human review after hardware timing tests. +Lower numeric values are higher priority in Zephyr preemptive priorities. The +priority order keeps physical MEMS and ADC cadence ahead of network, command, +and telemetry work; command ingress over serial and MQTT is treated as +equivalent at the command-executor layer. diff --git a/doc/todo.md b/doc/todo.md deleted file mode 100644 index 5452ad7..0000000 --- a/doc/todo.md +++ /dev/null @@ -1,51 +0,0 @@ -# TODO Review Follow-Up - -This page records owner-review items that remain after the documentation TODO -review. It is not a command specification; `commands.md` remains authoritative -for intended command behavior. - -## Addressed in This Pass - -- `commands.md` documents photodiode dark-measurement and dark-settings - commands, fixes the `status` response topic spelling, removes stale MEMS - response TODO text, and fixes the AS split route wording. - -## LLM Resolved; Human Review Requested - -- Hand-built command responses now have a final `_msg_builder()` size guard, and - `memsroute` uses checked append logic. Some older fixed-shape responses still - build into local buffers before that guard, so future edits should continue - converting risky builders to checked append helpers when payload shape expands. - -## Deferred Owner-Specified Capabilities - -Do not design or implement these without a detailed owner specification: - -- `measure_tput`. -- `laserbank/autowarm` and bank temperature management. -- Attenuator calibration/nonlinearity work and default coefficient selection. -- Laser/laser-bank command interface expansion. -- Broad `command.c` refactoring into domain-owned command helpers. - -## Remaining Implementation Items - -- TIB MEMS channels 7 and 8 need FFLS-specific pulse-width handling using - `MEMS_SWITCH_ELECTRICAL_PULSE_FFLS_MS`, and split/toggle behavior involving - those switches needs an owner decision. -- Temperature sensing currently exposes only ambient DS18B20 data. Decide - whether inactive TEC temperatures belong in the temperature thread or in a - future box-heater/bank-temperature loop. -- Thread priorities still need hardware-timing review. -- Consider whether `photodiode_publish_work` should be removed and photodiode - telemetry should push directly to `outbound_queue`. -- Replace the terse `help` response with a maintained command summary -- Decide whether the all-switch `mems` query response is acceptable for the - fixed MQTT payload budget or should be narrowed further. - -## Remaining Documentation/Human-Review Items - -- SNTP sync runs in system workqueue context and can block up to the SNTP - timeout. Decide whether that is acceptable for this firmware. -- CAL switch and route names are still provisional. -- Reconcile hardware/code mismatches for TIB attenuator DAC coverage and MEMS - electrical mode. diff --git a/doc/warnings_and_telemetry.md b/doc/warnings_and_telemetry.md index 16a7b97..487467d 100644 --- a/doc/warnings_and_telemetry.md +++ b/doc/warnings_and_telemetry.md @@ -2,12 +2,13 @@ ## Warning Flow -Warnings are emitted with `app_warning_emit(code, msg, context)`. +Warnings are emitted with `coo_cmd_runtime_warning_emit(command_runtime_get(), code, msg, context)`. Behavior: - Logs locally with `LOG_WRN`. -- Builds JSON with severity, code, message, context, and uptime. +- Uses the command-dispatch warning helper to build JSON with severity, code, + message, context, and uptime. - Enqueues one `OUT_TARGET_MQTT_BEST_EFFORT` message to `outbound_queue` with `K_NO_WAIT`. - Drops the MQTT warning if the queue is full, MQTT is unavailable, or publish @@ -18,9 +19,12 @@ Behavior: Warning topic: ```text -dt/hsfib-tib/warning +dt//warning ``` +The `` component follows the selected board strap: `hsfib-tib`, +`hsfib-rcal`, `hsfib-bcal`, or `hsfib-as`. + Current warning codes seen in code: - `serial_guard_active` @@ -28,24 +32,25 @@ Current warning codes seen in code: - `photodiode_noise` - `mems_rate_quantized` - `split_ratio_quantized` +- `outbound_queue_full` +- `laserbank_heater_override` -## Photodiode Telemetry +## Throughput Telemetry -Photodiode telemetry is produced by `photodiode_thread()` on: +Throughput telemetry is produced by `throughput_monitor_thread()` when +`measure_throughput` is active. It is published on: ```text -dt/hsfib-tib/photodiode +dt//yj_tput +dt//hk_tput ``` -Payload includes per-channel validity, raw counts, mV, dark-subtracted mV, -estimated power, residual RMS noise, dark settings, dark measurement state, -age, sample count, and uptime. +Payload format is selected by the command request and is specified in +`commands.md`. -Telemetry is best-effort. It is queued through `photodiode_queue` first, then -transferred to the normal outbound queue by `photodiode_publish_work`. If the -photodiode queue is full, old samples are purged and the current sample is -retried. If MQTT is unavailable or publish fails after transfer, the sample is -dropped. +Telemetry is best-effort. It is queued directly to `outbound_queue` with +`K_NO_WAIT`. If the outbound queue is full, the current sample is dropped. If +MQTT is unavailable or publish fails after transfer, the sample is dropped. ## Command Responses @@ -53,8 +58,8 @@ Command responses are `struct OutMsg` records built by handlers and drained by the main loop. MQTT response topic selection is: 1. MQTT 5 `response_topic` property when present and fitting the fixed buffer. -2. Default `cmd/hsfib-tib/resp/`. +2. Default `cmd//resp/`. MQTT 5 correlation data is opaque requester state. Accepted command requests -copy it into a fixed static buffer sized to the configured MQTT packet buffer, -and command responses echo those bytes exactly. +copy it into a fixed 16-byte static buffer, and command responses echo those +bytes exactly. diff --git a/drivers/gpio/ds2408/README.md b/drivers/gpio/ds2408/README.md index 154f351..2d1c96a 100644 --- a/drivers/gpio/ds2408/README.md +++ b/drivers/gpio/ds2408/README.md @@ -49,20 +49,28 @@ Example with a `zephyr,w1-gpio` bus and one DS2408: status = "okay"; gpio-controller; #gpio-cells = <2>; + ngpios = <8>; + init-gpios = <&ds2408 0 GPIO_ACTIVE_LOW>; + output-low; }; }; zephyr,user { - relay0-gpios = <&ds2408 0 GPIO_ACTIVE_HIGH>; + relay0-gpios = <&ds2408 0 GPIO_ACTIVE_LOW>; }; }; ``` For multidrop 1-Wire buses, provide a 64-bit ROM ID using `reg`. +`init-gpios` is optional. If present, the driver applies the listed pins during +DS2408 init using the logical `input`, `output-low`, or `output-high` property +on the DS2408 node. This is similar to Zephyr GPIO hog state naming, but it is +handled inside the DS2408 driver so optional missing relay hardware does not +fail global GPIO hog initialization. + ## Notes - DS2408 outputs are open-drain. - Pins configured as input are released (high-Z at the DS2408 transistor). -- On init, the driver releases all pins before regular GPIO configuration. - +- Without `init-gpios`, driver init does not write the DS2408 latch. diff --git a/drivers/gpio/ds2408/drivers/gpio/gpio_ds2408.c b/drivers/gpio/ds2408/drivers/gpio/gpio_ds2408.c index 242c91a..ed90ab3 100644 --- a/drivers/gpio/ds2408/drivers/gpio/gpio_ds2408.c +++ b/drivers/gpio/ds2408/drivers/gpio/gpio_ds2408.c @@ -39,9 +39,19 @@ LOG_MODULE_REGISTER(gpio_ds2408, CONFIG_GPIO_LOG_LEVEL); #define DS2408_TESTMODE_MAGIC_PREFIX 0x96 #define DS2408_TESTMODE_MAGIC_SUFFIX 0x3C +enum ds2408_init_mode { + DS2408_INIT_MODE_NONE, + DS2408_INIT_MODE_INPUT, + DS2408_INIT_MODE_OUTPUT_LOW, + DS2408_INIT_MODE_OUTPUT_HIGH, +}; + struct ds2408_config { struct gpio_driver_config common; const struct device *bus; + uint8_t init_mask; + uint8_t init_invert_mask; + enum ds2408_init_mode init_mode; uint8_t family; bool overdrive; uint64_t rom_id; @@ -115,6 +125,48 @@ static int ds2408_apply_outputs_locked(const struct device *dev) return ds2408_write_latch(dev, effective_raw); } +static int ds2408_apply_init_defaults_locked(const struct device *dev) +{ + const struct ds2408_config *cfg = dev->config; + struct ds2408_data *data = dev->data; + uint8_t logical_raw; + + if (cfg->init_mode == DS2408_INIT_MODE_NONE || cfg->init_mask == 0U) { + return 0; + } + + if ((cfg->init_mask & (uint8_t)~cfg->common.port_pin_mask) != 0U) { + return -EINVAL; + } + + data->common.invert &= (gpio_port_pins_t)~cfg->init_mask; + data->common.invert |= cfg->init_invert_mask; + + if (cfg->init_mode == DS2408_INIT_MODE_INPUT) { + data->direction_mask &= (uint8_t)~cfg->init_mask; + data->output_raw |= cfg->init_mask; + return ds2408_apply_outputs_locked(dev); + } + + data->direction_mask |= cfg->init_mask; + if (cfg->init_mode == DS2408_INIT_MODE_OUTPUT_HIGH) { + logical_raw = cfg->init_mask; + } else { + logical_raw = 0U; + } + + /* Convert logical defaults through the same active-low bit convention + * used by Zephyr's GPIO core for later gpio_pin_set() calls. + */ + logical_raw ^= cfg->init_invert_mask; + logical_raw &= cfg->init_mask; + + data->output_raw &= (uint8_t)~cfg->init_mask; + data->output_raw |= logical_raw; + + return ds2408_apply_outputs_locked(dev); +} + static int ds2408_setup_slave(const struct device *dev) { const struct ds2408_config *cfg = dev->config; @@ -139,6 +191,9 @@ static int ds2408_setup_slave(const struct device *dev) if (ds2408_rom_is_zero(&data->slave_cfg.rom)) { ret = w1_read_rom(cfg->bus, &data->slave_cfg.rom); if (ret != 0) { + LOG_ERR("Failed to read DS2408 ROM on %s (%d), raw=%016llx", + cfg->bus->name, ret, + (unsigned long long)w1_rom_to_uint64(&data->slave_cfg.rom)); return ret; } } @@ -147,6 +202,10 @@ static int ds2408_setup_slave(const struct device *dev) return -EINVAL; } + LOG_INF("DS2408 ROM on %s: %016llx", + cfg->bus->name, + (unsigned long long)w1_rom_to_uint64(&data->slave_cfg.rom)); + if ((cfg->family != 0U) && (cfg->family != data->slave_cfg.rom.family)) { LOG_ERR("ROM family 0x%02x does not match DS2408 family 0x%02x", data->slave_cfg.rom.family, cfg->family); @@ -406,6 +465,9 @@ static int ds2408_init(const struct device *dev) } k_mutex_init(&data->lock); + /* Leave the DS2408 released unless devicetree explicitly requests init + * defaults. A Channel Access Write always writes the full latch. + */ data->direction_mask = 0U; data->output_raw = (uint8_t)cfg->common.port_pin_mask; @@ -419,8 +481,7 @@ static int ds2408_init(const struct device *dev) LOG_WRN("Failed to run DS2408 test-mode clear sequence: %d", ret); } - /* Release all pins on startup; direction changes happen in pin_configure. */ - ret = ds2408_apply_outputs_locked(dev); + ret = ds2408_apply_init_defaults_locked(dev); if (ret != 0) { return ret; } @@ -454,6 +515,33 @@ static DEVICE_API(gpio, ds2408_gpio_api) = { #define DS2408_ROM_FROM_REG(inst) \ COND_CODE_1(DT_INST_NODE_HAS_PROP(inst, reg), (DT_INST_REG_ADDR_U64(inst)), (0ULL)) +#define DS2408_INIT_PIN_MASK_BY_IDX(idx, inst) \ + BIT(DT_INST_GPIO_PIN_BY_IDX(inst, init_gpios, idx)) + +#define DS2408_INIT_INVERT_MASK_BY_IDX(idx, inst) \ + (((DT_INST_GPIO_FLAGS_BY_IDX(inst, init_gpios, idx) & GPIO_ACTIVE_LOW) != 0U) ? \ + BIT(DT_INST_GPIO_PIN_BY_IDX(inst, init_gpios, idx)) : 0U) + +#define DS2408_INIT_PIN_MASK(inst) \ + COND_CODE_1(DT_INST_NODE_HAS_PROP(inst, init_gpios), \ + (LISTIFY(DT_INST_PROP_LEN(inst, init_gpios), \ + DS2408_INIT_PIN_MASK_BY_IDX, (|), inst)), \ + (0U)) + +#define DS2408_INIT_INVERT_MASK(inst) \ + COND_CODE_1(DT_INST_NODE_HAS_PROP(inst, init_gpios), \ + (LISTIFY(DT_INST_PROP_LEN(inst, init_gpios), \ + DS2408_INIT_INVERT_MASK_BY_IDX, (|), inst)), \ + (0U)) + +#define DS2408_INIT_MODE(inst) \ + COND_CODE_1(DT_INST_PROP(inst, input), (DS2408_INIT_MODE_INPUT), \ + (COND_CODE_1(DT_INST_PROP(inst, output_low), \ + (DS2408_INIT_MODE_OUTPUT_LOW), \ + (COND_CODE_1(DT_INST_PROP(inst, output_high), \ + (DS2408_INIT_MODE_OUTPUT_HIGH), \ + (DS2408_INIT_MODE_NONE)))))) + #define DS2408_DEFINE(inst) \ static struct ds2408_data ds2408_data_##inst; \ static const struct ds2408_config ds2408_config_##inst = { \ @@ -461,6 +549,9 @@ static DEVICE_API(gpio, ds2408_gpio_api) = { .port_pin_mask = GPIO_PORT_PIN_MASK_FROM_NGPIOS(DT_INST_PROP(inst, ngpios)), \ }, \ .bus = DEVICE_DT_GET(DT_INST_BUS(inst)), \ + .init_mask = DS2408_INIT_PIN_MASK(inst), \ + .init_invert_mask = DS2408_INIT_INVERT_MASK(inst), \ + .init_mode = DS2408_INIT_MODE(inst), \ .family = DT_INST_PROP_OR(inst, family_code, DS2408_FAMILY_CODE), \ .overdrive = DT_INST_PROP_OR(inst, overdrive_speed, false), \ .rom_id = DS2408_ROM_FROM_REG(inst), \ diff --git a/drivers/gpio/ds2408/dts/bindings/gpio/maxim,ds2408.yaml b/drivers/gpio/ds2408/dts/bindings/gpio/maxim,ds2408.yaml index 409a3ee..f0b1327 100644 --- a/drivers/gpio/ds2408/dts/bindings/gpio/maxim,ds2408.yaml +++ b/drivers/gpio/ds2408/dts/bindings/gpio/maxim,ds2408.yaml @@ -23,7 +23,33 @@ properties: topologies. If omitted, the driver auto-reads ROM only when the bus has a single slave. + init-gpios: + type: phandle-array + description: | + Optional DS2408 pins to configure during driver initialization. The + GPIO flags use normal Zephyr GPIO polarity flags. The input, output-low, + and output-high properties select the initial direction/state in the + same logical sense as Zephyr GPIO hogs, but this property is handled by + the DS2408 driver so a missing optional relay board does not fail global + GPIO hog initialization. + + input: + type: boolean + description: | + Configure init-gpios as inputs/released. Takes precedence over + output-low and output-high. + + output-low: + type: boolean + description: | + Configure init-gpios as outputs set to logical low/inactive. Takes + precedence over output-high. + + output-high: + type: boolean + description: | + Configure init-gpios as outputs set to logical high/active. + gpio-cells: - pin - flags - diff --git a/dts/bindings/gpio/maxim,ds2408.yaml b/dts/bindings/gpio/maxim,ds2408.yaml index 9e38820..f0b1327 100644 --- a/dts/bindings/gpio/maxim,ds2408.yaml +++ b/dts/bindings/gpio/maxim,ds2408.yaml @@ -23,6 +23,33 @@ properties: topologies. If omitted, the driver auto-reads ROM only when the bus has a single slave. + init-gpios: + type: phandle-array + description: | + Optional DS2408 pins to configure during driver initialization. The + GPIO flags use normal Zephyr GPIO polarity flags. The input, output-low, + and output-high properties select the initial direction/state in the + same logical sense as Zephyr GPIO hogs, but this property is handled by + the DS2408 driver so a missing optional relay board does not fail global + GPIO hog initialization. + + input: + type: boolean + description: | + Configure init-gpios as inputs/released. Takes precedence over + output-low and output-high. + + output-low: + type: boolean + description: | + Configure init-gpios as outputs set to logical low/inactive. Takes + precedence over output-high. + + output-high: + type: boolean + description: | + Configure init-gpios as outputs set to logical high/active. + gpio-cells: - pin - flags diff --git a/include/coo_commons/command_dispatch.h b/include/coo_commons/command_dispatch.h new file mode 100644 index 0000000..6a81161 --- /dev/null +++ b/include/coo_commons/command_dispatch.h @@ -0,0 +1,507 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef COO_COMMONS_COMMAND_DISPATCH_H +#define COO_COMMONS_COMMAND_DISPATCH_H + +#include +#include +#include +#include +#include +#include + +struct nvs_fs; + +/** + * @file command_dispatch.h + * @brief Fixed-buffer command request, dispatch, and response helpers. + * + * This utility is intentionally small. Applications own their command table and + * domain handlers. The helper owns reusable fixed-buffer MQTT/serial topic + * handling, built-in command execution, bounded serial payload normalization, + * optional lastcommand persistence, warning publication, and transport-shaped + * response handling. Applications may still provide a custom execute callback + * when they need to replace the default executor. + */ + +#define COO_CMD_TOPIC_MAX 96 +#define COO_CMD_KEY_MAX 48 +#define COO_CMD_REQID_MAX 32 +#define COO_CMD_SESSION_ID_MAX 48 +#define COO_CMD_CORRELATION_MAX 16 +#define COO_CMD_SERIAL_WRAP_COLUMN 80U + +#define COO_CMD_HELP_QUERY (1u << 0) +#define COO_CMD_HELP_EFFECT (1u << 1) +#define COO_CMD_HELP_SERIAL_GUARD_QUERY (1u << 2) +#define COO_CMD_HELP_BUILTIN (1u << 3) +#define COO_CMD_LASTCOMMAND_NVS_OVERHEAD 16U + +#if defined(CONFIG_CONSOLE_INPUT_MAX_LINE_LEN) +#define COO_CMD_SERIAL_LINE_MAX CONFIG_CONSOLE_INPUT_MAX_LINE_LEN +#else +#define COO_CMD_SERIAL_LINE_MAX 128 +#endif + +#if defined(CONFIG_COO_MQTT_PAYLOAD_SIZE) +#define COO_CMD_PAYLOAD_MAX CONFIG_COO_MQTT_PAYLOAD_SIZE +#else +#define COO_CMD_PAYLOAD_MAX 256 +#endif + +/** + * @brief Request/response class used by the command executor. + * + * Requests use only COO_CMD_QUERY and COO_CMD_EFFECT. Responses use only + * COO_CMD_RESP_OK and COO_CMD_RESP_ERROR. The enum remains shared so existing + * simple dispatch tables can choose a handler and build a response without a + * second conversion type. + */ +enum coo_cmd_msg_type { + COO_CMD_QUERY = 0, + COO_CMD_EFFECT = 1, + COO_CMD_ACK = 2, + COO_CMD_RESP_OK = 3, + COO_CMD_RESP_ERROR = 4, +}; + +/** Ingress path used for response routing and app-local guard policy. */ +enum coo_cmd_source { + COO_CMD_SOURCE_MQTT = 0, + COO_CMD_SOURCE_SERIAL = 1, +}; + +/** Publication target for responses, warnings, and telemetry. */ +enum coo_cmd_out_target { + COO_CMD_OUT_MQTT = 0, + COO_CMD_OUT_SERIAL = 1, + COO_CMD_OUT_MQTT_BEST_EFFORT = 2, +}; + +enum coo_cmd_class_policy { + COO_CMD_CLASS_DEFAULT = 0, + COO_CMD_CLASS_ALWAYS_QUERY, + COO_CMD_CLASS_ALWAYS_EFFECT, + COO_CMD_CLASS_SUFFIX_OR_PAYLOAD_EFFECT, + COO_CMD_CLASS_CUSTOM, +}; + +struct coo_cmd_request { + enum coo_cmd_msg_type msg_type; + enum coo_cmd_source source; + char key[COO_CMD_KEY_MAX]; + char session_id[COO_CMD_SESSION_ID_MAX]; + char response_topic[COO_CMD_TOPIC_MAX]; + size_t payload_len; + char payload[COO_CMD_PAYLOAD_MAX]; + uint8_t correlation_data[COO_CMD_CORRELATION_MAX]; + uint32_t corr_len; +}; + +struct coo_cmd_response { + enum coo_cmd_msg_type msg_type; + enum coo_cmd_out_target target; + char topic[COO_CMD_TOPIC_MAX]; + uint8_t qos; + size_t payload_len; + char payload[COO_CMD_PAYLOAD_MAX]; + uint8_t correlation_data[COO_CMD_CORRELATION_MAX]; + size_t corr_len; +}; + +struct coo_cmd_work { + struct k_work work; + struct coo_cmd_request cmd; +}; + +struct coo_cmd_spec; + +typedef struct coo_cmd_response (*coo_cmd_handler_fn)(const struct coo_cmd_request *cmd); + +typedef int (*coo_cmd_format_response_topic_fn)(const char *key, + char *out, + size_t out_len, + void *user_data); + +typedef int (*coo_cmd_serial_shorthand_fn)(const char *key, + const char *payload, + char *out, + size_t out_len, + void *user_data); + +typedef enum coo_cmd_msg_type (*coo_cmd_classify_fn)( + const struct coo_cmd_request *cmd, + const struct coo_cmd_spec *spec, + void *user_data); + +typedef bool (*coo_cmd_supported_fn)(const struct coo_cmd_spec *spec, + void *user_data); + +typedef void (*coo_cmd_reboot_prepare_fn)(void *user_data); + +struct coo_cmd_lastcommand { + bool valid; + int64_t time_ms; + struct coo_cmd_request request; +}; + +struct coo_cmd_help_entry { + const char *key; + const char *usage; + const char *args; + const char *values; + const char *notes; + uint32_t flags; +}; + +#define COO_CMD_SERIAL_POSITIONAL_MAX 3U + +struct coo_cmd_serial_positional { + const char *field[COO_CMD_SERIAL_POSITIONAL_MAX]; + uint8_t required_count; + uint8_t numeric_mask; +}; + +struct coo_cmd_spec { + const char *key; + coo_cmd_handler_fn query_handler; + coo_cmd_handler_fn effect_handler; + enum coo_cmd_class_policy class_policy; + coo_cmd_classify_fn custom_classify; + coo_cmd_serial_shorthand_fn serial_shorthand; + struct coo_cmd_serial_positional serial_positional; + coo_cmd_supported_fn supported; + const struct coo_cmd_help_entry *help; + bool mqtt_query_allowed_during_serial_guard; +}; + +/** + * @brief Runtime wiring for a simple command executor and output drain. + * + * The application owns the queues, optional execute callback, and MQTT message-id + * storage. The runtime owns the copied device identity and topic formatting + * derived from it. The runtime helpers do not allocate memory; they block only + * in the executor queue wait, optional NVS lastcommand persistence, reboot + * prepare callback, and MQTT publish path used by the outbound drain. + */ +struct coo_cmd_runtime { + struct k_msgq *inbound_queue; + struct k_msgq *outbound_queue; + char device_id[32]; + char request_prefix[COO_CMD_TOPIC_MAX]; + char warning_topic[COO_CMD_TOPIC_MAX]; + coo_cmd_handler_fn execute_handler; + uint16_t *mqtt_msg_id; + uint16_t serial_wrap_column; + void *user_data; + const struct coo_cmd_spec *command_specs; + size_t command_spec_count; + struct nvs_fs *lastcommand_nvs; + uint16_t lastcommand_nvs_id; + struct coo_cmd_lastcommand lastcommand; +#if defined(CONFIG_COO_CMD_REBOOT) + struct k_work_delayable reboot_work; + atomic_t reboot_pending; + uint32_t reboot_delay_ms; + coo_cmd_reboot_prepare_fn reboot_prepare; +#endif +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + struct k_work_delayable serial_guard_work; + atomic_t serial_guard_active; + uint32_t serial_guard_seconds; +#endif + bool outbound_full_warning_seen; + bool outbound_full_warning_mqtt_seen; + bool serial_initialized; + bool serial_line_overflow; + size_t serial_line_len; + char serial_line[COO_CMD_SERIAL_LINE_MAX]; + struct coo_cmd_request ingress_cmd; + /* Executor-owned buffers keep large request/response payload storage off + * the command thread stack. + */ + struct coo_cmd_request executor_cmd; + struct coo_cmd_response executor_out; + struct coo_cmd_response outbound_scratch; + struct coo_cmd_response warning_scratch; +}; + +struct coo_cmd_runtime_config { + const char *device_id; + struct k_msgq *inbound_queue; + struct k_msgq *outbound_queue; + coo_cmd_handler_fn execute_handler; + uint16_t *mqtt_msg_id; + uint16_t serial_wrap_column; + const struct coo_cmd_spec *command_specs; + size_t command_spec_count; + struct nvs_fs *lastcommand_nvs; + uint16_t lastcommand_nvs_id; +#if defined(CONFIG_COO_CMD_REBOOT) + uint32_t reboot_delay_ms; + coo_cmd_reboot_prepare_fn reboot_prepare; +#endif + void *user_data; +}; + +/** + * @brief Initialize a command runtime with stable app identity and callbacks. + * + * Copies @p cfg->device_id, preformats the request and warning topics, and + * initializes nonblocking Zephyr console input for serial commands. + * Applications normally call this once after board identity is known and before + * starting the runtime executor. + */ +int coo_cmd_runtime_configure(struct coo_cmd_runtime *runtime, + const struct coo_cmd_runtime_config *cfg); + +/** Find the longest exact-or-slash-prefix command spec for @p key. */ +const struct coo_cmd_spec * +coo_cmd_runtime_find_spec(const struct coo_cmd_runtime *runtime, + const char *key); + +/** Return whether a command spec is currently supported by the app/board. */ +bool coo_cmd_runtime_spec_supported(const struct coo_cmd_runtime *runtime, + const struct coo_cmd_spec *spec); + +/** + * @brief Copy the last effect command recorded by the runtime. + * + * The record is loaded from NVS during runtime configuration when an NVS + * backend and ID are supplied. Dispatch records only commands that resolve to a + * supported effect handler or accepted built-in effect. + */ +bool coo_cmd_runtime_get_lastcommand(const struct coo_cmd_runtime *runtime, + struct coo_cmd_lastcommand *out); + +/** Return a stable text label for a request source enum. */ +const char *coo_cmd_source_name(enum coo_cmd_source source); + +/** Return true when @p key starts with @p prefix and is exact or slash-delimited. */ +bool coo_cmd_key_matches_prefix(const char *key, const char *prefix); + +/** + * Return the suffix after an exact or slash-delimited command-key prefix. + * + * Returns an empty string for exact matches, missing inputs, or non-matches. + * For `laserbank/power/auto` with prefix `laserbank/power`, this returns + * `auto`. + */ +const char *coo_cmd_key_suffix_after(const char *key, const char *prefix); + +/** + * Copy one slash-delimited suffix segment after a command-key prefix. + * + * Returns 0 for keys like `mems/yj_cal_laser` with prefix `mems`. Exact + * matches, nested suffixes, missing inputs, and too-small output buffers fail + * with a negative errno value. + */ +int coo_cmd_key_suffix_segment_copy(const char *key, + const char *prefix, + char *suffix, + size_t suffix_len); + +/** + * Copy two slash-delimited suffix segments after a command-key prefix. + * + * Returns 0 for keys like `atten/1028y/value` with prefix `atten`. Exact + * matches, missing segments, extra nested segments, missing inputs, and + * too-small output buffers fail with a negative errno value. + */ +int coo_cmd_key_suffix_pair_copy(const char *key, + const char *prefix, + char *first, + size_t first_len, + char *second, + size_t second_len); + +/** True for a missing, empty, or "{}" payload. */ +bool coo_cmd_payload_empty(const struct coo_cmd_request *cmd); + +/** Copy one Zephyr MQTT UTF-8 field into a C string. */ +bool coo_cmd_copy_mqtt_utf8(const struct mqtt_utf8 *topic, + char *out, + size_t out_len); + +/** Format `cmd//req/` for MQTT command subscription and parsing. */ +int coo_cmd_format_request_prefix(const char *device_id, + char *buf, + size_t buf_len); + +/** Format `cmd//resp/` for default command responses. */ +int coo_cmd_format_response_topic(const char *device_id, + const char *key, + char *buf, + size_t buf_len); + +/** Format `dt//` for warning and telemetry publications. */ +int coo_cmd_format_data_topic(const char *device_id, + const char *suffix, + char *buf, + size_t buf_len); + +/** + * @brief Normalize a serial payload into compact JSON. + * + * Empty payload becomes "{}"; raw JSON beginning with "{" is copied unchanged; + * key=value tokens become a JSON object. Other payloads are passed to + * @p shorthand when supplied, otherwise a single token becomes {"value":...}. + */ +int coo_cmd_normalize_serial_payload(const char *key, + const char *payload, + coo_cmd_serial_shorthand_fn shorthand, + void *user_data, + char *out, + size_t out_len); + +/** Return the next whitespace-delimited serial token and advance @p cursor. */ +bool coo_cmd_serial_next_token(const char **cursor, char *out, size_t out_len); + +/** Return true when non-space payload text remains at @p cursor. */ +bool coo_cmd_serial_has_extra(const char *cursor); + +/** Return true when @p token is a complete JSON-compatible number token. */ +bool coo_cmd_serial_token_is_number(const char *token); + +/** Append one token as a JSON value, preserving numbers/bools/null. */ +int coo_cmd_serial_append_json_value(char *out, size_t out_len, size_t *off, + const char *token); + +/** Append one `"key":value` field, optionally preceded by a comma. */ +int coo_cmd_serial_append_json_field(char *out, size_t out_len, size_t *off, + const char *key, const char *token, + bool comma); + +/** + * @brief Build a response that preserves request routing metadata. + * + * When @p format_topic is non-NULL, it is called for the default response + * topic. A request-provided response_topic overrides it when present and + * fitting the fixed topic buffer. When @p format_topic is NULL, the already + * normalized cmd->response_topic is used directly. + * + * MQTT correlation data is echoed exactly when it fits the request buffer. + */ +struct coo_cmd_response +coo_cmd_make_response(const struct coo_cmd_request *cmd, + enum coo_cmd_msg_type msg_type, + const char *payload, + coo_cmd_format_response_topic_fn format_topic, + void *user_data); + +/** + * @brief Build a response using the request's normalized response topic. + * + * Applications that normalize cmd->response_topic before dispatch should use + * this helper rather than repeating a local response-topic wrapper in each + * command adapter. + */ +struct coo_cmd_response +coo_cmd_reply(const struct coo_cmd_request *cmd, + enum coo_cmd_msg_type msg_type, + const char *payload); + +/** @brief Build the standard data-less success response: {"status":"ok"}. */ +struct coo_cmd_response coo_cmd_ok(const struct coo_cmd_request *cmd); + +/** @brief Build a structured error response with one error string. */ +struct coo_cmd_response coo_cmd_error(const struct coo_cmd_request *cmd, + const char *msg); + +/** @brief Build a structured error response with one error string and rc. */ +struct coo_cmd_response coo_cmd_error_rc(const struct coo_cmd_request *cmd, + const char *msg, + int rc); + +/** @brief Build the standard malformed-command error response. */ +struct coo_cmd_response coo_cmd_invalid_response(const struct coo_cmd_request *cmd); + +/** @brief Build the standard unknown-command error response. */ +struct coo_cmd_response coo_cmd_unknown_response(const struct coo_cmd_request *cmd); + +/** @brief Build the standard unsupported-operation error response. */ +struct coo_cmd_response coo_cmd_unsupported_response(const struct coo_cmd_request *cmd); + +/** @brief Build the standard busy error response. */ +struct coo_cmd_response coo_cmd_busy_response(const struct coo_cmd_request *cmd); + +/** @brief Build the standard serial-guard-active error response. */ +struct coo_cmd_response coo_cmd_serial_active_response(const struct coo_cmd_request *cmd); + +/** + * @brief Build a best-effort warning publication. + * + * The caller supplies the already formatted warning topic, usually a telemetry + * topic such as `dt//warning`. The response target is + * COO_CMD_OUT_MQTT_BEST_EFFORT and the payload is a compact warning JSON + * object with severity, code, msg, context, and uptime_ms. + */ +int coo_cmd_build_warning(struct coo_cmd_response *out, + const char *topic, + const char *code, + const char *msg, + const char *context); + +/** + * @brief Log and enqueue one best-effort warning without blocking. + * + * Warnings are lossy by design. This helper never publishes MQTT directly and + * returns an error if the payload cannot be built or the queue is full. + */ +int coo_cmd_warning_emit(struct k_msgq *outbound_queue, + const char *topic, + const char *code, + const char *msg, + const char *context); + +/** Publish a formatted MQTT response/publication. May block in the socket layer. */ +int coo_cmd_publish_mqtt(struct mqtt_client *client, + const struct coo_cmd_response *out, + uint16_t *message_id); + +/** Execute commands from runtime->inbound_queue and enqueue one response each. */ +void coo_cmd_runtime_executor_thread(void *p1, void *p2, void *p3); + +/** Poll buffered console characters and queue completed serial commands. */ +void coo_cmd_runtime_serial_poll(struct coo_cmd_runtime *runtime); + +/** Copy and queue one MQTT publish as a normalized command request. */ +void coo_cmd_runtime_handle_mqtt_publish(struct coo_cmd_runtime *runtime, + const struct mqtt_publish_param *pub); + +/** MQTT wrapper callback adapter; @p user_data must be a coo_cmd_runtime. */ +void coo_cmd_runtime_mqtt_callback(const struct mqtt_publish_param *pub, + void *user_data); + +/** Parse one console line and queue a normalized serial command request. */ +void coo_cmd_runtime_handle_serial_line(struct coo_cmd_runtime *runtime, + char *line); + +/** Drain outbound serial/MQTT responses with bounded retry behavior. */ +void coo_cmd_runtime_drain_outbound(struct coo_cmd_runtime *runtime, + struct mqtt_client *client, + bool mqtt_available); + +/** Emit a best-effort warning using runtime identity and outbound queue. */ +int coo_cmd_runtime_warning_emit(struct coo_cmd_runtime *runtime, + const char *code, + const char *msg, + const char *context); + +/** Print a serial response as topic then space-indented wrapped payload. */ +void coo_cmd_print_serial_response(const struct coo_cmd_response *out, + uint16_t wrap_column); + +/** + * Print a serial response with JSON-aware indentation. + * + * This is presentation only. It does not change MQTT payloads or validate + * command responses before publication. + */ +void coo_cmd_print_serial_response_pretty(const struct coo_cmd_response *out, + uint16_t wrap_column); + +#endif /* COO_COMMONS_COMMAND_DISPATCH_H */ diff --git a/include/coo_commons/json_utils.h b/include/coo_commons/json_utils.h index bb7b8ae..9aa75c0 100644 --- a/include/coo_commons/json_utils.h +++ b/include/coo_commons/json_utils.h @@ -10,30 +10,52 @@ #include #include #include +#include /** * @file json_utils.h * @brief Lightweight JSON convenience wrappers for command handling. */ -enum coo_msg_type { - COO_MSG_GET = 0, - COO_MSG_SET = 1, -}; - enum coo_json_extract_status { COO_JSON_EXTRACT_MISSING = -1, COO_JSON_EXTRACT_OK = 0, COO_JSON_EXTRACT_ERR = 1, }; -bool coo_json_parse_msg_type(const char *payload, enum coo_msg_type *msg_type_out); +/* Fixed buffer used by coo_json_extract_string_choice(). */ +#define COO_JSON_STRING_CHOICE_MAX 32U + +struct coo_json_string_choice { + const char *name; + int value; +}; + +/** Return @p text advanced past ASCII JSON whitespace. */ +const char *coo_json_skip_ws(const char *text); + +/** + * Match @p text against a case-insensitive static string-choice table. + * + * This helper only validates the command token and writes the associated + * integer value; command modules still own the table and enum meaning. + */ +int coo_json_match_string_choice(const char *text, + const struct coo_json_string_choice *choices, + size_t choice_count, + int *value); /* Return values use enum coo_json_extract_status. */ int coo_json_extract_bool(const char *json, const char *key, bool *value); int coo_json_extract_u32(const char *json, const char *key, uint32_t *value); int coo_json_extract_u64(const char *json, const char *key, uint64_t *value); int coo_json_extract_float(const char *json, const char *key, float *value); +/** Extract one required JSON number into a double. */ +int coo_json_extract_double(const char *json, const char *key, double *value); +/** Extract a JSON number array into @p values. Supports up to 32 doubles. */ +int coo_json_extract_double_array(const char *json, const char *key, + double *values, size_t max_values, + size_t *parsed_len); /** * @brief Parse an optional float field and reject values outside a range. * @@ -46,6 +68,75 @@ int coo_json_extract_float(const char *json, const char *key, float *value); int coo_json_extract_optional_float_range(const char *json, const char *key, float *value, bool *changed, float min_value, float max_value); +/** + * @brief Parse an optional bool field. + * + * Missing fields are not errors and leave @p value unchanged. When @p changed + * is non-NULL, it is set true only when the field was present. + * + * @retval 0 Field was missing or parsed. + * @retval -EINVAL Bad arguments or malformed JSON field. + */ +int coo_json_extract_optional_bool(const char *json, const char *key, + bool *value, bool *changed); +/** + * @brief Parse an optional unsigned integer field. + * + * Missing fields are not errors and leave @p value unchanged. When @p changed + * is non-NULL, it is set true only when the field was present. + * + * @retval 0 Field was missing or parsed. + * @retval -EINVAL Bad arguments or malformed JSON field. + */ +int coo_json_extract_optional_u32(const char *json, const char *key, + uint32_t *value, bool *changed); +/** Parse an optional unsigned integer field into a uint16_t destination. */ +int coo_json_extract_optional_u16(const char *json, const char *key, + uint16_t *value, bool *changed); +/** + * @brief Parse an optional double field and reject values outside a range. + * + * Missing fields are not errors and leave @p value unchanged. On success with + * a present field, @p value is updated and @p changed is set true. + * + * @retval 0 Field was missing or parsed within range. + * @retval -EINVAL Bad arguments, malformed JSON field, or out-of-range value. + */ +int coo_json_extract_optional_double_range(const char *json, const char *key, + double *value, bool *changed, + double min_value, double max_value); int coo_json_extract_string(const char *json, const char *key, char *out, size_t out_len); +/** + * Extract a JSON string field and match it against a static choice table. + * + * Return values use enum coo_json_extract_status. Missing fields remain + * distinguishable from malformed JSON or unknown values. + */ +int coo_json_extract_string_choice(const char *json, + const char *key, + const struct coo_json_string_choice *choices, + size_t choice_count, + int *value); +/** + * @brief Copy a nested JSON object value into @p out. + * + * This is a bounded helper for command schemas with optional nested objects. + * The copied string includes the surrounding braces. + */ +int coo_json_extract_object(const char *json, const char *key, char *out, size_t out_len); +/** + * @brief Append formatted JSON text to a fixed buffer. + * + * Updates @p offset only on success. This is intentionally small and format- + * oriented because command telemetry uses static buffers and must avoid + * dynamic allocation. + */ +int coo_json_append(char *buf, size_t buf_len, size_t *offset, + const char *fmt, ...); +int coo_json_vappend(char *buf, size_t buf_len, size_t *offset, + const char *fmt, va_list args); +/** Append a JSON number or null when @p value is NaN. */ +int coo_json_append_float_or_null(char *buf, size_t buf_len, size_t *offset, + double value, int precision); #endif /* APP_LIB_JSON_UTILS_H_ */ diff --git a/include/coo_commons/mqtt_client.h b/include/coo_commons/mqtt_client.h index 9ce53d9..ed89180 100644 --- a/include/coo_commons/mqtt_client.h +++ b/include/coo_commons/mqtt_client.h @@ -43,14 +43,26 @@ bool coo_mqtt_parse_broker_endpoint(const char *endpoint, int coo_mqtt_format_broker_endpoint(const struct coo_mqtt_broker_config *cfg, char *out, size_t out_len); +/** + * @brief Resolve a broker config without changing the active MQTT broker. + * + * Numeric IPv4 hosts succeed without DNS. Hostnames require DNS support and a + * configured resolver that can return an IPv4 address. @p resolved_ip may be + * NULL when the caller only needs validation. + */ +int coo_mqtt_resolve_broker_config(const struct coo_mqtt_broker_config *cfg, + char *resolved_ip, size_t resolved_ip_len); + /** * @brief MQTT message callback function type * * Called when an MQTT message is received on a subscribed topic. * * @param pub Pointer to MQTT publish parameters containing topic, payload, QoS, etc. + * @param user_data Caller-supplied callback context. */ -typedef void (*mqtt_message_cb_t)(const struct mqtt_publish_param *pub); +typedef void (*mqtt_message_cb_t)(const struct mqtt_publish_param *pub, + void *user_data); /** * @brief Initialize the MQTT client @@ -112,8 +124,9 @@ int coo_mqtt_subscribe(struct mqtt_client *client); * subscribed topic. * * @param cb Callback function pointer + * @param user_data Caller-owned pointer passed to cb, or NULL if unused */ -void coo_mqtt_set_message_callback(mqtt_message_cb_t cb); +void coo_mqtt_set_message_callback(mqtt_message_cb_t cb, void *user_data); /** * @brief Process MQTT events diff --git a/include/coo_commons/scheduled_action.h b/include/coo_commons/scheduled_action.h new file mode 100644 index 0000000..9d7a69f --- /dev/null +++ b/include/coo_commons/scheduled_action.h @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef COO_COMMONS_SCHEDULED_ACTION_H +#define COO_COMMONS_SCHEDULED_ACTION_H + +#include +#include +#include +#include + +/** + * @file scheduled_action.h + * @brief Fixed-table wrapper for named Zephyr delayable work items. + * + * Callers own a static array of actions and stable numeric IDs. The helper does + * not allocate memory or create threads; callbacks run in Zephyr's system + * workqueue context and must stay short. + */ + +typedef void (*coo_scheduled_action_handler_t)(size_t id, void *user_data); + +struct coo_scheduled_action { + const char *name; + struct k_work_delayable work; + coo_scheduled_action_handler_t handler; + void *user_data; + atomic_t pending; + size_t id; +}; + +/** Initialize every action in a static caller-owned table. */ +int coo_scheduled_actions_init(struct coo_scheduled_action *actions, size_t action_count); + +/** Attach the callback and user data for one action ID. */ +int coo_scheduled_action_register(struct coo_scheduled_action *actions, + size_t action_count, + size_t id, + coo_scheduled_action_handler_t handler, + void *user_data); + +/** Schedule or refresh one named action after @p delay. */ +int coo_scheduled_action_schedule(struct coo_scheduled_action *actions, + size_t action_count, + size_t id, + k_timeout_t delay); + +/** Cancel one action if it has not already started running. */ +int coo_scheduled_action_cancel(struct coo_scheduled_action *actions, + size_t action_count, + size_t id); + +/** Return whether one action is currently pending. */ +bool coo_scheduled_action_is_pending(const struct coo_scheduled_action *actions, + size_t action_count, + size_t id); + +/** Get the approximate remaining delay in milliseconds. */ +int coo_scheduled_action_remaining_ms(struct coo_scheduled_action *actions, + size_t action_count, + size_t id, + int64_t *remaining_ms); + +/** Return a stable name for logs/status, or "unknown" for an invalid ID. */ +const char *coo_scheduled_action_name(const struct coo_scheduled_action *actions, + size_t action_count, + size_t id); + +#endif /* COO_COMMONS_SCHEDULED_ACTION_H */ diff --git a/lib/coo_commons/CMakeLists.txt b/lib/coo_commons/CMakeLists.txt index ad09a11..e826d4a 100644 --- a/lib/coo_commons/CMakeLists.txt +++ b/lib/coo_commons/CMakeLists.txt @@ -2,9 +2,6 @@ zephyr_library() -# Always include PID controller -zephyr_library_sources(pid.c) - # Network utilities (requires networking support) # Includes both low-level sockets and high-level connection manager zephyr_library_sources_ifdef(CONFIG_COO_NETWORK network.c) @@ -14,3 +11,9 @@ zephyr_library_sources_ifdef(CONFIG_COO_JSON json_utils.c) # MQTT client wrapper (requires MQTT library) zephyr_library_sources_ifdef(CONFIG_COO_MQTT mqtt_client.c) + +# Static command dispatch and MQTT/serial response helpers. +zephyr_library_sources_ifdef(CONFIG_COO_MQTT command_dispatch.c) + +# Generic fixed-table delayable-work helper. +zephyr_library_sources_ifdef(CONFIG_COO_SCHEDULED_ACTIONS scheduled_action.c) diff --git a/lib/coo_commons/Kconfig b/lib/coo_commons/Kconfig index fedc9d5..72124d7 100644 --- a/lib/coo_commons/Kconfig +++ b/lib/coo_commons/Kconfig @@ -5,8 +5,8 @@ config COO_COMMONS bool "COO common libraries" default y help - Enable COO common libraries including PID controller, - network utilities, JSON helpers, and MQTT client. + Enable COO common libraries including network utilities, + JSON helpers, and MQTT client. if COO_COMMONS @@ -42,7 +42,14 @@ config COO_JSON default n help Enable lightweight JSON helper wrappers used by command handling - (msg_type parsing, keyed primitive extraction, and key splitting). + (keyed primitive extraction and key splitting). + +config COO_SCHEDULED_ACTIONS + bool "COO scheduled action helper" + default n + help + Enable a small fixed-table wrapper around Zephyr delayable work for named + firmware actions. This does not create a user-programmable scheduler. config COO_MQTT bool "COO MQTT client wrapper" @@ -76,6 +83,31 @@ config COO_MQTT_PAYLOAD_SIZE Maximum size for MQTT message payloads. This defines the size of RX and TX buffers (2x this value total). +config COO_CMD_SERIAL_GUARD + bool "Command dispatcher serial guard" + depends on COO_JSON + default n + help + Let the command dispatcher temporarily reject MQTT commands after local + serial activity. The guard is runtime-only and is not persisted. + +config COO_CMD_SERIAL_GUARD_DEFAULT_SECONDS + int "Default serial guard duration" + depends on COO_CMD_SERIAL_GUARD + default 30 + help + Default number of seconds that MQTT effect commands remain blocked after + serial activity. A serialguard command can change this until reboot. + +config COO_CMD_REBOOT + bool "Command dispatcher reboot command" + depends on REBOOT + default y + help + Let the command dispatcher own a built-in reboot command, including + command rejection after reboot is accepted and an optional application + prepare callback immediately before sys_reboot(). + endif # COO_MQTT endif # COO_COMMONS diff --git a/lib/coo_commons/command_dispatch.c b/lib/coo_commons/command_dispatch.c new file mode 100644 index 0000000..1bbcc98 --- /dev/null +++ b/lib/coo_commons/command_dispatch.c @@ -0,0 +1,2503 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(CONFIG_COO_CMD_REBOOT) +#include +#endif +#include + +LOG_MODULE_REGISTER(coo_command_dispatch, LOG_LEVEL_INF); + +#define SERIAL_POLL_CHAR_BUDGET 64 +#define COO_CMD_LASTCOMMAND_MAGIC 0x434c4344U /* "CLCD" */ +#define COO_CMD_LASTCOMMAND_VERSION 1U +#define COO_CMD_REBOOT_DEFAULT_DELAY_MS 3000U + +static void serial_reset_line(struct coo_cmd_runtime *runtime); +static int runtime_init_serial_console(struct coo_cmd_runtime *runtime); +static void runtime_enqueue_response(struct coo_cmd_runtime *runtime, + const struct coo_cmd_response *out); +static void runtime_load_lastcommand(struct coo_cmd_runtime *runtime); +static struct coo_cmd_response runtime_execute_default(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd); +#if defined(CONFIG_COO_CMD_REBOOT) +static void reboot_work_handler(struct k_work *work); +#endif +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) +static void serial_guard_expire_work_handler(struct k_work *work); +#endif + +struct coo_cmd_lastcommand_nvs_record { + uint32_t magic; + uint16_t version; + uint16_t size; + int64_t time_ms; + struct coo_cmd_request request; +}; + +int coo_cmd_runtime_configure(struct coo_cmd_runtime *runtime, + const struct coo_cmd_runtime_config *cfg) +{ + int rc; + + if (runtime == NULL || cfg == NULL || cfg->device_id == NULL || + cfg->device_id[0] == '\0' || cfg->inbound_queue == NULL || + cfg->outbound_queue == NULL || cfg->mqtt_msg_id == NULL || + strlen(cfg->device_id) >= sizeof(runtime->device_id)) { + return -EINVAL; + } + + memset(runtime, 0, sizeof(*runtime)); + strncpy(runtime->device_id, cfg->device_id, sizeof(runtime->device_id) - 1U); + rc = coo_cmd_format_request_prefix(runtime->device_id, + runtime->request_prefix, + sizeof(runtime->request_prefix)); + if (rc != 0) { + return rc; + } + rc = coo_cmd_format_data_topic(runtime->device_id, "warning", + runtime->warning_topic, + sizeof(runtime->warning_topic)); + if (rc != 0) { + return rc; + } + + runtime->inbound_queue = cfg->inbound_queue; + runtime->outbound_queue = cfg->outbound_queue; + runtime->execute_handler = cfg->execute_handler; + runtime->mqtt_msg_id = cfg->mqtt_msg_id; + runtime->serial_wrap_column = cfg->serial_wrap_column != 0U ? + cfg->serial_wrap_column : + COO_CMD_SERIAL_WRAP_COLUMN; + runtime->command_specs = cfg->command_specs; + runtime->command_spec_count = cfg->command_spec_count; + runtime->lastcommand_nvs = cfg->lastcommand_nvs; + runtime->lastcommand_nvs_id = cfg->lastcommand_nvs_id; + runtime->user_data = cfg->user_data; +#if defined(CONFIG_COO_CMD_REBOOT) + runtime->reboot_delay_ms = cfg->reboot_delay_ms != 0U ? + cfg->reboot_delay_ms : + COO_CMD_REBOOT_DEFAULT_DELAY_MS; + runtime->reboot_prepare = cfg->reboot_prepare; + k_work_init_delayable(&runtime->reboot_work, reboot_work_handler); + (void)atomic_clear(&runtime->reboot_pending); +#endif +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + runtime->serial_guard_seconds = CONFIG_COO_CMD_SERIAL_GUARD_DEFAULT_SECONDS; + k_work_init_delayable(&runtime->serial_guard_work, + serial_guard_expire_work_handler); + (void)atomic_clear(&runtime->serial_guard_active); +#endif + runtime_load_lastcommand(runtime); + + return runtime_init_serial_console(runtime); +} + +const struct coo_cmd_spec * +coo_cmd_runtime_find_spec(const struct coo_cmd_runtime *runtime, + const char *key) +{ + const struct coo_cmd_spec *best = NULL; + size_t best_len = 0U; + + if (runtime == NULL || key == NULL || runtime->command_specs == NULL) { + return NULL; + } + + for (size_t i = 0U; i < runtime->command_spec_count; ++i) { + const struct coo_cmd_spec *spec = &runtime->command_specs[i]; + const size_t len = spec->key != NULL ? strlen(spec->key) : 0U; + + if (len == 0U || !coo_cmd_key_matches_prefix(key, spec->key)) { + continue; + } + if (len > best_len) { + best = spec; + best_len = len; + } + } + + return best; +} + +bool coo_cmd_runtime_spec_supported(const struct coo_cmd_runtime *runtime, + const struct coo_cmd_spec *spec) +{ + if (spec == NULL) { + return false; + } + + return spec->supported == NULL || spec->supported(spec, runtime != NULL ? + runtime->user_data : NULL); +} + +const char *coo_cmd_source_name(enum coo_cmd_source source) +{ + switch (source) { + case COO_CMD_SOURCE_SERIAL: + return "serial"; + case COO_CMD_SOURCE_MQTT: + return "mqtt"; + default: + return "unknown"; + } +} + +static bool fixed_string_terminated(const char *text, size_t text_len) +{ + return text != NULL && memchr(text, '\0', text_len) != NULL; +} + +static bool lastcommand_record_valid(const struct coo_cmd_lastcommand_nvs_record *record) +{ + const struct coo_cmd_request *request; + + if (record == NULL || + record->magic != COO_CMD_LASTCOMMAND_MAGIC || + record->version != COO_CMD_LASTCOMMAND_VERSION || + record->size != sizeof(*record)) { + return false; + } + + request = &record->request; + return request->payload_len < sizeof(request->payload) && + request->corr_len <= sizeof(request->correlation_data) && + fixed_string_terminated(request->key, sizeof(request->key)) && + fixed_string_terminated(request->session_id, sizeof(request->session_id)) && + fixed_string_terminated(request->response_topic, sizeof(request->response_topic)) && + fixed_string_terminated(request->payload, sizeof(request->payload)); +} + +static void runtime_load_lastcommand(struct coo_cmd_runtime *runtime) +{ + struct coo_cmd_lastcommand_nvs_record record; + int rc; + + if (runtime == NULL || runtime->lastcommand_nvs == NULL || + runtime->lastcommand_nvs_id == 0U) { + return; + } + + rc = nvs_read(runtime->lastcommand_nvs, runtime->lastcommand_nvs_id, + &record, sizeof(record)); + if (rc == -ENOENT) { + return; + } + if (rc != (int)sizeof(record) || !lastcommand_record_valid(&record)) { + LOG_WRN("Ignoring invalid persisted command lastcommand (%d)", rc); + return; + } + + runtime->lastcommand.valid = true; + runtime->lastcommand.time_ms = record.time_ms; + runtime->lastcommand.request = record.request; +} + +static void runtime_record_lastcommand(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + struct coo_cmd_lastcommand_nvs_record record = { + .magic = COO_CMD_LASTCOMMAND_MAGIC, + .version = COO_CMD_LASTCOMMAND_VERSION, + .size = sizeof(record), + }; + int rc; + + if (runtime == NULL || cmd == NULL || cmd->msg_type != COO_CMD_EFFECT) { + return; + } + + record.time_ms = k_uptime_get(); + record.request = *cmd; + runtime->lastcommand.valid = true; + runtime->lastcommand.time_ms = record.time_ms; + runtime->lastcommand.request = record.request; + + if (runtime->lastcommand_nvs == NULL || runtime->lastcommand_nvs_id == 0U) { + return; + } + + rc = nvs_write(runtime->lastcommand_nvs, runtime->lastcommand_nvs_id, + &record, sizeof(record)); + if (rc < 0) { + LOG_WRN("NVS lastcommand write failed (%d)", rc); + } +} + +bool coo_cmd_runtime_get_lastcommand(const struct coo_cmd_runtime *runtime, + struct coo_cmd_lastcommand *out) +{ + if (runtime == NULL || out == NULL || !runtime->lastcommand.valid) { + return false; + } + + *out = runtime->lastcommand; + return true; +} + +static int format_device_topic(const char *device_id, char *buf, size_t buf_len, + const char *prefix, const char *suffix) +{ + int written; + + if (device_id == NULL || device_id[0] == '\0' || buf == NULL || + buf_len == 0U || prefix == NULL) { + return -EINVAL; + } + + written = snprintk(buf, buf_len, "%s%s%s", + prefix, device_id, suffix != NULL ? suffix : ""); + return (written < 0 || written >= (int)buf_len) ? -ENOSPC : 0; +} + +int coo_cmd_format_request_prefix(const char *device_id, + char *buf, + size_t buf_len) +{ + return format_device_topic(device_id, buf, buf_len, "cmd/", "/req/"); +} + +int coo_cmd_format_response_topic(const char *device_id, + const char *key, + char *buf, + size_t buf_len) +{ + char suffix[96]; + int written; + + written = snprintk(suffix, sizeof(suffix), "/resp/%s", + key != NULL ? key : ""); + if (written < 0 || written >= (int)sizeof(suffix)) { + return -ENOSPC; + } + + return format_device_topic(device_id, buf, buf_len, "cmd/", suffix); +} + +int coo_cmd_format_data_topic(const char *device_id, + const char *suffix, + char *buf, + size_t buf_len) +{ + char topic_suffix[64]; + int written; + + if (suffix == NULL || suffix[0] == '\0') { + return -EINVAL; + } + + written = snprintk(topic_suffix, sizeof(topic_suffix), "/%s", suffix); + if (written < 0 || written >= (int)sizeof(topic_suffix)) { + return -ENOSPC; + } + + return format_device_topic(device_id, buf, buf_len, "dt/", topic_suffix); +} + +bool coo_cmd_key_matches_prefix(const char *key, const char *prefix) +{ + size_t len; + + if (key == NULL || prefix == NULL) { + return false; + } + + len = strlen(prefix); + if (strncmp(key, prefix, len) != 0) { + return false; + } + + return key[len] == '\0' || key[len] == '/'; +} + +const char *coo_cmd_key_suffix_after(const char *key, const char *prefix) +{ + size_t len; + + if (!coo_cmd_key_matches_prefix(key, prefix)) { + return ""; + } + + len = strlen(prefix); + return key[len] == '/' ? key + len + 1U : ""; +} + +int coo_cmd_key_suffix_segment_copy(const char *key, + const char *prefix, + char *suffix, + size_t suffix_len) +{ + const char *start; + size_t prefix_len; + size_t parsed_len; + + if (key == NULL || prefix == NULL || suffix == NULL || suffix_len == 0U) { + return -EINVAL; + } + suffix[0] = '\0'; + + if (!coo_cmd_key_matches_prefix(key, prefix)) { + return -EINVAL; + } + + prefix_len = strlen(prefix); + if (key[prefix_len] != '/') { + return -ENOENT; + } + + start = key + prefix_len + 1U; + parsed_len = strcspn(start, "/"); + if (parsed_len == 0U || start[parsed_len] != '\0') { + return -EINVAL; + } + if (parsed_len >= suffix_len) { + return -ENOSPC; + } + + memcpy(suffix, start, parsed_len); + suffix[parsed_len] = '\0'; + return 0; +} + +int coo_cmd_key_suffix_pair_copy(const char *key, + const char *prefix, + char *first, + size_t first_len, + char *second, + size_t second_len) +{ + const char *start; + const char *slash; + size_t prefix_len; + size_t first_parsed_len; + size_t second_parsed_len; + + if (key == NULL || prefix == NULL || first == NULL || second == NULL || + first_len == 0U || second_len == 0U) { + return -EINVAL; + } + first[0] = '\0'; + second[0] = '\0'; + + if (!coo_cmd_key_matches_prefix(key, prefix)) { + return -EINVAL; + } + + prefix_len = strlen(prefix); + if (key[prefix_len] != '/') { + return -ENOENT; + } + + start = key + prefix_len + 1U; + slash = strchr(start, '/'); + if (slash == NULL) { + return -EINVAL; + } + + first_parsed_len = (size_t)(slash - start); + second_parsed_len = strcspn(slash + 1, "/"); + if (first_parsed_len == 0U || + second_parsed_len == 0U || + (slash + 1)[second_parsed_len] != '\0') { + return -EINVAL; + } + if (first_parsed_len >= first_len || second_parsed_len >= second_len) { + return -ENOSPC; + } + + memcpy(first, start, first_parsed_len); + first[first_parsed_len] = '\0'; + memcpy(second, slash + 1, second_parsed_len); + second[second_parsed_len] = '\0'; + return 0; +} + +bool coo_cmd_payload_empty(const struct coo_cmd_request *cmd) +{ + return cmd == NULL || cmd->payload_len == 0U || strcmp(cmd->payload, "{}") == 0; +} + +bool coo_cmd_copy_mqtt_utf8(const struct mqtt_utf8 *topic, + char *out, + size_t out_len) +{ + if (topic == NULL || out == NULL || topic->size == 0U || + topic->size >= out_len) { + return false; + } + + memcpy(out, topic->utf8, topic->size); + out[topic->size] = '\0'; + return true; +} + +static const char *skip_serial_space(const char *s) +{ + while (s != NULL && (*s == ' ' || *s == '\t')) { + s++; + } + + return s; +} + +bool coo_cmd_serial_next_token(const char **cursor, char *out, size_t out_len) +{ + const char *start; + size_t len; + + if (cursor == NULL || *cursor == NULL || out == NULL || out_len == 0U) { + return false; + } + + start = skip_serial_space(*cursor); + if (*start == '\0') { + *cursor = start; + return false; + } + + len = strcspn(start, " \t"); + if (len >= out_len) { + len = out_len - 1U; + } + + memcpy(out, start, len); + out[len] = '\0'; + *cursor = start + strcspn(start, " \t"); + return true; +} + +bool coo_cmd_serial_has_extra(const char *cursor) +{ + cursor = skip_serial_space(cursor); + return cursor != NULL && *cursor != '\0'; +} + +bool coo_cmd_serial_token_is_number(const char *token) +{ + char *end = NULL; + double value; + + if (token == NULL || token[0] == '\0') { + return false; + } + + value = strtod(token, &end); + return end != token && end != NULL && *end == '\0' && isfinite(value); +} + +static bool serial_token_has_control(const char *token) +{ + if (token == NULL) { + return true; + } + + for (const char *p = token; *p != '\0'; ++p) { + if (iscntrl((unsigned char)*p)) { + return true; + } + } + + return false; +} + +static bool serial_token_is_json_number(const char *token) +{ + const char *p = token; + + if (p == NULL || *p == '\0') { + return false; + } + + if (*p == '-') { + p++; + } + + if (*p == '0') { + p++; + } else if (*p >= '1' && *p <= '9') { + do { + p++; + } while (isdigit((unsigned char)*p)); + } else { + return false; + } + + if (*p == '.') { + p++; + if (!isdigit((unsigned char)*p)) { + return false; + } + while (isdigit((unsigned char)*p)) { + p++; + } + } + + if (*p == 'e' || *p == 'E') { + p++; + if (*p == '+' || *p == '-') { + p++; + } + if (!isdigit((unsigned char)*p)) { + return false; + } + while (isdigit((unsigned char)*p)) { + p++; + } + } + + return *p == '\0'; +} + +static int serial_append_json_number(char *out, size_t out_len, size_t *off, + const char *token) +{ + char *end = NULL; + double value; + int written; + + value = strtod(token, &end); + if (end == token || end == NULL || *end != '\0' || !isfinite(value)) { + return -EINVAL; + } + + if (serial_token_is_json_number(token)) { + written = snprintk(out + *off, out_len - *off, "%s", token); + } else { + /* Serial accepts human shorthand such as .5; normalize it before + * handlers parse the generated JSON. + */ + written = snprintk(out + *off, out_len - *off, "%.17g", value); + } + + if (written < 0 || written >= (int)(out_len - *off)) { + return -ENOSPC; + } + + *off += (size_t)written; + return 0; +} + +static const char *serial_token_bool_json(const char *token) +{ + if (token == NULL) { + return NULL; + } + + if (strcasecmp(token, "true") == 0 || strcasecmp(token, "on") == 0 || + strcasecmp(token, "yes") == 0) { + return "true"; + } + if (strcasecmp(token, "false") == 0 || strcasecmp(token, "off") == 0 || + strcasecmp(token, "no") == 0) { + return "false"; + } + + return NULL; +} + +int coo_cmd_serial_append_json_value(char *out, size_t out_len, size_t *off, + const char *token) +{ + const char *bool_json = serial_token_bool_json(token); + int written; + + if (out == NULL || off == NULL || token == NULL || *off >= out_len) { + return -EINVAL; + } + if (serial_token_has_control(token)) { + return -EINVAL; + } + + if (bool_json != NULL) { + written = snprintk(out + *off, out_len - *off, "%s", bool_json); + } else if (coo_cmd_serial_token_is_number(token)) { + return serial_append_json_number(out, out_len, off, token); + } else if (strcasecmp(token, "null") == 0) { + written = snprintk(out + *off, out_len - *off, "%s", token); + } else { + if (strchr(token, '"') != NULL || strchr(token, '\\') != NULL) { + return -EINVAL; + } + written = snprintk(out + *off, out_len - *off, "\"%s\"", token); + } + + if (written < 0 || written >= (int)(out_len - *off)) { + return -ENOSPC; + } + *off += (size_t)written; + return 0; +} + +int coo_cmd_serial_append_json_field(char *out, size_t out_len, size_t *off, + const char *key, const char *token, + bool comma) +{ + int written; + + if (key == NULL || token == NULL || key[0] == '\0' || + strchr(key, '"') != NULL || strchr(key, '\\') != NULL || + serial_token_has_control(key) || serial_token_has_control(token) || + *off >= out_len) { + return -EINVAL; + } + + written = snprintk(out + *off, out_len - *off, + "%s\"%s\":", comma ? "," : "", key); + if (written < 0 || written >= (int)(out_len - *off)) { + return -ENOSPC; + } + *off += (size_t)written; + + return coo_cmd_serial_append_json_value(out, out_len, off, token); +} + +static int serial_payload_from_key_values(const char *payload, char *out, + size_t out_len) +{ + const char *cursor = payload; + char token[128]; + bool first = true; + size_t off = 0U; + int written; + + written = snprintk(out, out_len, "{"); + if (written < 0 || written >= (int)out_len) { + return -ENOSPC; + } + off = (size_t)written; + + while (coo_cmd_serial_next_token(&cursor, token, sizeof(token))) { + char *eq = strchr(token, '='); + + if (eq == NULL || eq == token || eq[1] == '\0') { + return -EINVAL; + } + *eq = '\0'; + + if (coo_cmd_serial_append_json_field(out, out_len, &off, token, eq + 1, + !first) != 0) { + return -EINVAL; + } + first = false; + } + + written = snprintk(out + off, out_len - off, "}"); + return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; +} + +static int serial_payload_from_value(const char *payload, char *out, size_t out_len) +{ + const char *cursor = payload; + char token[128] = {0}; + size_t off = 0U; + int written; + + if (!coo_cmd_serial_next_token(&cursor, token, sizeof(token)) || + coo_cmd_serial_has_extra(cursor)) { + return -EINVAL; + } + + written = snprintk(out, out_len, "{\"value\":"); + if (written < 0 || written >= (int)out_len) { + return -ENOSPC; + } + off = (size_t)written; + if (coo_cmd_serial_append_json_value(out, out_len, &off, token) != 0) { + return -EINVAL; + } + written = snprintk(out + off, out_len - off, "}"); + return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; +} + +static int serial_payload_from_positional(const char *payload, + const struct coo_cmd_serial_positional *pos, + char *out, + size_t out_len) +{ + const char *cursor = payload; + char token[COO_CMD_SERIAL_POSITIONAL_MAX][128] = {{0}}; + uint8_t count = 0U; + size_t off = 0U; + int written; + + if (pos == NULL || pos->field[0] == NULL || + pos->required_count > COO_CMD_SERIAL_POSITIONAL_MAX) { + return -EINVAL; + } + + while (count < COO_CMD_SERIAL_POSITIONAL_MAX && + coo_cmd_serial_next_token(&cursor, token[count], sizeof(token[count]))) { + count++; + } + if (coo_cmd_serial_has_extra(cursor) || count < pos->required_count) { + return -EINVAL; + } + + written = snprintk(out, out_len, "{"); + if (written < 0 || written >= (int)out_len) { + return -ENOSPC; + } + off = (size_t)written; + + for (uint8_t i = 0U; i < count; ++i) { + if (pos->field[i] == NULL || token[i][0] == '\0') { + return -EINVAL; + } + if ((pos->numeric_mask & BIT(i)) != 0U && + !coo_cmd_serial_token_is_number(token[i])) { + return -EINVAL; + } + if (coo_cmd_serial_append_json_field(out, out_len, &off, + pos->field[i], token[i], + i != 0U) != 0) { + return -EINVAL; + } + } + + written = snprintk(out + off, out_len - off, "}"); + return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; +} + +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) +static int serial_payload_from_serial_guard(const char *payload, char *out, size_t out_len) +{ + const char *cursor = payload; + const char *seconds; + char token[32] = {0}; + size_t off = 0U; + int written; + + if (!coo_cmd_serial_next_token(&cursor, token, sizeof(token)) || + coo_cmd_serial_has_extra(cursor)) { + return -EINVAL; + } + + seconds = (strcasecmp(token, "off") == 0) ? "0" : token; + written = snprintk(out, out_len, "{\"seconds\":"); + if (written < 0 || written >= (int)out_len) { + return -ENOSPC; + } + off = (size_t)written; + if (coo_cmd_serial_append_json_value(out, out_len, &off, seconds) != 0) { + return -EINVAL; + } + written = snprintk(out + off, out_len - off, "}"); + return (written < 0 || written >= (int)(out_len - off)) ? -ENOSPC : 0; +} +#endif + +int coo_cmd_normalize_serial_payload(const char *key, + const char *payload, + coo_cmd_serial_shorthand_fn shorthand, + void *user_data, + char *out, + size_t out_len) +{ + if (out == NULL || out_len == 0U) { + return -EINVAL; + } + + payload = skip_serial_space(payload); + if (payload == NULL || payload[0] == '\0') { + int written = snprintk(out, out_len, "{}"); + + return (written < 0 || written >= (int)out_len) ? -ENOSPC : 0; + } + + if (payload[0] == '{') { + if (strlen(payload) >= out_len) { + return -ENOSPC; + } + strncpy(out, payload, out_len - 1U); + out[out_len - 1U] = '\0'; + return 0; + } + + if (strchr(payload, '=') != NULL) { + return serial_payload_from_key_values(payload, out, out_len); + } + +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + if (key != NULL && strcmp(key, "serialguard") == 0) { + return serial_payload_from_serial_guard(payload, out, out_len); + } +#endif + + if (shorthand != NULL) { + return shorthand(key, payload, out, out_len, user_data); + } + + return serial_payload_from_value(payload, out, out_len); +} + +static int runtime_normalize_serial_payload(const struct coo_cmd_spec *spec, + const char *key, + const char *payload, + void *user_data, + char *out, + size_t out_len) +{ + payload = skip_serial_space(payload); + if (payload != NULL && payload[0] != '\0' && + payload[0] != '{' && strchr(payload, '=') == NULL && + spec != NULL && spec->serial_positional.field[0] != NULL) { + return serial_payload_from_positional(payload, + &spec->serial_positional, + out, out_len); + } + + return coo_cmd_normalize_serial_payload(key, payload, + spec != NULL ? spec->serial_shorthand : NULL, + user_data, out, out_len); +} + +struct coo_cmd_response +coo_cmd_make_response(const struct coo_cmd_request *cmd, + enum coo_cmd_msg_type msg_type, + const char *payload, + coo_cmd_format_response_topic_fn format_topic, + void *user_data) +{ + static const char overflow_msg[] = "{\"error\":\"response too large\"}"; + struct coo_cmd_response r = {0}; + + r.msg_type = msg_type; + r.target = (cmd != NULL && cmd->source == COO_CMD_SOURCE_SERIAL) ? + COO_CMD_OUT_SERIAL : COO_CMD_OUT_MQTT; + r.qos = MQTT_QOS_1_AT_LEAST_ONCE; + + if (format_topic != NULL) { + (void)format_topic(cmd != NULL ? cmd->key : "", + r.topic, sizeof(r.topic), user_data); + } + + if (cmd != NULL && cmd->response_topic[0] != '\0' && + strlen(cmd->response_topic) < sizeof(r.topic)) { + strncpy(r.topic, cmd->response_topic, sizeof(r.topic) - 1U); + } + + if (cmd != NULL && cmd->corr_len > 0U && + cmd->corr_len <= sizeof(r.correlation_data)) { + memcpy(r.correlation_data, cmd->correlation_data, cmd->corr_len); + r.corr_len = cmd->corr_len; + } + + if (payload != NULL && strlen(payload) >= sizeof(r.payload)) { + r.msg_type = COO_CMD_RESP_ERROR; + snprintk(r.payload, sizeof(r.payload), "%s", overflow_msg); + r.payload_len = strlen(r.payload); + return r; + } + + snprintk(r.payload, sizeof(r.payload), "%s", payload != NULL ? payload : ""); + r.payload_len = strlen(r.payload); + return r; +} + +struct coo_cmd_response +coo_cmd_reply(const struct coo_cmd_request *cmd, + enum coo_cmd_msg_type msg_type, + const char *payload) +{ + return coo_cmd_make_response(cmd, msg_type, payload, NULL, NULL); +} + +struct coo_cmd_response coo_cmd_ok(const struct coo_cmd_request *cmd) +{ + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, "{\"status\":\"ok\"}"); +} + +struct coo_cmd_response coo_cmd_error(const struct coo_cmd_request *cmd, + const char *msg) +{ + char payload[COO_CMD_PAYLOAD_MAX]; + + snprintk(payload, sizeof(payload), "{\"error\":\"%s\"}", + msg != NULL ? msg : "Unspecified error"); + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, payload); +} + +struct coo_cmd_response coo_cmd_error_rc(const struct coo_cmd_request *cmd, + const char *msg, + int rc) +{ + char payload[COO_CMD_PAYLOAD_MAX]; + + snprintk(payload, sizeof(payload), "{\"error\":\"%s\",\"rc\":%d}", + msg != NULL ? msg : "", rc); + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, payload); +} + +struct coo_cmd_response coo_cmd_invalid_response(const struct coo_cmd_request *cmd) +{ + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Invalid or unrecognized command\"}"); +} + +struct coo_cmd_response coo_cmd_unknown_response(const struct coo_cmd_request *cmd) +{ + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Unknown request\"}"); +} + +struct coo_cmd_response coo_cmd_unsupported_response(const struct coo_cmd_request *cmd) +{ + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"Unsupported operation\"}"); +} + +struct coo_cmd_response coo_cmd_busy_response(const struct coo_cmd_request *cmd) +{ + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, "{\"error\":\"busy\"}"); +} + +struct coo_cmd_response coo_cmd_serial_active_response(const struct coo_cmd_request *cmd) +{ + return coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"try later. local serial commands active\"}"); +} + +static int append_format(char *buf, size_t buf_len, size_t *off, const char *fmt, ...) +{ + va_list args; + int written; + + if (buf == NULL || off == NULL || fmt == NULL || *off >= buf_len) { + return -EINVAL; + } + + va_start(args, fmt); + written = vsnprintk(buf + *off, buf_len - *off, fmt, args); + va_end(args); + + if (written < 0 || written >= (int)(buf_len - *off)) { + return -ENOSPC; + } + *off += (size_t)written; + return 0; +} + +static int append_json_string(char *buf, size_t buf_len, size_t *off, + const char *text) +{ + const char *s = text != NULL ? text : ""; + + if (buf == NULL || off == NULL || *off >= buf_len) { + return -EINVAL; + } + + for (; *s != '\0'; ++s) { + int written; + + if (*s == '"' || *s == '\\') { + written = snprintk(buf + *off, buf_len - *off, "\\%c", *s); + } else if ((unsigned char)*s < 0x20U) { + written = snprintk(buf + *off, buf_len - *off, "?"); + } else { + written = snprintk(buf + *off, buf_len - *off, "%c", *s); + } + + if (written < 0 || written >= (int)(buf_len - *off)) { + return -ENOSPC; + } + *off += (size_t)written; + } + + return 0; +} + +int coo_cmd_build_warning(struct coo_cmd_response *out, + const char *topic, + const char *code, + const char *msg, + const char *context) +{ + size_t off; + int written; + + if (out == NULL || topic == NULL || + strlen(topic) >= sizeof(out->topic)) { + return -EINVAL; + } + + memset(out, 0, sizeof(*out)); + out->msg_type = COO_CMD_RESP_OK; + out->target = COO_CMD_OUT_MQTT_BEST_EFFORT; + out->qos = 0U; + strncpy(out->topic, topic, sizeof(out->topic) - 1U); + + written = snprintk(out->payload, sizeof(out->payload), + "{\"severity\":\"warning\",\"code\":\""); + if (written < 0 || written >= (int)sizeof(out->payload)) { + return -ENOSPC; + } + off = (size_t)written; + + if (append_json_string(out->payload, sizeof(out->payload), &off, code) != 0) { + return -ENOSPC; + } + + written = snprintk(out->payload + off, sizeof(out->payload) - off, + "\",\"msg\":\""); + if (written < 0 || written >= (int)(sizeof(out->payload) - off)) { + return -ENOSPC; + } + off += (size_t)written; + + if (append_json_string(out->payload, sizeof(out->payload), &off, msg) != 0) { + return -ENOSPC; + } + + written = snprintk(out->payload + off, sizeof(out->payload) - off, + "\",\"context\":\""); + if (written < 0 || written >= (int)(sizeof(out->payload) - off)) { + return -ENOSPC; + } + off += (size_t)written; + + if (append_json_string(out->payload, sizeof(out->payload), &off, context) != 0) { + return -ENOSPC; + } + + written = snprintk(out->payload + off, sizeof(out->payload) - off, + "\",\"uptime_ms\":%lld}", + (long long)k_uptime_get()); + if (written < 0 || written >= (int)(sizeof(out->payload) - off)) { + return -ENOSPC; + } + off += (size_t)written; + out->payload_len = off; + return 0; +} + +int coo_cmd_warning_emit(struct k_msgq *outbound_queue, + const char *topic, + const char *code, + const char *msg, + const char *context) +{ + struct coo_cmd_response out; + int rc; + + LOG_WRN("%s: %s%s%s", + code != NULL ? code : "warning", + msg != NULL ? msg : "", + context != NULL && context[0] != '\0' ? " context=" : "", + context != NULL ? context : ""); + + rc = coo_cmd_build_warning(&out, topic, code, msg, context); + if (rc != 0) { + LOG_WRN("warning payload too large; MQTT warning dropped"); + return rc; + } + + if (outbound_queue == NULL || k_msgq_put(outbound_queue, &out, K_NO_WAIT) != 0) { + LOG_WRN("warning MQTT queue full; warning was only logged locally"); + return -ENOSPC; + } + + return 0; +} + +int coo_cmd_runtime_warning_emit(struct coo_cmd_runtime *runtime, + const char *code, + const char *msg, + const char *context) +{ + if (runtime == NULL || runtime->warning_topic[0] == '\0') { + return -EINVAL; + } + + return coo_cmd_warning_emit(runtime->outbound_queue, + runtime->warning_topic, + code, msg, context); +} + +static bool payload_has_text(const char *payload) +{ + payload = skip_serial_space(payload); + return payload != NULL && payload[0] != '\0'; +} + +static const struct coo_cmd_help_entry builtin_help_entries[] = { + { + .key = "help", + .usage = "help", + .args = "none", + .values = NULL, + .notes = "serial prints full command help directly; MQTT returns compact endpoints", + .flags = COO_CMD_HELP_QUERY | COO_CMD_HELP_SERIAL_GUARD_QUERY | + COO_CMD_HELP_BUILTIN, + }, +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + { + .key = "serialguard", + .usage = "serialguard [seconds=|off]", + .args = "[seconds=] or [off]", + .values = "seconds: 0 disables MQTT holdoff until changed again", + .notes = "runtime-only local serial guard; not persisted across reboot", + .flags = COO_CMD_HELP_QUERY | COO_CMD_HELP_EFFECT | + COO_CMD_HELP_SERIAL_GUARD_QUERY | COO_CMD_HELP_BUILTIN, + }, +#endif +#if defined(CONFIG_COO_CMD_REBOOT) + { + .key = "reboot", + .usage = "reboot", + .args = "none", + .values = NULL, + .notes = "schedules a non-cancelable reboot after the response window", + .flags = COO_CMD_HELP_EFFECT | COO_CMD_HELP_BUILTIN, + }, +#endif +}; + +static bool runtime_key_is_help(const char *key) +{ + return key != NULL && strcmp(key, "help") == 0; +} + +static bool runtime_key_is_serial_guard(const char *key) +{ +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + return key != NULL && strcmp(key, "serialguard") == 0; +#else + ARG_UNUSED(key); + return false; +#endif +} + +static bool runtime_key_is_reboot(const char *key) +{ +#if defined(CONFIG_COO_CMD_REBOOT) + return key != NULL && strcmp(key, "reboot") == 0; +#else + ARG_UNUSED(key); + return false; +#endif +} + +static void serial_line_end(void) +{ + printk("\r\n"); +} + +static uint16_t serial_print_prefix(const char *prefix) +{ + uint16_t col = 0U; + + for (const char *s = prefix != NULL ? prefix : ""; *s != '\0'; ++s) { + printk("%c", *s); + col++; + } + + return col; +} + +static void serial_print_wrapped_text(const char *prefix, + const char *text, + uint16_t wrap_column) +{ + uint16_t col; + size_t word_len = 0U; + + if (text == NULL || text[0] == '\0') { + return; + } + + col = serial_print_prefix(prefix); + for (const char *s = text; *s != '\0'; ++s) { + const bool at_space = (*s == ' ' || *s == '\t'); + + if (!at_space) { + word_len++; + } + + if (wrap_column != 0U && col >= wrap_column && at_space) { + serial_line_end(); + col = serial_print_prefix(" "); + word_len = 0U; + continue; + } + if (wrap_column != 0U && col + word_len >= wrap_column && + word_len > 0U && at_space) { + serial_line_end(); + col = serial_print_prefix(" "); + word_len = 0U; + continue; + } + + printk("%c", at_space ? ' ' : *s); + col++; + if (at_space) { + word_len = 0U; + } + } + serial_line_end(); +} + +static void serial_print_help_entry(const struct coo_cmd_runtime *runtime, + const char *key, + const struct coo_cmd_help_entry *entry, + const struct coo_cmd_spec *spec, + uint16_t wrap_column) +{ + if (entry == NULL || key == NULL) { + return; + } + + printk(" %s", key); + if (spec != NULL && !coo_cmd_runtime_spec_supported(runtime, spec)) { + printk(" [unsupported]"); + } + if ((entry->flags & COO_CMD_HELP_QUERY) != 0U && + (entry->flags & COO_CMD_HELP_EFFECT) != 0U) { + printk(" query/effect"); + } else if ((entry->flags & COO_CMD_HELP_QUERY) != 0U) { + printk(" query"); + } else if ((entry->flags & COO_CMD_HELP_EFFECT) != 0U) { + printk(" effect"); + } + serial_line_end(); + + serial_print_wrapped_text(" use: ", entry->usage, wrap_column); + serial_print_wrapped_text(" args: ", entry->args, wrap_column); + serial_print_wrapped_text(" values: ", entry->values, wrap_column); + serial_print_wrapped_text(" notes: ", entry->notes, wrap_column); +} + +static void runtime_print_serial_help(const struct coo_cmd_runtime *runtime, + uint16_t wrap_column) +{ + if (wrap_column == 0U) { + wrap_column = COO_CMD_SERIAL_WRAP_COLUMN; + } + + printk("serial help"); + serial_line_end(); + printk(" device: %s", runtime != NULL ? runtime->device_id : ""); + serial_line_end(); + printk(" request prefix: %s", runtime != NULL ? runtime->request_prefix : ""); + serial_line_end(); + printk(" [] marks optional payload fields or serial tokens"); + serial_line_end(); + + for (size_t i = 0U; i < ARRAY_SIZE(builtin_help_entries); ++i) { + serial_print_help_entry(runtime, builtin_help_entries[i].key, + &builtin_help_entries[i], NULL, + wrap_column); + } + if (runtime != NULL) { + for (size_t i = 0U; i < runtime->command_spec_count; ++i) { + const struct coo_cmd_spec *spec = &runtime->command_specs[i]; + + if (spec->help != NULL) { + serial_print_help_entry(runtime, spec->key, + spec->help, spec, + wrap_column); + } + } + } +} + +static int append_help_key(char *payload, size_t payload_len, size_t *off, + const char *key, bool *first) +{ + if (key == NULL) { + return 0; + } + + if (append_format(payload, payload_len, off, "%s\"", + *first ? "" : ",") != 0 || + append_json_string(payload, payload_len, off, key) != 0 || + append_format(payload, payload_len, off, "\"") != 0) { + return -ENOSPC; + } + *first = false; + return 0; +} + +static struct coo_cmd_response runtime_help_response(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + char payload[COO_CMD_PAYLOAD_MAX]; + char response_prefix[COO_CMD_TOPIC_MAX]; + size_t off = 0U; + bool first = true; + + if (runtime == NULL || + coo_cmd_format_response_topic(runtime->device_id, "", + response_prefix, + sizeof(response_prefix)) != 0) { + return coo_cmd_error(cmd, "help topic formatting failed"); + } + + if (append_format(payload, sizeof(payload), + &off, + "{\"device\":\"%s\",\"request_prefix\":\"%s\"," + "\"response_prefix\":\"%s\",\"commands\":[", + runtime->device_id, + runtime->request_prefix, + response_prefix) != 0) { + return coo_cmd_error(cmd, "help response too large"); + } + for (size_t i = 0U; i < ARRAY_SIZE(builtin_help_entries); ++i) { + if (append_help_key(payload, sizeof(payload), &off, + builtin_help_entries[i].key, &first) != 0) { + return coo_cmd_error(cmd, "help response too large"); + } + } + for (size_t i = 0U; i < runtime->command_spec_count; ++i) { + const struct coo_cmd_spec *spec = &runtime->command_specs[i]; + + if (spec->help != NULL && + append_help_key(payload, sizeof(payload), &off, + spec->key, &first) != 0) { + return coo_cmd_error(cmd, "help response too large"); + } + } + if (append_format(payload, sizeof(payload), &off, "]}") != 0) { + return coo_cmd_error(cmd, "help response too large"); + } + + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) +static bool runtime_serial_guard_active(const struct coo_cmd_runtime *runtime) +{ + return runtime != NULL && atomic_get(&runtime->serial_guard_active) != 0; +} + +static void runtime_clear_serial_guard(struct coo_cmd_runtime *runtime) +{ + if (runtime == NULL) { + return; + } + + (void)atomic_clear(&runtime->serial_guard_active); + (void)k_work_cancel_delayable(&runtime->serial_guard_work); +} + +static void runtime_note_serial_guard_activity(struct coo_cmd_runtime *runtime) +{ + int rc; + + if (runtime == NULL) { + return; + } + if (runtime->serial_guard_seconds == 0U) { + runtime_clear_serial_guard(runtime); + return; + } + + (void)atomic_set(&runtime->serial_guard_active, 1); + rc = k_work_reschedule(&runtime->serial_guard_work, + K_SECONDS(runtime->serial_guard_seconds)); + if (rc < 0) { + (void)atomic_clear(&runtime->serial_guard_active); + LOG_ERR("Failed to schedule serial guard expiration (%d)", rc); + } +} + +static void serial_guard_expire_work_handler(struct k_work *work) +{ + struct k_work_delayable *dwork = k_work_delayable_from_work(work); + struct coo_cmd_runtime *runtime = + CONTAINER_OF(dwork, struct coo_cmd_runtime, serial_guard_work); + + (void)atomic_clear(&runtime->serial_guard_active); + LOG_INF("Serial guard expired; MQTT command execution is enabled"); +} + +static bool runtime_mqtt_allowed_during_serial_guard(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + const struct coo_cmd_spec *spec; + + if (!runtime_serial_guard_active(runtime)) { + return true; + } + + if (cmd == NULL || cmd->msg_type != COO_CMD_QUERY) { + return false; + } + + if (runtime_key_is_help(cmd->key) || runtime_key_is_serial_guard(cmd->key)) { + return true; + } + + spec = coo_cmd_runtime_find_spec(runtime, cmd->key); + return spec != NULL && + spec->query_handler != NULL && + spec->mqtt_query_allowed_during_serial_guard; +} + +static struct coo_cmd_response runtime_serial_guard_get(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + char payload[COO_CMD_PAYLOAD_MAX]; + int64_t remaining_ms = 0; + k_ticks_t remaining_ticks; + + if (runtime == NULL) { + return coo_cmd_error(cmd, "serial guard unavailable"); + } + + remaining_ticks = k_work_delayable_remaining_get(&runtime->serial_guard_work); + remaining_ms = k_ticks_to_ms_floor64(remaining_ticks); + snprintk(payload, sizeof(payload), + "{\"serialguard_s\":%u,\"active\":%s,\"remaining_ms\":%lld}", + runtime->serial_guard_seconds, + runtime_serial_guard_active(runtime) ? "true" : "false", + (long long)remaining_ms); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} + +static struct coo_cmd_response runtime_serial_guard_set(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + uint32_t holdoff_s = 0U; + bool persistent = false; + int parse_rc_seconds; + int parse_rc_value; + int parse_rc_persistent; + + if (runtime == NULL || cmd == NULL) { + return coo_cmd_error(cmd, "serial guard unavailable"); + } + + parse_rc_seconds = coo_json_extract_u32(cmd->payload, "seconds", &holdoff_s); + parse_rc_value = coo_json_extract_u32(cmd->payload, "value", &holdoff_s); + if (parse_rc_seconds == COO_JSON_EXTRACT_ERR || + parse_rc_value == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid seconds"); + } + if (parse_rc_seconds == COO_JSON_EXTRACT_MISSING && + parse_rc_value == COO_JSON_EXTRACT_MISSING) { + return coo_cmd_error(cmd, "missing seconds"); + } + + parse_rc_persistent = coo_json_extract_bool(cmd->payload, "persistent", &persistent); + if (parse_rc_persistent == COO_JSON_EXTRACT_ERR) { + return coo_cmd_error(cmd, "invalid persistent"); + } + if (parse_rc_persistent == COO_JSON_EXTRACT_OK) { + return coo_cmd_error(cmd, "serialguard persistence unsupported"); + } + + runtime->serial_guard_seconds = holdoff_s; + if (cmd->source == COO_CMD_SOURCE_SERIAL) { + runtime_note_serial_guard_activity(runtime); + } else if (holdoff_s == 0U) { + runtime_clear_serial_guard(runtime); + } + + return coo_cmd_ok(cmd); +} +#endif + +#if defined(CONFIG_COO_CMD_REBOOT) +static void reboot_work_handler(struct k_work *work) +{ + struct k_work_delayable *dwork = k_work_delayable_from_work(work); + struct coo_cmd_runtime *runtime = + CONTAINER_OF(dwork, struct coo_cmd_runtime, reboot_work); + + if (runtime->reboot_prepare != NULL) { + runtime->reboot_prepare(runtime->user_data); + } + + LOG_WRN("Executing scheduled reboot"); + sys_reboot(SYS_REBOOT_COLD); +} + +static bool runtime_reboot_pending(const struct coo_cmd_runtime *runtime) +{ + return runtime != NULL && atomic_get(&runtime->reboot_pending) != 0; +} + +static struct coo_cmd_response runtime_reboot_set(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + char payload[COO_CMD_PAYLOAD_MAX]; + int rc; + + if (runtime == NULL || cmd == NULL) { + return coo_cmd_error(cmd, "reboot unavailable"); + } + if (!coo_cmd_payload_empty(cmd)) { + return coo_cmd_error(cmd, "reboot takes no payload"); + } + if (!atomic_cas(&runtime->reboot_pending, 0, 1)) { + return coo_cmd_error(cmd, "reboot already pending"); + } + + LOG_WRN("Reboot command accepted; rebooting in %u ms", + runtime->reboot_delay_ms); + rc = k_work_schedule(&runtime->reboot_work, + K_MSEC(runtime->reboot_delay_ms)); + if (rc < 0) { + (void)atomic_clear(&runtime->reboot_pending); + return coo_cmd_error(cmd, "failed to schedule reboot"); + } + + runtime_record_lastcommand(runtime, cmd); + snprintk(payload, sizeof(payload), + "{\"status\":\"ok\",\"reboot_ms\":%u}", + runtime->reboot_delay_ms); + return coo_cmd_reply(cmd, COO_CMD_RESP_OK, payload); +} +#endif + +static bool runtime_handle_builtin_request(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + struct coo_cmd_response out; + + if (runtime == NULL || cmd == NULL) { + return false; + } + +#if defined(CONFIG_COO_CMD_REBOOT) + if (runtime_reboot_pending(runtime) && !runtime_key_is_reboot(cmd->key)) { + out = coo_cmd_error(cmd, "reboot pending"); + runtime_enqueue_response(runtime, &out); + return true; + } +#endif + + if (runtime_key_is_help(cmd->key)) { + out = runtime_help_response(runtime, cmd); + runtime_enqueue_response(runtime, &out); + return true; + } + +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + if (runtime_key_is_serial_guard(cmd->key)) { + out = cmd->msg_type == COO_CMD_EFFECT ? + runtime_serial_guard_set(runtime, cmd) : + runtime_serial_guard_get(runtime, cmd); + runtime_enqueue_response(runtime, &out); + return true; + } +#endif + +#if defined(CONFIG_COO_CMD_REBOOT) + if (runtime_key_is_reboot(cmd->key)) { + out = runtime_reboot_set(runtime, cmd); + runtime_enqueue_response(runtime, &out); + return true; + } +#endif + + return false; +} + +int coo_cmd_publish_mqtt(struct mqtt_client *client, + const struct coo_cmd_response *out, + uint16_t *message_id) +{ + struct mqtt_publish_param param; + + if (client == NULL || out == NULL || message_id == NULL) { + return -EINVAL; + } + + memset(¶m, 0, sizeof(param)); + param.message.topic.qos = out->qos; + param.message.topic.topic.utf8 = (uint8_t *)out->topic; + param.message.topic.topic.size = strlen(out->topic); + param.message.payload.data = (uint8_t *)out->payload; + param.message.payload.len = out->payload_len; + param.prop.correlation_data.data = (uint8_t *)out->correlation_data; + param.prop.correlation_data.len = out->corr_len; + param.message_id = (*message_id)++; + param.dup_flag = 0U; + param.retain_flag = 0U; + + return mqtt_publish(client, ¶m); +} + +static struct coo_cmd_response runtime_execute_default(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + const struct coo_cmd_spec *spec; + coo_cmd_handler_fn handler; + + if (runtime == NULL || cmd == NULL) { + return coo_cmd_invalid_response(cmd); + } + +#if defined(CONFIG_COO_CMD_REBOOT) + if (runtime_reboot_pending(runtime)) { + return coo_cmd_error(cmd, "reboot pending"); + } +#endif + + spec = coo_cmd_runtime_find_spec(runtime, cmd->key); + LOG_INF("Dispatching: %s", cmd->key); + if (spec == NULL) { + return coo_cmd_unknown_response(cmd); + } + if (!coo_cmd_runtime_spec_supported(runtime, spec)) { + return coo_cmd_error(cmd, "command unavailable on this board"); + } + + handler = cmd->msg_type == COO_CMD_EFFECT ? + spec->effect_handler : spec->query_handler; + if (handler == NULL) { + return coo_cmd_unsupported_response(cmd); + } + + if (cmd->msg_type == COO_CMD_EFFECT) { + runtime_record_lastcommand(runtime, cmd); + } + + return handler(cmd); +} + +void coo_cmd_runtime_executor_thread(void *p1, void *p2, void *p3) +{ + struct coo_cmd_runtime *runtime = p1; + struct coo_cmd_request *cmd; + struct coo_cmd_response *out; + + ARG_UNUSED(p2); + ARG_UNUSED(p3); + + if (runtime == NULL || runtime->inbound_queue == NULL || + runtime->outbound_queue == NULL) { + LOG_ERR("command runtime executor missing queues"); + return; + } + + cmd = &runtime->executor_cmd; + out = &runtime->executor_out; + + while (1) { + /* K_FOREVER sleeps until ingress queues a complete command. */ + k_msgq_get(runtime->inbound_queue, cmd, K_FOREVER); + *out = runtime->execute_handler != NULL ? + runtime->execute_handler(cmd) : + runtime_execute_default(runtime, cmd); + if (k_msgq_put(runtime->outbound_queue, out, K_NO_WAIT) != 0) { + LOG_WRN("Outbound queue full; dropping command response"); + } + } +} + +static enum coo_cmd_msg_type runtime_classify(struct coo_cmd_runtime *runtime, + const struct coo_cmd_request *cmd) +{ + const struct coo_cmd_spec *spec; + + if (cmd == NULL) { + return COO_CMD_QUERY; + } + + if (runtime_key_is_reboot(cmd->key)) { + return COO_CMD_EFFECT; + } + + spec = coo_cmd_runtime_find_spec(runtime, cmd->key); + if (spec != NULL) { + switch (spec->class_policy) { + case COO_CMD_CLASS_ALWAYS_QUERY: + return COO_CMD_QUERY; + case COO_CMD_CLASS_ALWAYS_EFFECT: + return COO_CMD_EFFECT; + case COO_CMD_CLASS_SUFFIX_OR_PAYLOAD_EFFECT: + return (!coo_cmd_payload_empty(cmd) || + coo_cmd_key_suffix_after(cmd->key, spec->key)[0] != '\0') ? + COO_CMD_EFFECT : COO_CMD_QUERY; + case COO_CMD_CLASS_CUSTOM: + if (spec->custom_classify != NULL) { + return spec->custom_classify(cmd, spec, + runtime != NULL ? + runtime->user_data : NULL); + } + break; + case COO_CMD_CLASS_DEFAULT: + default: + break; + } + } + + return coo_cmd_payload_empty(cmd) ? COO_CMD_QUERY : COO_CMD_EFFECT; +} + +static void runtime_enqueue_response(struct coo_cmd_runtime *runtime, + const struct coo_cmd_response *out) +{ + if (runtime == NULL || runtime->outbound_queue == NULL || out == NULL) { + return; + } + + if (k_msgq_put(runtime->outbound_queue, out, K_NO_WAIT) != 0) { + LOG_WRN("Outbound queue full; dropping immediate command response"); + } +} + +static void runtime_enqueue_serial_error(struct coo_cmd_runtime *runtime, const char *msg) +{ + struct coo_cmd_response *out; + + if (runtime == NULL) { + return; + } + + out = &runtime->outbound_scratch; + memset(out, 0, sizeof(*out)); + out->target = COO_CMD_OUT_SERIAL; + out->msg_type = COO_CMD_RESP_ERROR; + out->qos = MQTT_QOS_1_AT_LEAST_ONCE; + (void)coo_cmd_format_response_topic(runtime->device_id, "serial", + out->topic, sizeof(out->topic)); + out->payload_len = snprintk(out->payload, sizeof(out->payload), + "{\"error\":\"%s\"}", msg); + runtime_enqueue_response(runtime, out); +} + +void coo_cmd_runtime_handle_serial_line(struct coo_cmd_runtime *runtime, char *line) +{ + struct coo_cmd_request *cmd; + const struct coo_cmd_spec *spec; + char *cursor = line; + char *key; + char *payload = NULL; + char *sep; + + if (runtime == NULL || line == NULL) { + return; + } + + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + if (*cursor == '\0') { + return; + } + +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + runtime_note_serial_guard_activity(runtime); +#endif + + sep = strpbrk(cursor, " \t"); + if (sep == NULL) { + key = cursor; + } else { + *sep = '\0'; + key = cursor; + cursor = sep + 1; + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + payload = cursor; + } + + if (key == NULL || *key == '\0') { + runtime_enqueue_serial_error(runtime, "missing command key"); + return; + } + spec = coo_cmd_runtime_find_spec(runtime, key); + + if (runtime_key_is_help(key)) { + if (payload_has_text(payload)) { + runtime_enqueue_serial_error(runtime, "help takes no arguments"); + } else { + runtime_print_serial_help(runtime, runtime->serial_wrap_column); + } + return; + } + + cmd = &runtime->ingress_cmd; + memset(cmd, 0, sizeof(*cmd)); + cmd->source = COO_CMD_SOURCE_SERIAL; + strncpy(cmd->key, key, sizeof(cmd->key) - 1U); + if (coo_cmd_format_response_topic(runtime->device_id, cmd->key, + cmd->response_topic, + sizeof(cmd->response_topic)) != 0) { + runtime_enqueue_serial_error(runtime, "invalid command key"); + return; + } + + if (runtime_normalize_serial_payload(spec, cmd->key, payload, + runtime->user_data, cmd->payload, + sizeof(cmd->payload)) != 0) { + runtime_enqueue_serial_error(runtime, "invalid serial payload"); + return; + } + cmd->payload_len = strlen(cmd->payload); + cmd->msg_type = runtime_classify(runtime, cmd); + + if (runtime_handle_builtin_request(runtime, cmd)) { + return; + } + + if (k_msgq_put(runtime->inbound_queue, cmd, K_NO_WAIT) != 0) { + struct coo_cmd_response *out = &runtime->outbound_scratch; + + *out = coo_cmd_busy_response(cmd); + runtime_enqueue_response(runtime, out); + } +} + +static void serial_reset_line(struct coo_cmd_runtime *runtime) +{ + runtime->serial_line_len = 0U; + runtime->serial_line[0] = '\0'; + runtime->serial_line_overflow = false; +} + +static void serial_accept_char(struct coo_cmd_runtime *runtime, char ch) +{ + if (ch == '\r' || ch == '\n') { + if (runtime->serial_line_overflow) { + runtime_enqueue_serial_error(runtime, "serial line too long"); + } else if (runtime->serial_line_len > 0U) { + runtime->serial_line[runtime->serial_line_len] = '\0'; + coo_cmd_runtime_handle_serial_line(runtime, runtime->serial_line); + } + serial_reset_line(runtime); + return; + } + + if (ch == '\b' || ch == 0x7f) { + if (runtime->serial_line_len > 0U) { + runtime->serial_line_len--; + runtime->serial_line[runtime->serial_line_len] = '\0'; + } + return; + } + + if ((unsigned char)ch < 0x20U && ch != '\t') { + return; + } + + if (runtime->serial_line_len + 1U >= sizeof(runtime->serial_line)) { + runtime->serial_line_overflow = true; + return; + } + + runtime->serial_line[runtime->serial_line_len++] = ch; + runtime->serial_line[runtime->serial_line_len] = '\0'; +} + +static int runtime_init_serial_console(struct coo_cmd_runtime *runtime) +{ + int rc; + + if (runtime == NULL) { + return -EINVAL; + } + + rc = console_init(); + if (rc != 0) { + return rc; + } + + console_set_rx_timeout(K_NO_WAIT); + serial_reset_line(runtime); + runtime->serial_initialized = true; + return 0; +} + +void coo_cmd_runtime_serial_poll(struct coo_cmd_runtime *runtime) +{ + int budget = SERIAL_POLL_CHAR_BUDGET; + + if (runtime == NULL || !runtime->serial_initialized) { + return; + } + + while (budget-- > 0) { + char ch; + ssize_t read_len = console_read(NULL, &ch, sizeof(ch)); + + if (read_len == 1) { + serial_accept_char(runtime, ch); + continue; + } + + if (read_len < 0 && read_len != -EAGAIN) { + LOG_WRN("Serial console read failed (%zd)", read_len); + } + break; + } +} + +void coo_cmd_runtime_handle_mqtt_publish(struct coo_cmd_runtime *runtime, + const struct mqtt_publish_param *pub) +{ + struct coo_cmd_request *cmd; + char req_topic[COO_CMD_TOPIC_MAX]; + const char *suffix; + size_t prefix_len; + size_t suffix_len; + + if (runtime == NULL || pub == NULL || + !coo_cmd_copy_mqtt_utf8(&pub->message.topic.topic, + req_topic, sizeof(req_topic))) { + return; + } + cmd = &runtime->ingress_cmd; + memset(cmd, 0, sizeof(*cmd)); + + prefix_len = strlen(runtime->request_prefix); + if (prefix_len == 0U || + strncmp(req_topic, runtime->request_prefix, prefix_len) != 0) { + return; + } + + suffix = req_topic + prefix_len; + suffix_len = strlen(suffix); + if (suffix_len == 0U || suffix_len >= sizeof(cmd->key)) { + LOG_WRN("Invalid MQTT command topic suffix"); + return; + } + + cmd->source = COO_CMD_SOURCE_MQTT; + memcpy(cmd->key, suffix, suffix_len); + cmd->key[suffix_len] = '\0'; + + if (coo_cmd_format_response_topic(runtime->device_id, cmd->key, + cmd->response_topic, + sizeof(cmd->response_topic)) != 0) { + struct coo_cmd_response *out = &runtime->outbound_scratch; + + *out = coo_cmd_invalid_response(cmd); + runtime_enqueue_response(runtime, out); + return; + } + + if (pub->retain_flag != 0U) { + struct coo_cmd_response *out = &runtime->outbound_scratch; + + *out = coo_cmd_reply(cmd, COO_CMD_RESP_ERROR, + "{\"error\":\"retained MQTT command ignored\"}"); + LOG_WRN("Ignoring retained MQTT command '%s'", cmd->key); + runtime_enqueue_response(runtime, out); + return; + } + + if (pub->prop.response_topic.utf8 != NULL && + pub->prop.response_topic.size > 0U && + pub->prop.response_topic.size < sizeof(cmd->response_topic)) { + memcpy(cmd->response_topic, pub->prop.response_topic.utf8, + pub->prop.response_topic.size); + cmd->response_topic[pub->prop.response_topic.size] = '\0'; + } + + if (pub->message.payload.len >= sizeof(cmd->payload)) { + struct coo_cmd_response *out = &runtime->outbound_scratch; + + *out = coo_cmd_invalid_response(cmd); + runtime_enqueue_response(runtime, out); + return; + } + + if (pub->message.payload.len > 0U) { + memcpy(cmd->payload, pub->message.payload.data, + pub->message.payload.len); + cmd->payload[pub->message.payload.len] = '\0'; + cmd->payload_len = pub->message.payload.len; + } else { + snprintk(cmd->payload, sizeof(cmd->payload), "{}"); + cmd->payload_len = strlen(cmd->payload); + } + cmd->msg_type = runtime_classify(runtime, cmd); + + if (pub->prop.correlation_data.len > 0U && + pub->prop.correlation_data.len <= sizeof(cmd->correlation_data)) { + memcpy(cmd->correlation_data, pub->prop.correlation_data.data, + pub->prop.correlation_data.len); + cmd->corr_len = pub->prop.correlation_data.len; + } else if (pub->prop.correlation_data.len > sizeof(cmd->correlation_data)) { + LOG_WRN("MQTT correlation_data too long (%zu > %zu); response will not echo it", + pub->prop.correlation_data.len, sizeof(cmd->correlation_data)); + } + +#if defined(CONFIG_COO_CMD_SERIAL_GUARD) + if (!runtime_mqtt_allowed_during_serial_guard(runtime, cmd)) { + struct coo_cmd_response *out = &runtime->outbound_scratch; + + *out = coo_cmd_serial_active_response(cmd); + LOG_WRN("Rejecting MQTT command '%s': local serial control is active", cmd->key); + runtime_enqueue_response(runtime, out); + (void)coo_cmd_runtime_warning_emit( + runtime, + "serial_guard_active", + "MQTT command rejected while serial command guard is active", + cmd->key); + return; + } +#endif + + if (runtime_handle_builtin_request(runtime, cmd)) { + return; + } + + if (k_msgq_put(runtime->inbound_queue, cmd, K_NO_WAIT) != 0) { + struct coo_cmd_response *out = &runtime->outbound_scratch; + + *out = coo_cmd_busy_response(cmd); + runtime_enqueue_response(runtime, out); + } +} + +void coo_cmd_runtime_mqtt_callback(const struct mqtt_publish_param *pub, + void *user_data) +{ + coo_cmd_runtime_handle_mqtt_publish(user_data, pub); +} + +static void publish_outbound_queue_full_warning(struct coo_cmd_runtime *runtime, + struct mqtt_client *client, + bool mqtt_available) +{ + struct coo_cmd_response *warning; + uint16_t wrap_column = runtime != NULL && runtime->serial_wrap_column != 0U ? + runtime->serial_wrap_column : COO_CMD_SERIAL_WRAP_COLUMN; + + if (runtime == NULL) { + return; + } + + warning = &runtime->warning_scratch; + if (runtime == NULL || runtime->warning_topic[0] == '\0' || + coo_cmd_build_warning(warning, runtime->warning_topic, + "outbound_queue_full", + "outbound queue reached capacity", + "command_drain") != 0) { + return; + } + + coo_cmd_print_serial_response_pretty(warning, wrap_column); + + if (mqtt_available && + coo_cmd_publish_mqtt(client, warning, runtime->mqtt_msg_id) != 0) { + LOG_WRN("Failed to publish outbound_queue_full warning"); + } +} + +void coo_cmd_runtime_drain_outbound(struct coo_cmd_runtime *runtime, + struct mqtt_client *client, + bool mqtt_available) +{ + struct coo_cmd_response *out; + int budget = 8; + bool outbound_full; + uint16_t wrap_column; + + if (runtime == NULL || runtime->outbound_queue == NULL || + runtime->mqtt_msg_id == NULL) { + return; + } + wrap_column = runtime->serial_wrap_column != 0U ? + runtime->serial_wrap_column : COO_CMD_SERIAL_WRAP_COLUMN; + out = &runtime->outbound_scratch; + + outbound_full = (k_msgq_num_free_get(runtime->outbound_queue) == 0U); + if (outbound_full) { + if (!runtime->outbound_full_warning_seen || + (mqtt_available && !runtime->outbound_full_warning_mqtt_seen)) { + publish_outbound_queue_full_warning(runtime, client, mqtt_available); + runtime->outbound_full_warning_seen = true; + if (mqtt_available) { + runtime->outbound_full_warning_mqtt_seen = true; + } + } + } else { + runtime->outbound_full_warning_seen = false; + runtime->outbound_full_warning_mqtt_seen = false; + } + + while (budget-- > 0 && + k_msgq_get(runtime->outbound_queue, out, K_NO_WAIT) == 0) { + const bool best_effort = (out->target == COO_CMD_OUT_MQTT_BEST_EFFORT); + + if (out->target == COO_CMD_OUT_SERIAL) { + coo_cmd_print_serial_response_pretty(out, wrap_column); + continue; + } + + if (!mqtt_available) { + if (best_effort) { + LOG_DBG("Dropping best-effort MQTT msg while MQTT unavailable"); + continue; + } + if (k_msgq_put(runtime->outbound_queue, out, K_NO_WAIT) != 0) { + LOG_WRN("Dropping MQTT msg (queue full while requeueing)"); + } + continue; + } + + if (coo_cmd_publish_mqtt(client, out, runtime->mqtt_msg_id) != 0) { + if (best_effort) { + LOG_WRN("Best-effort MQTT publish failed; dropping msg"); + continue; + } + LOG_WRN("MQTT publish failed; will retry"); + if (k_msgq_put(runtime->outbound_queue, out, K_NO_WAIT) != 0) { + LOG_WRN("Dropping MQTT msg (queue full after publish failure)"); + } + break; + } + } +} + +void coo_cmd_print_serial_response(const struct coo_cmd_response *out, + uint16_t wrap_column) +{ + size_t len; + uint16_t col = 0U; + + if (out == NULL) { + return; + } + + printk("%s\r\n ", out->topic[0] != '\0' ? out->topic : "serial"); + col = 8U; + len = out->payload_len > 0U ? out->payload_len : strlen(out->payload); + + for (size_t i = 0U; i < len && out->payload[i] != '\0'; ++i) { + const char ch = out->payload[i]; + + if (ch == '\r') { + continue; + } + + if (ch == '\n' || (wrap_column != 0U && col >= wrap_column)) { + printk("\r\n "); + col = 8U; + if (ch == '\n') { + continue; + } + } + + printk("%c", ch); + col++; + + if (wrap_column != 0U && + (ch == ',' || ch == '}') && col >= (wrap_column - 8U) && + i + 1U < len) { + printk("\r\n "); + col = 8U; + } + } + + printk("\r\n"); +} + +static const char *serial_payload_start(const char *payload) +{ + while (payload != NULL && isspace((unsigned char)*payload)) { + payload++; + } + + return payload; +} + +static void serial_response_newline_indent(uint8_t indent, uint16_t *col) +{ + printk("\r\n"); + *col = 8U; + for (uint8_t i = 0U; i < 8U; ++i) { + printk(" "); + } + for (uint8_t i = 0U; i < indent; ++i) { + printk(" "); + *col += 2U; + } +} + +static bool json_stack_push(char *stack, size_t stack_len, uint8_t *depth, char close_ch) +{ + if (*depth >= stack_len) { + return false; + } + + stack[*depth] = close_ch; + (*depth)++; + return true; +} + +static bool json_stack_pop(char *stack, uint8_t *depth, char close_ch) +{ + if (*depth == 0U || stack[*depth - 1U] != close_ch) { + return false; + } + + (*depth)--; + return true; +} + +static bool json_scalar_array_end(const char *payload, size_t len, + size_t start, size_t *end) +{ + bool in_string = false; + bool escaped = false; + + if (payload == NULL || end == NULL || start >= len || payload[start] != '[') { + return false; + } + + for (size_t i = start + 1U; i < len && payload[i] != '\0'; ++i) { + const char ch = payload[i]; + + if (in_string) { + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + in_string = false; + } else if ((unsigned char)ch < 0x20U) { + return false; + } + continue; + } + + if (isspace((unsigned char)ch)) { + continue; + } + + switch (ch) { + case '"': + in_string = true; + break; + case '[': + case '{': + return false; + case ']': + *end = i; + return true; + default: + if ((unsigned char)ch < 0x20U) { + return false; + } + break; + } + } + + return false; +} + +static bool serial_print_json_scalar_array(const char *payload, size_t start, + size_t end, uint16_t *col) +{ + bool in_string = false; + bool escaped = false; + + for (size_t i = start; i <= end; ++i) { + const char ch = payload[i]; + + if (in_string) { + printk("%c", ch); + (*col)++; + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + in_string = false; + } else if ((unsigned char)ch < 0x20U) { + return false; + } + continue; + } + + if (isspace((unsigned char)ch)) { + continue; + } + + if (ch == '"') { + in_string = true; + printk("%c", ch); + (*col)++; + } else if (ch == ',') { + printk(", "); + *col += 2U; + } else { + if ((unsigned char)ch < 0x20U) { + return false; + } + printk("%c", ch); + (*col)++; + } + } + + return !in_string && !escaped; +} + +static bool serial_print_json_payload(const char *payload, size_t len) +{ + char stack[16]; + uint8_t depth = 0U; + uint16_t col = 8U; + bool in_string = false; + bool escaped = false; + bool saw_token = false; + + for (size_t i = 0U; i < len && payload[i] != '\0'; ++i) { + const char ch = payload[i]; + + if (in_string) { + printk("%c", ch); + col++; + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + in_string = false; + } else if ((unsigned char)ch < 0x20U) { + return false; + } + continue; + } + + if (isspace((unsigned char)ch)) { + continue; + } + + saw_token = true; + switch (ch) { + case '"': + in_string = true; + printk("%c", ch); + col++; + break; + case '{': + if (!json_stack_push(stack, sizeof(stack), &depth, '}')) { + return false; + } + printk("%c", ch); + col++; + serial_response_newline_indent(depth, &col); + break; + case '[': + { + size_t array_end; + + if (json_scalar_array_end(payload, len, i, &array_end)) { + if (!serial_print_json_scalar_array(payload, i, array_end, &col)) { + return false; + } + i = array_end; + break; + } + if (!json_stack_push(stack, sizeof(stack), &depth, ']')) { + return false; + } + printk("%c", ch); + col++; + serial_response_newline_indent(depth, &col); + break; + } + case '}': + case ']': + if (!json_stack_pop(stack, &depth, ch)) { + return false; + } + serial_response_newline_indent(depth, &col); + printk("%c", ch); + col++; + break; + case ',': + printk("%c", ch); + col++; + serial_response_newline_indent(depth, &col); + break; + case ':': + printk(": "); + col += 2U; + break; + default: + if ((unsigned char)ch < 0x20U) { + return false; + } + printk("%c", ch); + col++; + break; + } + } + + return saw_token && !in_string && !escaped && depth == 0U; +} + +void coo_cmd_print_serial_response_pretty(const struct coo_cmd_response *out, + uint16_t wrap_column) +{ + const char *payload; + size_t len; + + if (out == NULL) { + return; + } + + payload = out->payload; + len = out->payload_len > 0U ? out->payload_len : strlen(out->payload); + printk("%s\r\n ", out->topic[0] != '\0' ? out->topic : "serial"); + + payload = serial_payload_start(payload); + if (payload != NULL && (*payload == '{' || *payload == '[')) { + if (!serial_print_json_payload(payload, len - (size_t)(payload - out->payload))) { + uint16_t col = 13U; + + printk("{\"error\":\"serial JSON render failed\"}\r\n raw: "); + for (size_t i = 0U; i < len && out->payload[i] != '\0'; ++i) { + const char ch = out->payload[i]; + + if (ch == '\r') { + continue; + } + if (ch == '\n' || + (wrap_column != 0U && col >= wrap_column)) { + printk("\r\n "); + col = 8U; + if (ch == '\n') { + continue; + } + } + printk("%c", ch); + col++; + } + printk("\r\n"); + return; + } + printk("\r\n"); + return; + } + + for (size_t i = 0U; i < len && out->payload[i] != '\0'; ++i) { + const char ch = out->payload[i]; + + if (ch == '\r') { + continue; + } + if (ch == '\n') { + printk("\r\n "); + continue; + } + printk("%c", ch); + } + printk("\r\n"); +} diff --git a/lib/coo_commons/json_utils.c b/lib/coo_commons/json_utils.c index 0271cb5..ca80f92 100644 --- a/lib/coo_commons/json_utils.c +++ b/lib/coo_commons/json_utils.c @@ -12,16 +12,45 @@ */ #include +#include #include +#include #include #include #include #include #include -struct json_type_msg { - char msg_type[8]; -}; +#define COO_JSON_DOUBLE_ARRAY_MAX 32U + +const char *coo_json_skip_ws(const char *text) +{ + while (text != NULL && isspace((unsigned char)*text)) { + text++; + } + + return text; +} + +int coo_json_match_string_choice(const char *text, + const struct coo_json_string_choice *choices, + size_t choice_count, + int *value) +{ + if (text == NULL || choices == NULL || value == NULL) { + return -EINVAL; + } + + for (size_t i = 0U; i < choice_count; ++i) { + if (choices[i].name != NULL && + strcasecmp(text, choices[i].name) == 0) { + *value = choices[i].value; + return 0; + } + } + + return -ENOENT; +} /** * @brief Extract one JSON field by key using Zephyr's descriptor parser. @@ -81,34 +110,6 @@ static int find_json_key_value(const char *json, return COO_JSON_EXTRACT_OK; } -bool coo_json_parse_msg_type(const char *payload, enum coo_msg_type *msg_type_out) -{ - struct json_type_msg msg = {0}; - const struct json_obj_descr descr[] = { - JSON_OBJ_DESCR_PRIM(struct json_type_msg, msg_type, JSON_TOK_STRING) - }; - - if (payload == NULL || msg_type_out == NULL) { - return false; - } - - int rc = json_obj_parse((char *)payload, strlen(payload), descr, ARRAY_SIZE(descr), &msg); - if (rc < 0) { - return false; - } - - /* Case-insensitive check for supported types */ - if (strncasecmp(msg.msg_type, "get", 4) == 0) { - *msg_type_out = COO_MSG_GET; - return true; - } - if (strncasecmp(msg.msg_type, "set", 4) == 0) { - *msg_type_out = COO_MSG_SET; - return true; - } - return false; -} - int coo_json_extract_bool(const char *json, const char *key, bool *value) { struct json_bool_field { @@ -190,7 +191,7 @@ int coo_json_extract_float(const char *json, const char *key, float *value) } rc = find_json_key_value(json, key, - JSON_TOK_NUMBER, + JSON_TOK_FLOAT_FP, &parsed, sizeof(parsed.value), offsetof(struct json_float_field, value), @@ -201,6 +202,75 @@ int coo_json_extract_float(const char *json, const char *key, float *value) return rc; } +int coo_json_extract_double(const char *json, const char *key, double *value) +{ + struct json_double_field { + double value; + } parsed = { 0 }; + int rc; + + if (value == NULL) { + return COO_JSON_EXTRACT_ERR; + } + + rc = find_json_key_value(json, key, + JSON_TOK_DOUBLE_FP, + &parsed, + sizeof(parsed.value), + offsetof(struct json_double_field, value), + Z_ALIGN_SHIFT(struct json_double_field)); + if (rc == COO_JSON_EXTRACT_OK) { + *value = parsed.value; + } + return rc; +} + +int coo_json_extract_double_array(const char *json, const char *key, + double *values, size_t max_values, + size_t *parsed_len) +{ + struct json_double_array_field { + double values[COO_JSON_DOUBLE_ARRAY_MAX]; + size_t values_len; + } parsed = { 0 }; + struct json_obj_descr descr[] = { + JSON_OBJ_DESCR_ARRAY(struct json_double_array_field, values, + COO_JSON_DOUBLE_ARRAY_MAX, values_len, + JSON_TOK_DOUBLE_FP), + }; + size_t key_len; + int64_t rc; + + if (json == NULL || key == NULL || values == NULL || parsed_len == NULL || + max_values == 0U || max_values > COO_JSON_DOUBLE_ARRAY_MAX) { + return COO_JSON_EXTRACT_ERR; + } + *parsed_len = 0U; + + key_len = strlen(key); + if (key_len == 0U || key_len > 127U) { + return COO_JSON_EXTRACT_ERR; + } + + descr[0].field_name = key; + descr[0].field_name_len = key_len; + + rc = json_obj_parse((char *)json, strlen(json), descr, ARRAY_SIZE(descr), &parsed); + if (rc < 0) { + return COO_JSON_EXTRACT_ERR; + } + if ((rc & BIT64(0)) == 0) { + return COO_JSON_EXTRACT_MISSING; + } + if (parsed.values_len > max_values) { + return COO_JSON_EXTRACT_ERR; + } + + memcpy(values, parsed.values, parsed.values_len * sizeof(values[0])); + *parsed_len = parsed.values_len; + return COO_JSON_EXTRACT_OK; +} + int coo_json_extract_optional_float_range(const char *json, const char *key, float *value, bool *changed, float min_value, float max_value) @@ -226,6 +296,106 @@ int coo_json_extract_optional_float_range(const char *json, const char *key, return 0; } +int coo_json_extract_optional_bool(const char *json, const char *key, + bool *value, bool *changed) +{ + bool parsed; + int rc; + + if (value == NULL) { + return -EINVAL; + } + + rc = coo_json_extract_bool(json, key, &parsed); + if (rc == COO_JSON_EXTRACT_MISSING) { + return 0; + } + if (rc == COO_JSON_EXTRACT_ERR) { + return -EINVAL; + } + + *value = parsed; + if (changed != NULL) { + *changed = true; + } + return 0; +} + +int coo_json_extract_optional_u32(const char *json, const char *key, + uint32_t *value, bool *changed) +{ + uint32_t parsed; + int rc; + + if (value == NULL) { + return -EINVAL; + } + + rc = coo_json_extract_u32(json, key, &parsed); + if (rc == COO_JSON_EXTRACT_MISSING) { + return 0; + } + if (rc == COO_JSON_EXTRACT_ERR) { + return -EINVAL; + } + + *value = parsed; + if (changed != NULL) { + *changed = true; + } + return 0; +} + +int coo_json_extract_optional_u16(const char *json, const char *key, + uint16_t *value, bool *changed) +{ + uint32_t parsed; + bool present = false; + + if (value == NULL) { + return -EINVAL; + } + + parsed = *value; + if (coo_json_extract_optional_u32(json, key, &parsed, &present) != 0 || + parsed > UINT16_MAX) { + return -EINVAL; + } + if (present) { + *value = (uint16_t)parsed; + if (changed != NULL) { + *changed = true; + } + } + + return 0; +} + +int coo_json_extract_optional_double_range(const char *json, const char *key, + double *value, bool *changed, + double min_value, double max_value) +{ + double parsed; + int rc; + + if (value == NULL || changed == NULL || !(min_value <= max_value)) { + return -EINVAL; + } + + rc = coo_json_extract_double(json, key, &parsed); + if (rc == COO_JSON_EXTRACT_MISSING) { + return 0; + } + if (rc == COO_JSON_EXTRACT_ERR || + !(parsed >= min_value && parsed <= max_value)) { + return -EINVAL; + } + + *value = parsed; + *changed = true; + return 0; +} + int coo_json_extract_string(const char *json, const char *key, char *out, size_t out_len) { if (out == NULL || out_len == 0U) { @@ -239,3 +409,163 @@ int coo_json_extract_string(const char *json, const char *key, char *out, size_t 0U, 0U); } + +int coo_json_extract_string_choice(const char *json, + const char *key, + const struct coo_json_string_choice *choices, + size_t choice_count, + int *value) +{ + char text[COO_JSON_STRING_CHOICE_MAX] = {0}; + int rc; + + if (choices == NULL || choice_count == 0U || value == NULL) { + return COO_JSON_EXTRACT_ERR; + } + + rc = coo_json_extract_string(json, key, text, sizeof(text)); + if (rc != COO_JSON_EXTRACT_OK) { + return rc; + } + + return coo_json_match_string_choice(text, choices, choice_count, value) == 0 ? + COO_JSON_EXTRACT_OK : COO_JSON_EXTRACT_ERR; +} + +int coo_json_extract_object(const char *json, const char *key, char *out, size_t out_len) +{ + char pattern[80]; + const char *match; + const char *cursor; + const char *start; + int depth = 0; + bool in_string = false; + bool escaped = false; + int written; + + if (json == NULL || key == NULL || out == NULL || out_len == 0U || + strlen(key) > 60U) { + return COO_JSON_EXTRACT_ERR; + } + + written = snprintf(pattern, sizeof(pattern), "\"%s\"", key); + if (written < 0 || written >= (int)sizeof(pattern)) { + return COO_JSON_EXTRACT_ERR; + } + + match = strstr(json, pattern); + if (match == NULL) { + return COO_JSON_EXTRACT_MISSING; + } + + cursor = match + strlen(pattern); + while (*cursor == ' ' || *cursor == '\t' || *cursor == '\r' || *cursor == '\n') { + cursor++; + } + if (*cursor != ':') { + return COO_JSON_EXTRACT_ERR; + } + cursor++; + while (*cursor == ' ' || *cursor == '\t' || *cursor == '\r' || *cursor == '\n') { + cursor++; + } + if (*cursor != '{') { + return COO_JSON_EXTRACT_ERR; + } + + start = cursor; + for (; *cursor != '\0'; ++cursor) { + char c = *cursor; + + if (escaped) { + escaped = false; + continue; + } + if (in_string && c == '\\') { + escaped = true; + continue; + } + if (c == '"') { + in_string = !in_string; + continue; + } + if (in_string) { + continue; + } + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + size_t len = (size_t)(cursor - start + 1); + + if (len >= out_len) { + return COO_JSON_EXTRACT_ERR; + } + memcpy(out, start, len); + out[len] = '\0'; + return COO_JSON_EXTRACT_OK; + } + } + } + + return COO_JSON_EXTRACT_ERR; +} + +int coo_json_vappend(char *buf, size_t buf_len, size_t *offset, + const char *fmt, va_list args) +{ + int written; + + if (buf == NULL || offset == NULL || fmt == NULL || *offset >= buf_len) { + return -ENOSPC; + } + + written = vsnprintf(buf + *offset, buf_len - *offset, fmt, args); + if (written < 0 || written >= (int)(buf_len - *offset)) { + return -ENOSPC; + } + + *offset += (size_t)written; + return 0; +} + +int coo_json_append(char *buf, size_t buf_len, size_t *offset, + const char *fmt, ...) +{ + va_list args; + int rc; + + va_start(args, fmt); + rc = coo_json_vappend(buf, buf_len, offset, fmt, args); + va_end(args); + + return rc; +} + +int coo_json_append_float_or_null(char *buf, size_t buf_len, size_t *offset, + double value, int precision) +{ + if (value != value) { + return coo_json_append(buf, buf_len, offset, "null"); + } + + switch (precision) { + case 0: + return coo_json_append(buf, buf_len, offset, "%.0f", value); + case 1: + return coo_json_append(buf, buf_len, offset, "%.1f", value); + case 2: + return coo_json_append(buf, buf_len, offset, "%.2f", value); + case 4: + return coo_json_append(buf, buf_len, offset, "%.4f", value); + case 6: + return coo_json_append(buf, buf_len, offset, "%.6f", value); + case 9: + return coo_json_append(buf, buf_len, offset, "%.9g", value); + case 12: + return coo_json_append(buf, buf_len, offset, "%.12g", value); + default: + return coo_json_append(buf, buf_len, offset, "%.3f", value); + } +} diff --git a/lib/coo_commons/mqtt_client.c b/lib/coo_commons/mqtt_client.c index ddccacb..34c2878 100644 --- a/lib/coo_commons/mqtt_client.c +++ b/lib/coo_commons/mqtt_client.c @@ -41,14 +41,17 @@ static uint8_t client_id[50]; /* User callback for messages */ static mqtt_message_cb_t user_mqtt_cb = NULL; +static void *user_mqtt_cb_data; /* Subscriptions */ #define MAX_SUBSCRIPTIONS 4 static struct mqtt_topic subscriptions[MAX_SUBSCRIPTIONS]; static int num_subscriptions = 0; -/* Retry configuration */ -#define MSECS_NET_POLL_TIMEOUT 30000 +/* Keep failed broker attempts below the application watchdog interval. */ +#define MSECS_NET_POLL_TIMEOUT 3000 +/* Keep connected idle polling short enough for the application watchdog loop. */ +#define MSECS_PROCESS_POLL_TIMEOUT 1000 bool coo_mqtt_parse_broker_endpoint(const char *endpoint, struct coo_mqtt_broker_config *cfg) @@ -102,12 +105,15 @@ int coo_mqtt_format_broker_endpoint(const struct coo_mqtt_broker_config *cfg, return 0; } -static int resolve_broker_addr(void) +static int resolve_broker_addr_for_config(const struct coo_mqtt_broker_config *cfg, + struct sockaddr_storage *addr_out, + char *resolved_ip, + size_t resolved_ip_len) { int rc; char port_str[6]; char broker_ip[NET_IPV4_ADDR_LEN]; - struct sockaddr_in *broker4; + struct sockaddr_in broker4 = {0}; struct zsock_addrinfo *result = NULL; struct in_addr numeric_addr = { 0 }; #if defined(CONFIG_DNS_RESOLVER) @@ -120,29 +126,26 @@ static int resolve_broker_addr(void) .ai_socktype = SOCK_STREAM }; - if (active_broker_cfg.host[0] == '\0' || active_broker_cfg.port == 0U) { + if (cfg == NULL || cfg->host[0] == '\0' || cfg->port == 0U) { LOG_ERR("Broker config missing"); return -EINVAL; } - (void)snprintk(port_str, sizeof(port_str), "%u", active_broker_cfg.port); + (void)snprintk(port_str, sizeof(port_str), "%u", cfg->port); - broker4 = (struct sockaddr_in *)&broker; - memset(broker4, 0, sizeof(*broker4)); - - if (net_addr_pton(AF_INET, active_broker_cfg.host, &numeric_addr) == 0) { - broker4->sin_family = AF_INET; - broker4->sin_port = htons(active_broker_cfg.port); - broker4->sin_addr = numeric_addr; + if (net_addr_pton(AF_INET, cfg->host, &numeric_addr) == 0) { + broker4.sin_family = AF_INET; + broker4.sin_port = htons(cfg->port); + broker4.sin_addr = numeric_addr; goto log_addr; } if (!dns_supported) { LOG_ERR("Broker '%s' is not numeric IPv4 and DNS_RESOLVER is disabled", - active_broker_cfg.host); + cfg->host); return -ENOTSUP; } - rc = zsock_getaddrinfo(active_broker_cfg.host, port_str, &hints, &result); + rc = zsock_getaddrinfo(cfg->host, port_str, &hints, &result); if (rc != 0) { LOG_ERR("Failed to resolve broker hostname [%s]", zsock_gai_strerror(rc)); return -EIO; @@ -152,20 +155,39 @@ static int resolve_broker_addr(void) return -ENOENT; } - broker4->sin_addr.s_addr = ((struct sockaddr_in *)result->ai_addr)->sin_addr.s_addr; - broker4->sin_family = AF_INET; - broker4->sin_port = ((struct sockaddr_in *)result->ai_addr)->sin_port; + broker4.sin_addr.s_addr = ((struct sockaddr_in *)result->ai_addr)->sin_addr.s_addr; + broker4.sin_family = AF_INET; + broker4.sin_port = ((struct sockaddr_in *)result->ai_addr)->sin_port; zsock_freeaddrinfo(result); log_addr: - if (net_addr_ntop(AF_INET, &broker4->sin_addr, broker_ip, sizeof(broker_ip)) == NULL) { + if (net_addr_ntop(AF_INET, &broker4.sin_addr, broker_ip, sizeof(broker_ip)) == NULL) { snprintk(broker_ip, sizeof(broker_ip), "?.?.?.?"); } - LOG_INF("MQTT broker resolved: %s:%u", broker_ip, active_broker_cfg.port); + LOG_INF("MQTT broker resolved: %s:%u", broker_ip, cfg->port); + if (resolved_ip != NULL && resolved_ip_len > 0U) { + strncpy(resolved_ip, broker_ip, resolved_ip_len - 1U); + resolved_ip[resolved_ip_len - 1U] = '\0'; + } + if (addr_out != NULL) { + memset(addr_out, 0, sizeof(*addr_out)); + memcpy(addr_out, &broker4, sizeof(broker4)); + } return 0; } +int coo_mqtt_resolve_broker_config(const struct coo_mqtt_broker_config *cfg, + char *resolved_ip, size_t resolved_ip_len) +{ + return resolve_broker_addr_for_config(cfg, NULL, resolved_ip, resolved_ip_len); +} + +static int resolve_broker_addr(void) +{ + return resolve_broker_addr_for_config(&active_broker_cfg, &broker, NULL, 0U); +} + int coo_mqtt_set_broker_config(const struct coo_mqtt_broker_config *cfg) { if (cfg == NULL || cfg->host[0] == '\0' || cfg->port == 0U) { @@ -179,9 +201,10 @@ int coo_mqtt_set_broker_config(const struct coo_mqtt_broker_config *cfg) return 0; } -void coo_mqtt_set_message_callback(mqtt_message_cb_t cb) +void coo_mqtt_set_message_callback(mqtt_message_cb_t cb, void *user_data) { user_mqtt_cb = cb; + user_mqtt_cb_data = user_data; } int coo_mqtt_add_subscription(const char *topic_str, uint8_t qos) @@ -252,13 +275,16 @@ static void on_mqtt_publish(struct mqtt_client *const client, const struct mqtt_ payload[rc] = '\0'; LOG_INF("MQTT payload received!"); - LOG_INF("topic: '%s', payload: %s", evt->param.publish.message.topic.topic.utf8, payload); + LOG_INF("topic: '%.*s', payload: %s", + (int)evt->param.publish.message.topic.topic.size, + evt->param.publish.message.topic.topic.utf8, + payload); publish_param.message.payload.data = payload; publish_param.message.payload.len = rc; if (user_mqtt_cb) { - user_mqtt_cb(&publish_param); + user_mqtt_cb(&publish_param, user_mqtt_cb_data); } } @@ -404,8 +430,16 @@ int coo_mqtt_subscribe(struct mqtt_client *client) int coo_mqtt_process(struct mqtt_client *client) { int rc; + int keepalive_ms = mqtt_keepalive_time_left(client); + int timeout_ms = keepalive_ms; + bool waited_for_keepalive; - rc = poll_mqtt_socket(client, mqtt_keepalive_time_left(client)); + if (timeout_ms < 0 || timeout_ms > MSECS_PROCESS_POLL_TIMEOUT) { + timeout_ms = MSECS_PROCESS_POLL_TIMEOUT; + } + waited_for_keepalive = (keepalive_ms >= 0 && timeout_ms == keepalive_ms); + + rc = poll_mqtt_socket(client, timeout_ms); if (rc < 0) { return rc; } @@ -424,9 +458,12 @@ int coo_mqtt_process(struct mqtt_client *client) return -ENOTCONN; } } - } else { - /* Socket poll timed out, time to call mqtt_live() */ + } else if (waited_for_keepalive) { + /* Socket poll reached the MQTT keepalive deadline. */ rc = mqtt_live(client); + if (rc == -EAGAIN) { + return 0; + } if (rc != 0) { LOG_ERR("MQTT Live failed [%d]", rc); return rc; diff --git a/lib/coo_commons/network.c b/lib/coo_commons/network.c index cbbd36f..f74240b 100644 --- a/lib/coo_commons/network.c +++ b/lib/coo_commons/network.c @@ -21,6 +21,9 @@ #include #include #include +#if defined(CONFIG_DNS_RESOLVER) +#include +#endif #if defined(CONFIG_NET_DHCPV4) #include #endif @@ -39,8 +42,13 @@ static struct net_mgmt_event_callback net_l4_mgmt_cb; static struct net_mgmt_event_callback net_ipv4_mgmt_cb; static struct k_work_delayable reconnect_work; static bool network_initialized; +#if defined(CONFIG_NET_DHCPV4) +static atomic_t dhcp_bound_seen; +#endif -//TODO is this really necessary, I'm inclined to axe it +/* Tiny local copies keep profile/default setup readable without dynamic + * allocation or repeating strncpy termination rules. + */ static void str_set(char *dst, size_t dst_size, const char *src) { if (dst == NULL || dst_size == 0U) { @@ -56,7 +64,6 @@ static void str_set(char *dst, size_t dst_size, const char *src) dst[dst_size - 1U] = '\0'; } -//TODO is this really necessary, I'm inclined to axe it, use net_addr_pton directly, this looks like defensive coding static bool parse_ipv4(const char *text, struct in_addr *out) { if (text == NULL || out == NULL || text[0] == '\0') { @@ -66,6 +73,53 @@ static bool parse_ipv4(const char *text, struct in_addr *out) return net_addr_pton(AF_INET, text, out) == 0; } +static bool parse_ipv4_nonzero(const char *text, struct in_addr *out) +{ + struct in_addr addr = {0}; + + if (!parse_ipv4(text, &addr) || net_ipv4_is_addr_unspecified(&addr)) { + return false; + } + + if (out != NULL) { + *out = addr; + } + return true; +} + +#if defined(CONFIG_DNS_RESOLVER) +static int configure_manual_dns(const struct network_ipv4_profile *profile) +{ + struct dns_resolve_context *ctx; + struct in_addr dns = {0}; + const char *servers[2]; + int rc; + + if (profile == NULL || profile->dns[0] == '\0') { + return 0; + } + if (!parse_ipv4_nonzero(profile->dns, &dns)) { + return -EINVAL; + } + + ctx = dns_resolve_get_default(); + servers[0] = profile->dns; + servers[1] = NULL; + rc = dns_resolve_reconfigure(ctx, servers, NULL, DNS_SOURCE_MANUAL); + if (rc != 0) { + LOG_WRN("Manual DNS reconfigure failed (%d)", rc); + } + + return rc; +} +#else +static int configure_manual_dns(const struct network_ipv4_profile *profile) +{ + ARG_UNUSED(profile); + return 0; +} +#endif + static bool profile_has_valid_static_ipv4(const struct network_ipv4_profile *profile) { struct in_addr ip = { 0 }; @@ -152,6 +206,10 @@ static int apply_static_profile(struct net_if *iface, net_if_ipv4_set_gw(iface, &gateway); } + if (configure_manual_dns(profile) != 0) { + return -EINVAL; + } + active_source = source; LOG_INF("Using static IPv4 (%s): %s / %s gw %s", network_ipv4_source_str(source), @@ -163,6 +221,30 @@ static int apply_static_profile(struct net_if *iface, } #if defined(CONFIG_NET_DHCPV4) +/* Zephyr's generic IPv4 address lookup is address-source agnostic; DHCP success + * must check either the DHCP-bound event or addr_type so compiled/manual static + * addresses do not end the wait. + */ +static bool iface_has_preferred_dhcp_addr(struct net_if *iface) +{ + struct in_addr *addr; + struct net_if *owner = NULL; + struct net_if_addr *if_addr; + + if (iface == NULL) { + return false; + } + + addr = net_if_ipv4_get_global_addr(iface, NET_ADDR_PREFERRED); + if (addr == NULL) { + return false; + } + + if_addr = net_if_ipv4_addr_lookup(addr, &owner); + return if_addr != NULL && owner == iface && + if_addr->addr_type == NET_ADDR_DHCP; +} + static int try_dhcp(struct net_if *iface, uint32_t timeout_ms) { uint32_t elapsed = 0U; @@ -175,10 +257,12 @@ static int try_dhcp(struct net_if *iface, uint32_t timeout_ms) /* DHCP restart is followed by a bounded polling loop so application boot * can fall back to static service addresses when no DHCP server responds. */ + atomic_clear(&dhcp_bound_seen); net_dhcpv4_restart(iface); while (elapsed < timeout_ms || timeout_ms == 0U) { - if (net_if_ipv4_get_global_addr(iface, NET_ADDR_PREFERRED) != NULL) { + if (atomic_get(&dhcp_bound_seen) != 0 || + iface_has_preferred_dhcp_addr(iface)) { active_source = NETWORK_IPV4_SOURCE_DHCP; LOG_INF("DHCPv4 acquired address"); return 0; @@ -264,7 +348,12 @@ static void net_ipv4_evt_handler(struct net_mgmt_event_callback *cb, { ARG_UNUSED(cb); - if (mgmt_event == NET_EVENT_IPV4_ADDR_ADD) { + if (mgmt_event == NET_EVENT_IPV4_DHCP_BOUND) { +#if defined(CONFIG_NET_DHCPV4) + atomic_set(&dhcp_bound_seen, 1); +#endif + active_source = NETWORK_IPV4_SOURCE_DHCP; + } else if (mgmt_event == NET_EVENT_IPV4_ADDR_ADD) { struct network_ipv4_info info = {0}; active_source = infer_source_from_iface(iface); @@ -296,6 +385,12 @@ static int apply_active_config(struct net_if *iface) if (active_cfg.try_dhcp_first) { rc = try_dhcp(iface, active_cfg.dhcp_timeout_ms); if (rc == 0) { +#if defined(CONFIG_DNS_RESOLVER) + if (!active_cfg.prefer_dhcp_dns && + configure_manual_dns(&active_cfg.static_profile) != 0) { + return -EINVAL; + } +#endif return 0; } LOG_WRN("DHCPv4 timed out (%d), trying static", rc); @@ -461,6 +556,8 @@ int network_reconfigure(const struct network_config *cfg) { int rc; struct net_if *iface; + struct network_config prior_cfg; + enum network_ipv4_source prior_source; if (cfg == NULL) { return -EINVAL; @@ -472,10 +569,15 @@ int network_reconfigure(const struct network_config *cfg) return -ENETDOWN; } + prior_cfg = active_cfg; + prior_source = active_source; active_cfg = *cfg; rc = apply_active_config(iface); if (rc != 0) { LOG_WRN("No IPv4 configuration could be applied (%d)", rc); + active_cfg = prior_cfg; + active_source = prior_source; + (void)apply_active_config(iface); } return rc; @@ -507,7 +609,9 @@ int network_init(const struct network_config *cfg, network_event_cb_t event_cb) net_mgmt_add_event_callback(&net_l4_mgmt_cb); net_mgmt_init_event_callback(&net_ipv4_mgmt_cb, net_ipv4_evt_handler, - NET_EVENT_IPV4_ADDR_ADD | NET_EVENT_IPV4_ADDR_DEL); + NET_EVENT_IPV4_ADDR_ADD | + NET_EVENT_IPV4_ADDR_DEL | + NET_EVENT_IPV4_DHCP_BOUND); net_mgmt_add_event_callback(&net_ipv4_mgmt_cb); network_initialized = true; diff --git a/lib/coo_commons/scheduled_action.c b/lib/coo_commons/scheduled_action.c new file mode 100644 index 0000000..cf9bc10 --- /dev/null +++ b/lib/coo_commons/scheduled_action.c @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Caltech Optical Observatories + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include +#include +#include + +LOG_MODULE_REGISTER(coo_scheduled_action, LOG_LEVEL_INF); + +static bool valid_action(const struct coo_scheduled_action *actions, + size_t action_count, + size_t id) +{ + return actions != NULL && id < action_count; +} + +static void scheduled_action_work_handler(struct k_work *work) +{ + struct k_work_delayable *dwork = k_work_delayable_from_work(work); + struct coo_scheduled_action *action = + CONTAINER_OF(dwork, struct coo_scheduled_action, work); + + (void)atomic_clear(&action->pending); + + if (action->handler == NULL) { + LOG_WRN("Scheduled action %s has no handler", + action->name != NULL ? action->name : "unknown"); + return; + } + + action->handler(action->id, action->user_data); +} + +int coo_scheduled_actions_init(struct coo_scheduled_action *actions, size_t action_count) +{ + if (actions == NULL || action_count == 0U) { + return -EINVAL; + } + + for (size_t i = 0U; i < action_count; ++i) { + /* k_work_init_delayable() binds each action to Zephyr's system + * workqueue; callers should not do slow I/O in handlers. + */ + k_work_init_delayable(&actions[i].work, scheduled_action_work_handler); + (void)atomic_clear(&actions[i].pending); + actions[i].id = i; + } + + return 0; +} + +int coo_scheduled_action_register(struct coo_scheduled_action *actions, + size_t action_count, + size_t id, + coo_scheduled_action_handler_t handler, + void *user_data) +{ + if (!valid_action(actions, action_count, id) || handler == NULL) { + return -EINVAL; + } + + actions[id].handler = handler; + actions[id].user_data = user_data; + return 0; +} + +int coo_scheduled_action_schedule(struct coo_scheduled_action *actions, + size_t action_count, + size_t id, + k_timeout_t delay) +{ + int rc; + + if (!valid_action(actions, action_count, id)) { + return -EINVAL; + } + + /* k_work_reschedule() implements "do this later unless refreshed" by + * updating the same delayable work item in place. + */ + rc = k_work_reschedule(&actions[id].work, delay); + if (rc >= 0) { + (void)atomic_set(&actions[id].pending, 1); + } + return rc; +} + +int coo_scheduled_action_cancel(struct coo_scheduled_action *actions, + size_t action_count, + size_t id) +{ + if (!valid_action(actions, action_count, id)) { + return -EINVAL; + } + + (void)atomic_clear(&actions[id].pending); + return k_work_cancel_delayable(&actions[id].work); +} + +bool coo_scheduled_action_is_pending(const struct coo_scheduled_action *actions, + size_t action_count, + size_t id) +{ + if (!valid_action(actions, action_count, id)) { + return false; + } + + return atomic_get(&actions[id].pending) != 0; +} + +int coo_scheduled_action_remaining_ms(struct coo_scheduled_action *actions, + size_t action_count, + size_t id, + int64_t *remaining_ms) +{ + k_ticks_t remaining_ticks; + + if (!valid_action(actions, action_count, id) || remaining_ms == NULL) { + return -EINVAL; + } + + remaining_ticks = k_work_delayable_remaining_get(&actions[id].work); + *remaining_ms = k_ticks_to_ms_floor64(remaining_ticks); + return 0; +} + +const char *coo_scheduled_action_name(const struct coo_scheduled_action *actions, + size_t action_count, + size_t id) +{ + if (!valid_action(actions, action_count, id) || actions[id].name == NULL) { + return "unknown"; + } + + return actions[id].name; +} diff --git a/tools/hispec_fibpcb.py b/tools/hispec_fibpcb.py new file mode 100644 index 0000000..ecf9896 --- /dev/null +++ b/tools/hispec_fibpcb.py @@ -0,0 +1,1844 @@ +#!/usr/bin/env python3 +"""Notebook-friendly MQTT client for the HISPEC FIB PCB firmware. + +The firmware command API is JSON over MQTT v5. This module keeps JSON as a +transport detail: command methods accept ordinary Python arguments and return +typed Python objects. Throughput telemetry is collected in a background worker +and exported as NumPy record arrays or pandas DataFrames for live plotting. +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import logging +import math +import queue +import re +import struct +import threading +import time +from collections import deque +from dataclasses import dataclass, field, fields +from types import SimpleNamespace +from typing import Any, Callable, Deque, Iterable, Literal, Mapping, Sequence + +import numpy as np +import paho.mqtt.client as mqtt +from paho.mqtt.packettypes import PacketTypes +from paho.mqtt.properties import Properties + + +LOGGER = logging.getLogger(__name__) + +DEVICE_NAMES = ("hsfib-tib", "hsfib-rcal", "hsfib-bcal", "hsfib-as") +LASER_NAMES = ("1028y", "1270j", "1430yj", "1430hk", "1510h", "2330k") +ATTENUATOR_NAMES = LASER_NAMES + ("lfc",) +PD_CHANNELS = ("yj", "hk") +FIBERS = ("M", "S") +OVERRIDE_MODES = ("auto", "override_on", "override_off") +MEMS_STATES = ("A", "B") +MEMS_MAX_TOGGLE_DURATION_S = 4 * 60 * 60 + +_LASER_TO_PD_CHANNEL = { + "1028y": "yj", + "1270j": "yj", + "1430yj": "yj", + "1430hk": "hk", + "1510h": "hk", + "2330k": "hk", +} + +_THROUGHPUT_BINARY = struct.Struct("<8sQ10dh9f") +THROUGHPUT_DTYPE = np.dtype( + [ + ("channel", "U8"), + ("laser", "U16"), + ("autolevel", "?"), + ("time", "u8"), + ("tp", "f8"), + ("tp_err", "f8"), + ("tp_rms_err", "f8"), + ("pd_flux_ph_s", "f8"), + ("pd_flux_err_ph_s", "f8"), + ("laser_flux_ph_s", "f8"), + ("laser_flux_err_ph_s", "f8"), + ("pd_route_tx", "f8"), + ("laser_route_tx", "f8"), + ("atten_tx", "f8"), + ("pd_raw", "i2"), + ("pd_mv", "f4"), + ("pd_net_mv", "f4"), + ("pd_mean_mv_1s", "f4"), + ("pd_rms_mv_0p5s", "f4"), + ("laser_current_ma", "f4"), + ("atten_db", "f4"), + ("wavelength_nm", "f4"), + ("pd_ontime_s", "f4"), + ("laser_current_ontime_s", "f4"), + ("flags", "O"), + ] +) + + +class HispecFibError(RuntimeError): + """Local Python-side client error.""" + + +class HispecFibPCBError(HispecFibError): + """Remote firmware error response from the PCB.""" + + def __init__(self, message: str, *, topic: str | None = None, response: Any = None): + super().__init__(message) + self.topic = topic + self.response = response + + +@dataclass(frozen=True) +class NamedValue: + name: str + value: Any + + +@dataclass(frozen=True) +class CommandOk: + status: str = "ok" + + +@dataclass(frozen=True) +class HelpSummary: + help: str + + +@dataclass(frozen=True) +class MqttConfig: + broker: str + dns_supported: bool + + +@dataclass(frozen=True) +class IpManualConfig: + ip: str + subnet: str + gateway: str + dns: str + ntp: str + + +@dataclass(frozen=True) +class IpActiveConfig: + ready: bool + ip: str + + +@dataclass(frozen=True) +class NtpConfig: + source: str + server: str + + +@dataclass(frozen=True) +class IpConfig: + source: str + trydhcpfirst: bool + preferdhcpdns: bool + preferdhcpntp: bool + manual: IpManualConfig + active: IpActiveConfig + ntp: NtpConfig + + +@dataclass(frozen=True) +class PartialSupport: + dhcp: str = "ok" + dns: str = "ok" + ntp: str = "ok" + + +@dataclass(frozen=True) +class TimeStatus: + utc: int + uptime: int + + +@dataclass(frozen=True) +class SerialGuardStatus: + serialguard_s: int + active: bool + remaining_ms: int + + +@dataclass(frozen=True) +class LastCommand: + name: str + source: str + time: int + + +@dataclass(frozen=True) +class StatusLaserSummary: + power_mw: float | None = None + tec_on_time_s: float = 0.0 + offin_s: int = 0 + + +@dataclass(frozen=True) +class StatusAttenSummary: + level_percent: float | None = None + + +@dataclass(frozen=True) +class Status: + fwversion: str + bootcount: int + board_type: str + board_valid: bool + mems_switches: int + relay_gpio_error: int + temp_c: float | None + pd_ontime: float + laserbank_ontime: int + lastcommand: LastCommand + ip: IpConfig | None = None + lasers: tuple[NamedValue, ...] = () + attens: tuple[NamedValue, ...] = () + + +@dataclass(frozen=True) +class TempStatus: + ambient_c: float | None + laserbank_c: float | None + laser: tuple[NamedValue, ...] + + +@dataclass(frozen=True) +class MemsSwitchState: + name: str + state: str + duty_cycle: float + + +@dataclass(frozen=True) +class MemsSwitchDetail: + name: str + state: str + duty_cycle: float + requested_toggle_rate_hz: float = 0.0 + toggle_rate_hz: float = 0.0 + stopafter_s: int = 0 + + +@dataclass(frozen=True) +class MemsRoutes: + active_routes: tuple[NamedValue, ...] + + +@dataclass(frozen=True) +class RouteLoss: + route: str + lasers: tuple[NamedValue, ...] = () + split: tuple[float, float, float] | None = None + + +@dataclass(frozen=True) +class LaserStatus: + name: str + powered: bool + tec_on_s: float + emit_on_s: float + emit_total_s: float + temp_c: float | None + current_ma: float | None + level: float | None + power_mw: float | None + nominal_nm: float + tuned_nm: float | None + tune_nm: float + tec_ma: float | None + diode_v: float | None + tec_v: float | None + offin_s: int + oc_fault: bool + + +@dataclass(frozen=True) +class LaserTune: + name: str + tune_nm: float + + +@dataclass(frozen=True) +class TecPid: + p: int + i: int + d: int + + +@dataclass(frozen=True) +class LaserSettings: + name: str + model: str + nominal_current_ma: float + max_current_ma: float + current_set_calibration_pct: float + threshold_current_ma: float + efficiency_mw_per_ma: float + wavelength_nm: float + operating_temp_range_c: tuple[float, float] + default_operating_temp_c: float + thermistor_kohm: float + isolation_db: float + tec_max_current_a: float + tec_pid: TecPid + disable_tec_at_autooff: bool + ntc_t_coefficient_per_c: float + dlambda_dT_nm_per_k: float + dlambda_dA_nm_per_ma: float + autooff_s: int + tune_nm: float + emit_total_s: float + + +@dataclass(frozen=True) +class LaserEngStatus: + name: str + read_rc: int + powered: bool + dev_id: int + serial: int + serial_ok: bool + raw_state: int + raw_lock: int + raw_tec: int + op_started: bool + ready: bool + curr_set_internal: bool + enable_internal: bool + ext_ntc_denied: bool + interlock_denied: bool + interlock: bool + ext_ntc_interlock: bool + ld_overcurrent: bool + ld_overheat: bool + tec_started: bool + tec_set_internal: bool + tec_enable_internal: bool + tec_error: bool + tec_selfheat: bool + curr_ma: float | None + curr_meas_ma: float | None + curr_min_ma: float | None + curr_max_ma: float | None + drv_max_ma: float | None + ocp_ma: float | None + curr_cal_pct: float | None + diode_v: float | None + tec_temp_set_c: float | None + tec_temp_c: float | None + pcb_temp_c: float | None + tec_curr_a: float | None + tec_curr_lim_a: float | None + tec_v: float | None + pid: tuple[int, int, int] + ntc_t_coeff: float | None + + +@dataclass(frozen=True) +class LaserBankPower: + mode: str + powered: bool + + +@dataclass(frozen=True) +class LaserBankClearFaults: + off_ms: int + + +@dataclass(frozen=True) +class LaserBankHeater: + heater_mode: str + heater_on: bool + bank_power: bool + ambient_valid: bool + ambient_c: float + valid_temps: int + stale_temps: int + any_disabled_below_15c: bool + any_disabled_above_off_threshold: bool + all_tecs_enabled: bool + all_tecs_enabled_ms: int + last_error: int + last_poll_age_ms: int + + +@dataclass(frozen=True) +class AttenuatorState: + db: float + linear: float + voltage1: float + voltage2: float + db1: float + db2: float + + +@dataclass(frozen=True) +class AttenuatorCoeff: + dac1: tuple[float, float] + dac2: tuple[float, float] + + +@dataclass(frozen=True) +class AttenuatorFitMetrics: + valid: bool + points: int = 0 + slope: float | None = None + offset: float | None = None + corr: float | None = None + rms_db: float | None = None + max_abs_db: float | None = None + min_tx: float | None = None + max_tx: float | None = None + voltage_span_mv: float | None = None + + +@dataclass(frozen=True) +class AttenuatorCalibrationBatch: + voltage_mv: tuple[float, ...] + flux: tuple[float, ...] + + +@dataclass(frozen=True) +class AttenuatorCalibrationStatus: + state: str + mode: str + physical: str + fit: str + n: int + t_ms: int + complete_pct: int + point: str + mv: float + other_mv: float + error: int + dac1: AttenuatorFitMetrics + dac2: AttenuatorFitMetrics + voltage_mv: tuple[float, ...] = () + + +@dataclass(frozen=True) +class PhotodiodeValues: + yjvalue: float + yjvalue_err: float + hkvalue: float + hkvalue_err: float + yj_raw: int + hk_raw: int + yj_mv: float + hk_mv: float + yj_noise_rms_mv: float + hk_noise_rms_mv: float + yj_mean_mv_1s: float + hk_mean_mv_1s: float + yj_rms_mv_0p5s: float + hk_rms_mv_0p5s: float + uptime: int + + +@dataclass(frozen=True) +class DarkStatus: + state: str + channel: str + duration_ms: int + samples: int + target_samples: int + stored_on_complete: bool | None = None + stored: bool | None = None + mean_dark_mv: float | None = None + rms_mv: float | None = None + min_mv: float | None = None + max_mv: float | None = None + previous_dark_mv: float | None = None + configured_dark_mv: float | None = None + lowest_dark_mv: float | None = None + lowest_dark_valid: bool | None = None + + +@dataclass(frozen=True) +class PhotodiodeSettings: + channel: str + dark_mv: float + lowest_dark_mv: float + lowest_dark_valid: bool + average: str + average_duration_ms: int + average_samples: int + average_target_samples: int + noise_rms_mV: float + responsivity_a_per_w: float + transimpedance_v_per_a: float + + +@dataclass(frozen=True) +class SplitSwitchState: + name: str + state: str + duty_cycle: float + numerator: int + denominator: int + tick_ms: int + + +@dataclass(frozen=True) +class SplitState: + channel: str + ratio_ask: tuple[float, float, float] + ratio_actual: tuple[float, float, float] + ratio_out: tuple[float, float, float] + split_transmission: tuple[float, float, float] + switches: tuple[SplitSwitchState, SplitSwitchState, SplitSwitchState] + stopsin_s: int + + +@dataclass(frozen=True) +class WarningEvent: + severity: str + code: str + msg: str + context: str + uptime_ms: int + + +@dataclass(frozen=True) +class ThroughputSample: + channel: str + laser: str + autolevel: bool + time: int + tp: float + tp_err: float + tp_rms_err: float + pd_flux_ph_s: float + pd_flux_err_ph_s: float + laser_flux_ph_s: float + laser_flux_err_ph_s: float + pd_route_tx: float + laser_route_tx: float + atten_tx: float + pd_raw: int + pd_mv: float + pd_net_mv: float + pd_mean_mv_1s: float + pd_rms_mv_0p5s: float + laser_current_ma: float + atten_db: float + wavelength_nm: float + pd_ontime_s: float + laser_current_ontime_s: float + flags: tuple[str, ...] = () + + def as_tuple(self) -> tuple[Any, ...]: + return tuple(getattr(self, name) for name in THROUGHPUT_DTYPE.names) + + +@dataclass +class _PendingRequest: + topic: str + event: threading.Event = field(default_factory=threading.Event) + payload: bytes | None = None + properties: Any = None + + +def _mqtt_client(client_id: str) -> mqtt.Client: + if mqtt is None: + raise HispecFibError("paho-mqtt is required for MQTT access") + try: + return mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + client_id=client_id, + protocol=mqtt.MQTTv5, + ) + except (AttributeError, TypeError): + return mqtt.Client(client_id=client_id, protocol=mqtt.MQTTv5) + + +def _require_choice(name: str, value: str, choices: Sequence[str]) -> str: + if value not in choices: + raise HispecFibError(f"{name} must be one of {', '.join(choices)}") + return value + + +def _require_float(name: str, value: float, min_value: float, max_value: float) -> float: + value = float(value) + if not math.isfinite(value) or value < min_value or value > max_value: + raise HispecFibError(f"{name} must be in [{min_value}, {max_value}]") + return value + + +def _float_or_nan(value: Any) -> float: + return math.nan if value is None else float(value) + + +def _require_nonnegative_u32(name: str, value: int) -> int: + value = int(value) + if value < 0 or value > 0xFFFFFFFF: + raise HispecFibError(f"{name} must be a non-negative uint32") + return value + + +def _optional_payload(**items: Any) -> dict[str, Any] | None: + payload = {key: value for key, value in items.items() if value is not None} + return payload or None + + +def _loads(payload: bytes | str) -> Any: + text = payload.decode("utf-8") if isinstance(payload, (bytes, bytearray)) else payload + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise HispecFibError(f"failed to decode PCB JSON response: {exc}") from exc + + +def _as_tuple3(value: Sequence[float], name: str) -> tuple[float, float, float]: + if len(value) != 3: + raise HispecFibError(f"{name} must contain exactly 3 values") + return (float(value[0]), float(value[1]), float(value[2])) + + +def _dataclass_from(cls: type[Any], mapping: Mapping[str, Any], **overrides: Any) -> Any: + names = {f.name for f in fields(cls)} + values = {name: mapping.get(name) for name in names if name in mapping} + values.update(overrides) + return cls(**values) + + +def _named_values(mapping: Mapping[str, Any], value_fn: Callable[[str, Any], Any] | None = None) -> tuple[NamedValue, ...]: + if value_fn is None: + value_fn = lambda _name, value: _to_object(value) + return tuple(NamedValue(str(name), value_fn(str(name), value)) for name, value in mapping.items()) + + +def _to_object(value: Any) -> Any: + if isinstance(value, Mapping): + return SimpleNamespace(**{str(k): _to_object(v) for k, v in value.items()}) + if isinstance(value, list): + return tuple(_to_object(v) for v in value) + return value + + +def _decode_ok_or_raise(topic: str, payload: bytes) -> Any: + if not payload: + return CommandOk() + data = _loads(payload) + if isinstance(data, Mapping) and "error" in data: + raise HispecFibPCBError(str(data["error"]), topic=topic, response=_to_object(data)) + if data == {"status": "ok"}: + return CommandOk() + return data + + +def _decode_help(data: Mapping[str, Any]) -> HelpSummary: + return HelpSummary(help=str(data.get("help", ""))) + + +def _decode_ip_config(data: Mapping[str, Any]) -> IpConfig: + return IpConfig( + source=str(data["source"]), + trydhcpfirst=bool(data["trydhcpfirst"]), + preferdhcpdns=bool(data["preferdhcpdns"]), + preferdhcpntp=bool(data["preferdhcpntp"]), + manual=_dataclass_from(IpManualConfig, data["manual"]), + active=_dataclass_from(IpActiveConfig, data["active"]), + ntp=_dataclass_from(NtpConfig, data["ntp"]), + ) + + +def _decode_status(data: Mapping[str, Any]) -> Status: + lasers = _named_values( + data.get("lasers", {}), + lambda _name, value: StatusLaserSummary( + power_mw=value.get("power_mw"), + tec_on_time_s=float(value.get("tec_on_time_s", 0.0)), + offin_s=int(value.get("offin_s", 0)), + ), + ) + attens = _named_values( + data.get("attens", {}), + lambda _name, value: StatusAttenSummary(level_percent=value.get("level_%")), + ) + return Status( + fwversion=str(data["fwversion"]), + bootcount=int(data["bootcount"]), + board_type=str(data["board_type"]), + board_valid=bool(data["board_valid"]), + mems_switches=int(data["mems_switches"]), + relay_gpio_error=int(data["relay_gpio_error"]), + temp_c=data.get("temp_c"), + pd_ontime=float(data["pd_ontime"]), + laserbank_ontime=int(data["laserbank_ontime"]), + lastcommand=_dataclass_from(LastCommand, data["lastcommand"]), + ip=_decode_ip_config(data["ip"]) if "ip" in data else None, + lasers=lasers, + attens=attens, + ) + + +def _decode_temp(data: Mapping[str, Any]) -> TempStatus: + return TempStatus( + ambient_c=data.get("ambient_c"), + laserbank_c=data.get("laserbank_c"), + laser=_named_values(data.get("laser", {}), lambda _name, value: value), + ) + + +def _default_mems_duty_cycle(state: str) -> float: + if state.startswith("A"): + return 1.0 + if state.startswith("B"): + return 0.0 + return 0.0 + + +def _decode_mems(data: Mapping[str, Any]) -> tuple[MemsSwitchState, ...]: + return tuple( + MemsSwitchState( + name=name, + state=str(value["state"]), + duty_cycle=float( + value.get("duty_cycle", _default_mems_duty_cycle(str(value["state"]))) + ), + ) + for name, value in data.items() + ) + + +def _decode_mems_detail(name: str, data: Mapping[str, Any]) -> MemsSwitchDetail: + state = str(data["state"]) + + return MemsSwitchDetail( + name=name, + state=state, + duty_cycle=float(data.get("duty_cycle", _default_mems_duty_cycle(state))), + requested_toggle_rate_hz=float(data.get("requested_toggle_rate_hz", 0.0)), + toggle_rate_hz=float(data.get("toggle_rate_hz", 0.0)), + stopafter_s=int(data.get("stopafter_s", 0)), + ) + + +def _decode_mems_routes(data: Mapping[str, Any]) -> MemsRoutes: + active = data.get("active_routes", {}) + return MemsRoutes( + active_routes=tuple(NamedValue(str(name), tuple(value)) for name, value in active.items()) + ) + + +def _decode_route_loss(data: Mapping[str, Any]) -> RouteLoss: + return RouteLoss( + route=str(data["route"]), + lasers=_named_values(data.get("lasers", {}), lambda _name, value: float(value)), + split=_as_tuple3(data["split"], "split") if "split" in data else None, + ) + + +def _decode_laser_settings(data: Mapping[str, Any]) -> LaserSettings: + settings = data["settings"] + pid = settings["tec_pid"] + return LaserSettings( + name=str(data["name"]), + model=str(settings["model"]), + nominal_current_ma=float(settings["nominal_current_ma"]), + max_current_ma=float(settings["max_current_ma"]), + current_set_calibration_pct=float(settings["current_set_calibration_pct"]), + threshold_current_ma=float(settings["threshold_current_ma"]), + efficiency_mw_per_ma=float(settings["efficiency_mw_per_ma"]), + wavelength_nm=float(settings["wavelength_nm"]), + operating_temp_range_c=(float(settings["operating_temp_range_c"][0]), float(settings["operating_temp_range_c"][1])), + default_operating_temp_c=float(settings["default_operating_temp_c"]), + thermistor_kohm=float(settings["thermistor_kohm"]), + isolation_db=float(settings["isolation_db"]), + tec_max_current_a=float(settings["tec_max_current_a"]), + tec_pid=TecPid(p=int(pid["p"]), i=int(pid["i"]), d=int(pid["d"])), + disable_tec_at_autooff=bool(settings["disable_tec_at_autooff"]), + ntc_t_coefficient_per_c=float(settings["ntc_t_coefficient_per_c"]), + dlambda_dT_nm_per_k=float(settings["dlambda_dT_nm_per_k"]), + dlambda_dA_nm_per_ma=float(settings["dlambda_dA_nm_per_ma"]), + autooff_s=int(settings["autooff_s"]), + tune_nm=float(settings["tune_nm"]), + emit_total_s=float(settings["emit_total_s"]), + ) + + +def _decode_laser_eng(data: Mapping[str, Any]) -> LaserEngStatus: + return _dataclass_from(LaserEngStatus, data, pid=tuple(int(v) for v in data.get("pid", (0, 0, 0)))) + + +def _decode_atten_coeff(data: Mapping[str, Any]) -> AttenuatorCoeff: + return AttenuatorCoeff( + dac1=(float(data["dac1"][0]), float(data["dac1"][1])), + dac2=(float(data["dac2"][0]), float(data["dac2"][1])), + ) + + +def _decode_atten_fit(data: Mapping[str, Any]) -> AttenuatorFitMetrics: + if not bool(data.get("valid", False)): + return AttenuatorFitMetrics(valid=False) + return AttenuatorFitMetrics( + valid=True, + points=int(data.get("points", 0)), + slope=float(data["slope"]), + offset=float(data["offset"]), + corr=float(data["corr"]), + rms_db=float(data["rms_db"]), + max_abs_db=float(data["max_abs_db"]), + min_tx=float(data["min_tx"]), + max_tx=float(data["max_tx"]), + voltage_span_mv=float(data["voltage_span_mv"]), + ) + + +def _decode_atten_cal_status(data: Mapping[str, Any]) -> AttenuatorCalibrationStatus: + return AttenuatorCalibrationStatus( + state=str(data["state"]), + mode=str(data["mode"]), + physical=str(data["physical"]), + fit=str(data["fit"]), + n=int(data["n"]), + t_ms=int(data["t_ms"]), + complete_pct=int(data["complete_pct"]), + point=str(data["point"]), + mv=float(data["mv"]), + other_mv=float(data["other_mv"]), + error=int(data["error"]), + dac1=_decode_atten_fit(data.get("dac1", {"valid": False})), + dac2=_decode_atten_fit(data.get("dac2", {"valid": False})), + voltage_mv=tuple(float(v) for v in data.get("voltage_mv", ())), + ) + + +def _atten_cal_batch_payload(name: str, batch: AttenuatorCalibrationBatch | Mapping[str, Any] | Sequence[Any]) -> dict[str, list[float]]: + if isinstance(batch, AttenuatorCalibrationBatch): + voltage_mv = batch.voltage_mv + flux = batch.flux + elif isinstance(batch, Mapping): + voltage_mv = batch.get("voltage_mv", ()) + flux = batch.get("flux", ()) + else: + if len(batch) != 2: + raise HispecFibError(f"{name} must be AttenuatorCalibrationBatch or (voltage_mv, flux)") + voltage_mv = batch[0] + flux = batch[1] + + voltage_values = tuple(float(v) for v in voltage_mv) + flux_values = tuple(float(v) for v in flux) + if len(voltage_values) != len(flux_values): + raise HispecFibError(f"{name} voltage_mv and flux lengths differ") + if len(voltage_values) < 6 or len(voltage_values) > 20: + raise HispecFibError(f"{name} must contain 6 to 20 points") + if any(not math.isfinite(v) for v in voltage_values): + raise HispecFibError(f"{name} voltage_mv contains non-finite values") + if any((not math.isfinite(v)) or v <= 0.0 for v in flux_values): + raise HispecFibError(f"{name} flux values must be positive and finite") + return {"voltage_mv": list(voltage_values), "flux": list(flux_values)} + + +def _decode_dark_status(data: Mapping[str, Any]) -> DarkStatus: + return _dataclass_from(DarkStatus, data) + + +def _decode_split_state(data: Mapping[str, Any]) -> SplitState: + return SplitState( + channel=str(data["channel"]), + ratio_ask=_as_tuple3(data["ratio_ask"], "ratio_ask"), + ratio_actual=_as_tuple3(data["ratio_actual"], "ratio_actual"), + ratio_out=_as_tuple3(data["ratio_out"], "ratio_out"), + split_transmission=_as_tuple3(data["split_transmission"], "split_transmission"), + switches=tuple(_dataclass_from(SplitSwitchState, item) for item in data["switches"]), # type: ignore[arg-type] + stopsin_s=int(data["stopsin_s"]), + ) + + +def decode_warning(payload: bytes | str) -> WarningEvent: + data = _loads(payload) + if not isinstance(data, Mapping): + raise HispecFibError("warning payload is not a JSON object") + return WarningEvent( + severity=str(data.get("severity", "warning")), + code=str(data.get("code", "")), + msg=str(data.get("msg", "")), + context=str(data.get("context", "")), + uptime_ms=int(data.get("uptime_ms", 0)), + ) + + +def decode_throughput_payload(payload: bytes | str) -> ThroughputSample: + if isinstance(payload, str) or payload[:1] == b"{": + data = _loads(payload) + if not isinstance(data, Mapping): + raise HispecFibError("throughput payload is not a JSON object") + return ThroughputSample( + channel=str(data.get("channel", "")), + laser=str(data.get("laser", "")), + autolevel=bool(data.get("autolevel", False)), + time=int(data.get("time", 0)), + tp=_float_or_nan(data.get("tp", math.nan)), + tp_err=_float_or_nan(data.get("tp_err", math.nan)), + tp_rms_err=_float_or_nan(data.get("tp_rms_err", math.nan)), + pd_flux_ph_s=_float_or_nan(data.get("pd_flux_ph_s", math.nan)), + pd_flux_err_ph_s=_float_or_nan(data.get("pd_flux_err_ph_s", math.nan)), + laser_flux_ph_s=_float_or_nan(data.get("laser_flux_ph_s", math.nan)), + laser_flux_err_ph_s=_float_or_nan(data.get("laser_flux_err_ph_s", math.nan)), + pd_route_tx=_float_or_nan(data.get("pd_route_tx", math.nan)), + laser_route_tx=_float_or_nan(data.get("laser_route_tx", math.nan)), + atten_tx=_float_or_nan(data.get("atten_tx", math.nan)), + pd_raw=int(data.get("pd_raw", 0)), + pd_mv=_float_or_nan(data.get("pd_mv", math.nan)), + pd_net_mv=_float_or_nan(data.get("pd_net_mv", math.nan)), + pd_mean_mv_1s=_float_or_nan(data.get("pd_mean_mv_1s", math.nan)), + pd_rms_mv_0p5s=_float_or_nan(data.get("pd_rms_mv_0p5s", math.nan)), + laser_current_ma=_float_or_nan(data.get("laser_current_ma", math.nan)), + atten_db=_float_or_nan(data.get("atten_db", math.nan)), + wavelength_nm=_float_or_nan(data.get("wavelength_nm", math.nan)), + pd_ontime_s=_float_or_nan(data.get("pd_ontime_s", math.nan)), + laser_current_ontime_s=_float_or_nan(data.get("laser_current_ontime_s", math.nan)), + flags=tuple(str(flag) for flag in (data.get("flags") or ())), + ) + + if len(payload) != _THROUGHPUT_BINARY.size: + raise HispecFibError( + f"binary throughput payload is {len(payload)} bytes, expected {_THROUGHPUT_BINARY.size}" + ) + values = _THROUGHPUT_BINARY.unpack(payload) + channel = values[0].split(b"\0", 1)[0].decode("ascii", "replace") + f64 = values[2:12] + pd_raw = values[12] + f32 = values[13:22] + return ThroughputSample( + channel=channel, + laser="", + autolevel=False, + time=int(values[1]), + tp=float(f64[0]), + tp_err=float(f64[1]), + tp_rms_err=float(f64[2]), + pd_flux_ph_s=float(f64[3]), + pd_flux_err_ph_s=float(f64[4]), + laser_flux_ph_s=float(f64[5]), + laser_flux_err_ph_s=float(f64[6]), + pd_route_tx=float(f64[7]), + laser_route_tx=float(f64[8]), + atten_tx=float(f64[9]), + pd_raw=int(pd_raw), + pd_mv=float(f32[0]), + pd_net_mv=float(f32[1]), + pd_mean_mv_1s=float(f32[2]), + pd_rms_mv_0p5s=float(f32[3]), + laser_current_ma=float(f32[4]), + atten_db=float(f32[5]), + wavelength_nm=float(f32[6]), + pd_ontime_s=float(f32[7]), + laser_current_ontime_s=float(f32[8]), + flags=(), + ) + + +class ThroughputMonitor: + """Background throughput telemetry collector. + + Use ``to_recarray()`` or ``to_dataframe()`` for plotting. The worker thread + decodes MQTT payloads outside the paho callback path. + """ + + def __init__( + self, + client: HispecFibPcb, + channel: Literal["yj", "hk", "all"] = "all", + *, + max_samples: int = 20000, + ): + self.client = client + self.channel = _require_choice("channel", channel, ("yj", "hk", "all")) + self.max_samples = int(max_samples) + if self.max_samples <= 0: + raise HispecFibError("max_samples must be positive") + self._messages: queue.Queue[bytes | None] = queue.Queue() + self._samples: Deque[tuple[Any, ...]] = deque(maxlen=self.max_samples) + self._lock = threading.Lock() + self._running = threading.Event() + self._thread = threading.Thread(target=self._run, name=f"hispec-tput-{channel}", daemon=True) + + def start(self) -> ThroughputMonitor: + self._running.set() + self.client._register_throughput_monitor(self) + self._thread.start() + return self + + def stop(self) -> None: + self.client._unregister_throughput_monitor(self) + self._running.clear() + self._messages.put(None) + if self._thread.is_alive(): + self._thread.join(timeout=2.0) + + def clear(self) -> None: + with self._lock: + self._samples.clear() + + def enqueue_payload(self, payload: bytes) -> None: + if self._running.is_set(): + self._messages.put(payload) + + def to_recarray(self) -> np.recarray: + with self._lock: + data = list(self._samples) + return np.array(data, dtype=THROUGHPUT_DTYPE).view(np.recarray) + + def to_dataframe(self): + try: + import pandas as pd + except ImportError as exc: + raise HispecFibError("pandas is not installed") from exc + return pd.DataFrame.from_records(self.to_recarray(), columns=THROUGHPUT_DTYPE.names) + + def arrays(self, *names: str) -> tuple[np.ndarray, ...] | np.ndarray: + rec = self.to_recarray() + missing = [name for name in names if name not in rec.dtype.names] + if missing: + raise HispecFibError(f"unknown throughput field(s): {', '.join(missing)}") + arrays = tuple(rec[name] for name in names) + return arrays[0] if len(arrays) == 1 else arrays + + def plot_live( + self, + *, + x: str = "time", + y: str = "tp", + interval_s: float = 0.5, + max_points: int | None = None, + ) -> None: + import matplotlib.pyplot as plt + from IPython.display import clear_output, display + + while self._running.is_set(): + rec = self.to_recarray() + if max_points is not None and len(rec) > max_points: + rec = rec[-max_points:] + fig, ax = plt.subplots() + if len(rec): + ax.plot(rec[x], rec[y], marker=".", linestyle="-") + ax.set_xlabel(x) + ax.set_ylabel(y) + clear_output(wait=True) + display(fig) + plt.close(fig) + time.sleep(interval_s) + + def _run(self) -> None: + while self._running.is_set(): + payload = self._messages.get() + if payload is None: + break + try: + sample = decode_throughput_payload(payload) + except Exception: + self.client.logger.exception("failed to decode throughput telemetry") + continue + with self._lock: + self._samples.append(sample.as_tuple()) + + +class HispecFibPcb: + """MQTT client for a HISPEC FIB PCB. + + ``device`` is the formal MQTT device name, for example ``"hsfib-tib"``. + Board-profile names are intentionally not part of the public API. + """ + + def __init__( + self, + host: str, + *, + device: str = "hsfib-tib", + port: int = 1883, + client_id: str | None = None, + keepalive: int = 60, + timeout_s: float = 5.0, + auto_connect: bool = True, + connect: bool = False, + logger: logging.Logger | None = None, + warning_history: int = 200, + ): + if not re.fullmatch(r"hsfib-[A-Za-z0-9_-]+", device): + raise HispecFibError("device must be a formal MQTT name such as hsfib-tib") + self.host = host + self.port = int(port) + self.device = device + self.keepalive = int(keepalive) + self.timeout_s = float(timeout_s) + self.auto_connect = bool(auto_connect) + self.logger = logger or LOGGER + self._client_id = client_id or f"hispec-fibpcb-{device}-{int(time.time())}" + self._client: Any = None + self._connected = threading.Event() + self._connect_rc: Any = None + self._pending: dict[bytes, _PendingRequest] = {} + self._pending_lock = threading.Lock() + self._corr_counter = itertools.count(1) + self._warnings: Deque[WarningEvent] = deque(maxlen=int(warning_history)) + self._warning_lock = threading.Lock() + self._throughput_monitors: set[ThroughputMonitor] = set() + self._throughput_lock = threading.Lock() + self._loop_started = False + if connect: + self.connect() + + def __enter__(self) -> HispecFibPcb: + self.connect() + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.close() + + @property + def is_connected(self) -> bool: + return self._connected.is_set() + + @property + def warnings(self) -> tuple[WarningEvent, ...]: + with self._warning_lock: + return tuple(self._warnings) + + def connect(self, timeout_s: float | None = None) -> None: + if self.is_connected: + return + self._ensure_client() + self._connect_rc = None + try: + rc = self._client.connect(self.host, self.port, self.keepalive) + except OSError as exc: + raise HispecFibError(f"failed to connect to MQTT broker {self.host}:{self.port}: {exc}") from exc + if rc != mqtt.MQTT_ERR_SUCCESS: + raise HispecFibError(f"MQTT connect failed immediately with rc={rc}") + if not self._loop_started: + self._client.loop_start() + self._loop_started = True + if not self._connected.wait(self.timeout_s if timeout_s is None else timeout_s): + raise HispecFibError(f"timed out connecting to MQTT broker {self.host}:{self.port}") + self._subscribe_control_topics() + + def close(self) -> None: + with self._throughput_lock: + monitors = tuple(self._throughput_monitors) + for monitor in monitors: + monitor.stop() + if self._client is not None and self._loop_started: + self._client.disconnect() + self._client.loop_stop() + self._loop_started = False + self._connected.clear() + + def help(self) -> HelpSummary: + return _decode_help(self._request_json("help")) + + def status(self, *, ip: bool = False, lasers: bool = False, attens: bool = False) -> Status: + payload = _optional_payload(ip=ip or None, lasers=lasers or None, attens=attens or None) + return _decode_status(self._request_json("status", payload)) + + def ip_config(self) -> IpConfig: + return _decode_ip_config(self._request_json("ip")) + + def set_ip_config( + self, + *, + ip: str | None = None, + ntp: str | None = None, + dns: str | None = None, + subnet: str | None = None, + gateway: str | None = None, + trydhcpfirst: bool | None = None, + preferdhcpntp: bool | None = None, + preferdhcpdns: bool | None = None, + persistent: bool = False, + ) -> CommandOk | PartialSupport: + payload = _optional_payload( + ip=ip, + ntp=ntp, + dns=dns, + subnet=subnet, + gateway=gateway, + trydhcpfirst=trydhcpfirst, + preferdhcpntp=preferdhcpntp, + preferdhcpdns=preferdhcpdns, + persistent=persistent, + ) + if payload is None or set(payload) == {"persistent"}: + raise HispecFibError("at least one IP field must be supplied") + result = self._request_json("ip", payload) + if isinstance(result, Mapping) and "status" not in result: + return _dataclass_from(PartialSupport, result) + return CommandOk() + + def mqtt_config(self) -> MqttConfig: + return _dataclass_from(MqttConfig, self._request_json("mqtt")) + + def set_mqtt_config(self, broker: str, *, persistent: bool = False) -> CommandOk: + return self._request_ok("mqtt", {"broker": broker, "persistent": persistent}) + + def time(self) -> TimeStatus: + return _dataclass_from(TimeStatus, self._request_json("time")) + + def set_time(self, linuxtime_ms: int | None = None) -> CommandOk: + if linuxtime_ms is None: + linuxtime_ms = int(time.time() * 1000) + return self._request_ok("time", {"linuxtime_ms": int(linuxtime_ms)}) + + def temp(self) -> TempStatus: + return _decode_temp(self._request_json("temp")) + + def reboot(self) -> CommandOk: + return self._request_ok("reboot") + + def serialguard(self) -> SerialGuardStatus: + return _dataclass_from(SerialGuardStatus, self._request_json("serialguard")) + + def set_serialguard(self, seconds: int, *, persistent: bool = False) -> CommandOk: + return self._request_ok( + "serialguard", + {"seconds": _require_nonnegative_u32("seconds", seconds), "persistent": persistent}, + ) + + def mems(self) -> tuple[MemsSwitchState, ...]: + return _decode_mems(self._request_json("mems")) + + def mems_switch( + self, + name: str, + *, + state: Literal["A", "B"] | None = None, + duty_cycle: float | None = None, + toggle_rate_hz: float | None = None, + stopafter_s: float | None = None, + ) -> MemsSwitchDetail: + key = f"mems/{name}" + if state is None and duty_cycle is None and toggle_rate_hz is None and stopafter_s is None: + return _decode_mems_detail(name, self._request_json(key)) + if state is None: + raise HispecFibError("state is required when setting a MEMS switch") + payload: dict[str, Any] = {"state": _require_choice("state", state, MEMS_STATES)} + if duty_cycle is not None: + payload["duty_cycle"] = _require_float("duty_cycle", duty_cycle, 0.0, 1.0) + if toggle_rate_hz is not None: + payload["toggle_rate_hz"] = _require_float("toggle_rate_hz", toggle_rate_hz, 0.0, 1.0e9) + if payload["toggle_rate_hz"] == 0.0: + raise HispecFibError("toggle_rate_hz must be > 0") + if stopafter_s is not None: + payload["stopafter_s"] = _require_float( + "stopafter_s", stopafter_s, 0.0, MEMS_MAX_TOGGLE_DURATION_S + ) + return _decode_mems_detail(name, self._request_json(key, payload)) + + def memsroute(self) -> MemsRoutes: + return _decode_mems_routes(self._request_json("memsroute")) + + def set_memsroute(self, input: str, output: str) -> CommandOk: + return self._request_ok("memsroute", {"input": input, "output": output}) + + def route_loss(self, route: str) -> RouteLoss: + return _decode_route_loss(self._request_json("memsroute/route_loss", {"route": route})) + + def set_route_loss( + self, + route: str, + *, + laser: str | None = None, + transmission: float | None = None, + loss_db: float | None = None, + split: Sequence[float | str] | None = None, + persistent: bool = False, + ) -> CommandOk: + payload: dict[str, Any] = {"route": route, "persistent": persistent} + if split is not None: + if laser is not None or transmission is not None or loss_db is not None: + raise HispecFibError("route loss uses either split or a laser value") + if len(split) != 3: + raise HispecFibError("split route loss must contain three values") + payload["split"] = list(split) + else: + if laser is None: + raise HispecFibError("laser is required for non-split route loss") + _require_choice("laser", laser, LASER_NAMES) + if transmission is not None and loss_db is not None: + raise HispecFibError("use transmission or loss_db, not both") + if loss_db is not None: + if float(loss_db) < 0.0: + raise HispecFibError("loss_db must be non-negative") + payload[laser] = f"{float(loss_db)} dB" + elif transmission is not None: + payload[laser] = _require_float("transmission", transmission, 1e-300, 1.0) + else: + raise HispecFibError("transmission or loss_db is required") + return self._request_ok("memsroute/route_loss", payload) + + def laser_status(self, name: str) -> LaserStatus: + _require_choice("name", name, LASER_NAMES) + return _dataclass_from(LaserStatus, self._request_json("laser/status", {"name": name})) + + def laser(self, name: str) -> LaserStatus: + _require_choice("name", name, LASER_NAMES) + return _dataclass_from(LaserStatus, self._request_json("laser", {"name": name})) + + def set_laser_level(self, name: str, level: float, *, autooff_s: int | None = None) -> CommandOk: + _require_choice("name", name, LASER_NAMES) + payload: dict[str, Any] = {"name": name, "level": _require_float("level", level, 0.0, 100.0)} + if autooff_s is not None: + payload["autooff_s"] = _require_nonnegative_u32("autooff_s", autooff_s) + return self._request_ok("laser", payload) + + def laser_tune(self, name: str) -> LaserTune: + _require_choice("name", name, LASER_NAMES) + return _dataclass_from(LaserTune, self._request_json("laser/tune", {"name": name})) + + def set_laser_tune( + self, + name: str, + tune_nm: float | None = None, + *, + delta_nm: float | None = None, + ) -> CommandOk: + _require_choice("name", name, LASER_NAMES) + if tune_nm is None and delta_nm is None: + raise HispecFibError("tune_nm or delta_nm is required") + if tune_nm is not None and delta_nm is not None: + raise HispecFibError("use tune_nm or delta_nm, not both") + tune_nm = delta_nm if tune_nm is None else tune_nm + return self._request_ok("laser/tune", {"name": name, "tune_nm": float(tune_nm)}) + + def laser_settings(self, name: str) -> LaserSettings: + _require_choice("name", name, LASER_NAMES) + return _decode_laser_settings(self._request_json("laser/settings", {"name": name})) + + def set_laser_settings( + self, + name: str, + *, + nominal_current_ma: float | None = None, + max_current_ma: float | None = None, + threshold_current_ma: float | None = None, + efficiency_mw_per_ma: float | None = None, + wavelength_nm: float | None = None, + current_set_calibration_pct: float | None = None, + default_operating_temp_c: float | None = None, + operating_temp_range_c: tuple[float, float] | None = None, + tec_max_current_a: float | None = None, + tec_pid: TecPid | tuple[int, int, int] | None = None, + disable_tec_at_autooff: bool | None = None, + dlambda_dT_nm_per_k: float | None = None, + dlambda_dA_nm_per_ma: float | None = None, + autooff_s: int | None = None, + ) -> CommandOk: + _require_choice("name", name, LASER_NAMES) + settings = _optional_payload( + nominal_current_ma=nominal_current_ma, + max_current_ma=max_current_ma, + threshold_current_ma=threshold_current_ma, + efficiency_mw_per_ma=efficiency_mw_per_ma, + wavelength_nm=wavelength_nm, + current_set_calibration_pct=current_set_calibration_pct, + default_operating_temp_c=default_operating_temp_c, + operating_temp_range_c=operating_temp_range_c, + tec_max_current_a=tec_max_current_a, + disable_tec_at_autooff=disable_tec_at_autooff, + dlambda_dT_nm_per_k=dlambda_dT_nm_per_k, + dlambda_dA_nm_per_ma=dlambda_dA_nm_per_ma, + autooff_s=autooff_s, + ) + settings = settings or {} + if tec_pid is not None: + if isinstance(tec_pid, TecPid): + settings["tec_pid"] = {"p": tec_pid.p, "i": tec_pid.i, "d": tec_pid.d} + else: + if len(tec_pid) != 3: + raise HispecFibError("tec_pid must contain p, i, d") + settings["tec_pid"] = {"p": int(tec_pid[0]), "i": int(tec_pid[1]), "d": int(tec_pid[2])} + if not settings: + raise HispecFibError("at least one laser settings field must be supplied") + return self._request_ok("laser/settings", {"name": name, "settings": settings}) + + def laser_engstatus(self, name: str) -> LaserEngStatus: + _require_choice("name", name, LASER_NAMES) + return _decode_laser_eng(self._request_json("laser/engstatus", {"name": name})) + + def laserbank_power(self, mode: Literal["auto", "override_on", "override_off"] | None = None) -> LaserBankPower: + if mode is None: + return _dataclass_from(LaserBankPower, self._request_json("laserbank/power")) + _require_choice("mode", mode, OVERRIDE_MODES) + return _dataclass_from(LaserBankPower, self._request_json(f"laserbank/power/{mode}")) + + def laserbank_heater(self, mode: Literal["auto", "override_on", "override_off"] | None = None) -> LaserBankHeater: + if mode is None: + return _dataclass_from(LaserBankHeater, self._request_json("laserbank/heater")) + _require_choice("mode", mode, OVERRIDE_MODES) + return _dataclass_from(LaserBankHeater, self._request_json(f"laserbank/heater/{mode}")) + + def laserbank_clearfaults(self) -> LaserBankClearFaults: + return _dataclass_from(LaserBankClearFaults, self._request_json("laserbank/clearfaults")) + + def atten_value(self, laser: str) -> AttenuatorState: + _require_choice("laser", laser, ATTENUATOR_NAMES) + return _dataclass_from(AttenuatorState, self._request_json(f"atten/{laser}/value")) + + def set_atten_value(self, laser: str, value: float) -> CommandOk: + _require_choice("laser", laser, ATTENUATOR_NAMES) + return self._request_ok(f"atten/{laser}/value", {"value": _require_float("value", value, 1e-300, 1.0)}) + + def atten_db(self, laser: str) -> AttenuatorState: + _require_choice("laser", laser, ATTENUATOR_NAMES) + return _dataclass_from(AttenuatorState, self._request_json(f"atten/{laser}/valuedb")) + + def set_atten_db(self, laser: str, value_db: float) -> CommandOk: + _require_choice("laser", laser, ATTENUATOR_NAMES) + return self._request_ok(f"atten/{laser}/valuedb", {"value": _require_float("value_db", value_db, 0.0, 1e9)}) + + def atten_coeff(self, laser: str) -> AttenuatorCoeff: + _require_choice("laser", laser, ATTENUATOR_NAMES) + return _decode_atten_coeff(self._request_json(f"atten/{laser}/coeff")) + + def set_atten_coeff( + self, + laser: str, + dac1: tuple[float, float], + dac2: tuple[float, float], + *, + persistent: bool = False, + ) -> CommandOk: + _require_choice("laser", laser, ATTENUATOR_NAMES) + if len(dac1) != 2 or len(dac2) != 2: + raise HispecFibError("dac1 and dac2 must each contain slope and offset") + return self._request_ok( + f"atten/{laser}/coeff", + {"dac1": [float(dac1[0]), float(dac1[1])], "dac2": [float(dac2[0]), float(dac2[1])], "persistent": persistent}, + ) + + def atten_calibration_status(self) -> AttenuatorCalibrationStatus: + return _decode_atten_cal_status(self._request_json("atten/calibrate")) + + def atten_calibrate_auto( + self, + laser: str, + *, + output: str, + fiber: Literal["M", "S"] = "M", + dwell_ms: int = 300, + persistent: bool = False, + ) -> AttenuatorCalibrationStatus: + _require_choice("laser", laser, LASER_NAMES) + fiber = _require_choice("fiber", fiber.upper(), FIBERS) # type: ignore[assignment] + payload = { + "laser": laser, + "output": str(output), + "fiber": fiber, + "dwell_ms": _require_nonnegative_u32("dwell_ms", dwell_ms), + "persistent": bool(persistent), + } + return _decode_atten_cal_status(self._request_json("atten/calibrate", payload)) + + def atten_calibrate_manual( + self, + attenuator: str = "lfc", + *, + dwell_ms: int = 300, + persistent: bool = False, + ) -> AttenuatorCalibrationStatus: + _require_choice("attenuator", attenuator, ATTENUATOR_NAMES) + payload = { + "mode": "manual", + "attenuator": attenuator, + "dwell_ms": _require_nonnegative_u32("dwell_ms", dwell_ms), + "persistent": bool(persistent), + } + return _decode_atten_cal_status(self._request_json("atten/calibrate", payload)) + + def atten_calibration_continue(self, *, other_mv: float | None = None) -> AttenuatorCalibrationStatus: + payload: dict[str, Any] = {"continue": True} + if other_mv is not None: + payload["other_mv"] = _require_float("other_mv", other_mv, 0.0, 4096.0) + return _decode_atten_cal_status(self._request_json("atten/calibrate", payload)) + + def atten_calibration_stop(self) -> AttenuatorCalibrationStatus: + return _decode_atten_cal_status(self._request_json("atten/calibrate", {"stop": True})) + + def atten_calibrate_manual_fit( + self, + attenuator: str = "lfc", + *, + dac1: AttenuatorCalibrationBatch | Mapping[str, Any] | Sequence[Any], + dac2: AttenuatorCalibrationBatch | Mapping[str, Any] | Sequence[Any], + persistent: bool = False, + ) -> AttenuatorCalibrationStatus: + _require_choice("attenuator", attenuator, ATTENUATOR_NAMES) + payload = { + "mode": "manual", + "attenuator": attenuator, + "persistent": bool(persistent), + "dac1": _atten_cal_batch_payload("dac1", dac1), + "dac2": _atten_cal_batch_payload("dac2", dac2), + } + return _decode_atten_cal_status(self._request_json("atten/calibrate", payload)) + + def pd(self) -> PhotodiodeValues: + return _dataclass_from(PhotodiodeValues, self._request_json("pd")) + + def measure_dark(self, channel: Literal["yj", "hk"], *, duration_ms: int = 0, store: bool = False) -> DarkStatus: + _require_choice("channel", channel, PD_CHANNELS) + payload = { + "action": "measure_dark", + "channel": channel, + "duration_ms": _require_nonnegative_u32("duration_ms", duration_ms), + "store": bool(store), + } + return _decode_dark_status(self._request_json("pd", payload)) + + def dark_status(self, channel: Literal["yj", "hk"]) -> DarkStatus: + _require_choice("channel", channel, PD_CHANNELS) + return _decode_dark_status(self._request_json("pd", {"action": "dark_status", "channel": channel})) + + def reset_lowest_dark(self, channel: Literal["yj", "hk"], *, persistent: bool = True) -> CommandOk: + _require_choice("channel", channel, PD_CHANNELS) + return self._request_ok( + "pd", + {"action": "reset_lowest_dark", "channel": channel, "persistent": persistent}, + ) + + def pdsettings(self, channel: Literal["yj", "hk"]) -> PhotodiodeSettings: + _require_choice("channel", channel, PD_CHANNELS) + return _dataclass_from(PhotodiodeSettings, self._request_json(f"pdsettings/{channel}")) + + def set_pdsettings( + self, + channel: Literal["yj", "hk"], + *, + dark_mv: float | None = None, + noise_rms_mV: float | None = None, + responsivity_a_per_w: float | None = None, + transimpedance_v_per_a: float | None = None, + persistent: bool = False, + ) -> CommandOk: + _require_choice("channel", channel, PD_CHANNELS) + payload = _optional_payload( + dark_mv=dark_mv, + noise_rms_mV=noise_rms_mV, + responsivity_a_per_w=responsivity_a_per_w, + transimpedance_v_per_a=transimpedance_v_per_a, + persistent=persistent, + ) + if payload is None or set(payload) == {"persistent"}: + raise HispecFibError("at least one photodiode setting must be supplied") + if dark_mv is not None: + payload["dark_mv"] = _require_float("dark_mv", dark_mv, -5000.0, 5000.0) + if noise_rms_mV is not None: + payload["noise_rms_mV"] = _require_float("noise_rms_mV", noise_rms_mV, 0.0, 5000.0) + if responsivity_a_per_w is not None: + payload["responsivity_a_per_w"] = _require_float( + "responsivity_a_per_w", responsivity_a_per_w, 0.000001, 10.0 + ) + if transimpedance_v_per_a is not None: + payload["transimpedance_v_per_a"] = _require_float( + "transimpedance_v_per_a", transimpedance_v_per_a, 1.0, 1.0e12 + ) + return self._request_ok(f"pdsettings/{channel}", payload) + + def split_status(self, channel: Literal["yj", "hk"]) -> SplitState: + _require_choice("channel", channel, PD_CHANNELS) + return _decode_split_state(self._request_json(f"split/{channel}")) + + def split( + self, + channel: Literal["yj", "hk"], + ratio1: float, + ratio2: float, + *, + stopafter_s: int = 0, + ) -> SplitState: + _require_choice("channel", channel, PD_CHANNELS) + ratio1 = _require_float("ratio1", ratio1, 0.0, 1.0) + ratio2 = _require_float("ratio2", ratio2, 0.0, 1.0) + if ratio1 + ratio2 > 1.000001: + raise HispecFibError("ratio1 + ratio2 must be <= 1.0") + payload = { + "channel": channel, + "ratio1": ratio1, + "ratio2": ratio2, + "stopafter_s": int( + _require_float("stopafter_s", stopafter_s, 0.0, MEMS_MAX_TOGGLE_DURATION_S) + ), + } + return _decode_split_state(self._request_json("split", payload)) + + def measure_throughput( + self, + laser: str, + *, + fiber: Literal["M", "S"] = "M", + autolevel: bool = True, + input: str | None = None, + output: str | None = None, + max_flux_ph_s: float | None = None, + stopafter_s: int = 300, + format: Literal["json", "binary"] = "json", + collect: bool = False, + channel: Literal["yj", "hk"] | None = None, + max_samples: int = 20000, + ) -> CommandOk | ThroughputMonitor: + if laser != "none": + _require_choice("laser", laser, LASER_NAMES) + fiber = _require_choice("fiber", fiber.upper(), FIBERS) # type: ignore[assignment] + _require_choice("format", format, ("json", "binary")) + if max_flux_ph_s is not None and not autolevel: + raise HispecFibError("max_flux_ph_s is valid only with autolevel=True") + if laser == "none": + if autolevel: + raise HispecFibError('laser="none" requires autolevel=False') + if input is None or output is None: + raise HispecFibError('laser="none" requires input and output routes') + if channel is None and collect: + if str(input).startswith("yj") or str(output).startswith("yj"): + channel = "yj" + elif str(input).startswith("hk") or str(output).startswith("hk"): + channel = "hk" + else: + raise HispecFibError('collecting laser="none" throughput requires channel="yj" or "hk"') + elif channel is not None: + _require_choice("channel", channel, PD_CHANNELS) + elif channel is None: + channel = _LASER_TO_PD_CHANNEL[laser] + else: + _require_choice("channel", channel, PD_CHANNELS) + + payload: dict[str, Any] = { + "laser": laser, + "fiber": fiber, + "autolevel": bool(autolevel), + "stopafter_s": _require_nonnegative_u32("stopafter_s", stopafter_s), + "format": format, + } + if input is not None: + payload["input"] = str(input) + if output is not None: + payload["output"] = str(output) + if max_flux_ph_s is not None: + payload["max_flux_ph_s"] = _require_float("max_flux_ph_s", max_flux_ph_s, 1e-300, 1e300) + + monitor = None + if collect: + assert channel is not None + monitor = self.start_throughput_monitor(channel, max_samples=max_samples) + try: + self._request_ok("measure_throughput", payload) + except Exception: + if monitor is not None: + monitor.stop() + raise + return monitor if monitor is not None else CommandOk() + + def stop_throughput(self, channel: Literal["yj", "hk", "all"] = "all") -> CommandOk: + _require_choice("channel", channel, ("yj", "hk", "all")) + return self._request_ok("measure_throughput", {"stop": channel}) + + def start_throughput_monitor( + self, + channel: Literal["yj", "hk", "all"] = "all", + *, + max_samples: int = 20000, + ) -> ThroughputMonitor: + monitor = ThroughputMonitor(self, channel, max_samples=max_samples) + return monitor.start() + + def smoke_test(self) -> SimpleNamespace: + return SimpleNamespace( + status=self.status(), + time=self.time(), + temp=self.temp(), + mems=self.mems(), + ) + + def _request_ok(self, key: str, payload: Mapping[str, Any] | None = None) -> CommandOk: + result = self._request_json(key, payload) + if isinstance(result, CommandOk): + return result + if isinstance(result, Mapping) and result.get("status") == "ok": + return CommandOk() + return CommandOk() + + def _request_json(self, key: str, payload: Mapping[str, Any] | None = None) -> Any: + result = self._request(key, payload) + if isinstance(result, Mapping) and "error" in result: + raise HispecFibPCBError(str(result["error"]), response=_to_object(result)) + return result + + def _request(self, key: str, payload: Mapping[str, Any] | None = None) -> Any: + self._ensure_connected() + topic = f"cmd/{self.device}/req/{key}" + response_topic = f"cmd/{self.device}/resp/{key}" + corr = next(self._corr_counter).to_bytes(8, "little", signed=False) + pending = _PendingRequest(topic=response_topic) + with self._pending_lock: + self._pending[corr] = pending + props = Properties(PacketTypes.PUBLISH) + props.ResponseTopic = response_topic + props.CorrelationData = corr + data = b"" if payload is None else json.dumps(payload, separators=(",", ":"), allow_nan=False).encode("utf-8") + try: + info = self._client.publish(topic, payload=data, qos=0, properties=props) + if info.rc != mqtt.MQTT_ERR_SUCCESS: + raise HispecFibError(f"MQTT publish failed with rc={info.rc}") + if not pending.event.wait(self.timeout_s): + raise HispecFibError(f"timed out waiting for {response_topic}") + assert pending.payload is not None + return _decode_ok_or_raise(response_topic, pending.payload) + finally: + with self._pending_lock: + self._pending.pop(corr, None) + + def _ensure_connected(self) -> None: + if not self.is_connected: + if not self.auto_connect: + raise HispecFibError("MQTT client is not connected") + self.connect() + + def _ensure_client(self) -> None: + if self._client is not None: + return + self._client = _mqtt_client(self._client_id) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + def _subscribe_control_topics(self) -> None: + topics = ( + f"cmd/{self.device}/resp/#", + f"dt/{self.device}/warning", + f"dt/{self.device}/yj_tput", + f"dt/{self.device}/hk_tput", + ) + for topic in topics: + rc, _mid = self._client.subscribe(topic, qos=0) + if rc != mqtt.MQTT_ERR_SUCCESS: + raise HispecFibError(f"MQTT subscribe failed for {topic} with rc={rc}") + + def _register_throughput_monitor(self, monitor: ThroughputMonitor) -> None: + self._ensure_connected() + with self._throughput_lock: + self._throughput_monitors.add(monitor) + + def _unregister_throughput_monitor(self, monitor: ThroughputMonitor) -> None: + with self._throughput_lock: + self._throughput_monitors.discard(monitor) + + def _on_connect(self, client: mqtt.Client, userdata: Any, *args: Any) -> None: + reason = args[1] if len(args) >= 3 else args[0] if args else 0 + self._connect_rc = reason + try: + ok = int(reason) == 0 + except Exception: + ok = str(reason).lower() in ("success", "0") + if ok: + self._connected.set() + else: + self.logger.error("MQTT connect failed: %s", reason) + + def _on_disconnect(self, client: mqtt.Client, userdata: Any, *args: Any) -> None: + self._connected.clear() + + def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None: + topic = msg.topic + if topic.startswith(f"cmd/{self.device}/resp/"): + corr = getattr(msg.properties, "CorrelationData", None) + if corr is not None: + with self._pending_lock: + pending = self._pending.get(corr) + if pending is not None: + pending.payload = bytes(msg.payload) + pending.properties = msg.properties + pending.event.set() + return + self.logger.debug("unmatched response on %s", topic) + return + + if topic == f"dt/{self.device}/warning": + self._handle_warning(msg.payload) + return + + if topic in (f"dt/{self.device}/yj_tput", f"dt/{self.device}/hk_tput"): + channel = "yj" if topic.endswith("/yj_tput") else "hk" + with self._throughput_lock: + monitors = tuple(self._throughput_monitors) + for monitor in monitors: + if monitor.channel in ("all", channel): + monitor.enqueue_payload(bytes(msg.payload)) + + def _handle_warning(self, payload: bytes) -> None: + try: + event = decode_warning(payload) + except Exception: + self.logger.exception("failed to decode PCB warning") + return + with self._warning_lock: + self._warnings.append(event) + self.logger.warning( + "PCB warning %s: %s context=%s uptime_ms=%s", + event.code, + event.msg, + event.context, + event.uptime_ms, + ) + + +def _build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="HISPEC FIB PCB MQTT test client") + parser.add_argument("--host", required=True) + parser.add_argument("--port", type=int, default=1883) + parser.add_argument("--device", default="hsfib-tib") + parser.add_argument("--timeout", type=float, default=5.0) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("status") + sub.add_parser("smoke") + laser_status = sub.add_parser("laser-status") + laser_status.add_argument("laser", choices=LASER_NAMES) + atten_get = sub.add_parser("atten-db") + atten_get.add_argument("laser", choices=ATTENUATOR_NAMES) + mems_one = sub.add_parser("mems-switch") + mems_one.add_argument("name") + sub.add_parser("mems") + sub.add_parser("warnings") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s") + args = _build_arg_parser().parse_args(argv) + with HispecFibPcb( + args.host, + port=args.port, + device=args.device, + timeout_s=args.timeout, + connect=True, + ) as fib: + if args.command == "status": + print(fib.status(ip=True)) + elif args.command == "smoke": + print(fib.smoke_test()) + elif args.command == "laser-status": + print(fib.laser_status(args.laser)) + elif args.command == "atten-db": + print(fib.atten_db(args.laser)) + elif args.command == "mems-switch": + print(fib.mems_switch(args.name)) + elif args.command == "mems": + print(fib.mems()) + elif args.command == "warnings": + while True: + time.sleep(1.0) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/west.yml b/west.yml index 309ebe8..c0611ff 100644 --- a/west.yml +++ b/west.yml @@ -20,6 +20,10 @@ manifest: - cmsis_6 # required by the ARM port for Cortex-M [TODO is this the true rational? I thoguht this was for flash/debug? -JIB"] - hal_stm32 # required by the st32 nucleo board - hal_rpi_pico # required for RP2350 (W5500-EVB-Pico2) + - name: zscilib + remote: zephyrproject-rtos + revision: master + path: modules/lib/zscilib # Out-of-tree DAC Driver for optical attenuators - name: dac7578 url: https://github.com/CaltechOpticalObservatories/dac7578