From d01b63f0c3d38cf08b706d5ba1ac6da31f011747 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 16:08:33 -0700 Subject: [PATCH] Keep RTTTL tones and queued speech running across timer rollover --- .github/workflows/run-unit-tests.yml | 2 + platformio.ini | 4 + scripts/rtttl_rollover_fix.py | 96 ++++++++ test/fixtures/rtttl_rollover/1.3.0/AUTHORS | 9 + test/fixtures/rtttl_rollover/1.3.0/LICENSE | 21 ++ test/fixtures/rtttl_rollover/1.3.0/README.txt | 22 ++ .../1.3.0/src/NonBlockingRtttl.cpp.json | 3 + .../1.3.0/src/NonBlockingRtttl.h | 161 ++++++++++++ test/fixtures/rtttl_rollover/1.4.0/AUTHORS | 10 + test/fixtures/rtttl_rollover/1.4.0/LICENSE | 21 ++ test/fixtures/rtttl_rollover/1.4.0/README.txt | 26 ++ .../1.4.0/src/NonBlockingRtttl.cpp.json | 3 + .../1.4.0/src/NonBlockingRtttl.h | 163 ++++++++++++ test/fixtures/rtttl_rollover/Arduino.h | 22 ++ test/fixtures/rtttl_rollover/HardwarePWM.h | 29 +++ test/fixtures/rtttl_rollover/queue.cpp | 46 ++++ test/fixtures/rtttl_rollover/regression.cpp | 110 +++++++++ test/fixtures/rtttl_rollover/stdint.h | 2 + test/test_rtttl_rollover.py | 232 ++++++++++++++++++ test/test_rtttl_rollover_recipes.py | 88 +++++++ 20 files changed, 1070 insertions(+) create mode 100644 scripts/rtttl_rollover_fix.py create mode 100644 test/fixtures/rtttl_rollover/1.3.0/AUTHORS create mode 100644 test/fixtures/rtttl_rollover/1.3.0/LICENSE create mode 100644 test/fixtures/rtttl_rollover/1.3.0/README.txt create mode 100644 test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.cpp.json create mode 100644 test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.h create mode 100644 test/fixtures/rtttl_rollover/1.4.0/AUTHORS create mode 100644 test/fixtures/rtttl_rollover/1.4.0/LICENSE create mode 100644 test/fixtures/rtttl_rollover/1.4.0/README.txt create mode 100644 test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.cpp.json create mode 100644 test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.h create mode 100644 test/fixtures/rtttl_rollover/Arduino.h create mode 100644 test/fixtures/rtttl_rollover/HardwarePWM.h create mode 100644 test/fixtures/rtttl_rollover/queue.cpp create mode 100644 test/fixtures/rtttl_rollover/regression.cpp create mode 100644 test/fixtures/rtttl_rollover/stdint.h create mode 100644 test/test_rtttl_rollover.py create mode 100644 test/test_rtttl_rollover_recipes.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index fa5399ccd..02c05024e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -780,6 +780,8 @@ jobs: - name: Verify Companion Bluetooth settings and WebConfig run: | + python3 -B test/test_rtttl_rollover.py -v + python3 -B test/test_rtttl_rollover_recipes.py -v python3 -B test/test_gps_voice_buttons.py -v python3 -B test/test_gps_voice_generator.py -v python3 -B test/test_button_voice_actions.py -v diff --git a/platformio.ini b/platformio.ini index 9ad05fe8c..42aaf84c6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -85,6 +85,7 @@ monitor_filters = esp32_exception_decoder ; it needs a custom bootloader; RP2040/STM32 have no A/B path yet.) extra_scripts = pre:scripts/client_acl_role.py + pre:scripts/rtttl_rollover_fix.py pre:scripts/generate_webconfig_html.py pre:scripts/meshcore_image_identity.py pre:scripts/ota_device_deflate_role.py @@ -162,6 +163,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 extra_scripts = create-uf2.py pre:scripts/client_acl_role.py + pre:scripts/rtttl_rollover_fix.py pre:scripts/ota_device_deflate_role.py pre:scripts/nrf52_usb_power_fix.py pre:scripts/nrf52_internal_flash_fix.py @@ -339,6 +341,7 @@ lib_deps = ${nrf52_lora_ota.lib_deps} [rp2040_base] extends = arduino_base extra_scripts = pre:scripts/client_acl_role.py + pre:scripts/rtttl_rollover_fix.py post:scripts/check_firmware_ram.py upload_protocol = picotool board_build.core = earlephilhower @@ -359,6 +362,7 @@ extends = arduino_base platform = ststm32@19.5.0 extra_scripts = post:arch/stm32/build_hex.py pre:scripts/client_acl_role.py + pre:scripts/rtttl_rollover_fix.py post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D STM32_PLATFORM diff --git a/scripts/rtttl_rollover_fix.py b/scripts/rtttl_rollover_fix.py new file mode 100644 index 000000000..894df8523 --- /dev/null +++ b/scripts/rtttl_rollover_fix.py @@ -0,0 +1,96 @@ +"""Fix reviewed RTTTL timers in private build copies, never in libdeps. + +An absolute millis() comparison can truncate a tone at rollover or keep it +busy until the next wrap, blocking queued speech. Use a signed 32-bit delta +and make the initial deadline due now, including in the upper counter half. +As with other Arduino deadline comparisons, waits must remain below 2^31 ms; +the firmware's note durations and repeat gaps are far below that limit. +""" +import hashlib +from pathlib import Path + + +# Each entry binds the actual source, its matching header, and the result. +# The recipes allow both releases; do not silently upgrade cached 1.3.0. +REVIEWED = { + "b50c159c8806d177398972bf8f94ca762532a855b60cb1d3aa9c3ebf021423ca": ( + "abea0a4870e58222fdefd1e88d291136518912e83880af30d1c85d39d7bd5cc1", + "29204341d99b8adeef501c0d4cb47a4137534ebe6a30f2e6b3032d558aeef802", + ), + "d4fddfa663c8adeacea3b1daf56ec5fbe63ecaaf68e5477ac96dc4fb2e28d363": ( + "5a33eec77e2ba53ed9b83a8242633b8ae11873d3b4f8cfd257abbb2eb61b6220", + "e7710c4e3309b25517f5bace697c0c2574a5ce219f3cb156d352f739c178d75f", + ), +} +ORIGINAL_BEGIN = " noteDelay = 0;\n" +PATCHED_BEGIN = " noteDelay = millis();\n" +ORIGINAL_COMPARE = " if (m < noteDelay)\n" +PATCHED_COMPARE = " if (static_cast(static_cast(m - noteDelay)) < 0)\n" + + +def digest(source): + return hashlib.sha256(source.encode("utf-8")).hexdigest() + + +def reviewed_pair(source): + source_hash = digest(source.replace("\r\n", "\n")) + if source_hash in REVIEWED: + return REVIEWED[source_hash] + for header_hash, patched_hash in REVIEWED.values(): + if source_hash == patched_hash: + return header_hash, patched_hash + raise RuntimeError("RTTTL timing fix: changed reviewed source; review dependency update") + + +def patched_source(source): + source = source.replace("\r\n", "\n") + _, patched_hash = reviewed_pair(source) + if digest(source) == patched_hash: + return source + if source.count(ORIGINAL_BEGIN) != 1 or source.count(ORIGINAL_COMPARE) != 1: + raise RuntimeError("RTTTL timing fix: changed reviewed timing statements") + source = source.replace(ORIGINAL_BEGIN, PATCHED_BEGIN, 1) + source = source.replace(ORIGINAL_COMPARE, PATCHED_COMPARE, 1) + # Version 1.4.0 has a degree symbol only in a disabled debug string. + # Keep generated project-private source ASCII without changing playback. + source = source.replace("\u00b0", "") + if digest(source) != patched_hash: + raise RuntimeError("RTTTL timing fix: unexpected transform result") + source.encode("ascii") + return source + + +def write_if_changed(path, content): + if not path.exists() or path.read_text(encoding="ascii") != content: + path.write_text(content, encoding="ascii") + + +def replace_rtttl_source(build_env, node): + source = Path(node.srcnode().get_abspath()) + if source.name != "NonBlockingRtttl.cpp" or source.parent.name != "src": + return node + original = source.read_text(encoding="utf-8") + header_hash, _ = reviewed_pair(original) + try: + header = source.with_suffix(".h").read_text(encoding="ascii").replace("\r\n", "\n") + except (OSError, UnicodeError) as error: + raise RuntimeError("RTTTL timing fix: missing or changed reviewed header") from error + if digest(header) != header_hash: + raise RuntimeError("RTTTL timing fix: changed reviewed header; review dependency update") + patched = patched_source(original) + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-rtttl" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + # A quoted include must resolve to the reviewed matching header, not a + # same-named library earlier in the build clone's include path. + write_if_changed(destination.with_suffix(".h"), header) + write_if_changed(destination, patched) + return build_env.File(str(destination)) + + +def install(build_env): + build_env.AddBuildMiddleware(replace_rtttl_source, "*NonBlockingRtttl.cpp") + + +if "Import" in globals(): + Import("env") + install(env) diff --git a/test/fixtures/rtttl_rollover/1.3.0/AUTHORS b/test/fixtures/rtttl_rollover/1.3.0/AUTHORS new file mode 100644 index 000000000..a71bdcad6 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.3.0/AUTHORS @@ -0,0 +1,9 @@ +# This file contains a list of people who've made contribution to +# the project. People who commit code are encouraged to add +# their names here. Please keep the list sorted by first names. + +Antoine Beauchamp +Chris Dennis +James Brown +per1234 +Erwan d'Orgeville diff --git a/test/fixtures/rtttl_rollover/1.3.0/LICENSE b/test/fixtures/rtttl_rollover/1.3.0/LICENSE new file mode 100644 index 000000000..dd4add21c --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.3.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Antoine Beauchamp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/fixtures/rtttl_rollover/1.3.0/README.txt b/test/fixtures/rtttl_rollover/1.3.0/README.txt new file mode 100644 index 000000000..e4370a2c7 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.3.0/README.txt @@ -0,0 +1,22 @@ +NonBlockingRTTTL 1.3.0 regression fixture + +Source: https://github.com/end2endzone/NonBlockingRTTTL/tree/1.3.0 +The official 1.3.0 tag's source is vendored as ASCII JSON for offline tests. +Decode the "source" field in src/NonBlockingRtttl.cpp.json to recover the +exact reviewed upstream CPP. The header is unchanged. LICENSE and AUTHORS +retain the original MIT attribution. +No firmware code is linked from this directory. + +Canonical normalization is LF line endings and ASCII-only text. The upstream +tag already has both, so no bytes or executable statements are changed. + +Unpatched CPP SHA256: +b50c159c8806d177398972bf8f94ca762532a855b60cb1d3aa9c3ebf021423ca +Patched private CPP SHA256: +29204341d99b8adeef501c0d4cb47a4137534ebe6a30f2e6b3032d558aeef802 +Unchanged header SHA256: +abea0a4870e58222fdefd1e88d291136518912e83880af30d1c85d39d7bd5cc1 +LICENSE SHA256: +44bbc2541c595d5d8790f9b0be9a56fc88c9a500ab6bd2a88b4e00f8e28f0585 +Original AUTHORS SHA256 (trailing whitespace trimmed in copy): +35b555001d2fd35c64002cb517bd1abce2d502b88540aff61d6ae5387b697c45 diff --git a/test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.cpp.json b/test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.cpp.json new file mode 100644 index 000000000..699364d68 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.cpp.json @@ -0,0 +1,3 @@ +{ + "source": "// ---------------------------------------------------------------------------\n// Created by Antoine Beauchamp based on the code from the ToneLibrary\n//\n// See \"NonBlockingRtttl.h\" for purpose, syntax, version history, links, and more.\n// ---------------------------------------------------------------------------\n\n#include \"Arduino.h\"\n#include \"NonBlockingRtttl.h\"\n\n/*********************************************************\n * RTTTL Library data\n *********************************************************/\n\nnamespace rtttl\n{\n\nconst int notes[] = { 0,\nNOTE_C4, NOTE_CS4, NOTE_D4, NOTE_DS4, NOTE_E4, NOTE_F4, NOTE_FS4, NOTE_G4, NOTE_GS4, NOTE_A4, NOTE_AS4, NOTE_B4,\nNOTE_C5, NOTE_CS5, NOTE_D5, NOTE_DS5, NOTE_E5, NOTE_F5, NOTE_FS5, NOTE_G5, NOTE_GS5, NOTE_A5, NOTE_AS5, NOTE_B5,\nNOTE_C6, NOTE_CS6, NOTE_D6, NOTE_DS6, NOTE_E6, NOTE_F6, NOTE_FS6, NOTE_G6, NOTE_GS6, NOTE_A6, NOTE_AS6, NOTE_B6,\nNOTE_C7, NOTE_CS7, NOTE_D7, NOTE_DS7, NOTE_E7, NOTE_F7, NOTE_FS7, NOTE_G7, NOTE_GS7, NOTE_A7, NOTE_AS7, NOTE_B7\n};\n\n//#define isdigit(n) (n >= '0' && n <= '9')\n#define OCTAVE_OFFSET 0\n\nconst char * buffer = \"\";\nint bufferIndex = -32760;\nbyte default_dur = 4;\nbyte default_oct = 5;\nint bpm = 63;\nlong wholenote;\nbyte pin = -1;\nunsigned long noteDelay = 0; //m will always be after which means the last note is done playing\nbool playing = false;\n\n//pre-declaration\nvoid nextnote();\n\n// tone() and noTone() are not implemented for Arduino core for the ESP32\n// See https://github.com/espressif/arduino-esp32/issues/980\n// and https://github.com/espressif/arduino-esp32/issues/1720\n#if defined(ESP32)\nvoid noTone(){\n ledcWrite(0, 0); // channel, volume\n}\n\nvoid noTone(int pin){\n noTone();\n}\n\nvoid tone(int frq) {\n ledcWriteTone(0, frq); // channel, freq\n ledcWrite(0, 255); // channel, volume\n}\n\nvoid tone(int pin, int frq, int duration){\n tone(frq);\n}\n#endif\n\nvoid begin(byte iPin, const char * iSongBuffer)\n{\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.print(\"playing: \");\n Serial.println(iSongBuffer);\n #endif\n \n //init values\n pin = iPin;\n #if defined(ESP32)\n ledcSetup(0, 1000, 10); // resolution always seems to be 10bit, no matter what is given\n ledcAttachPin(pin, 0);\n #endif\n buffer = iSongBuffer;\n bufferIndex = 0;\n default_dur = 4;\n default_oct = 6;\n bpm=63;\n playing = true;\n noteDelay = 0;\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.print(\"noteDelay=\");\n Serial.println(noteDelay);\n #endif\n \n //stop current note\n noTone(pin);\n\n //read buffer until first note\n int num;\n\n // format: d=N,o=N,b=NNN:\n // find the start (skip name, etc)\n\n while(*buffer != ':') buffer++; // ignore name\n buffer++; // skip ':'\n\n // get default duration\n if(*buffer == 'd')\n {\n buffer++; buffer++; // skip \"d=\"\n num = 0;\n while(isdigit(*buffer))\n {\n num = (num * 10) + (*buffer++ - '0');\n }\n if(num > 0) default_dur = num;\n buffer++; // skip comma\n }\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"ddur: \"); Serial.println(default_dur, 10);\n #endif\n \n // get default octave\n if(*buffer == 'o')\n {\n buffer++; buffer++; // skip \"o=\"\n num = *buffer++ - '0';\n if(num >= 3 && num <=7) default_oct = num;\n buffer++; // skip comma\n }\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"doct: \"); Serial.println(default_oct, 10);\n #endif\n \n // get BPM\n if(*buffer == 'b')\n {\n buffer++; buffer++; // skip \"b=\"\n num = 0;\n while(isdigit(*buffer))\n {\n num = (num * 10) + (*buffer++ - '0');\n }\n bpm = num;\n buffer++; // skip colon\n }\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"bpm: \"); Serial.println(bpm, 10);\n #endif\n\n // BPM usually expresses the number of quarter notes per minute\n wholenote = (60 * 1000L / bpm) * 4; // this is the time for whole note (in milliseconds)\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"wn: \"); Serial.println(wholenote, 10);\n #endif\n}\n\nvoid nextnote()\n{\n long duration;\n byte note;\n byte scale;\n\n //stop current note\n noTone(pin);\n\n // first, get note duration, if available\n int num = 0;\n while(isdigit(*buffer))\n {\n num = (num * 10) + (*buffer++ - '0');\n }\n \n if(num) duration = wholenote / num;\n else duration = wholenote / default_dur; // we will need to check if we are a dotted note after\n\n // now get the note\n note = 0;\n\n switch(*buffer)\n {\n case 'c':\n note = 1;\n break;\n case 'd':\n note = 3;\n break;\n case 'e':\n note = 5;\n break;\n case 'f':\n note = 6;\n break;\n case 'g':\n note = 8;\n break;\n case 'a':\n note = 10;\n break;\n case 'b':\n note = 12;\n break;\n case 'p':\n default:\n note = 0;\n }\n buffer++;\n\n // now, get optional '#' sharp\n if(*buffer == '#')\n {\n note++;\n buffer++;\n }\n\n // now, get optional '.' dotted note\n if(*buffer == '.')\n {\n duration += duration/2;\n buffer++;\n }\n\n // now, get scale\n if(isdigit(*buffer))\n {\n scale = *buffer - '0';\n buffer++;\n }\n else\n {\n scale = default_oct;\n }\n\n scale += OCTAVE_OFFSET;\n\n // now, get optional '.' dotted note again.\n // A dot /after/ the octave is allowed too, depending on which\n // RTTTL specification you read.\n // See https://github.com/end2endzone/NonBlockingRTTTL/issues/10\n if(*buffer == '.')\n {\n duration += duration/2;\n buffer++;\n }\n\n if(*buffer == ',')\n buffer++; // skip comma for next note (or we may be at the end)\n\n // now play the note\n\n if(note)\n {\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"Playing: \");\n Serial.print(scale, 10); Serial.print(' ');\n Serial.print(note, 10); Serial.print(\" (\");\n Serial.print(notes[(scale - 4) * 12 + note], 10);\n Serial.print(\") \");\n Serial.println(duration, 10);\n #endif\n \n tone(pin, notes[(scale - 4) * 12 + note], duration);\n \n noteDelay = millis() + (duration+1);\n }\n else\n {\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"Pausing: \");\n Serial.println(duration, 10);\n #endif\n \n noteDelay = millis() + (duration);\n }\n}\n\nvoid play()\n{\n //if done playing the song, return\n if (!playing)\n {\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"done playing...\");\n #endif\n \n return;\n }\n \n //are we still playing a note ?\n unsigned long m = millis();\n if (m < noteDelay)\n {\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"still playing a note...\");\n #endif\n \n //wait until the note is completed\n return;\n }\n\n //ready to play the next note\n if (*buffer == '\\0')\n {\n //no more notes. Reached the end of the last note\n\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"end of note...\");\n #endif\n \n //issue #6: Bug for ESP8266 environment - noTone() not called at end of sound.\n //disable sound at the end of a normal playback.\n stop();\n\n return; //end of the song\n }\n else\n {\n //more notes to play...\n\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"next note...\");\n #endif\n \n nextnote();\n }\n}\n\nvoid stop()\n{\n if (playing)\n {\n //increase song buffer until the end\n while (*buffer != '\\0')\n {\n buffer++;\n }\n\n //issue #6: Bug for ESP8266 environment - noTone() not called at end of sound.\n //disable sound if one abort playback using the stop() command.\n noTone(pin);\n\n playing = false;\n }\n}\n\nbool done()\n{\n return !playing;\n}\n\nbool isPlaying()\n{\n return playing;\n}\n\n}; //namespace\n" +} diff --git a/test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.h b/test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.h new file mode 100644 index 000000000..3b8184aa8 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.3.0/src/NonBlockingRtttl.h @@ -0,0 +1,161 @@ +// --------------------------------------------------------------------------- +// AUTHOR/LICENSE: +// The following code was written by Antoine Beauchamp. For other authors, see AUTHORS file. +// The code & updates for the library can be found at http://github.com/end2endzone/NonBlockingRTTTL +// Original source code for the RTTL player: https://code.google.com/archive/p/rogue-code/wikis/ToneLibraryDocumentation.wiki +// MIT License: http://www.opensource.org/licenses/mit-license.php +// --------------------------------------------------------------------------- + +#ifndef NonBlockingRtttl_h +#define NonBlockingRtttl_h + +#define NONBLOCKINGRTTTL_VERSION 1.3.0 + +#include "Arduino.h" + +//if notes are not already defined +#ifndef NOTE_B0 + +#define NOTE_H 0 +#define NOTE_B0 31 +#define NOTE_C1 33 +#define NOTE_CS1 35 +#define NOTE_D1 37 +#define NOTE_DS1 39 +#define NOTE_E1 41 +#define NOTE_F1 44 +#define NOTE_FS1 46 +#define NOTE_G1 49 +#define NOTE_GS1 52 +#define NOTE_A1 55 +#define NOTE_AS1 58 +#define NOTE_B1 62 +#define NOTE_C2 65 +#define NOTE_CS2 69 +#define NOTE_D2 73 +#define NOTE_DS2 78 +#define NOTE_E2 82 +#define NOTE_F2 87 +#define NOTE_FS2 93 +#define NOTE_G2 98 +#define NOTE_GS2 104 +#define NOTE_A2 110 +#define NOTE_AS2 117 +#define NOTE_B2 123 +#define NOTE_C3 131 +#define NOTE_CS3 139 +#define NOTE_D3 147 +#define NOTE_DS3 156 +#define NOTE_E3 165 +#define NOTE_F3 175 +#define NOTE_FS3 185 +#define NOTE_G3 196 +#define NOTE_GS3 208 +#define NOTE_A3 220 +#define NOTE_AS3 233 +#define NOTE_B3 247 +#define NOTE_C4 262 +#define NOTE_CS4 277 +#define NOTE_D4 294 +#define NOTE_DS4 311 +#define NOTE_E4 330 +#define NOTE_F4 349 +#define NOTE_FS4 370 +#define NOTE_G4 392 +#define NOTE_GS4 415 +#define NOTE_A4 440 +#define NOTE_AS4 466 +#define NOTE_B4 494 +#define NOTE_C5 523 +#define NOTE_CS5 554 +#define NOTE_D5 587 +#define NOTE_DS5 622 +#define NOTE_E5 659 +#define NOTE_F5 698 +#define NOTE_FS5 740 +#define NOTE_G5 784 +#define NOTE_GS5 831 +#define NOTE_A5 880 +#define NOTE_AS5 932 +#define NOTE_B5 988 +#define NOTE_C6 1047 +#define NOTE_CS6 1109 +#define NOTE_D6 1175 +#define NOTE_DS6 1245 +#define NOTE_E6 1319 +#define NOTE_F6 1397 +#define NOTE_FS6 1480 +#define NOTE_G6 1568 +#define NOTE_GS6 1661 +#define NOTE_A6 1760 +#define NOTE_AS6 1865 +#define NOTE_B6 1976 +#define NOTE_C7 2093 +#define NOTE_CS7 2217 +#define NOTE_D7 2349 +#define NOTE_DS7 2489 +#define NOTE_E7 2637 +#define NOTE_F7 2794 +#define NOTE_FS7 2960 +#define NOTE_G7 3136 +#define NOTE_GS7 3322 +#define NOTE_A7 3520 +#define NOTE_AS7 3729 +#define NOTE_B7 3951 +#define NOTE_C8 4186 +#define NOTE_CS8 4435 +#define NOTE_D8 4699 +#define NOTE_DS8 4978 + +//if notes are not already defined +#endif + +//#define RTTTL_NONBLOCKING_DEBUG +//#define RTTTL_NONBLOCKING_INFO + +namespace rtttl +{ + +/**************************************************************************** + * Description: + * begin() setups the NON-BLOCKING RTTTL library for + * decoding a new RTTTL song. + * Parameters: + * iPin: The pin which is connected to the piezo buffer. + * iSongBuffer: The string buffer of the RTTTL song. + ****************************************************************************/ +void begin(byte iPin, const char * iSongBuffer); + +/**************************************************************************** + * Description: + * play() automatically plays a new note when required. + * This function must constantly be called within the loop() function. + * Warning: inserting too long delays within the loop function may + * disrupt the NON-BLOCKING RTTTL library from playing properly. + ****************************************************************************/ +void play(); + +/**************************************************************************** + * Description: + * stop() stops playing the current song. + ****************************************************************************/ +void stop(); + +/**************************************************************************** + * Description: + * isPlaying() return true when the library is playing + * the given RTTTL song. + ****************************************************************************/ +bool isPlaying(); + +/**************************************************************************** + * Description: + * done() return true when the library is done playing + * the given RTTTL song. + ****************************************************************************/ +bool done(); + +}; //namespace + +//NonBlockingRtttl_h +#endif diff --git a/test/fixtures/rtttl_rollover/1.4.0/AUTHORS b/test/fixtures/rtttl_rollover/1.4.0/AUTHORS new file mode 100644 index 000000000..6c8f83281 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.4.0/AUTHORS @@ -0,0 +1,10 @@ +# This file contains a list of people who've made contribution to +# the project. People who commit code are encouraged to add +# their names here. Please keep the list sorted by first names. + +Antoine Beauchamp +Chris Dennis +James Brown +per1234 +Erwan d'Orgeville +Emanuele Palombo diff --git a/test/fixtures/rtttl_rollover/1.4.0/LICENSE b/test/fixtures/rtttl_rollover/1.4.0/LICENSE new file mode 100644 index 000000000..dd4add21c --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.4.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Antoine Beauchamp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/fixtures/rtttl_rollover/1.4.0/README.txt b/test/fixtures/rtttl_rollover/1.4.0/README.txt new file mode 100644 index 000000000..67ef379f0 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.4.0/README.txt @@ -0,0 +1,26 @@ +NonBlockingRTTTL 1.4.0 regression fixture + +Source: PlatformIO end2endzone/NonBlockingRTTTL 1.4.0 +Upstream: https://github.com/end2endzone/NonBlockingRTTTL +The installed library's source is vendored as ASCII JSON for offline tests. +Decode the "source" field in src/NonBlockingRtttl.cpp.json to recover the +exact reviewed upstream CPP, including its degree symbol. The header is +unchanged. LICENSE and AUTHORS retain the original MIT attribution. +No firmware code is linked from this directory. + +Canonical source normalization for hash comparison is LF line endings. +The fixture file uses a JSON escape for the single U+00B0 degree symbol in +the disabled diagnostic string "Loop n". The production private-build patch +removes that diagnostic-only symbol to make generated copies ASCII as well. +No executable statement in the unpatched fixture is changed. + +Original installed CPP SHA256: +d4fddfa663c8adeacea3b1daf56ec5fbe63ecaaf68e5477ac96dc4fb2e28d363 +Patched private CPP SHA256: +e7710c4e3309b25517f5bace697c0c2574a5ce219f3cb156d352f739c178d75f +Unchanged header SHA256: +5a33eec77e2ba53ed9b83a8242633b8ae11873d3b4f8cfd257abbb2eb61b6220 +LICENSE SHA256: +44bbc2541c595d5d8790f9b0be9a56fc88c9a500ab6bd2a88b4e00f8e28f0585 +Original AUTHORS SHA256 (trailing whitespace trimmed in copy): +5ee7d6fd1d7730b5d9184accadc45ec5ee67e57bca50ed1729d0043acf8ff303 diff --git a/test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.cpp.json b/test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.cpp.json new file mode 100644 index 000000000..ef9b12cc6 --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.cpp.json @@ -0,0 +1,3 @@ +{ + "source": "// ---------------------------------------------------------------------------\n// Created by Antoine Beauchamp based on the code from the ToneLibrary\n//\n// See \"NonBlockingRtttl.h\" for purpose, syntax, version history, links, and more.\n// ---------------------------------------------------------------------------\n\n#include \"Arduino.h\"\n#include \"NonBlockingRtttl.h\"\n\n/*********************************************************\n * RTTTL Library data\n *********************************************************/\n\nnamespace rtttl\n{\n\nconst int notes[] = { 0,\nNOTE_C4, NOTE_CS4, NOTE_D4, NOTE_DS4, NOTE_E4, NOTE_F4, NOTE_FS4, NOTE_G4, NOTE_GS4, NOTE_A4, NOTE_AS4, NOTE_B4,\nNOTE_C5, NOTE_CS5, NOTE_D5, NOTE_DS5, NOTE_E5, NOTE_F5, NOTE_FS5, NOTE_G5, NOTE_GS5, NOTE_A5, NOTE_AS5, NOTE_B5,\nNOTE_C6, NOTE_CS6, NOTE_D6, NOTE_DS6, NOTE_E6, NOTE_F6, NOTE_FS6, NOTE_G6, NOTE_GS6, NOTE_A6, NOTE_AS6, NOTE_B6,\nNOTE_C7, NOTE_CS7, NOTE_D7, NOTE_DS7, NOTE_E7, NOTE_F7, NOTE_FS7, NOTE_G7, NOTE_GS7, NOTE_A7, NOTE_AS7, NOTE_B7\n};\n\n//#define isdigit(n) (n >= '0' && n <= '9')\n#define OCTAVE_OFFSET 0\n\nconst char * buffer = \"\";\nconst char * firstNote = \"\";\nint bufferIndex = -32760;\nbyte default_dur = 4;\nbyte default_oct = 5;\nint bpm = 63;\nlong wholenote;\nbyte pin = -1;\nunsigned long noteDelay = 0; //m will always be after which means the last note is done playing\nunsigned long loopGap;\nbyte loopCount;\nbool playing = false;\n\n//pre-declaration\nvoid nextnote();\n\n#ifdef ESP32\n#ifdef ESP_ARDUINO_VERSION\n #if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)\n // Code specific to ESP32 core 3.x\n // Core 3.x manages channels automatically:\n // In all esp32 functions, input parameter 'channel' has been changed to 'pin'.\n // See official documentation for migrating from core 2.x to 3.x:\n // https://docs.espressif.com/projects/arduino-esp32/en/latest/migration_guides/2.x_to_3.0.html\n\n #define LEDC_RESOLUTION 10\n\n // Function noTone() stop the PWM signal for the given pin.\n // It does not detach the pin from it's assigned channel.\n // You can have sequential calls to tone() and noTone()\n // without having to call toneSetup() between calls.\n void noTone(uint8_t pin) {\n ledcWriteTone(pin, 0);\n }\n\n // Function tone() set a pin to output a PWM signal that matches the given frequency.\n // The duration argument is ignored. The function signature \n // matches arduino's tone() function for compatibility reasons.\n // Arduino tone() function:\n // https://docs.arduino.cc/language-reference/en/functions/advanced-io/tone/\n // ESP32 ledcWriteTone() function:\n // https://github.com/espressif/arduino-esp32/blob/2.0.17/cores/esp32/esp32-hal-ledc.c#L118\n void tone(uint8_t pin, unsigned int frq, unsigned long duration) {\n // don't care about the given duration\n ledcWriteTone(pin, frq);\n }\n\n // Function toneSetup() setup the given pin to output a PWM signal for generating tones with a piezo buzzer.\n void toneSetup(uint8_t pin) {\n ledcAttach(pin, 1000, LEDC_RESOLUTION);\n }\n \n #elif ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(2, 0, 0)\n // Code specific to ESP32 core 2.x\n // Core 2.x uses channels instead of pins:\n // In all esp32 functions, input parameter is a 'channel' instead of a given pin.\n // This library uses the function getEsp32ChannelForPin() to map a pin to a channel number.\n // This channel is then used with all esp32 apis functions.\n // See official documentation for migrating from core 2.x to 3.x:\n // https://docs.espressif.com/projects/arduino-esp32/en/latest/migration_guides/2.x_to_3.0.html\n\n #define ESP32_INVALID_CHANNEL 0xFF\n\n // Function getEsp32ChannelForPin() maps a given pin to a esp32 channel.\n // Returns a value between 0 and n where n is the maximum of channel for your board.\n // Returns ESP32_INVALID_CHANNEL if there is no assigned channel for the given pin number.\n // See your board documentation for details.\n // See https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/ledc.html#led-control-ledc\n uint8_t getEsp32ChannelForPin(uint8_t pin) {\n // This library only supports 1 pin at a time.\n // Always returns channel 0\n return 0;\n }\n\n // Function noTone() stop the PWM signal for the given pin.\n // It does not detach the pin from it's assigned channel.\n // You can have sequential calls to tone() and noTone()\n // without having to call toneSetup() between calls.\n void noTone(uint8_t pin) {\n uint8_t channel = getEsp32ChannelForPin(pin);\n ledcWriteTone(channel, 0); // Silence the buzzer without detaching\n }\n\n // Function tone() set a pin to output a PWM signal that matches the given frequency.\n // The duration argument is ignored. The function signature \n // matches arduino's tone() function for compatibility reasons.\n // Arduino tone() function:\n // https://docs.arduino.cc/language-reference/en/functions/advanced-io/tone/\n // ESP32 ledcWriteTone() function:\n // https://github.com/espressif/arduino-esp32/blob/2.0.17/cores/esp32/esp32-hal-ledc.c#L118\n void tone(uint8_t pin, unsigned int frq, unsigned long duration) {\n // don't care about the given duration\n uint8_t channel = getEsp32ChannelForPin(pin);\n ledcWriteTone(channel, frq);\n }\n\n // Function toneSetup() setup the given pin to output a PWM signal for generating tones with a piezo buzzer.\n void toneSetup(uint8_t pin) {\n uint8_t channel = getEsp32ChannelForPin(pin);\n ledcAttachPin(pin, channel); // Attach the pin to the LEDC channel\n }\n\n #else\n #error ESP32 arduino version unsupported\n #endif\n#else\n // ESP_ARDUINO_VERSION is undefined.\n #error ESP32 arduino version unsupported.\n#endif\n#endif\n\nvoid begin(byte iPin, const char * iSongBuffer, byte iLoopCount, unsigned long iLoopGap)\n{\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.print(\"playing: \");\n Serial.println(iSongBuffer);\n #endif\n \n //init values\n pin = iPin;\n #if defined(ESP32)\n toneSetup(pin);\n #endif\n buffer = iSongBuffer;\n bufferIndex = 0;\n default_dur = 4;\n default_oct = 6;\n bpm=63;\n playing = true;\n noteDelay = 0;\n loopCount = iLoopCount;\n loopGap = iLoopGap;\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.print(\"noteDelay=\");\n Serial.println(noteDelay);\n #endif\n \n //stop current note\n noTone(pin);\n\n //read buffer until first note\n int num;\n\n // format: d=N,o=N,b=NNN:\n // find the start (skip name, etc)\n\n while(*buffer != ':') buffer++; // ignore name\n buffer++; // skip ':'\n\n // get default duration\n if(*buffer == 'd')\n {\n buffer++; buffer++; // skip \"d=\"\n num = 0;\n while(isdigit(*buffer))\n {\n num = (num * 10) + (*buffer++ - '0');\n }\n if(num > 0) default_dur = num;\n buffer++; // skip comma\n }\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"ddur: \"); Serial.println(default_dur, 10);\n #endif\n \n // get default octave\n if(*buffer == 'o')\n {\n buffer++; buffer++; // skip \"o=\"\n num = *buffer++ - '0';\n if(num >= 3 && num <=7) default_oct = num;\n buffer++; // skip comma\n }\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"doct: \"); Serial.println(default_oct, 10);\n #endif\n \n // get BPM\n if(*buffer == 'b')\n {\n buffer++; buffer++; // skip \"b=\"\n num = 0;\n while(isdigit(*buffer))\n {\n num = (num * 10) + (*buffer++ - '0');\n }\n bpm = num;\n buffer++; // skip colon\n }\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"bpm: \"); Serial.println(bpm, 10);\n #endif\n\n // BPM usually expresses the number of quarter notes per minute\n wholenote = (60 * 1000L / bpm) * 4; // this is the time for whole note (in milliseconds)\n\n firstNote = buffer;\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"wn: \"); Serial.println(wholenote, 10);\n #endif\n}\n\nvoid nextnote()\n{\n long duration;\n byte note;\n byte scale;\n\n //stop current note\n noTone(pin);\n\n // first, get note duration, if available\n int num = 0;\n while(isdigit(*buffer))\n {\n num = (num * 10) + (*buffer++ - '0');\n }\n \n if(num) duration = wholenote / num;\n else duration = wholenote / default_dur; // we will need to check if we are a dotted note after\n\n // now get the note\n note = 0;\n\n switch(*buffer)\n {\n case 'c':\n note = 1;\n break;\n case 'd':\n note = 3;\n break;\n case 'e':\n note = 5;\n break;\n case 'f':\n note = 6;\n break;\n case 'g':\n note = 8;\n break;\n case 'a':\n note = 10;\n break;\n case 'b':\n note = 12;\n break;\n case 'p':\n default:\n note = 0;\n }\n buffer++;\n\n // now, get optional '#' sharp\n if(*buffer == '#')\n {\n note++;\n buffer++;\n }\n\n // now, get optional '.' dotted note\n if(*buffer == '.')\n {\n duration += duration/2;\n buffer++;\n }\n\n // now, get scale\n if(isdigit(*buffer))\n {\n scale = *buffer - '0';\n buffer++;\n }\n else\n {\n scale = default_oct;\n }\n\n scale += OCTAVE_OFFSET;\n\n // now, get optional '.' dotted note again.\n // A dot /after/ the octave is allowed too, depending on which\n // RTTTL specification you read.\n // See https://github.com/end2endzone/NonBlockingRTTTL/issues/10\n if(*buffer == '.')\n {\n duration += duration/2;\n buffer++;\n }\n\n if(*buffer == ',')\n buffer++; // skip comma for next note (or we may be at the end)\n\n // now play the note\n\n if(note)\n {\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"Playing: \");\n Serial.print(scale, 10); Serial.print(' ');\n Serial.print(note, 10); Serial.print(\" (\");\n Serial.print(notes[(scale - 4) * 12 + note], 10);\n Serial.print(\") \");\n Serial.println(duration, 10);\n #endif\n \n tone(pin, notes[(scale - 4) * 12 + note], duration);\n \n noteDelay = millis() + (duration+1);\n }\n else\n {\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"Pausing: \");\n Serial.println(duration, 10);\n #endif\n \n noteDelay = millis() + (duration);\n }\n}\n\nvoid play()\n{\n //if done playing the song, return\n if (!playing)\n {\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"done playing...\");\n #endif\n \n return;\n }\n \n //are we still playing a note ?\n unsigned long m = millis();\n if (m < noteDelay)\n {\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"still playing a note...\");\n #endif\n \n //wait until the note is completed\n return;\n }\n\n //ready to play the next note\n if (*buffer == '\\0')\n {\n //no more notes. Reached the end of the last note\n\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"end of note...\");\n #endif\n \n //issue #6: Bug for ESP8266 environment - noTone() not called at end of sound.\n //disable sound at the end of a normal playback.\n if(--loopCount) {\n noteDelay = millis() + loopGap;\n buffer = firstNote;\n\n #ifdef RTTTL_NONBLOCKING_INFO\n Serial.print(\"Loop n\u00b0\");\n Serial.print(loopCount, 10);\n Serial.print(\" with delay gap \");\n Serial.print(loopGap, 10);\n Serial.println(\"ms\");\n #endif\n }\n else stop();\n\n return; //end of the song\n }\n else\n {\n //more notes to play...\n\n #ifdef RTTTL_NONBLOCKING_DEBUG\n Serial.println(\"next note...\");\n #endif\n \n nextnote();\n }\n}\n\nvoid stop()\n{\n if (playing)\n {\n //increase song buffer until the end\n while (*buffer != '\\0')\n {\n buffer++;\n }\n\n //issue #6: Bug for ESP8266 environment - noTone() not called at end of sound.\n //disable sound if one abort playback using the stop() command.\n noTone(pin);\n\n playing = false;\n }\n}\n\nbool done()\n{\n return !playing;\n}\n\nbool isPlaying()\n{\n return playing;\n}\n\n}; //namespace\n" +} diff --git a/test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.h b/test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.h new file mode 100644 index 000000000..86582551a --- /dev/null +++ b/test/fixtures/rtttl_rollover/1.4.0/src/NonBlockingRtttl.h @@ -0,0 +1,163 @@ +// --------------------------------------------------------------------------- +// AUTHOR/LICENSE: +// The following code was written by Antoine Beauchamp. For other authors, see AUTHORS file. +// The code & updates for the library can be found at http://github.com/end2endzone/NonBlockingRTTTL +// Original source code for the RTTL player: https://code.google.com/archive/p/rogue-code/wikis/ToneLibraryDocumentation.wiki +// MIT License: http://www.opensource.org/licenses/mit-license.php +// --------------------------------------------------------------------------- + +#ifndef NonBlockingRtttl_h +#define NonBlockingRtttl_h + +#define NONBLOCKINGRTTTL_VERSION 1.4.0 + +#include "Arduino.h" + +//if notes are not already defined +#ifndef NOTE_B0 + +#define NOTE_H 0 +#define NOTE_B0 31 +#define NOTE_C1 33 +#define NOTE_CS1 35 +#define NOTE_D1 37 +#define NOTE_DS1 39 +#define NOTE_E1 41 +#define NOTE_F1 44 +#define NOTE_FS1 46 +#define NOTE_G1 49 +#define NOTE_GS1 52 +#define NOTE_A1 55 +#define NOTE_AS1 58 +#define NOTE_B1 62 +#define NOTE_C2 65 +#define NOTE_CS2 69 +#define NOTE_D2 73 +#define NOTE_DS2 78 +#define NOTE_E2 82 +#define NOTE_F2 87 +#define NOTE_FS2 93 +#define NOTE_G2 98 +#define NOTE_GS2 104 +#define NOTE_A2 110 +#define NOTE_AS2 117 +#define NOTE_B2 123 +#define NOTE_C3 131 +#define NOTE_CS3 139 +#define NOTE_D3 147 +#define NOTE_DS3 156 +#define NOTE_E3 165 +#define NOTE_F3 175 +#define NOTE_FS3 185 +#define NOTE_G3 196 +#define NOTE_GS3 208 +#define NOTE_A3 220 +#define NOTE_AS3 233 +#define NOTE_B3 247 +#define NOTE_C4 262 +#define NOTE_CS4 277 +#define NOTE_D4 294 +#define NOTE_DS4 311 +#define NOTE_E4 330 +#define NOTE_F4 349 +#define NOTE_FS4 370 +#define NOTE_G4 392 +#define NOTE_GS4 415 +#define NOTE_A4 440 +#define NOTE_AS4 466 +#define NOTE_B4 494 +#define NOTE_C5 523 +#define NOTE_CS5 554 +#define NOTE_D5 587 +#define NOTE_DS5 622 +#define NOTE_E5 659 +#define NOTE_F5 698 +#define NOTE_FS5 740 +#define NOTE_G5 784 +#define NOTE_GS5 831 +#define NOTE_A5 880 +#define NOTE_AS5 932 +#define NOTE_B5 988 +#define NOTE_C6 1047 +#define NOTE_CS6 1109 +#define NOTE_D6 1175 +#define NOTE_DS6 1245 +#define NOTE_E6 1319 +#define NOTE_F6 1397 +#define NOTE_FS6 1480 +#define NOTE_G6 1568 +#define NOTE_GS6 1661 +#define NOTE_A6 1760 +#define NOTE_AS6 1865 +#define NOTE_B6 1976 +#define NOTE_C7 2093 +#define NOTE_CS7 2217 +#define NOTE_D7 2349 +#define NOTE_DS7 2489 +#define NOTE_E7 2637 +#define NOTE_F7 2794 +#define NOTE_FS7 2960 +#define NOTE_G7 3136 +#define NOTE_GS7 3322 +#define NOTE_A7 3520 +#define NOTE_AS7 3729 +#define NOTE_B7 3951 +#define NOTE_C8 4186 +#define NOTE_CS8 4435 +#define NOTE_D8 4699 +#define NOTE_DS8 4978 + +//if notes are not already defined +#endif + +//#define RTTTL_NONBLOCKING_DEBUG +//#define RTTTL_NONBLOCKING_INFO + +namespace rtttl +{ + +/**************************************************************************** + * Description: + * begin() setups the NON-BLOCKING RTTTL library for + * decoding a new RTTTL song. + * Parameters: + * iPin: The pin which is connected to the piezo buffer. + * iSongBuffer: The string buffer of the RTTTL song. + * iLoopCount: Number of times the RTTTL song will be playback (default = 1). + * iLoopGap: Pause of milliseconds between the song repetition (default = 1000ms). + ****************************************************************************/ +void begin(byte iPin, const char * iSongBuffer, byte iLoopCount = 1, unsigned long iLoopGap = 1000); + +/**************************************************************************** + * Description: + * play() automatically plays a new note when required. + * This function must constantly be called within the loop() function. + * Warning: inserting too long delays within the loop function may + * disrupt the NON-BLOCKING RTTTL library from playing properly. + ****************************************************************************/ +void play(); + +/**************************************************************************** + * Description: + * stop() stops playing the current song. + ****************************************************************************/ +void stop(); + +/**************************************************************************** + * Description: + * isPlaying() return true when the library is playing + * the given RTTTL song. + ****************************************************************************/ +bool isPlaying(); + +/**************************************************************************** + * Description: + * done() return true when the library is done playing + * the given RTTTL song. + ****************************************************************************/ +bool done(); + +}; //namespace + +//NonBlockingRtttl_h +#endif diff --git a/test/fixtures/rtttl_rollover/Arduino.h b/test/fixtures/rtttl_rollover/Arduino.h new file mode 100644 index 000000000..4376a908f --- /dev/null +++ b/test/fixtures/rtttl_rollover/Arduino.h @@ -0,0 +1,22 @@ +#pragma once +typedef unsigned char byte; +typedef unsigned int uint32_t; +typedef int int32_t; +typedef unsigned int size_t; +typedef unsigned char uint8_t; +typedef unsigned short uint16_t; +typedef short int16_t; +typedef signed char int8_t; +typedef unsigned int uintptr_t; +#define LOW 0 +#define HIGH 1 +#define OUTPUT 1 +extern uint32_t now_ms; +extern unsigned tone_calls; +extern unsigned silence_calls; +inline uint32_t millis() { return now_ms; } +inline int isdigit(int c) { return c >= '0' && c <= '9'; } +inline void tone(byte, unsigned int, unsigned long) { ++tone_calls; } +inline void noTone(byte) { ++silence_calls; } +inline void pinMode(int, int) {} +inline void digitalWrite(int, int) {} diff --git a/test/fixtures/rtttl_rollover/HardwarePWM.h b/test/fixtures/rtttl_rollover/HardwarePWM.h new file mode 100644 index 000000000..cb7667f2c --- /dev/null +++ b/test/fixtures/rtttl_rollover/HardwarePWM.h @@ -0,0 +1,29 @@ +#pragma once +struct Sequence { uintptr_t PTR; uint32_t CNT, REFRESH, ENDDELAY; }; +struct Registers { + uint32_t INTENCLR, SHORTS, ENABLE, MODE, COUNTERTOP, PRESCALER, + DECODER, LOOP, EVENTS_STOPPED, EVENTS_LOOPSDONE, INTENSET; + Sequence SEQ[2]; + uint32_t EVENTS_SEQEND[2], EVENTS_SEQSTARTED[2], TASKS_SEQSTART[2]; +}; +static Registers registers; +#define NRF_PWM3 (®isters) +#define PWM_MODE_UPDOWN_Up 0 +#define PWM_PRESCALER_PRESCALER_DIV_1 0 +#define PWM_DECODER_LOAD_Common 0 +#define PWM_SHORTS_LOOPSDONE_SEQSTART0_Msk 4 +#define PWM_INTENSET_SEQEND0_Msk 1 +#define PWM_INTENSET_SEQEND1_Msk 2 +#define PWM3_IRQn 45 +inline void NVIC_DisableIRQ(int) {} +inline void NVIC_EnableIRQ(int) {} +inline void NVIC_ClearPendingIRQ(int) {} +inline void NVIC_SetPriority(int, int) {} +struct HardwarePWM { + bool owned; + bool takeOwnership(uint32_t) { if (owned) return false; return owned = true; } + bool addPin(int) { return true; } + void removePin(int) {} + bool releaseOwnership(uint32_t) { owned = false; return true; } +}; +static HardwarePWM HwPWM3; diff --git a/test/fixtures/rtttl_rollover/queue.cpp b/test/fixtures/rtttl_rollover/queue.cpp new file mode 100644 index 000000000..7292efbb7 --- /dev/null +++ b/test/fixtures/rtttl_rollover/queue.cpp @@ -0,0 +1,46 @@ +#define PIN_BUZZER 25 +#define MESH_GPS_VOICE 1 +#include +static_assert(sizeof(unsigned long) == 4, "Use device-width unsigned long"); +uint32_t now_ms = 0; +unsigned tone_calls = 0; +unsigned silence_calls = 0; +extern "C" void* memset(void* dst, int value, unsigned length) { + unsigned char* bytes = static_cast(dst); + for (unsigned i = 0; i < length; ++i) bytes[i] = static_cast(value); + return dst; +} +static void quit(unsigned code) { + __asm__ volatile("int $0x80" : : "a"(1), "b"(code)); + __builtin_unreachable(); +} +static void check(bool okay, unsigned line) { + if (!okay) quit(line); +} +#define CHECK(expression) check((expression), __LINE__) +extern "C" void _start() { + genericBuzzer buzzer; + buzzer.begin(); + now_ms = 0xffffffffU - 100; + CHECK(buzzer.speakGps(true)); + buzzer.loop(); + CHECK(tone_calls == 1 && buzzer.isPlaying() && !registers.ENABLE); + now_ms = 0xffffffffU - 10; + buzzer.loop(); + CHECK(tone_calls == 1 && buzzer.isPlaying()); + now_ms = 20; + buzzer.loop(); + CHECK(tone_calls == 2 && buzzer.isPlaying() && !registers.ENABLE); + now_ms += 94; buzzer.loop(); + CHECK(tone_calls == 3 && !registers.ENABLE); + now_ms += 188; buzzer.loop(); + CHECK(rtttl::done() && !registers.ENABLE); + buzzer.loop(); // Start the intentional speech gap. + now_ms += 99; buzzer.loop(); + CHECK(!registers.ENABLE); + now_ms += 1; buzzer.loop(); + CHECK(registers.ENABLE && buzzer.isPlaying() && HwPWM3.owned); + buzzer.stop(); + CHECK(!registers.ENABLE && !buzzer.isPlaying() && !HwPWM3.owned); + quit(0); +} diff --git a/test/fixtures/rtttl_rollover/regression.cpp b/test/fixtures/rtttl_rollover/regression.cpp new file mode 100644 index 000000000..e416c9d1b --- /dev/null +++ b/test/fixtures/rtttl_rollover/regression.cpp @@ -0,0 +1,110 @@ +#include +static_assert(sizeof(unsigned long) == 4, "Use device-width unsigned long"); +uint32_t now_ms = 0; +unsigned tone_calls = 0; +unsigned silence_calls = 0; +extern unsigned long noteDelay; +static const char rising[] = "Startup:d=4,o=5,b=160:16c6,16e6,8g6"; +static const char empty[] = "Empty:d=4,o=5,b=160:"; +static void quit(unsigned code) { + __asm__ volatile("int $0x80" : : "a"(1), "b"(code)); + __builtin_unreachable(); +} +static void check(bool okay, unsigned line) { + if (!okay) quit(line); +} +#define CHECK(expression) check((expression), __LINE__) + +static void advance(uint32_t amount) { + now_ms += amount; + rtttl::play(); +} +static void checkStartup(uint32_t origin) { + now_ms = origin; + tone_calls = silence_calls = 0; + rtttl::begin(25, rising); + CHECK(rtttl::isPlaying() && tone_calls == 0); + rtttl::play(); + CHECK(tone_calls == 1 && rtttl::isPlaying()); + for (unsigned i = 0; i < 4; ++i) rtttl::play(); + CHECK(tone_calls == 1 && rtttl::isPlaying()); + advance(93); + CHECK(tone_calls == 1 && rtttl::isPlaying()); + advance(1); + CHECK(tone_calls == 2 && rtttl::isPlaying()); + advance(93); + CHECK(tone_calls == 2 && rtttl::isPlaying()); + advance(1); + CHECK(tone_calls == 3 && rtttl::isPlaying()); + advance(187); + CHECK(tone_calls == 3 && rtttl::isPlaying()); + advance(1); + CHECK(tone_calls == 3 && rtttl::done()); +} +extern "C" void _start() { + // Identical note timing, including first note in the upper counter half. + for (unsigned i = 0; i < 5; ++i) { + const uint32_t origins[] = {0, 1000, 0x80000000U, 0xfffffff0U, 0xffffffffU - 100}; + checkStartup(origins[i]); + } + // A foreground loop which crosses an expired pre-wrap deadline must + // continue the melody and allow the pending control clip to start. + now_ms = 0xffffffffU - 100; + tone_calls = 0; + rtttl::begin(25, rising); + rtttl::play(); + CHECK(tone_calls == 1); + advance(90); + CHECK(tone_calls == 1); + now_ms = 20; + rtttl::play(); + CHECK(tone_calls == 2 && rtttl::isPlaying()); + advance(94); + CHECK(tone_calls == 3); + advance(188); + CHECK(rtttl::done()); + +#ifndef RTTTL_NO_LOOPS + // Repeating gap also wraps safely; no new note during the gap. + now_ms = 0xffffffffU - 500; + tone_calls = 0; + rtttl::begin(25, rising, 2, 1000); + rtttl::play(); advance(94); advance(94); advance(188); + CHECK(rtttl::isPlaying() && tone_calls == 3); + advance(999); + CHECK(tone_calls == 3); + advance(1); + CHECK(tone_calls == 4); + advance(94); advance(94); advance(188); + CHECK(rtttl::done() && tone_calls == 6); +#endif + + // A rest uses the duration directly, without the audible note's extra ms. + now_ms = 0xfffffff0U; + tone_calls = 0; + rtttl::begin(25, "Rest:d=4,o=5,b=160:16p,16c6"); + rtttl::play(); + CHECK(tone_calls == 0 && rtttl::isPlaying()); + advance(92); + CHECK(tone_calls == 0 && rtttl::isPlaying()); + advance(1); + CHECK(tone_calls == 1 && rtttl::isPlaying()); + advance(93); + CHECK(rtttl::isPlaying()); + advance(1); + CHECK(rtttl::done()); + + // Empty melody, stop, and replacement do not remain artificially busy. + now_ms = 0xfffffff0U; + tone_calls = 0; + rtttl::begin(25, empty); + rtttl::play(); CHECK(rtttl::done() && tone_calls == 0); + rtttl::begin(25, rising); rtttl::play(); + CHECK(tone_calls == 1); + rtttl::stop(); CHECK(rtttl::done()); + advance(500); CHECK(tone_calls == 1); + rtttl::begin(25, rising); rtttl::play(); + CHECK(tone_calls == 2 && rtttl::isPlaying()); + rtttl::stop(); + quit(0); +} diff --git a/test/fixtures/rtttl_rollover/stdint.h b/test/fixtures/rtttl_rollover/stdint.h new file mode 100644 index 000000000..354d1bd64 --- /dev/null +++ b/test/fixtures/rtttl_rollover/stdint.h @@ -0,0 +1,2 @@ +#pragma once +#include "Arduino.h" diff --git a/test/test_rtttl_rollover.py b/test/test_rtttl_rollover.py new file mode 100644 index 000000000..e7b5cdd4e --- /dev/null +++ b/test/test_rtttl_rollover.py @@ -0,0 +1,232 @@ +"""Exercise reviewed real RTTTL dependencies with actual 32-bit clock widths. + +The freestanding ELF32 binaries do not require 32-bit libc/libstdc++ packages. +Only the clock, pins, IRQ controller and PWM registers are mocked. The queue +test compiles the real buzzer, decoder, clip catalog and voice-player sources. +""" +from pathlib import Path +import importlib.util +import json +import platform +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures/rtttl_rollover" +SPEC = importlib.util.spec_from_file_location("rtttl_rollover_fix", ROOT / "scripts/rtttl_rollover_fix.py") +FIX = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FIX) +CXX = shutil.which("g++") +VERSIONS = ("1.3.0", "1.4.0") +PINS = { + "1.3.0": ( + "b50c159c8806d177398972bf8f94ca762532a855b60cb1d3aa9c3ebf021423ca", + "abea0a4870e58222fdefd1e88d291136518912e83880af30d1c85d39d7bd5cc1", + "29204341d99b8adeef501c0d4cb47a4137534ebe6a30f2e6b3032d558aeef802", + ), + "1.4.0": ( + "d4fddfa663c8adeacea3b1daf56ec5fbe63ecaaf68e5477ac96dc4fb2e28d363", + "5a33eec77e2ba53ed9b83a8242633b8ae11873d3b4f8cfd257abbb2eb61b6220", + "e7710c4e3309b25517f5bace697c0c2574a5ce219f3cb156d352f739c178d75f", + ), +} + + +def source_for(version): + path = FIXTURES / version / "src/NonBlockingRtttl.cpp.json" + return json.loads(path.read_text(encoding="ascii"))["source"] + + +def header_for(version): + return (FIXTURES / version / "src/NonBlockingRtttl.h").read_text(encoding="ascii") + + +class Node: + def __init__(self, path): + self.path = Path(path) + + def srcnode(self): + return self + + def get_abspath(self): + return str(self.path) + + +class BuildEnv: + def __init__(self, build): + self.build = Path(build) + self.registrations = [] + + def subst(self, value): + assert value == "$BUILD_DIR" + return str(self.build) + + def File(self, value): + return Path(value) + + def AddBuildMiddleware(self, callback, pattern): + self.registrations.append((callback, pattern)) + + +class TransformTests(unittest.TestCase): + def test_fixtures_are_ascii_licensed_and_exact_reviewed_bytes_after_json_decode(self): + for path in FIXTURES.rglob("*"): + if path.is_file(): + path.read_text(encoding="ascii") + for version in VERSIONS: + with self.subTest(version=version): + source, header = source_for(version), header_for(version) + expected_source, expected_header, _ = PINS[version] + self.assertEqual(FIX.digest(source), expected_source) + self.assertEqual(FIX.digest(header), expected_header) + self.assertIn("#define NONBLOCKINGRTTTL_VERSION " + version, header) + license_text = (FIXTURES / version / "LICENSE").read_text(encoding="ascii") + self.assertIn("MIT License", license_text) + self.assertIn("Copyright (c) 2018 Antoine Beauchamp", license_text) + self.assertIn("Permission is hereby granted, free of charge", license_text) + self.assertIn("THE SOFTWARE IS PROVIDED", license_text) + self.assertIn("Antoine Beauchamp", (FIXTURES / version / "AUTHORS").read_text()) + + def test_both_reviewed_versions_raw_crlf_idempotence_and_expected_patch_digest(self): + for version in VERSIONS: + with self.subTest(version=version): + original = source_for(version) + patched = FIX.patched_source(original) + self.assertEqual(FIX.digest(patched), PINS[version][2]) + patched.encode("ascii") + self.assertEqual(FIX.patched_source(original.replace("\n", "\r\n")), patched) + self.assertEqual(FIX.patched_source(patched), patched) + self.assertEqual(FIX.patched_source(patched.replace("\n", "\r\n")), patched) + self.assertEqual(patched.count(FIX.PATCHED_BEGIN), 1) + self.assertEqual(patched.count(FIX.PATCHED_COMPARE), 1) + + def test_unknown_changed_or_half_patched_sources_fail_closed(self): + for version in VERSIONS: + original = source_for(version) + for changed in ( + original + "\n", original.replace("bpm=63", "bpm=64", 1), + original.replace(FIX.ORIGINAL_BEGIN, FIX.PATCHED_BEGIN, 1), + original.replace(FIX.ORIGINAL_COMPARE, FIX.PATCHED_COMPARE, 1), + ): + with self.subTest(version=version), self.assertRaisesRegex(RuntimeError, "changed reviewed source"): + FIX.patched_source(changed) + + def test_middleware_registers_only_expected_source_filter(self): + env = BuildEnv("unused") + FIX.install(env) + self.assertEqual(env.registrations, [(FIX.replace_rtttl_source, "*NonBlockingRtttl.cpp")]) + + def test_unrelated_source_is_unchanged_and_not_read(self): + with tempfile.TemporaryDirectory() as temporary: + env = BuildEnv(Path(temporary) / "build") + for path in ("src/not_rtttl.cpp", "other/NonBlockingRtttl.cpp"): + node = Node(Path(temporary) / path) + self.assertIs(FIX.replace_rtttl_source(env, node), node) + self.assertFalse(env.build.exists()) + + def test_private_source_and_header_are_paired_idempotent_and_leave_shared_files_unchanged(self): + for version in VERSIONS: + for crlf in (False, True): + with self.subTest(version=version, crlf=crlf), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + source = directory / "libdeps/NonBlockingRTTTL/src/NonBlockingRtttl.cpp" + source.parent.mkdir(parents=True) + original, header = source_for(version), header_for(version) + if crlf: + original, header = original.replace("\n", "\r\n"), header.replace("\n", "\r\n") + source.write_bytes(original.encode("utf-8")) + source.with_suffix(".h").write_bytes(header.encode("ascii")) + before = (source.read_bytes(), source.with_suffix(".h").read_bytes()) + env = BuildEnv(directory / "build") + destination = FIX.replace_rtttl_source(env, Node(source)) + self.assertEqual(destination, env.build / "patched-rtttl/NonBlockingRtttl.cpp") + self.assertEqual(FIX.digest(destination.read_text(encoding="ascii")), PINS[version][2]) + self.assertEqual(destination.with_suffix(".h").read_text(encoding="ascii"), header_for(version)) + times = (destination.stat().st_mtime_ns, destination.with_suffix(".h").stat().st_mtime_ns) + self.assertEqual(FIX.replace_rtttl_source(env, Node(source)), destination) + self.assertEqual(times, (destination.stat().st_mtime_ns, destination.with_suffix(".h").stat().st_mtime_ns)) + self.assertEqual(before, (source.read_bytes(), source.with_suffix(".h").read_bytes())) + + def test_missing_unknown_or_wrong_version_headers_fail_before_creating_private_output(self): + for version in VERSIONS: + other = "1.4.0" if version == "1.3.0" else "1.3.0" + for header in (None, header_for(version) + "\n", header_for(other), "// unknown\n", "\u00b0"): + with self.subTest(version=version, header=header is None), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + source = directory / "libdeps/NonBlockingRTTTL/src/NonBlockingRtttl.cpp" + source.parent.mkdir(parents=True) + source.write_text(source_for(version), encoding="utf-8") + if header is not None: + source.with_suffix(".h").write_text(header, encoding="utf-8") + env = BuildEnv(directory / "build") + with self.assertRaisesRegex(RuntimeError, "reviewed header"): + FIX.replace_rtttl_source(env, Node(source)) + self.assertFalse(env.build.exists()) + + +@unittest.skipUnless(CXX and platform.system() == "Linux" and platform.machine() in ("x86_64", "i386", "i686"), + "Freestanding ELF32 tests require Linux x86 and g++") +class RuntimeTests(unittest.TestCase): + def build_run(self, dependency, version, fixture): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + source = directory / "NonBlockingRtttl.cpp" + source.write_text(dependency, encoding="utf-8") + source.with_suffix(".h").write_text(header_for(version), encoding="ascii") + binary = directory / "case" + flags = ["-DRTTTL_NO_LOOPS=1"] if version == "1.3.0" else [] + subprocess.run([ + CXX, "-m32", "-std=c++17", "-Wall", "-Wextra", "-Werror", *flags, + "-fno-exceptions", "-fno-rtti", "-fno-stack-protector", "-nostdlib", "-no-pie", + "-I" + str(FIXTURES), "-I" + str(directory), "-I" + str(ROOT / "src"), + str(FIXTURES / fixture), str(source), "-o", str(binary), + ], check=True, capture_output=True, timeout=30) + self.assertEqual(binary.read_bytes()[:5], b"\x7fELF\x01") + return subprocess.run([str(binary)], capture_output=True, timeout=10).returncode + + def test_actual_unpatched_versions_reproduce_both_semantic_rollover_failures(self): + for version in VERSIONS: + with self.subTest(version=version): + self.assertEqual(self.build_run(source_for(version), version, "regression.cpp"), 30) + self.assertEqual(self.build_run(source_for(version), version, "queue.cpp"), 33) + + def test_patched_versions_keep_tempo_empty_stop_replacement_and_real_voice_queue(self): + for version in VERSIONS: + with self.subTest(version=version): + patched = FIX.patched_source(source_for(version)) + self.assertEqual(self.build_run(patched, version, "regression.cpp"), 0) + self.assertEqual(self.build_run(patched, version, "queue.cpp"), 0) + + def test_each_timing_fix_is_required_not_merely_accepted_by_source_checks(self): + for version in VERSIONS: + with self.subTest(version=version): + original = source_for(version) + initial_only = original.replace(FIX.ORIGINAL_BEGIN, FIX.PATCHED_BEGIN, 1) + signed_only = original.replace(FIX.ORIGINAL_COMPARE, FIX.PATCHED_COMPARE, 1) + self.assertEqual(self.build_run(initial_only, version, "regression.cpp"), 30) + self.assertEqual(self.build_run(signed_only, version, "regression.cpp"), 28) + + def test_private_cpp_quotes_its_paired_header_even_with_poison_include_path_first(self): + for version in VERSIONS: + with self.subTest(version=version), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + original = directory / "libdeps/NonBlockingRTTTL/src/NonBlockingRtttl.cpp" + original.parent.mkdir(parents=True) + original.write_text(source_for(version), encoding="utf-8") + original.with_suffix(".h").write_text(header_for(version), encoding="ascii") + destination = FIX.replace_rtttl_source(BuildEnv(directory / "build"), Node(original)) + poison = directory / "poison" + poison.mkdir() + (poison / "NonBlockingRtttl.h").write_text('#error "wrong header selected"\n', encoding="ascii") + subprocess.run([ + CXX, "-m32", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fno-exceptions", "-fno-rtti", "-fno-stack-protector", + "-I" + str(poison), "-I" + str(FIXTURES), + "-c", str(destination), "-o", str(directory / "dependency.o"), + ], check=True, capture_output=True, timeout=30) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_rtttl_rollover_recipes.py b/test/test_rtttl_rollover_recipes.py new file mode 100644 index 000000000..60153ab1b --- /dev/null +++ b/test/test_rtttl_rollover_recipes.py @@ -0,0 +1,88 @@ +"""Resolve tracked RTTTL build hooks, including synthesized Full Companions.""" +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +from platformio.project.config import ProjectConfig + +ROOT = Path(__file__).resolve().parents[1] +HOOK = "pre:scripts/rtttl_rollover_fix.py" + + +class RtttlRolloverRecipeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Ignore developer-local overlays when checking the tracked matrix. + cls.config = ProjectConfig(str(ROOT / "platformio.ini"), parse_extra=False) + for path in sorted((ROOT / "variants").glob("*/platformio.ini")): + cls.config.read(str(path), parse_extra=False) + + def has_rtttl(self, section): + return any("NonBlockingRTTTL" in dep + for dep in self.config.get(section, "lib_deps", [])) + + def test_every_resolved_rtttl_recipe_loads_one_fix(self): + tested = [] + for section in self.config.sections(): + if not section.startswith("env:") or not self.has_rtttl(section): + continue + with self.subTest(target=section): + self.assertEqual(self.config.get(section, "extra_scripts", []).count(HOOK), 1) + tested.append(section) + self.assertGreater(len(tested), 70) + self.assertIn("env:t1000e_companion_radio_usb", tested) + self.assertIn("env:heltec_v4_r8_tft_companion_radio_ble", tested) + + def test_all_arduino_platform_bases_propagate_fix(self): + for base in ("esp32_base", "nrf52_base", "rp2040_base", "stm32_base"): + with self.subTest(base=base): + self.assertEqual(self.config.get(base, "extra_scripts", []).count(HOOK), 1) + + def test_actual_builder_synthesized_full_targets_retain_their_base_hook(self): + # Feed resolved tracked options into the real builder's target mapper. + # No PlatformIO command, dependency download, compilation or flashing. + options = ["build_flags", "lib_deps", "build_src_filter", "board", "extra_scripts"] + records = [[section, [[key, self.config.get(section, key, [])] for key in options]] + for section in self.config.sections() if section.startswith("env:")] + shell = r''' +source "$1/build_legacy.sh" +pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; } +ALL_PIO_ENVS=(fixture) +PIO_CONFIG_JSON=$(<"$2") +init_project_context +for target in "${SUPPORTED_PIO_ENVS[@]}"; do + if is_companion_radio_full_target "$target"; then + printf '%s\t%s\n' "$target" "${PIO_ENV_BUILD_BASE_BY_NAME[$target]:-$target}" + fi +done +''' + with tempfile.TemporaryDirectory(prefix="rtttl-recipes-") as temporary: + path = Path(temporary) / "config.json" + path.write_text(json.dumps(records, ensure_ascii=True), encoding="ascii") + result = subprocess.run(["bash", "-c", shell, "rtttl-recipes", str(ROOT), str(path)], + cwd=ROOT, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr) + pairs = dict(line.split("\t") for line in result.stdout.splitlines()) + self.assertIn("t1000e_companion_radio_full", pairs) + self.assertIn("heltec_v4_r8_tft_companion_radio_full", pairs) + tested = [] + for target, base in pairs.items(): + section = "env:" + base + if not self.has_rtttl(section): + continue + with self.subTest(target=target, base=base): + self.assertEqual(self.config.get(section, "extra_scripts", []).count(HOOK), 1) + tested.append(target) + self.assertGreater(len(tested), 20) + + def test_regression_suites_are_required_in_ci(self): + workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text(encoding="ascii") + self.assertIn("python3 -B test/test_rtttl_rollover.py -v", workflow) + self.assertIn("python3 -B test/test_rtttl_rollover_recipes.py -v", workflow) + + +if __name__ == "__main__": + unittest.main()