diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ac7b84..afd34e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,8 @@ # worker - Cloudflare Worker (cloud-plugins/) typecheck + wrangler dry-run bundle # esp32 - ESP32-S2 WiFi devboard firmware via the ESP-IDF v5.2.8 docker image # web - syntax check of the inline JavaScript in web-image-prep/index.html -# lint - `ufbt lint` (clang-format), ADVISORY ONLY - never fails the run +# tests - host unit tests (`make test`) with ASan + UBSan +# lint - `ufbt lint` (clang-format), ADVISORY ONLY - never fails the run # # There are deliberately NO `paths:` filters: esp32-wifi-fw/shared/tt_wifi_proto.h # is compiled into BOTH the FAP (via shared/tt_wifi_proto_fap.h) and the ESP @@ -261,6 +262,21 @@ jobs: process.exit(failed ? 1 : 0); JS + # --------------------------------------------------------------------------- + # Host unit tests - plain C on the runner, no Flipper SDK needed. + # Sanitizers catch out-of-bounds writes in the DMA table builder. + # --------------------------------------------------------------------------- + tests: + name: Host unit tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run tests (ASan + UBSan) + run: make test CC="gcc -fsanitize=address,undefined -fno-sanitize-recover=all" + # --------------------------------------------------------------------------- # clang-format lint - ADVISORY ONLY # --------------------------------------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b572b80..b7334d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thanks for wanting to help out! Here's how to get involved. 1. Fork the repo 2. Create a feature branch: `git checkout -b my-feature` 3. Make your changes -4. Test on hardware if possible (or at minimum, make sure `make build-fap` (plain `ufbt`) passes). CI builds the Flipper FAP, the cloud worker and the ESP32 firmware on every PR, so keep all three compiling. +4. Test on hardware if possible (or at minimum, make sure `make build-fap` (plain `ufbt`) and `make test` pass). CI builds the Flipper FAP, the cloud worker and the ESP32 firmware and runs the host unit tests on every PR, so keep all three compiling and the tests green. 5. Commit with a clear message 6. Open a pull request diff --git a/Makefile b/Makefile index 9d2007f..61d36f7 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ IDF_ENV = idf_log=$$(mktemp) \ && rm -f "$$idf_log" .PHONY: help setup build-fap build-worker build-esp build-all launch flash-esp \ - serve-web lint check-worker clean distclean check-esp-env check-root worker-deps + serve-web lint check-worker test clean distclean check-esp-env check-root worker-deps # --- Help ------------------------------------------------------------------- help: ## Show this help @@ -161,6 +161,12 @@ lint: ## ADVISORY: run ufbt lint (clang-format). Fails on main by design; not a check-worker: worker-deps ## Type-check the worker only (npx tsc --noEmit) cd $(WORKER_DIR) && $(NPX) tsc --noEmit +test: ## Run host unit tests (plain C, no Flipper needed) -> dist/tests/ + @mkdir -p dist/tests + $(CC) -std=c99 -Wall -Wextra -Werror -O1 tests/test_ir_seq.c ir/tagtinker_ir_seq.c -lm \ + -o dist/tests/test_ir_seq + ./dist/tests/test_ir_seq + # --- Cleanup ---------------------------------------------------------------- clean: check-root ## Remove build outputs only (dist/, worker dist + .wrangler, ESP build/, generated .clang-format) rm -rf dist $(WORKER_DIR)/dist $(WORKER_DIR)/.wrangler $(ESP_DIR)/build diff --git a/README.md b/README.md index 76ea17d..1cd5f08 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,11 @@ Only test tags you own or are allowed to test. ## Features -- **TagTinker Flipper App:** High-performance RLE streaming IR engine. +- **TagTinker Flipper App:** High-performance RLE streaming IR engine. Frames are timed by hardware (TIM1 + DMA), so USB, Bluetooth and the screen keep running while a tag is updated. +- **Progress bar:** image and text sends show how far they are, in percent; Back cancels at any time. - **TagTinker Image Prep (web):** Single-file, dependency-free HTML page that lists every supported tag profile, runs a full image pipeline (tone, contrast, detail, sharpen, dither, photo-grade Oklab 3-colour quantisation) and exports a Flipper-ready BMP. Hosted at **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** (source: `web-image-prep/`). - **Drop-folder image flow:** Drop a prepared BMP into `apps_data/tagtinker/dropped/` on the Flipper SD card, then open `Targeted Payloads → → Set Image` and pick it. The Flipper rescales the 1-bit and two-plane BMPs that the image preparer exports, so one file can be sent to graphics tags of other sizes and to any page. Type 1626 (SmartTAG Color 2.6) targets only accept files up to 24 KB. +- **External IR module:** `Settings → IR Output` sends through a module on GPIO pin 2 (A7) instead of the built-in LED, like the stock Infrared app (`Internal`, `GPIO A7` or `Auto` to detect the module). `5V on GPIO` powers modules that take 5V from pin 1. A stronger emitter gives more range and more reliable image updates. - **NFC Tag Scan:** Add a target by scanning the tag's NFC chip instead of typing its barcode. This works when the tag's NFC data carries an ID TagTinker can decode; otherwise use `+ Type Barcode`. - **WiFi Plugins (optional):** Plug a Flipper WiFi Dev Board (ESP32-S2) into the GPIO header to unlock live, network-rendered tag designs — crypto price cards, weather tiles, identicons, and more — auto-discovered by the FAP. New plugins live entirely on the cloud worker; the Flipper firmware never has to be re-flashed to add one. The dev board firmware talks to the worker named by `CONFIG_TT_CLOUD_URL`, so you can point it at your own deployment of `cloud-plugins/` instead. image @@ -95,10 +97,15 @@ The repo holds three build products plus the static web tool. A root `Makefile` | `make build-esp` | Build the ESP32-S2 firmware → `esp32-wifi-fw/build/tagtinker_wifi.bin` | | `make flash-esp ESP_PORT=/dev/cu.usbserial-XXXX` | Build and flash the devboard (omit `ESP_PORT` to auto-detect) | | `make serve-web` | Serve the Image Prep tool at `http://localhost:8000/` | +| `make test` | Run the host unit tests (plain C, no Flipper or SDK needed) | | `make lint` | Advisory `ufbt lint`; it currently fails on deliberately column-aligned code and is not a CI gate | | `make clean` | Remove build outputs only (keeps `.venv/`, `node_modules/` and `sdkconfig`) | -CI ([`build.yml`](.github/workflows/build.yml)) builds all three components — FAP, worker and ESP32 firmware — on every pull request and every push to `main`; the web tool deploys to GitHub Pages from `main`. +**Windows without `make`:** double-click `build_and_flash.bat` (or run it from a terminal) with the Flipper plugged in over USB and qFlipper closed. It runs `ufbt update`, builds the FAP and installs it with `ufbt launch`. + +**Linux / macOS without `make`:** run `./build_and_flash.sh` the same way. On Linux your user needs access to the Flipper's serial port (`/dev/ttyACM0`): add it to the `dialout` group (`uucp` on Arch), then log out and back in. + +CI ([`build.yml`](.github/workflows/build.yml)) builds all three components — FAP, worker and ESP32 firmware — and runs the host unit tests on every pull request and every push to `main`; the web tool deploys to GitHub Pages from `main`. ## FAQ diff --git a/application.fam b/application.fam index 3e8ade1..9287101 100644 --- a/application.fam +++ b/application.fam @@ -3,7 +3,7 @@ App( name="TagTinker", apptype=FlipperAppType.EXTERNAL, entry_point="tagtinker_app_main", - requires=["gui", "notification", "dialogs", "storage", "bt", "nfc", "expansion"], + requires=["gui", "notification", "dialogs", "storage", "bt", "nfc", "expansion", "power"], stack_size=12 * 1024, fap_icon="tagtinker_10px.png", fap_category="Infrared", @@ -13,6 +13,7 @@ App( sources=[ "tagtinker_app.c", "ir/tagtinker_ir.c", + "ir/tagtinker_ir_seq.c", "protocol/tagtinker_proto.c", "scenes/tagtinker_scene.c", "scenes/tagtinker_scene_about.c", diff --git a/build_and_flash.bat b/build_and_flash.bat new file mode 100644 index 0000000..1731006 --- /dev/null +++ b/build_and_flash.bat @@ -0,0 +1,20 @@ +@echo off +REM Windows helper: build the FAP with ufbt and install it on a USB-connected Flipper (close qFlipper first) +cd /d "%~dp0" +where ufbt >nul 2>nul && (set "UFBT=ufbt") || (set "UFBT=py -m ufbt") +echo === ufbt update (SDK release) === +%UFBT% update +echo === build === +%UFBT% +if errorlevel 1 goto fail +echo === launch on the Flipper === +%UFBT% launch +if errorlevel 1 goto fail +echo. +echo OK: app installed and launched. +goto end +:fail +echo. +echo FAILED - see the messages above. +:end +pause diff --git a/build_and_flash.sh b/build_and_flash.sh new file mode 100755 index 0000000..86839eb --- /dev/null +++ b/build_and_flash.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Linux/macOS helper: build the FAP with ufbt and install it on a USB-connected Flipper (close qFlipper first) +set -euo pipefail +cd "$(dirname "$0")" + +if [ -x .venv/bin/ufbt ]; then + UFBT=(.venv/bin/ufbt) +elif command -v ufbt >/dev/null 2>&1; then + UFBT=(ufbt) +elif python3 -c 'import ufbt' >/dev/null 2>&1; then + UFBT=(python3 -m ufbt) +else + echo "ufbt not found. Install it with: python3 -m pip install --upgrade ufbt (or run: make setup)" >&2 + exit 1 +fi + +fail() { + echo + echo "FAILED - see the messages above." >&2 + exit 1 +} + +echo "=== ufbt update (SDK release) ===" +"${UFBT[@]}" update || echo "(update failed, building with the SDK already installed)" +echo "=== build ===" +"${UFBT[@]}" || fail +echo "=== launch on the Flipper ===" +"${UFBT[@]}" launch || fail +echo +echo "OK: app installed and launched." diff --git a/ir/tagtinker_ir.c b/ir/tagtinker_ir.c index 8d30384..93f8e86 100644 --- a/ir/tagtinker_ir.c +++ b/ir/tagtinker_ir.c @@ -1,174 +1,367 @@ /* - * IR transmitter. + * IR transmitter, DMA driven. * - * TIM1 CH3N drives the built-in IR LED carrier. - * DWT->CYCCCNT handles the symbol timing so we do not need another timer. + * TIM1 drives the IR output with a ~1.255 MHz carrier: CH3N for the built-in + * LED (PB9) or CH1N for an external module on GPIO pin 2 (PA7). + * The stock furi_hal_infrared API caps the carrier at 1 MHz, so this file + * drives TIM1 itself, using the same technique as the firmware: + * + * - TIM1 counts carrier periods. Its repetition counter (RCR) makes it + * raise an update event only every RCR + 1 periods, i.e. once per + * segment (burst or gap). + * - On each update event, DMA2 channel 1 writes the next output mode + * (PWM2 = carrier on, forced inactive = off) into TIM1->CCMR2 (CH3) or + * TIM1->CCMR1 (CH1), and + * DMA2 channel 2 writes the next segment length into TIM1->RCR. + * + * The whole frame is played by hardware: no busy loop, no critical section. + * The calling thread sleeps on a semaphore until the DMA "transfer complete" + * interrupt, so the GUI and the radio stack keep running during a send. */ #include "tagtinker_ir.h" +#include "tagtinker_ir_seq.h" #include #include #include #include #include -#include +#include +#include +#include +#include #include +#include + +#define TAG "TagTinkerIR" + +/* Carrier: 64 MHz / 51 = ~1.255 MHz, 49 % duty. */ +#define CARRIER_TIM TIM1 +#define CARRIER_ARR (TAGTINKER_IR_CARRIER_CYCLES - 1U) +#define CARRIER_CCR 25U + +/* DMA2 channels 1 and 2, the pair the firmware IR driver uses for TX. */ +#define IR_DMA DMA2 +#define IR_DMA_CH_MODE LL_DMA_CHANNEL_1 +#define IR_DMA_CH_RCR LL_DMA_CHANNEL_2 +#define IR_DMA_MODE_IRQ FuriHalInterruptIdDma2Ch1 + +/* CCMRx values written by DMA. OC3 sits in the low byte of CCMR2 exactly + * like OC1 sits in the low byte of CCMR1, so one value fits both outputs. + * OCxPE keeps CCRx preloaded. */ +#define CCMR_MARK ((uint8_t)(TIM_CCMR2_OC3PE | LL_TIM_OCMODE_PWM2)) +#define CCMR_SPACE ((uint8_t)(TIM_CCMR2_OC3PE | LL_TIM_OCMODE_FORCED_INACTIVE)) + +/* Let the 5V rail settle before the first frame. */ +#define IR_OTG_SETTLE_MS 100U + +/* Extra time allowed on top of the computed frame duration. */ +#define IR_TIMEOUT_MARGIN_MS 20U + +typedef struct { + const GpioPin* pin; + uint32_t channel; /* LL_TIM_CHANNEL_CHx, output compare config */ + uint32_t channel_n; /* LL_TIM_CHANNEL_CHxN, the pin actually driven */ + volatile uint32_t* ccmr; /* CCMRx register fed by DMA */ +} IrOutputHw; + +typedef struct { + bool initialized; + TagTinkerIrOutput output; + bool want_5v; + bool otg_enabled_by_us; + IrOutputHw hw; + volatile bool busy; + volatile bool stop_requested; + volatile bool dma_error; + FuriSemaphore* done; + uint8_t* mode; /* CCMR2 values, one byte per entry */ + uint16_t* rcr; /* RCR values, one halfword per entry */ +} TagTinkerIr; + +static TagTinkerIr ir = {0}; + +static IrOutputHw ir_output_hw(TagTinkerIrOutput output) { + IrOutputHw hw; + if(output == TagTinkerIrOutputGpioA7) { + hw.pin = &gpio_ext_pa7; + hw.channel = LL_TIM_CHANNEL_CH1; + hw.channel_n = LL_TIM_CHANNEL_CH1N; + hw.ccmr = &CARRIER_TIM->CCMR1; + } else { + hw.pin = &gpio_infrared_tx; + hw.channel = LL_TIM_CHANNEL_CH3; + hw.channel_n = LL_TIM_CHANNEL_CH3N; + hw.ccmr = &CARRIER_TIM->CCMR2; + } + return hw; +} -/* Carrier setup for the built-in IR LED on TIM1 CH3N. */ -#define CARRIER_TIM TIM1 -#define CARRIER_ARR (51 - 1) -#define CARRIER_CCR 25 +void tagtinker_ir_set_output(TagTinkerIrOutput output, bool enable_5v) { + /* Takes effect on the next init; never switch pins under a running timer. */ + ir.output = (output == TagTinkerIrOutputGpioA7) ? output : TagTinkerIrOutputInternal; + ir.want_5v = enable_5v && (ir.output == TagTinkerIrOutputGpioA7); +} -/* - * PP4 sends two bits per symbol. The gap selects the symbol value. - * These are pre-computed CPU cycle counts at 64 MHz to avoid any - * per-call overhead in the tight timing loop. - */ -#define PP4_BURST_CYCLES 2581 -static const uint32_t pp4_gap_cycles[4] = { - 3871, /* symbol 0 ~60 us */ - 15483, /* symbol 3 ~242 us */ - 7741, /* symbol 2 ~121 us */ - 11612, /* symbol 1 ~181 us */ -}; - -static bool ir_initialized = false; -static volatile bool ir_stop_requested = false; - -static inline void carrier_on(void) { - uint32_t ccmr2 = CARRIER_TIM->CCMR2; - ccmr2 &= ~(TIM_CCMR2_OC3M); - ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); /* PWM2 */ - CARRIER_TIM->CCMR2 = ccmr2; +TagTinkerIrOutput tagtinker_ir_detect_output(void) { + if(ir.initialized) return ir.output; + return (furi_hal_infrared_detect_tx_output() == FuriHalInfraredTxPinExtPA7) ? + TagTinkerIrOutputGpioA7 : + TagTinkerIrOutputInternal; } -static inline void carrier_off(void) { - uint32_t ccmr2 = CARRIER_TIM->CCMR2; - ccmr2 &= ~(TIM_CCMR2_OC3M); - ccmr2 |= TIM_CCMR2_OC3M_2; /* Force inactive */ - CARRIER_TIM->CCMR2 = ccmr2; +static void ir_set_5v(bool enable) { + Power* power = furi_record_open(RECORD_POWER); + power_enable_otg(power, enable); + furi_record_close(RECORD_POWER); } -static inline void delay_cycles(uint32_t cycles) { - uint32_t start = DWT->CYCCNT; - while((DWT->CYCCNT - start) < cycles) { +static void ir_dma_isr(void* context) { + UNUSED(context); + + if(LL_DMA_IsActiveFlag_TE1(IR_DMA)) { + LL_DMA_ClearFlag_TE1(IR_DMA); + ir.dma_error = true; + furi_semaphore_release(ir.done); } + if(LL_DMA_IsActiveFlag_TC1(IR_DMA)) { + LL_DMA_ClearFlag_TC1(IR_DMA); + furi_semaphore_release(ir.done); + } +} + +static void ir_dma_config(void) { + LL_DMA_InitTypeDef cfg = {0}; + + /* Channel 1: mode byte -> CCMR2 or CCMR1 (zero-extended to a word, so the + * unused channel 4 / channel 2 bits stay cleared). */ + cfg.PeriphOrM2MSrcAddress = (uint32_t)ir.hw.ccmr; + cfg.MemoryOrM2MDstAddress = (uint32_t)ir.mode; + cfg.Direction = LL_DMA_DIRECTION_MEMORY_TO_PERIPH; + cfg.Mode = LL_DMA_MODE_NORMAL; + cfg.PeriphOrM2MSrcIncMode = LL_DMA_PERIPH_NOINCREMENT; + cfg.MemoryOrM2MDstIncMode = LL_DMA_MEMORY_INCREMENT; + cfg.PeriphOrM2MSrcDataSize = LL_DMA_PDATAALIGN_WORD; + cfg.MemoryOrM2MDstDataSize = LL_DMA_MDATAALIGN_BYTE; + cfg.NbData = 0; + cfg.PeriphRequest = LL_DMAMUX_REQ_TIM1_UP; + cfg.Priority = LL_DMA_PRIORITY_VERYHIGH; + LL_DMA_Init(IR_DMA, IR_DMA_CH_MODE, &cfg); + + /* Channel 2: segment length -> RCR. */ + cfg.PeriphOrM2MSrcAddress = (uint32_t)&CARRIER_TIM->RCR; + cfg.MemoryOrM2MDstAddress = (uint32_t)ir.rcr; + cfg.PeriphOrM2MSrcDataSize = LL_DMA_PDATAALIGN_HALFWORD; + cfg.MemoryOrM2MDstDataSize = LL_DMA_MDATAALIGN_HALFWORD; + cfg.Priority = LL_DMA_PRIORITY_HIGH; + LL_DMA_Init(IR_DMA, IR_DMA_CH_RCR, &cfg); } -static void send_frame_pp4(const uint8_t* data, size_t len) { - for(size_t byte_idx = 0; byte_idx < len; byte_idx++) { - uint8_t current_byte = data[byte_idx]; - for(int sym = 0; sym < 4; sym++) { - uint8_t symbol = current_byte & 0x03; - current_byte >>= 2; +/* Stop the timer and both DMA channels, carrier forced off. */ +static void ir_hw_halt(void) { + LL_TIM_DisableCounter(CARRIER_TIM); + LL_TIM_DisableDMAReq_UPDATE(CARRIER_TIM); + LL_DMA_DisableChannel(IR_DMA, IR_DMA_CH_MODE); + LL_DMA_DisableChannel(IR_DMA, IR_DMA_CH_RCR); + LL_DMA_DisableIT_TC(IR_DMA, IR_DMA_CH_MODE); + LL_DMA_DisableIT_TE(IR_DMA, IR_DMA_CH_MODE); + *ir.hw.ccmr = CCMR_SPACE; +} + +/* Play the first `entries` entries of the tables once. Blocks until done. */ +static bool ir_play(size_t entries, uint32_t periods) { + /* Reset the timer with the carrier off and RCR = 0. UDE is off here, so + * this forced update loads the shadow registers without a DMA request. */ + ir_hw_halt(); + LL_TIM_SetRepetitionCounter(CARRIER_TIM, 0); + LL_TIM_SetCounter(CARRIER_TIM, 0); + LL_TIM_GenerateEvent_UPDATE(CARRIER_TIM); + LL_TIM_ClearFlag_UPDATE(CARRIER_TIM); + + LL_DMA_ClearFlag_GI1(IR_DMA); + LL_DMA_ClearFlag_GI2(IR_DMA); + LL_DMA_SetMemoryAddress(IR_DMA, IR_DMA_CH_MODE, (uint32_t)ir.mode); + LL_DMA_SetDataLength(IR_DMA, IR_DMA_CH_MODE, entries); + LL_DMA_SetMemoryAddress(IR_DMA, IR_DMA_CH_RCR, (uint32_t)ir.rcr); + LL_DMA_SetDataLength(IR_DMA, IR_DMA_CH_RCR, entries); + LL_DMA_EnableIT_TC(IR_DMA, IR_DMA_CH_MODE); + LL_DMA_EnableIT_TE(IR_DMA, IR_DMA_CH_MODE); + LL_DMA_EnableChannel(IR_DMA, IR_DMA_CH_RCR); + LL_DMA_EnableChannel(IR_DMA, IR_DMA_CH_MODE); + + /* Drop a stale completion; a stop already requested still wins below + * because the flag is set before the semaphore is released. */ + furi_semaphore_acquire(ir.done, 0); + ir.dma_error = false; + if(ir.stop_requested) { + ir_hw_halt(); + return false; + } + + LL_TIM_EnableDMAReq_UPDATE(CARRIER_TIM); + LL_TIM_EnableCounter(CARRIER_TIM); + + /* periods * 51 cycles / 64 MHz, in ms, rounded up, plus margin. */ + uint32_t us = (uint32_t)(((uint64_t)periods * TAGTINKER_IR_CARRIER_CYCLES + 63U) / 64U); + uint32_t timeout_ms = (us + 999U) / 1000U + IR_TIMEOUT_MARGIN_MS; + FuriStatus status = furi_semaphore_acquire(ir.done, furi_ms_to_ticks(timeout_ms)); - carrier_on(); - delay_cycles(PP4_BURST_CYCLES); + ir_hw_halt(); - carrier_off(); - delay_cycles(pp4_gap_cycles[symbol]); - } + if(status != FuriStatusOk) { + FURI_LOG_E(TAG, "DMA timeout"); + return false; } - /* Final closing burst */ - carrier_on(); - delay_cycles(PP4_BURST_CYCLES); - carrier_off(); + if(ir.dma_error) { + FURI_LOG_E(TAG, "DMA transfer error"); + return false; + } + return !ir.stop_requested; } void tagtinker_ir_init(void) { - if(ir_initialized) return; + if(ir.initialized) return; + + /* Another app or the CLI may be using the IR hardware. */ + if(furi_hal_infrared_is_busy()) { + FURI_LOG_E(TAG, "IR hardware busy"); + return; + } + + ir.mode = malloc(TAGTINKER_IR_SEQ_CAPACITY * sizeof(uint8_t)); + ir.rcr = malloc(TAGTINKER_IR_SEQ_CAPACITY * sizeof(uint16_t)); + ir.done = furi_semaphore_alloc(1, 0); + + ir.hw = ir_output_hw(ir.output); + + /* External modules are often powered from GPIO pin 1 (5V). Only switch + * the rail on if nobody else did, so deinit can restore it. */ + ir.otg_enabled_by_us = false; + if(ir.want_5v && !furi_hal_power_is_otg_enabled()) { + ir_set_5v(true); + ir.otg_enabled_by_us = true; + furi_delay_ms(IR_OTG_SETTLE_MS); + } if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) { furi_hal_bus_disable(FuriHalBusTIM1); } furi_hal_bus_enable(FuriHalBusTIM1); + /* Drive the pin low before handing it to the timer: no false pulse. */ + LL_GPIO_ResetOutputPin(ir.hw.pin->port, ir.hw.pin->pin); furi_hal_gpio_init_ex( - &gpio_infrared_tx, - GpioModeAltFunctionPushPull, - GpioPullNo, - GpioSpeedVeryHigh, - GpioAltFn1TIM1); + ir.hw.pin, GpioModeAltFunctionPushPull, GpioPullNo, GpioSpeedVeryHigh, GpioAltFn1TIM1); LL_TIM_SetPrescaler(CARRIER_TIM, 0); + LL_TIM_SetCounterMode(CARRIER_TIM, LL_TIM_COUNTERMODE_UP); + LL_TIM_EnableARRPreload(CARRIER_TIM); LL_TIM_SetAutoReload(CARRIER_TIM, CARRIER_ARR); + LL_TIM_SetRepetitionCounter(CARRIER_TIM, 0); LL_TIM_SetCounter(CARRIER_TIM, 0); - LL_TIM_OC_SetMode(CARRIER_TIM, LL_TIM_CHANNEL_CH3, LL_TIM_OCMODE_PWM2); - LL_TIM_OC_SetCompareCH3(CARRIER_TIM, CARRIER_CCR); - LL_TIM_OC_EnablePreload(CARRIER_TIM, LL_TIM_CHANNEL_CH3); - - LL_TIM_CC_EnableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N); + if(ir.output == TagTinkerIrOutputGpioA7) { + LL_TIM_OC_SetCompareCH1(CARRIER_TIM, CARRIER_CCR); + } else { + LL_TIM_OC_SetCompareCH3(CARRIER_TIM, CARRIER_CCR); + } + LL_TIM_OC_EnablePreload(CARRIER_TIM, ir.hw.channel); + LL_TIM_OC_SetMode(CARRIER_TIM, ir.hw.channel, LL_TIM_OCMODE_FORCED_INACTIVE); + LL_TIM_OC_SetPolarity(CARRIER_TIM, ir.hw.channel_n, LL_TIM_OCPOLARITY_HIGH); + LL_TIM_OC_DisableFast(CARRIER_TIM, ir.hw.channel); + LL_TIM_CC_EnableChannel(CARRIER_TIM, ir.hw.channel_n); + LL_TIM_DisableMasterSlaveMode(CARRIER_TIM); + LL_TIM_DisableIT_UPDATE(CARRIER_TIM); LL_TIM_EnableAllOutputs(CARRIER_TIM); - carrier_off(); - LL_TIM_EnableCounter(CARRIER_TIM); - LL_TIM_GenerateEvent_UPDATE(CARRIER_TIM); + ir_dma_config(); + furi_hal_interrupt_set_isr(IR_DMA_MODE_IRQ, ir_dma_isr, NULL); - ir_stop_requested = false; - ir_initialized = true; + ir_hw_halt(); + ir.stop_requested = false; + ir.busy = false; + ir.initialized = true; } void tagtinker_ir_deinit(void) { - if(!ir_initialized) return; + if(!ir.initialized) return; - tagtinker_ir_stop(); - carrier_off(); + ir_hw_halt(); + furi_hal_interrupt_set_isr(IR_DMA_MODE_IRQ, NULL, NULL); + LL_DMA_DeInit(IR_DMA, IR_DMA_CH_MODE); + LL_DMA_DeInit(IR_DMA, IR_DMA_CH_RCR); LL_TIM_DisableAllOutputs(CARRIER_TIM); - LL_TIM_CC_DisableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N); - LL_TIM_DisableCounter(CARRIER_TIM); + LL_TIM_CC_DisableChannel(CARRIER_TIM, ir.hw.channel_n); if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) { furi_hal_bus_disable(FuriHalBusTIM1); } - furi_hal_gpio_init(&gpio_infrared_tx, GpioModeAnalog, GpioPullNo, GpioSpeedLow); - ir_initialized = false; + furi_hal_gpio_init(ir.hw.pin, GpioModeAnalog, GpioPullNo, GpioSpeedLow); + + if(ir.otg_enabled_by_us) { + ir_set_5v(false); + ir.otg_enabled_by_us = false; + } + + furi_semaphore_free(ir.done); + free(ir.mode); + free(ir.rcr); + ir.done = NULL; + ir.mode = NULL; + ir.rcr = NULL; + ir.initialized = false; } bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw, uint8_t delay) { - if(!ir_initialized) return false; - if(!data || len == 0 || len > 255) return false; - - ir_stop_requested = false; - uint32_t repeats = repeats_raw & 0x7FFF; + if(!ir.initialized) return false; + if(!data || len == 0 || len > TAGTINKER_IR_MAX_FRAME_LEN) return false; + + /* Same contract as before: a new transmit clears an old stop request. */ + ir.stop_requested = false; + uint32_t repeats = repeats_raw & 0x7FFFU; + + TagTinkerIrSeqInfo info; + if(!tagtinker_ir_seq_build( + data, + len, + tagtinker_ir_seq_gap_periods(delay), + CCMR_MARK, + CCMR_SPACE, + ir.mode, + ir.rcr, + TAGTINKER_IR_SEQ_CAPACITY, + &info)) { + return false; + } + ir.busy = true; + bool ok = true; for(uint32_t rep = 0; rep <= repeats; rep++) { - if(ir_stop_requested) { - carrier_off(); - return false; - } - - /* - * FURI_CRITICAL_ENTER/EXIT wraps each individual frame to prevent - * OS interrupts from breaking the microsecond-level IR symbol timing. - * The critical section is short (~1-2ms per frame) so it won't stall - * the OS noticeably, and we yield via furi_delay_ms between repeats. - */ - FURI_CRITICAL_ENTER(); - send_frame_pp4(data, len); - FURI_CRITICAL_EXIT(); - - if(rep < repeats) { - /* Short gap between repeats using cycle-accurate delay. - * delay parameter = gap in units of 500µs. - * Always yield to OS every 5 repeats to prevent watchdog starvation. */ - if(delay > 0) { - uint32_t gap_cycles = (uint32_t)delay * 32000U; /* 500µs per unit at 64MHz */ - delay_cycles(gap_cycles); - } - /* Yield to OS periodically */ - if((rep % 5U) == 4U) furi_delay_ms(1); - } + /* Every repeat but the last plays the gap after the frame. */ + bool last = (rep == repeats); + ok = ir_play( + last ? info.frame_entries : info.total_entries, + last ? info.frame_periods : info.total_periods); + if(!ok) break; } + ir.busy = false; - return true; + return ok; +} + +bool tagtinker_ir_is_busy(void) { + return ir.busy; } void tagtinker_ir_stop(void) { - ir_stop_requested = true; - carrier_off(); + /* Called from the GUI thread: only signal, the TX thread halts the + * hardware itself when it wakes up. Flag first, then wake. */ + ir.stop_requested = true; + if(ir.initialized && ir.done) { + furi_semaphore_release(ir.done); + } } diff --git a/ir/tagtinker_ir.h b/ir/tagtinker_ir.h index b5097a5..f687104 100644 --- a/ir/tagtinker_ir.h +++ b/ir/tagtinker_ir.h @@ -1,7 +1,11 @@ /* * IR transmitter API. * - * Sends already-built ESL frames over the built-in IR LED. + * Sends already-built ESL frames over the built-in IR LED or over an + * external IR module wired to GPIO pin 2 (PA7), the same pin the stock + * Infrared app uses for external modules. + * Frames are played by TIM1 + DMA; tagtinker_ir_transmit() blocks the calling + * thread (sleeping, not spinning) until the last repeat has been sent. */ #pragma once @@ -10,11 +14,28 @@ #include #include +typedef enum { + TagTinkerIrOutputInternal = 0, /* built-in IR LED (PB9, TIM1 CH3N) */ + TagTinkerIrOutputGpioA7, /* external module on GPIO pin 2 (PA7, TIM1 CH1N) */ +} TagTinkerIrOutput; + +/* Pick the output used by the next tagtinker_ir_init(). enable_5v turns on + * the 5V rail of GPIO pin 1 while the transmitter is initialized (only used + * with TagTinkerIrOutputGpioA7; restored on deinit). Default: internal. */ +void tagtinker_ir_set_output(TagTinkerIrOutput output, bool enable_5v); + +/* Look for an external IR module on PA7, like the stock Infrared app's + * "Detect" setting: the module must pull PA7 low against a weak pull-up. + * Must be called while the transmitter is not initialized. */ +TagTinkerIrOutput tagtinker_ir_detect_output(void); + void tagtinker_ir_init(void); void tagtinker_ir_deinit(void); -/* Repeats are sent with PP4 timing; the high bit is ignored for legacy saved data. */ +/* Repeats are sent with PP4 timing; the high bit is ignored for legacy saved data. + * len must be 1..TAGTINKER_IR_MAX_FRAME_LEN (96). delay is the gap between + * repeats in 500 us units. Returns false if stopped, invalid or not init. */ bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats, uint8_t delay); bool tagtinker_ir_is_busy(void); diff --git a/ir/tagtinker_ir_seq.c b/ir/tagtinker_ir_seq.c new file mode 100644 index 0000000..8959368 --- /dev/null +++ b/ir/tagtinker_ir_seq.c @@ -0,0 +1,107 @@ +/* + * PP4 frame -> DMA segment table. See tagtinker_ir_seq.h for the layout. + */ + +#include "tagtinker_ir_seq.h" + +/* Gap after the burst, indexed by the 2-bit symbol value. */ +static const uint16_t pp4_gap_periods[4] = { + TAGTINKER_IR_GAP_SYM0, + TAGTINKER_IR_GAP_SYM1, + TAGTINKER_IR_GAP_SYM2, + TAGTINKER_IR_GAP_SYM3, +}; + +typedef struct { + uint8_t* mode; + uint16_t* rcr; + size_t capacity; + size_t count; /* segments pushed so far */ + uint32_t periods; + bool overflow; +} SeqWriter; + +/* Append one segment, split in chunks that fit the 16-bit RCR. */ +static void seq_push(SeqWriter* w, uint8_t level, uint32_t periods) { + while(periods > 0 && !w->overflow) { + uint32_t chunk = periods; + if(chunk > TAGTINKER_IR_MAX_SEGMENT_PERIODS) chunk = TAGTINKER_IR_MAX_SEGMENT_PERIODS; + + /* Segment k uses mode[k + 1] and rcr[k]; keep room for the terminal + * entry mode[count + 1] and its rcr slot. */ + if(w->count + 2U >= w->capacity) { + w->overflow = true; + return; + } + w->mode[w->count + 1U] = level; + w->rcr[w->count] = (uint16_t)(chunk - 1U); + w->count++; + w->periods += chunk; + periods -= chunk; + } +} + +uint32_t tagtinker_ir_seq_gap_periods(uint8_t delay) { + uint32_t cycles = (uint32_t)delay * TAGTINKER_IR_DELAY_UNIT_CYCLES; + return (cycles + TAGTINKER_IR_CARRIER_CYCLES / 2U) / TAGTINKER_IR_CARRIER_CYCLES; +} + +bool tagtinker_ir_seq_build( + const uint8_t* data, + size_t len, + uint32_t gap_periods, + uint8_t mark, + uint8_t space, + uint8_t* mode, + uint16_t* rcr, + size_t capacity, + TagTinkerIrSeqInfo* info) { + if(!data || !mode || !rcr || !info) return false; + if(len == 0 || len > TAGTINKER_IR_MAX_FRAME_LEN) return false; + if(capacity < 3U) return false; + + SeqWriter w = { + .mode = mode, + .rcr = rcr, + .capacity = capacity, + .count = 0, + .periods = 0, + .overflow = false, + }; + + /* Lead-in: the timer starts with RCR = 0, so this lasts one period. */ + mode[0] = space; + + /* LSB-first, 2 bits per symbol, exactly like the original bit-banger. */ + for(size_t i = 0; i < len; i++) { + uint8_t byte = data[i]; + for(int sym = 0; sym < 4; sym++) { + seq_push(&w, mark, TAGTINKER_IR_BURST_PERIODS); + seq_push(&w, space, pp4_gap_periods[byte & 0x03U]); + byte >>= 2; + } + } + seq_push(&w, mark, TAGTINKER_IR_BURST_PERIODS); /* closing burst */ + if(w.overflow) return false; + + /* Last repeat stops here: the next mode[] entry is a space, either the + * first gap chunk or the terminal entry written below. */ + size_t frame_segments = w.count; + uint32_t frame_periods = w.periods; + + seq_push(&w, space, gap_periods); + if(w.overflow) return false; + + /* Terminal entry: carrier stays off after the last DMA transfer. */ + mode[w.count + 1U] = space; + rcr[w.count] = 0; + rcr[w.count + 1U] = 0; + + /* Lead-in: 1 period before the first update (shadow RCR = 0) plus + * 1 period of mode[0] (preload RCR = 0 at the first update). */ + info->frame_entries = frame_segments + 2U; + info->total_entries = w.count + 2U; + info->frame_periods = frame_periods + 2U; + info->total_periods = w.periods + 2U; + return true; +} diff --git a/ir/tagtinker_ir_seq.h b/ir/tagtinker_ir_seq.h new file mode 100644 index 0000000..be62826 --- /dev/null +++ b/ir/tagtinker_ir_seq.h @@ -0,0 +1,71 @@ +/* + * PP4 frame -> DMA segment table. + * + * Pure C, no Flipper headers: this file also builds on a PC for host tests. + * + * The IR carrier runs on TIM1 at 64 MHz / 51 = ~1.255 MHz. Every segment is a + * whole number of carrier periods, either "mark" (carrier on) or "space" + * (carrier off). On each TIM1 update event two DMA channels feed the timer: + * - mode[]: written to TIM1->CCMR2, takes effect immediately; + * - rcr[]: written to TIM1->RCR, preloaded, takes effect one update later. + * Because of that one-update shift the two tables are offset by one entry: + * mode[0] = space (lead-in, 1 carrier period) + * mode[j] = level of segment j-1 (j = 1..n) + * rcr[j-1] = length of segment j-1, minus 1 + * mode[n + 1] = space (terminal: the LED stays off once the DMA is done) + */ + +#pragma once + +#include +#include +#include + +/* Largest frame the driver accepts (matches TAGTINKER_MAX_FRAME_SIZE). */ +#define TAGTINKER_IR_MAX_FRAME_LEN 96U + +/* Carrier period in CPU cycles at 64 MHz (TIM1 ARR + 1). */ +#define TAGTINKER_IR_CARRIER_CYCLES 51U + +/* PP4 timing in carrier periods (1 period = 0.797 us). */ +#define TAGTINKER_IR_BURST_PERIODS 51U /* ~40.6 us */ +#define TAGTINKER_IR_GAP_SYM0 76U /* ~60.6 us */ +#define TAGTINKER_IR_GAP_SYM1 304U /* ~242.3 us */ +#define TAGTINKER_IR_GAP_SYM2 152U /* ~121.2 us */ +#define TAGTINKER_IR_GAP_SYM3 228U /* ~181.7 us */ + +/* One "delay" unit between repeats = 500 us = 32000 CPU cycles. */ +#define TAGTINKER_IR_DELAY_UNIT_CYCLES 32000U + +/* TIM1 RCR is 16 bits: one update every RCR + 1 periods, so 65536 max. */ +#define TAGTINKER_IR_MAX_SEGMENT_PERIODS 65536U + +/* Worst case: 4 symbols x 2 segments per byte, closing burst, a 255-unit + * repeat gap split in 3 chunks, lead-in and terminal entries. */ +#define TAGTINKER_IR_SEQ_CAPACITY (TAGTINKER_IR_MAX_FRAME_LEN * 8U + 1U + 3U + 2U) + +typedef struct { + size_t frame_entries; /* DMA count for the last repeat (no trailing gap) */ + size_t total_entries; /* DMA count for a repeat followed by the gap */ + uint32_t frame_periods; /* carrier periods for frame_entries */ + uint32_t total_periods; /* carrier periods for total_entries */ +} TagTinkerIrSeqInfo; + +/* Repeat gap in carrier periods for a given delay value (500 us units). */ +uint32_t tagtinker_ir_seq_gap_periods(uint8_t delay); + +/* + * Fill mode[] and rcr[] (both `capacity` entries long) for one frame followed + * by `gap_periods` of space. `mark` and `space` are the raw CCMR2 values. + * Returns false if the tables are too small or the input is invalid. + */ +bool tagtinker_ir_seq_build( + const uint8_t* data, + size_t len, + uint32_t gap_periods, + uint8_t mark, + uint8_t space, + uint8_t* mode, + uint16_t* rcr, + size_t capacity, + TagTinkerIrSeqInfo* info); diff --git a/scenes/tagtinker_scene_settings.c b/scenes/tagtinker_scene_settings.c index 80e60ba..85b7ee0 100644 --- a/scenes/tagtinker_scene_settings.c +++ b/scenes/tagtinker_scene_settings.c @@ -7,11 +7,17 @@ enum { SettingsItemStartupWarning, SettingsItemFrameRepeat, + SettingsItemIrOutput, + SettingsItemIr5v, SettingsItemClearRecents, }; static const char* settings_toggle_labels[] = {"Off", "On"}; +/* Same choices as the stock Infrared app: built-in LED, an external module + * on GPIO pin 2 (A7), or detect the module when sending. */ +static const char* ir_output_labels[TagTinkerIrOutputSettingCount] = {"Internal", "GPIO A7", "Auto"}; + static void startup_warning_changed(VariableItem* item) { TagTinkerApp* app = variable_item_get_context(item); uint8_t index = variable_item_get_current_value_index(item); @@ -35,6 +41,26 @@ static void frame_repeat_changed(VariableItem* item) { tagtinker_settings_save(app); } +static void ir_output_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + + app->ir_output_setting = index; + variable_item_set_current_value_text(item, ir_output_labels[index]); + + tagtinker_settings_save(app); +} + +static void ir_5v_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + + app->ir_5v = (index == 1); + variable_item_set_current_value_text(item, settings_toggle_labels[index]); + + tagtinker_settings_save(app); +} + static void clear_recents_cb(VariableItem* item) { TagTinkerApp* app = variable_item_get_context(item); app->recent_count = 0; @@ -62,6 +88,17 @@ void tagtinker_scene_settings_on_enter(void* ctx) { variable_item_set_current_value_text(item, buf); } + item = variable_item_list_add( + list, "IR Output", TagTinkerIrOutputSettingCount, ir_output_changed, app); + variable_item_set_current_value_index(item, app->ir_output_setting); + variable_item_set_current_value_text(item, ir_output_labels[app->ir_output_setting]); + + /* 5V on GPIO pin 1 for modules powered from it. Auto turns it on by + * itself when a module is detected, like the stock Infrared app. */ + item = variable_item_list_add(list, "5V on GPIO", 2, ir_5v_changed, app); + variable_item_set_current_value_index(item, app->ir_5v ? 1 : 0); + variable_item_set_current_value_text(item, settings_toggle_labels[app->ir_5v ? 1 : 0]); + item = variable_item_list_add(list, "Clear Recents", 0, clear_recents_cb, app); variable_item_set_current_value_text(item, ""); diff --git a/scenes/tagtinker_scene_transmit.c b/scenes/tagtinker_scene_transmit.c index bd9911f..fa31d1d 100644 --- a/scenes/tagtinker_scene_transmit.c +++ b/scenes/tagtinker_scene_transmit.c @@ -48,6 +48,25 @@ static void tx_debug_log(const char* fmt, ...) { } +/* Progress: each payload covers [lo, hi] of the whole send; data frames move + * the bar inside that window. Values are permille, 16-bit writes are atomic + * on the Cortex-M4 so the GUI thread can read them without a lock. */ +static void tx_progress_set_window(TagTinkerApp* app, uint32_t done, uint32_t span, uint32_t total) { + if(total == 0U) total = 1U; + if(done > total) done = total; + if(done + span > total) span = total - done; + app->tx_progress_lo = (uint16_t)((done * TAGTINKER_TX_PROGRESS_FULL) / total); + app->tx_progress_hi = (uint16_t)(((done + span) * TAGTINKER_TX_PROGRESS_FULL) / total); +} + +static void tx_progress_step(TagTinkerApp* app, size_t sent, size_t count) { + /* Sends without a bar (single commands, Repeat mode) stay unknown. */ + if(count == 0U || app->tx_progress == TAGTINKER_TX_PROGRESS_UNKNOWN) return; + uint32_t lo = app->tx_progress_lo; + uint32_t hi = app->tx_progress_hi; + app->tx_progress = (uint16_t)(lo + ((hi - lo) * (uint32_t)sent) / (uint32_t)count); +} + static uint16_t tx_apply_signal_mode(const TagTinkerApp* app, uint16_t repeats) { UNUSED(app); return repeats & 0x7FFFU; @@ -123,6 +142,7 @@ static bool tx_send_color26_payload(TagTinkerApp* app, const TagTinkerImagePaylo (uint16_t)i, &payload->data[i * TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]); ok = tx_send_frame(app, frame, len, 1); + if(ok) tx_progress_step(app, i + 1U, frame_count); if(ok && (i + 1U) < frame_count) { furi_delay_ms(50); } @@ -188,6 +208,7 @@ static bool tx_send_payload_frames( (uint16_t)i, &payload->data[i * TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]); ok = tx_send_frame(app, frame, len, app->data_frame_repeats); + if(ok) tx_progress_step(app, i + 1U, frame_count); /* Short delay to avoid tag overflow */ if(ok && ((i + 1U) % 32U) == 0U && (i + 1U) < frame_count) { furi_delay_ms(1); @@ -534,6 +555,7 @@ static bool tx_stream_text_image(TagTinkerApp* app) { if(secondary) memset(secondary, 1, (size_t)job->width * actual_h); } + tx_progress_set_window(app, y, actual_h, job->height); ok = tx_send_ping(app, job->plid); if(ok) furi_delay_ms(10); ok = tx_send_image_chunk( @@ -991,6 +1013,24 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) { return ok; } +/* Resolve the IR Output setting into the pin used for this send. */ +static void tx_configure_ir_output(TagTinkerApp* app) { + TagTinkerIrOutput output = TagTinkerIrOutputInternal; + bool enable_5v = false; + + if(app->ir_output_setting == TagTinkerIrOutputSettingGpioA7) { + output = TagTinkerIrOutputGpioA7; + enable_5v = app->ir_5v; + } else if(app->ir_output_setting == TagTinkerIrOutputSettingAuto) { + output = tagtinker_ir_detect_output(); + /* Like the stock Infrared app: a detected module gets 5V. */ + enable_5v = (output == TagTinkerIrOutputGpioA7); + } + + tagtinker_ir_set_output(output, enable_5v); + app->ir_output_active = (uint8_t)output; +} + static int32_t tx_thread_callback(void* context) { TagTinkerApp* app = context; bool ok = true; @@ -998,10 +1038,21 @@ static int32_t tx_thread_callback(void* context) { /* Boost priority for IR timing */ furi_thread_set_current_priority(FuriThreadPriorityHighest); + tx_configure_ir_output(app); tagtinker_ir_init(); + /* Images and multi-frame sequences have a known length: show a bar. + * Single commands (and Repeat mode) keep the dots. */ + bool image_job = app->image_tx_job.mode == TagTinkerTxModeBmpImage || + app->image_tx_job.mode == TagTinkerTxModeTextImage; + app->tx_progress_lo = 0U; + app->tx_progress_hi = TAGTINKER_TX_PROGRESS_FULL; + app->tx_progress = (!app->tx_spam && (image_job || app->frame_seq_count > 1U)) ? + 0U : + TAGTINKER_TX_PROGRESS_UNKNOWN; + /* Let OS settle (especially BLE teardown) before IR blasting */ - if(app->image_tx_job.mode == TagTinkerTxModeBmpImage || app->image_tx_job.mode == TagTinkerTxModeTextImage) { + if(image_job) { furi_delay_ms(500); } @@ -1024,6 +1075,7 @@ static int32_t tx_thread_callback(void* context) { app->frame_sequence[i], app->frame_lengths[i], tx_apply_signal_mode(app, app->frame_repeats[i]), 10); if(!ok) break; + if(app->frame_seq_count > 1U) tx_progress_step(app, i + 1U, app->frame_seq_count); } } else { ok = tagtinker_ir_transmit( @@ -1046,31 +1098,48 @@ static void transmit_draw_cb(Canvas* canvas, void* _model) { TagTinkerApp* app = model->app; uint32_t t = model->tick; - /* Static title - large bold "Tinkering Tag" with the instruction below. - * Kept text-only (no busy animation) because the IR blaster runs at the - * highest thread priority and starves the GUI; trying to animate during - * transmission either looks janky or steals CPU from the IR timing. */ + /* Title, instruction, then a progress bar (images, multi-frame sends) or + * a three-dot indicator. The IR timing is done by TIM1 + DMA and the TX + * thread sleeps between frames, so redrawing here costs it nothing. */ canvas_set_font(canvas, FontPrimary); canvas_draw_str_aligned(canvas, 64, 12, AlignCenter, AlignTop, "Tinkering Tag"); canvas_set_font(canvas, FontSecondary); if(app->tx_active) { canvas_draw_str_aligned( - canvas, 64, 28, AlignCenter, AlignTop, "Point the flipper at the tag"); - - /* Tiny three-dot loading indicator: rotates one bright dot through - * the dot triplet so even when the tick is delayed by IR bursts the - * change is obvious and cheap to draw. */ - const int dot_y = 44; - const int dot_cx = 64; - const int dot_spacing = 6; - const uint8_t phase = (t / 4U) % 3U; - for(int i = 0; i < 3; i++) { - int x = dot_cx + (i - 1) * dot_spacing; - if((uint8_t)i == phase) { - canvas_draw_disc(canvas, x, dot_y, 2); - } else { - canvas_draw_circle(canvas, x, dot_y, 1); + canvas, + 64, + 28, + AlignCenter, + AlignTop, + (app->ir_output_active == TagTinkerIrOutputGpioA7) ? "Point the IR module at tag" : + "Point the flipper at the tag"); + + uint16_t progress = app->tx_progress; + if(progress != TAGTINKER_TX_PROGRESS_UNKNOWN) { + /* Progress bar: frame 92x7 px, percentage on the right. */ + if(progress > TAGTINKER_TX_PROGRESS_FULL) progress = TAGTINKER_TX_PROGRESS_FULL; + const int bar_x = 8, bar_y = 40, bar_w = 92, bar_h = 7; + canvas_draw_rframe(canvas, bar_x, bar_y, bar_w, bar_h, 2); + int fill = (int)(((uint32_t)(bar_w - 4) * progress) / TAGTINKER_TX_PROGRESS_FULL); + if(fill > 0) canvas_draw_box(canvas, bar_x + 2, bar_y + 2, fill, bar_h - 4); + char pct[6]; + snprintf(pct, sizeof(pct), "%u%%", (unsigned)(progress / 10U)); + canvas_draw_str_aligned(canvas, 121, bar_y, AlignRight, AlignTop, pct); + } else { + /* Tiny three-dot loading indicator: rotates one bright dot through + * the dot triplet. */ + const int dot_y = 44; + const int dot_cx = 64; + const int dot_spacing = 6; + const uint8_t phase = (t / 4U) % 3U; + for(int i = 0; i < 3; i++) { + int x = dot_cx + (i - 1) * dot_spacing; + if((uint8_t)i == phase) { + canvas_draw_disc(canvas, x, dot_y, 2); + } else { + canvas_draw_circle(canvas, x, dot_y, 1); + } } } } else if(model->cancelled) { @@ -1107,6 +1176,7 @@ void tagtinker_scene_transmit_on_enter(void* context) { model->completed = false; model->ok = true; model->cancelled = false; + app->tx_progress = TAGTINKER_TX_PROGRESS_UNKNOWN; view_commit_model(app->transmit_view, true); view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTransmit); diff --git a/tagtinker_app.c b/tagtinker_app.c index fa1822f..6f94988 100644 --- a/tagtinker_app.c +++ b/tagtinker_app.c @@ -453,6 +453,8 @@ void tagtinker_settings_load(TagTinkerApp* app) { app->show_startup_warning = true; app->data_frame_repeats = 2; app->signal_mode = TagTinkerSignalPP4; + app->ir_output_setting = TagTinkerIrOutputSettingInternal; + app->ir_5v = false; if(storage_file_open(file, APP_DATA_PATH("settings.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) { char buf[32]; @@ -460,8 +462,16 @@ void tagtinker_settings_load(TagTinkerApp* app) { buf[read] = '\0'; storage_file_close(file); - int warn, rep, sig; - if(sscanf(buf, "%d|%d|%d", &warn, &rep, &sig) == 3) { + /* warn|repeats|signal[|ir_output|ir_5v]: older files have 2 or 3 fields. */ + int warn, rep, sig, out, v5; + if(sscanf(buf, "%d|%d|%d|%d|%d", &warn, &rep, &sig, &out, &v5) == 5) { + app->show_startup_warning = (warn != 0); + app->data_frame_repeats = (uint16_t)rep; + if(out >= 0 && out < TagTinkerIrOutputSettingCount) { + app->ir_output_setting = (uint8_t)out; + } + app->ir_5v = (v5 != 0); + } else if(sscanf(buf, "%d|%d|%d", &warn, &rep, &sig) == 3) { app->show_startup_warning = (warn != 0); app->data_frame_repeats = (uint16_t)rep; } else if(sscanf(buf, "%d|%d", &warn, &rep) == 2) { @@ -490,10 +500,12 @@ bool tagtinker_settings_save(const TagTinkerApp* app) { int len = snprintf( buf, sizeof(buf), - "%d|%u|%d", + "%d|%u|%d|%u|%d", app->show_startup_warning ? 1 : 0, app->data_frame_repeats, - (int)TagTinkerSignalPP4); + (int)TagTinkerSignalPP4, + app->ir_output_setting, + app->ir_5v ? 1 : 0); if(len > 0 && storage_file_write(file, buf, (uint16_t)len)) { ok = true; } diff --git a/tagtinker_app.h b/tagtinker_app.h index c3bc57c..fbffa8d 100644 --- a/tagtinker_app.h +++ b/tagtinker_app.h @@ -44,6 +44,8 @@ #define TAGTINKER_PRESET_TEXT_LEN 32 #define TAGTINKER_IMAGE_PATH_LEN 255 #define TAGTINKER_SYNC_JOB_ID_LEN 32 +#define TAGTINKER_TX_PROGRESS_UNKNOWN 0xFFFFU +#define TAGTINKER_TX_PROGRESS_FULL 1000U typedef enum { TagTinkerTxModeNone = 0, @@ -55,6 +57,14 @@ typedef enum { TagTinkerSignalPP4 = 0, } TagTinkerSignalMode; +/* Where the IR signal goes. Mirrors the stock Infrared app GPIO settings. */ +typedef enum { + TagTinkerIrOutputSettingInternal = 0, /* built-in LED */ + TagTinkerIrOutputSettingGpioA7, /* external module on GPIO pin 2 (A7) */ + TagTinkerIrOutputSettingAuto, /* A7 if a module is detected, else internal */ + TagTinkerIrOutputSettingCount, +} TagTinkerIrOutputSetting; + typedef struct { TagTinkerTxMode mode; uint8_t plid[4]; @@ -140,6 +150,12 @@ struct TagTinkerApp { /* TX state */ bool tx_active; FuriThread* tx_thread; + /* Send progress in permille, written by the TX thread and drawn by the + * transmit view. TAGTINKER_TX_PROGRESS_UNKNOWN for single commands. */ + volatile uint16_t tx_progress; + /* Share of the whole send covered by the payload being sent [lo, hi]. */ + uint16_t tx_progress_lo; + uint16_t tx_progress_hi; /* NFC scan state */ Nfc* nfc; @@ -216,6 +232,9 @@ struct TagTinkerApp { uint8_t data_frame_repeats; TagTinkerSignalMode signal_mode; bool show_startup_warning; + uint8_t ir_output_setting; /* TagTinkerIrOutputSetting */ + bool ir_5v; /* 5V on GPIO pin 1 while sending through A7 */ + volatile uint8_t ir_output_active; /* TagTinkerIrOutput used by the current send */ /* Indicates which mode triggered raw cmd (0=broadcast, 1=targeted) */ uint8_t raw_mode; diff --git a/tests/test_ir_seq.c b/tests/test_ir_seq.c new file mode 100644 index 0000000..1e93757 --- /dev/null +++ b/tests/test_ir_seq.c @@ -0,0 +1,228 @@ +/* + * Host test for ir/tagtinker_ir_seq.c (runs on a PC, no Flipper needed). + * + * It replays the DMA tables through a small model of TIM1 + the two DMA + * channels, rebuilds the IR waveform, and checks it against the timing of + * the original bit-banging driver (CPU cycles at 64 MHz). + * + * Build and run: make test + */ + +#include "../ir/tagtinker_ir_seq.h" + +#include +#include +#include +#include + +#define MARK 1U +#define SPACE 0U + +/* Timing of the original driver, in CPU cycles, indexed by symbol value. */ +#define OLD_BURST_CYCLES 2581.0 +static const double old_gap_cycles[4] = {3871.0, 15483.0, 7741.0, 11612.0}; + +/* Rounding to whole carrier periods may move an edge by half a period. */ +#define MAX_EDGE_ERROR_US 0.40 + +typedef struct { + uint8_t level; + uint32_t periods; +} Seg; + +static int failures = 0; + +#define CHECK(cond, ...) \ + do { \ + if(!(cond)) { \ + failures++; \ + printf("FAIL %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while(0) + +static void push_seg(Seg* out, size_t* n, uint8_t level, uint32_t periods) { + if(*n > 0 && out[*n - 1].level == level) { + out[*n - 1].periods += periods; + } else { + out[*n].level = level; + out[*n].periods = periods; + (*n)++; + } +} + +/* + * Model of the hardware: + * - after the forced update: output = space, RCR shadow = preload = 0; + * - a segment lasts (RCR shadow + 1) carrier periods; + * - at each update: shadow <- preload, then DMA writes mode[i] (applies now) + * and rcr[i] (goes to preload); + * - when the last mode[] entry has been written, DMA "transfer complete" + * fires and the driver halts the timer. + */ +static size_t simulate( + const uint8_t* mode, + const uint16_t* rcr, + size_t entries, + Seg* out, + uint32_t* total_periods) { + uint8_t level = SPACE; + uint32_t shadow = 0; + uint32_t preload = 0; + size_t n = 0; + *total_periods = 0; + + for(size_t i = 0; i < entries; i++) { + uint32_t len = shadow + 1U; + push_seg(out, &n, level, len); + *total_periods += len; + + /* Update event */ + shadow = preload; + level = mode[i]; + preload = rcr[i]; + } + /* Transfer complete: the output is now mode[entries - 1], halted. */ + CHECK(level == SPACE, "carrier left on after transfer complete"); + return n; +} + +/* Reference waveform of the old driver, in CPU cycles. */ +static size_t reference(const uint8_t* data, size_t len, double* out_cycles, uint8_t* out_level) { + size_t n = 0; + for(size_t i = 0; i < len; i++) { + uint8_t b = data[i]; + for(int s = 0; s < 4; s++) { + out_level[n] = MARK; + out_cycles[n++] = OLD_BURST_CYCLES; + out_level[n] = SPACE; + out_cycles[n++] = old_gap_cycles[b & 3U]; + b >>= 2; + } + } + out_level[n] = MARK; + out_cycles[n++] = OLD_BURST_CYCLES; + return n; +} + +static double periods_to_us(uint32_t p) { + return p * (double)TAGTINKER_IR_CARRIER_CYCLES / 64.0; +} + +static void check_frame(const uint8_t* data, size_t len, uint8_t delay) { + static uint8_t mode[TAGTINKER_IR_SEQ_CAPACITY]; + static uint16_t rcr[TAGTINKER_IR_SEQ_CAPACITY]; + static Seg wave[TAGTINKER_IR_SEQ_CAPACITY + 8]; + static double ref_cycles[TAGTINKER_IR_SEQ_CAPACITY]; + static uint8_t ref_level[TAGTINKER_IR_SEQ_CAPACITY]; + + TagTinkerIrSeqInfo info; + uint32_t gap = tagtinker_ir_seq_gap_periods(delay); + bool ok = tagtinker_ir_seq_build( + data, len, gap, MARK, SPACE, mode, rcr, TAGTINKER_IR_SEQ_CAPACITY, &info); + CHECK(ok, "build failed len=%zu delay=%u", len, delay); + if(!ok) return; + + size_t ref_n = reference(data, len, ref_cycles, ref_level); + + /* --- Last repeat: frame only --- */ + uint32_t periods; + size_t n = simulate(mode, rcr, info.frame_entries, wave, &periods); + CHECK(periods == info.frame_periods, "frame periods %u != info %u", periods, info.frame_periods); + + /* wave[0] is the 2-period lead-in, then one segment per reference edge. */ + CHECK(wave[0].level == SPACE && wave[0].periods == 2, "bad lead-in"); + CHECK(n == ref_n + 1, "segment count %zu != %zu (len=%zu)", n, ref_n + 1, len); + + for(size_t i = 0; i < ref_n && i + 1 < n; i++) { + double want_us = ref_cycles[i] / 64.0; + double got_us = periods_to_us(wave[i + 1].periods); + CHECK(wave[i + 1].level == ref_level[i], "level mismatch at %zu", i); + CHECK( + fabs(got_us - want_us) <= MAX_EDGE_ERROR_US, + "segment %zu: %.2f us vs %.2f us", + i, + got_us, + want_us); + } + + /* PP4 is pulse-position coded: the tag decodes each symbol from the time + * between two bursts, so that interval is what must match. (Absolute + * time may drift a little over a long frame; that is harmless.) */ + for(size_t i = 0; i + 1 < ref_n && i + 2 < n; i += 2) { + double want_us = (ref_cycles[i] + ref_cycles[i + 1]) / 64.0; + double got_us = periods_to_us(wave[i + 1].periods + wave[i + 2].periods); + CHECK( + fabs(got_us - want_us) <= 2 * MAX_EDGE_ERROR_US, + "symbol %zu: %.2f us vs %.2f us", + i / 2, + got_us, + want_us); + } + + /* --- Other repeats: frame + gap --- */ + n = simulate(mode, rcr, info.total_entries, wave, &periods); + CHECK(periods == info.total_periods, "total periods %u != info %u", periods, info.total_periods); + CHECK(periods == info.frame_periods + gap, "gap not appended (delay=%u)", delay); + if(gap > 0) { + CHECK(wave[n - 1].level == SPACE && wave[n - 1].periods == gap, "trailing gap wrong"); + } +} + +static void test_known_bytes(void) { + /* Every symbol value, in every position of a byte. */ + const uint8_t data[] = {0x00, 0x55, 0xAA, 0xFF, 0xE4, 0x1B}; + for(uint8_t d = 0; d <= 10; d++) { + check_frame(data, sizeof(data), d); + } +} + +static void test_random_frames(void) { + uint8_t buf[TAGTINKER_IR_MAX_FRAME_LEN]; + srand(1234); + for(int t = 0; t < 2000; t++) { + size_t len = 1U + (size_t)(rand() % TAGTINKER_IR_MAX_FRAME_LEN); + for(size_t i = 0; i < len; i++) buf[i] = (uint8_t)rand(); + check_frame(buf, len, (uint8_t)(rand() % 12)); + } +} + +static void test_long_gap_split(void) { + /* 255 x 500 us = 127.5 ms > 65536 periods: must be split. */ + uint8_t buf[TAGTINKER_IR_MAX_FRAME_LEN]; + memset(buf, 0x5A, sizeof(buf)); + check_frame(buf, sizeof(buf), 255); +} + +static void test_invalid_input(void) { + static uint8_t mode[TAGTINKER_IR_SEQ_CAPACITY]; + static uint16_t rcr[TAGTINKER_IR_SEQ_CAPACITY]; + uint8_t buf[TAGTINKER_IR_MAX_FRAME_LEN + 1] = {0}; + TagTinkerIrSeqInfo info; + + CHECK(!tagtinker_ir_seq_build(NULL, 4, 0, 1, 0, mode, rcr, TAGTINKER_IR_SEQ_CAPACITY, &info), + "NULL data accepted"); + CHECK(!tagtinker_ir_seq_build(buf, 0, 0, 1, 0, mode, rcr, TAGTINKER_IR_SEQ_CAPACITY, &info), + "empty frame accepted"); + CHECK( + !tagtinker_ir_seq_build( + buf, sizeof(buf), 0, 1, 0, mode, rcr, TAGTINKER_IR_SEQ_CAPACITY, &info), + "oversized frame accepted"); + CHECK(!tagtinker_ir_seq_build(buf, 8, 0, 1, 0, mode, rcr, 20, &info), + "table overflow not detected"); +} + +int main(void) { + test_known_bytes(); + test_random_frames(); + test_long_gap_split(); + test_invalid_input(); + + if(failures) { + printf("%d check(s) failed\n", failures); + return 1; + } + printf("ir_seq: all checks passed\n"); + return 0; +}