From a16229a3eb5f085adbcc26b31498b61e489d9540 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 20:34:50 -0700 Subject: [PATCH] Bound GPS UTC fields and accept variable time precision --- .github/workflows/run-unit-tests.yml | 2 + platformio.ini | 5 + scripts/micronmea_time_fix.py | 148 +++++ test/fixtures/micronmea_time_fix/LICENSE | 504 ++++++++++++++++++ test/fixtures/micronmea_time_fix/README.txt | 18 + test/fixtures/micronmea_time_fix/runtime.cpp | 235 ++++++++ .../micronmea_time_fix/src/MicroNMEA.cpp | 383 +++++++++++++ .../micronmea_time_fix/src/MicroNMEA.h | 321 +++++++++++ .../test_gps_clock_freshness.cpp | 34 +- test/test_micronmea_time_fix.py | 204 +++++++ test/test_micronmea_time_fix_recipes.py | 98 ++++ 11 files changed, 1934 insertions(+), 18 deletions(-) create mode 100644 scripts/micronmea_time_fix.py create mode 100644 test/fixtures/micronmea_time_fix/LICENSE create mode 100644 test/fixtures/micronmea_time_fix/README.txt create mode 100644 test/fixtures/micronmea_time_fix/runtime.cpp create mode 100644 test/fixtures/micronmea_time_fix/src/MicroNMEA.cpp create mode 100644 test/fixtures/micronmea_time_fix/src/MicroNMEA.h create mode 100644 test/test_micronmea_time_fix.py create mode 100644 test/test_micronmea_time_fix_recipes.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 572f6c853..c7d0c1b8f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -578,6 +578,8 @@ jobs: python3 -B test/test_esp32_gps_discovery.py -v python3 -B test/test_gps_sync_interval.py -v python3 -B test/test_rak_gps_clock_freshness.py -v + python3 -B test/test_micronmea_time_fix.py -v + python3 -B test/test_micronmea_time_fix_recipes.py -v python3 -B test/test_gps_sync_preferences.py -v python3 -B test/test_rak_gps_power.py -v python3 -B test/test_rak_shutdown_power.py -v diff --git a/platformio.ini b/platformio.ini index 42aaf84c6..5222e3767 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/micronmea_time_fix.py pre:scripts/rtttl_rollover_fix.py pre:scripts/generate_webconfig_html.py pre:scripts/meshcore_image_identity.py @@ -163,6 +164,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 extra_scripts = create-uf2.py pre:scripts/client_acl_role.py + pre:scripts/micronmea_time_fix.py pre:scripts/rtttl_rollover_fix.py pre:scripts/ota_device_deflate_role.py pre:scripts/nrf52_usb_power_fix.py @@ -341,6 +343,7 @@ lib_deps = ${nrf52_lora_ota.lib_deps} [rp2040_base] extends = arduino_base extra_scripts = pre:scripts/client_acl_role.py + pre:scripts/micronmea_time_fix.py pre:scripts/rtttl_rollover_fix.py post:scripts/check_firmware_ram.py upload_protocol = picotool @@ -362,6 +365,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/micronmea_time_fix.py pre:scripts/rtttl_rollover_fix.py post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} @@ -475,6 +479,7 @@ lib_deps = [env:native] platform = native test_framework = googletest +extra_scripts = pre:scripts/micronmea_time_fix.py ; Exercise the real Arduino GPS parser against the native Stream mock. lib_compat_mode = off build_flags = -std=c++17 diff --git a/scripts/micronmea_time_fix.py b/scripts/micronmea_time_fix.py new file mode 100644 index 000000000..bb8869a87 --- /dev/null +++ b/scripts/micronmea_time_fix.py @@ -0,0 +1,148 @@ +"""Fix reviewed MicroNMEA UTC parsing in private build copies, not libdeps. + +MicroNMEA 2.0.6 skips nine characters for every time field, rejecting valid +integer seconds and one-digit fractions. Its fixed-width date/time reads can +also pass field boundaries. Scan and validate fields before numeric reads; +keep framing, raw sentences, position validity and the public ABI unchanged. +The provider still owns calendar validation and fresh-observation admission. +""" +import hashlib +from pathlib import Path + + +SOURCE_SHA256 = "925da47a9aaa75c43c786ffb5a6530544283dda5138cd7792c4e8f79c8238377" +HEADER_SHA256 = "d87810b25ff920fe5810b8bf7be17c69eb608853407be9da82214ef5616f1fb2" +PATCHED_SHA256 = "df2e660e79235e317ec00b86ec86883655a70a60a38b11ac2eb60189036fc9e0" + +ORIGINAL_TIME = """const char* MicroNMEA::parseTime(const char* s) +{ +\tif (*s == ',') +\t\treturn skipField(s); +\t_hour = parseUnsignedInt(s, 2); +\t_minute = parseUnsignedInt(s + 2, 2); +\t_second = parseUnsignedInt(s + 4, 2); +\t_hundredths = parseUnsignedInt(s + 7, 2); +\treturn skipField(s + 9); +}""" + +PATCHED_TIME = """const char* MicroNMEA::parseTime(const char* s) +{ +\t_hour = _minute = _second = 99; +\t_hundredths = 0; +\tconst char* end = s; +\twhile (*end != ',' && !isEndOfFields(*end)) +\t\t++end; +\tif (end - s < 6) +\t\treturn skipField(end); +\tfor (const char* digit = s; digit < s + 6; ++digit) +\t\tif (*digit < '0' || *digit > '9') +\t\t\treturn skipField(end); + +\tuint8_t hundredths = 0; +\tif (end - s > 6) { +\t\tif (s[6] != '.' || end - s < 8) +\t\t\treturn skipField(end); +\t\tfor (const char* digit = s + 7; digit < end; ++digit) { +\t\t\tif (*digit < '0' || *digit > '9') +\t\t\t\treturn skipField(end); +\t\t\tif (digit == s + 7) +\t\t\t\thundredths = (*digit - '0') * 10; +\t\t\telse if (digit == s + 8) +\t\t\t\thundredths += *digit - '0'; +\t\t} +\t} +\t_hour = parseUnsignedInt(s, 2); +\t_minute = parseUnsignedInt(s + 2, 2); +\t_second = parseUnsignedInt(s + 4, 2); +\t_hundredths = hundredths; +\treturn skipField(end); +}""" + +ORIGINAL_DATE = """const char* MicroNMEA::parseDate(const char* s) +{ +\tif (*s == ',') +\t\treturn skipField(s); +\t_day = parseUnsignedInt(s, 2); +\t_month = parseUnsignedInt(s + 2, 2); +\t_year = parseUnsignedInt(s + 4, 2) + 2000; +\treturn skipField(s + 6); +}""" + +PATCHED_DATE = """const char* MicroNMEA::parseDate(const char* s) +{ +\t_year = _month = _day = 0; +\tconst char* end = s; +\twhile (*end != ',' && !isEndOfFields(*end)) +\t\t++end; +\tif (end - s != 6) +\t\treturn skipField(end); +\tfor (const char* digit = s; digit < end; ++digit) +\t\tif (*digit < '0' || *digit > '9') +\t\t\treturn skipField(end); +\t_day = parseUnsignedInt(s, 2); +\t_month = parseUnsignedInt(s + 2, 2); +\t_year = parseUnsignedInt(s + 4, 2) + 2000; +\treturn skipField(end); +}""" + +PATCH_NOTICE = ( + "// MeshCore local changes (2026-10-17): bounded UTC fields and variable-width\n" + "// time fractions. Original MicroNMEA 2.0.6 by Steve Marple, GNU LGPL v2.1.\n" +) + + +def digest(source): + return hashlib.sha256(source.encode("ascii")).hexdigest() + + +def patched_source(source): + source = source.replace("\r\n", "\n") + source_hash = digest(source) + if source_hash == PATCHED_SHA256: + return source + if source_hash != SOURCE_SHA256: + raise RuntimeError("MicroNMEA UTC fix: changed reviewed source; review dependency update") + if (source.count(ORIGINAL_TIME) != 1 or source.count(ORIGINAL_DATE) != 1 + or source.count("#include ") != 1): + raise RuntimeError("MicroNMEA UTC fix: changed reviewed field parsers") + source = PATCH_NOTICE + source.replace("#include ", '#include "MicroNMEA.h"', 1) + source = source.replace(ORIGINAL_TIME, PATCHED_TIME, 1) + source = source.replace(ORIGINAL_DATE, PATCHED_DATE, 1) + if digest(source) != PATCHED_SHA256: + raise RuntimeError("MicroNMEA UTC fix: unexpected transform result") + 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_micronmea_source(build_env, node): + source = Path(node.srcnode().get_abspath()) + if source.name != "MicroNMEA.cpp" or source.parent.name != "src": + return node + original = source.read_text(encoding="ascii") + patched = patched_source(original) + try: + header = source.with_suffix(".h").read_text(encoding="ascii").replace("\r\n", "\n") + except (OSError, UnicodeError) as error: + raise RuntimeError("MicroNMEA UTC fix: missing or changed reviewed header") from error + if digest(header) != HEADER_SHA256: + raise RuntimeError("MicroNMEA UTC fix: changed reviewed header; review dependency update") + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-micronmea" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + # A quoted include binds the source to its reviewed header even if a mock + # or unrelated library has placed another MicroNMEA.h earlier in CPPPATH. + 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_micronmea_source, "*MicroNMEA.cpp") + + +if "Import" in globals(): + Import("env") + install(env) diff --git a/test/fixtures/micronmea_time_fix/LICENSE b/test/fixtures/micronmea_time_fix/LICENSE new file mode 100644 index 000000000..19e307187 --- /dev/null +++ b/test/fixtures/micronmea_time_fix/LICENSE @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +(This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.) + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + {signature of Ty Coon}, 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/test/fixtures/micronmea_time_fix/README.txt b/test/fixtures/micronmea_time_fix/README.txt new file mode 100644 index 000000000..0fd71ec22 --- /dev/null +++ b/test/fixtures/micronmea_time_fix/README.txt @@ -0,0 +1,18 @@ +MicroNMEA 2.0.6 regression fixtures +================================= +The src/MicroNMEA.cpp and src/MicroNMEA.h files are exact original sources from +the project's existing stevemarple/MicroNMEA 2.0.6 native dependency. +Author: Steve Marple +Upstream: https://github.com/stevemarple/MicroNMEA +License: GNU Lesser General Public License version 2.1, included in LICENSE. +Only the license file's trailing blank line was omitted; its text is unchanged. + +Original source SHA-256: +925da47a9aaa75c43c786ffb5a6530544283dda5138cd7792c4e8f79c8238377 +Original header SHA-256: +d87810b25ff920fe5810b8bf7be17c69eb608853407be9da82214ef5616f1fb2 + +runtime.cpp is MeshCore test code. It compiles the actual complete dependency +against the existing portable Arduino/Stream mocks. It does not reproduce a +simplified parser. The patched dependency source is generated privately using +scripts/micronmea_time_fix.py; the original fixtures remain unchanged. diff --git a/test/fixtures/micronmea_time_fix/runtime.cpp b/test/fixtures/micronmea_time_fix/runtime.cpp new file mode 100644 index 000000000..84b66191d --- /dev/null +++ b/test/fixtures/micronmea_time_fix/runtime.cpp @@ -0,0 +1,235 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void require(bool condition, const char* diagnostic) { + if (!condition) { + std::cerr << "SEMANTIC: " << diagnostic << "\n"; + std::exit(42); + } +} + +std::string sentence(const std::string& body) { + const std::string line = "$" + body; + char checksum[3] = {}; + MicroNMEA::generateChecksum(line.c_str(), checksum); + const std::string result = line + "*" + checksum; + require(MicroNMEA::testChecksum(result.c_str()), "fixture checksum"); + return result; +} + +std::string rmc(const std::string& time, const std::string& date = "171026", + bool terminal_date = false) { + return sentence("GPRMC," + time + + ",A,4807.038,N,01131.000,E,0.0,0.0," + date + + (terminal_date ? "" : ",,,A")); +} + +std::string gga(const std::string& time) { + return sentence("GPGGA," + time + + ",4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"); +} + +void feed(MicroNMEA& parser, const std::string& raw) { + for (char c : raw) parser.process(c); + parser.process('\r'); +} + +void seed(MicroNMEA& parser) { + feed(parser, gga("120000.00")); + feed(parser, rmc("120000.00")); + require(parser.isValid() && parser.getNumSatellites() == 8, + "valid fixture seed"); + require(parser.getYear() == 2026 && parser.getMonth() == 10 && + parser.getDay() == 17, "valid fixture seed date"); +} + +void checkTime(const MicroNMEA& parser, uint8_t hundredths) { + require(parser.getHour() == 12 && parser.getMinute() == 34 && + parser.getSecond() == 56, "parsed UTC time changed"); + require(parser.getHundredths() == hundredths, "hundredths pad/truncate"); +} + +void formats() { + const std::vector> inputs = { + {"123456", 0}, {"123456.1", 10}, {"123456.12", 12}, + {"123456.123456", 12}, {"123456.0", 0}, {"123456.09", 9}}; + for (const auto& input : inputs) { + char buffer[100] = {}; + MicroNMEA parser(buffer, sizeof(buffer)); + seed(parser); + const std::string raw_rmc = rmc(input.first); + feed(parser, raw_rmc); + require(parser.isValid(), "RMC short time format lost validity"); + checkTime(parser, input.second); + require(parser.getLatitude() == 48117300 && + parser.getLongitude() == 11516666, "RMC location changed"); + require(std::string(parser.getSentence()) == raw_rmc, "RMC raw changed"); + const std::string raw_gga = gga(input.first); + feed(parser, raw_gga); + require(parser.isValid() && parser.getNumSatellites() == 8, + "GGA short time format lost validity or satellites"); + checkTime(parser, input.second); + require(std::string(parser.getSentence()) == raw_gga, "GGA raw changed"); + } +} + +void invalidFields() { + const std::vector dates = { + "", "1", "12", "17102", "17102x", "171026x"}; + for (const auto& date : dates) { + char buffer[100] = {}; + MicroNMEA parser(buffer, sizeof(buffer)); + seed(parser); + const std::string raw = rmc("123456.12", date); + feed(parser, raw); + require(parser.getYear() == 0 && parser.getMonth() == 0 && + parser.getDay() == 0, "incomplete date retained old UTC date"); + checkTime(parser, 12); + require(parser.isValid() && parser.getNumSatellites() == 8 && + parser.getLatitude() == 48117300, + "date rejection destroyed position/satellite state"); + require(std::string(parser.getSentence()) == raw, "bad-date raw changed"); + feed(parser, rmc("123456.12")); + require(parser.getYear() == 2026 && parser.getMonth() == 10 && + parser.getDay() == 17, "date recovery"); + } + const std::vector times = { + "", "1", "12", "12345", "12345x", "123456.", + "123456.x", "123456.1x"}; + for (const auto& time : times) { + for (bool use_gga : {false, true}) { + char buffer[100] = {}; + MicroNMEA parser(buffer, sizeof(buffer)); + seed(parser); + const std::string raw = use_gga ? gga(time) : rmc(time); + feed(parser, raw); + require(parser.getHour() == 99 && parser.getMinute() == 99 && + parser.getSecond() == 99 && parser.getHundredths() == 0, + "incomplete time retained old UTC time"); + require(parser.isValid() && parser.getNumSatellites() == 8 && + parser.getLatitude() == 48117300, + "time rejection destroyed position/satellite state"); + require(std::string(parser.getSentence()) == raw, "bad-time raw changed"); + feed(parser, rmc("123456.12")); + checkTime(parser, 12); + } + } +} + +void terminalDate() { + char buffer[100] = {}; + MicroNMEA parser(buffer, sizeof(buffer)); + seed(parser); + const std::string raw = rmc("123456.12", "181026", true); + feed(parser, raw); + require(parser.isValid() && parser.getYear() == 2026 && + parser.getMonth() == 10 && parser.getDay() == 18, + "valid terminal RMC date rejected"); + require(std::string(parser.getSentence()) == raw, "terminal-date raw changed"); +} + +unsigned bad_count = 0, unknown_count = 0; +void badChecksum(MicroNMEA&) { ++bad_count; } +void unknownSentence(MicroNMEA&) { ++unknown_count; } + +class Output : public Stream { +public: + std::string bytes; + size_t write(uint8_t c) override { + bytes += static_cast(c); + return 1; + } +}; + +void callbacks() { + char buffer[100] = {}; + MicroNMEA parser(buffer, sizeof(buffer)); + parser.setBadChecksumHandler(badChecksum); + parser.setUnknownSentenceHandler(unknownSentence); + seed(parser); + std::string bad = rmc("235959.00", "181026"); + const size_t checksum = bad.find('*') + 1; + bad[checksum] = bad[checksum] == '0' ? '1' : '0'; + require(!MicroNMEA::testChecksum(bad.c_str()), "bad fixture checksum"); + feed(parser, bad); + require(bad_count == 1 && unknown_count == 0, "checksum callback changed"); + require(parser.getHour() == 12 && parser.getDay() == 17, + "bad checksum changed valid state"); + require(std::string(parser.getSentence()) == bad, "bad-checksum raw changed"); + parser.process('\n'); + require(bad_count == 1 && parser.getSentence()[0] == '\0', + "CRLF duplicated checksum callback"); + const std::string other = sentence("GPTXT,01,01,02,unchanged"); + feed(parser, other); + require(unknown_count == 1 && bad_count == 1, "unknown callback changed"); + require(std::string(parser.getSentence()) == other, "unknown raw changed"); + require(std::string(parser.getMessageID()) == "TXT" && + parser.getTalkerID() == 'P', "message identifiers changed"); + parser.process('\n'); + parser.process('\r'); + parser.process('\n'); + require(unknown_count == 1 && bad_count == 1, "empty CRLF callback"); + Output output; + MicroNMEA::sendSentence(output, "$PCAS04,7"); + require(output.bytes == sentence("PCAS04,7") + "\r\n", + "sendSentence framing changed"); +} + +void boundedBuffer() { + std::unique_ptr buffer(new char[16]()); + MicroNMEA parser(buffer.get(), 16); + const std::string long_input = "$GPTXT," + std::string(200, 'x') + "*00"; + feed(parser, long_input); + require(std::strlen(parser.getSentence()) == 15, + "bounded parser buffer length changed"); + require(parser.getSentence()[15] == '\0', "buffer terminator missing"); + parser.process('\n'); + feed(parser, sentence("GPTXT,x")); + require(std::strlen(parser.getSentence()) < 16, "buffer did not recover"); +} + +void nearBufferDate() { + // Date starts at index 95; "1*HH" ends at 98, NUL at 99. Original 2.0.6 + // unconditionally reads six date digits and crosses the exact heap buffer. + std::unique_ptr buffer(new char[100]()); + MicroNMEA parser(buffer.get(), 100); + seed(parser); + std::string prefix = "$GPRMC,123456.12,A,4807.038,N,01131.000,E,0.0,"; + require(prefix.size() < 94, "near-buffer fixture prefix"); + prefix += std::string(94 - prefix.size(), '0'); + prefix += ','; + require(prefix.size() == 95, "near-buffer date offset"); + const std::string raw = sentence(prefix.substr(1) + "1"); + require(raw.size() == 99 && raw[95] == '1' && raw[96] == '*', + "near-buffer sentence geometry"); + feed(parser, raw); + require(parser.getYear() == 0 && parser.getMonth() == 0 && + parser.getDay() == 0, "near-buffer short date retained UTC"); + require(parser.isValid() && parser.getLatitude() == 48117300, + "near-buffer date rejection destroyed position"); + require(std::string(parser.getSentence()) == raw, "near-buffer raw changed"); +} + +} // namespace + +int main(int argc, char** argv) { + require(argc == 2, "select one runtime scenario"); + const std::string mode = argv[1]; + if (mode == "formats") formats(); + else if (mode == "invalid") invalidFields(); + else if (mode == "terminal-date") terminalDate(); + else if (mode == "callbacks") callbacks(); + else if (mode == "bounds") boundedBuffer(); + else if (mode == "near-buffer-date") nearBufferDate(); + else require(false, "unknown runtime scenario"); + return 0; +} diff --git a/test/fixtures/micronmea_time_fix/src/MicroNMEA.cpp b/test/fixtures/micronmea_time_fix/src/MicroNMEA.cpp new file mode 100644 index 000000000..faf1eeb68 --- /dev/null +++ b/test/fixtures/micronmea_time_fix/src/MicroNMEA.cpp @@ -0,0 +1,383 @@ +#include + +// Allow debugging/regression testing under normal g++ environment. +#ifdef MICRONMEA_DEBUG +#include +#include +using namespace std; +#endif + + +static long exp10(uint8_t b) +{ + long r = 1; + while (b--) + r *= 10; + return r; +} + + +static char toHex(uint8_t nibble) +{ + if (nibble >= 10) + return nibble + 'A' - 10; + else + return nibble + '0'; + +} + + +const char* MicroNMEA::skipField(const char* s) +{ + if (s == nullptr) + return nullptr; + + while (!isEndOfFields(*s)) { + if (*s == ',') { + // Check next character + if (isEndOfFields(*++s)) + break; + else + return s; + } + ++s; + } + return nullptr; // End of string or valid sentence +} + + +unsigned int MicroNMEA::parseUnsignedInt(const char *s, uint8_t len) +{ + int r = 0; + while (len--) + r = 10 * r + *s++ - '0'; + return r; +} + + +long MicroNMEA::parseFloat(const char* s, uint8_t log10Multiplier, const char** eptr) +{ + int8_t neg = 1; + long r = 0; + while (isspace(*s)) + ++s; + if (*s == '-') { + neg = -1; + ++s; + } + else if (*s == '+') + ++s; + + while (isdigit(*s)) + r = 10*r + *s++ - '0'; + r *= exp10(log10Multiplier); + + if (*s == '.') { + ++s; + long frac = 0; + while (isdigit(*s) && log10Multiplier) { + frac = 10 * frac + *s++ -'0'; + --log10Multiplier; + } + frac *= exp10(log10Multiplier); + r += frac; + } + r *= neg; // Include effect of any minus sign + + if (eptr) + *eptr = skipField(s); + + return r; +} + + +long MicroNMEA::parseDegreeMinute(const char* s, uint8_t degWidth, + const char **eptr) +{ + if (*s == ',') { + if (eptr) + *eptr = skipField(s); + return 0; + } + long r = parseUnsignedInt(s, degWidth) * 1000000L; + s += degWidth; + r += parseFloat(s, 6, eptr) / 60; + return r; +} + + +const char* MicroNMEA::parseField(const char* s, char *result, int len) +{ + if (s == nullptr) + return nullptr; + + int i = 0; + while (*s != ',' && !isEndOfFields(*s)) { + if (result && i++ < len) + *result++ = *s; + ++s; + } + if (result && i < len) + *result = '\0'; // Terminate unless too long + + if (*s == ',') + return ++s; // Location of start of next field + else + return nullptr; // End of string or valid sentence +} + + +const char* MicroNMEA::generateChecksum(const char* s, char* checksum) +{ + uint8_t c = 0; + // Initial $ is omitted from checksum, if present ignore it. + if (*s == '$') + ++s; + + while (*s != '\0' && *s != '*') + c ^= *s++; + + if (checksum) { + checksum[0] = toHex(c / 16); + checksum[1] = toHex(c % 16); + } + return s; +} + + +bool MicroNMEA::testChecksum(const char* s) +{ + char checksum[2]; + const char* p = generateChecksum(s, checksum); + return *p == '*' && p[1] == checksum[0] && p[2] == checksum[1]; +} + + +#ifndef MICRONMEA_DEBUG +// When debugging in normal g++ environment ostream doesn't have a +// print member function. As sendSentence() isn't needed when +// debugging don't compile it. +Stream& MicroNMEA::sendSentence(Stream& s, const char* sentence) +{ + char checksum[3]; + generateChecksum(sentence, checksum); + checksum[2] = '\0'; + s.print(sentence); + s.print('*'); + s.print(checksum); + s.print("\r\n"); + return s; +} +#endif + + +MicroNMEA::MicroNMEA(void) : + _talkerID('\0'), + _messageID{0}, + _badChecksumHandler(nullptr), + _unknownSentenceHandler(nullptr) +{ + setBuffer(nullptr, 0); + clear(); +} + + +MicroNMEA::MicroNMEA(void* buf, uint8_t len) : + _talkerID('\0'), + _messageID{0}, + _badChecksumHandler(nullptr), + _unknownSentenceHandler(nullptr) +{ + setBuffer(buf, len); + clear(); +} + + +void MicroNMEA::setBuffer(void* buf, uint8_t len) +{ + _bufferLen = len; + _buffer = (char*)buf; + _ptr = _buffer; + if (_bufferLen) { + *_ptr = '\0'; + _buffer[_bufferLen - 1] = '\0'; + } +} + + +void MicroNMEA::clear(void) +{ + _navSystem = '\0'; + _numSat = 0; + _hdop = 255; + _isValid = false; + _latitude = 999000000L; + _longitude = 999000000L; + _altitude = _speed = _course = LONG_MIN; + _altitudeValid = false; + _year = _month = _day = 0; + _hour = _minute = _second = 99; + _hundredths = 0; +} + + +bool MicroNMEA::process(char c) +{ + if (_buffer == nullptr || _bufferLen == 0) + return false; + if (c == '\0' || c == '\n' || c == '\r') { + // Terminate buffer then reset pointer + *_ptr = '\0'; + _ptr = _buffer; + + if (*_buffer == '$' && testChecksum(_buffer)) { + // Valid message + const char* data; + if (_buffer[1] == 'G') { + _talkerID = _buffer[2]; + data = parseField(&_buffer[3], &_messageID[0], sizeof(_messageID)); + } + else { + _talkerID = '\0'; + data = parseField(&_buffer[1], &_messageID[0], sizeof(_messageID)); + } + + if (data != nullptr && strcmp(&_messageID[0], "GGA") == 0) + return processGGA(data); + else if (data != nullptr && strcmp(&_messageID[0], "RMC") == 0) + return processRMC(data); + else if (_unknownSentenceHandler) + (*_unknownSentenceHandler)(*this); + } + else { + if (_badChecksumHandler && *_buffer != '\0') // don't send empty buffers as bad checksums! + (*_badChecksumHandler)(*this); + } + // Return true for a complete, non-empty, sentence (even if not a valid one). + return *_buffer != '\0'; // + } + else { + *_ptr = c; + if (_ptr < &_buffer[_bufferLen - 1]) + ++_ptr; + } + + return false; +} + + +const char* MicroNMEA::parseTime(const char* s) +{ + if (*s == ',') + return skipField(s); + _hour = parseUnsignedInt(s, 2); + _minute = parseUnsignedInt(s + 2, 2); + _second = parseUnsignedInt(s + 4, 2); + _hundredths = parseUnsignedInt(s + 7, 2); + return skipField(s + 9); +} + + +const char* MicroNMEA::parseDate(const char* s) +{ + if (*s == ',') + return skipField(s); + _day = parseUnsignedInt(s, 2); + _month = parseUnsignedInt(s + 2, 2); + _year = parseUnsignedInt(s + 4, 2) + 2000; + return skipField(s + 6); +} + + +bool MicroNMEA::processGGA(const char *s) +{ + // If GxGSV messages are received _talker_ID can be changed after + // other MicroNMEA sentences. Compatibility modes can set the talker ID + // to indicate GPS regardless of actual navigation system used. + _navSystem = _talkerID; + + s = parseTime(s); + if (s == nullptr) + return false; + // ++s; + _latitude = parseDegreeMinute(s, 2, &s); + if (s == nullptr) + return false; + if (*s == ',') + ++s; + else { + if (*s == 'S') + _latitude *= -1; + s += 2; // Skip N/S and comma + } + _longitude = parseDegreeMinute(s, 3, &s); + if (s == nullptr) + return false; + if (*s == ',') + ++s; + else { + if (*s == 'W') + _longitude *= -1; + s += 2; // Skip E/W and comma + } + _isValid = (*s >= '1' && *s <= '5'); + s += 2; // Skip position fix flag and comma + long tmp = parseFloat(s, 0, &s); + _numSat = (tmp > 255 ? 255 : (tmp < 0 ? 0 : tmp)); + if (s == nullptr) + return false; + tmp = parseFloat(s, 1, &s); + _hdop = (tmp > 255 || tmp < 0 ? 255 : tmp); + if (s == nullptr) + return false; + _altitude = parseFloat(s, 3, &s); + if (s == nullptr) + return false; + _altitudeValid = true; + // That's all we care about + return true; +} + + +bool MicroNMEA::processRMC(const char* s) +{ + // If GxGSV messages are received _talker_ID can be changed after + // other MicroNMEA sentences. Compatibility modes can set the talker + // ID to indicate GPS regardless of actual navigation system used. + _navSystem = _talkerID; + + s = parseTime(s); + if (s == nullptr) + return false; + _isValid = (*s == 'A'); + s += 2; // Skip validity and comma + _latitude = parseDegreeMinute(s, 2, &s); + if (s == nullptr) + return false; + if (*s == ',') + ++s; + else { + if (*s == 'S') + _latitude *= -1; + s += 2; // Skip N/S and comma + } + _longitude = parseDegreeMinute(s, 3, &s); + if (s == nullptr) + return false; + if (*s == ',') + ++s; + else { + if (*s == 'W') + _longitude *= -1; + s += 2; // Skip E/W and comma + } + _speed = parseFloat(s, 3, &s); + if (s == nullptr) + return false; + _course = parseFloat(s, 3, &s); + if (s == nullptr) + return false; + s = parseDate(s); + // That's all we care about + return true; +} diff --git a/test/fixtures/micronmea_time_fix/src/MicroNMEA.h b/test/fixtures/micronmea_time_fix/src/MicroNMEA.h new file mode 100644 index 000000000..16f992488 --- /dev/null +++ b/test/fixtures/micronmea_time_fix/src/MicroNMEA.h @@ -0,0 +1,321 @@ +#ifndef MICRONMEA_H +#define MICRONMEA_H + +#define MICRONMEA_VERSION "2.0.6" +#include +#include + +/** + * @file MicroNMEA.h + * @author Steve Marple + */ + + + +/** + * @class MicroNMEA + * @brief Process MicroNMEA sentences from GPS and GNSS receivers. + * @details The user is responsible to allocating the buffer that MicroNMEA uses. This + * enables a static buffer to be used if desired so that `malloc()` is not required. + * Values returned are integers, floating-point maths is not used. + */ +class MicroNMEA { +public: + + static const char* skipField(const char* s); + static unsigned int parseUnsignedInt(const char* s, uint8_t len); + static long parseFloat(const char* s, uint8_t log10Multiplier, + const char** eptr = nullptr); + static long parseDegreeMinute(const char* s, uint8_t degWidth, + const char** eptr = nullptr); + static const char* parseToComma(const char* s, char *result = nullptr, + int len = 0); + static const char* parseField(const char* s, char *result = nullptr, + int len = 0); + static const char* generateChecksum(const char* s, char* checksum); + static bool testChecksum(const char* s); + + /** + * @brief Send a NMEA sentence to the GNSS receiver. + * + * @param s Stream to which the GNSS receiver is connected + * @param sentence The NMEA sentence to send + * @details The sentence must start with `$`; the checksum + * and `\r\n` terminators will be appended automatically. + * @return The GNSS stream + */ + static Stream& sendSentence(Stream &s, const char* sentence); + + /** + * @brief Default constructor + * @details User **must** call setrBuffer() before use + */ + MicroNMEA(void); + + + /** + * @brief Construct object and pass in the buffer allocated for MicroNMEA to use + */ + MicroNMEA(void* buffer, uint8_t len); + + /** + * @brief Set the buffer object + * + * @param buf Address of the buffer + * @param len Number of bytes allocated + */ + void setBuffer(void* buf, uint8_t len); + + // Clear all fix information. isValid() will return false, Year, + // month and day will all be zero. Hour, minute and second time will + // be set to 99. Speed, course and altitude will be set to + // LONG_MIN; the altitude validity flag will be false. Latitude and + // longitude will be set to 999 degrees. + /** + * @brief Clear all fix information + * @details `isValid()` will return false, year, + * month and day will all be zero. Hour, minute and second will + * be set to 99. Speed, course and altitude will be set to + * `LONG_MIN`; the altitude validity flag will be false. Latitude and + * longitude will be set to 999 degrees. + */ + void clear(void); + + /** + * @brief Get the navigation system in use + * @details `N` = GNSS, `P` = GPS, `L` = GLONASS, `A` = Galileo, `\0` = none + * @return char + */ + char getNavSystem(void) const { + return _navSystem; + } + + /** + * @brief Get the number of satellites in use + * + * @return uint8_t + */ + uint8_t getNumSatellites(void) const { + return _numSat; + } + + /** + * @brief Get the horizontal dilution of precision (HDOP), in tenths + * @details A HDOP value of 1.1 is returned as `11` + * @return uint8_t + */ + uint8_t getHDOP(void) const { + return _hdop; + } + + /** + * @brief Inquire if latest fix is valid + * + * @return true Valid + * @return false Not valid + */ + bool isValid(void) const { + return _isValid; + } + + /** + * @brief Get the latitude, in millionths of a degree + * @details North is positive. + * @return long + */ + long getLatitude(void) const { + return _latitude; + } + + /** + * @brief Get the longitude, in millionths of a degree + * @details East is positive. + * @return long + */ + long getLongitude(void) const { + return _longitude; + } + + // Altitude in millimetres. + /** + * @brief Get the altitude in millmetres + * + * @param alt Reference to long value where altitude is to be stored + * @return true Altitude is valid + * @return false Altitude not valid + */ + bool getAltitude(long &alt) const { + if (_altitudeValid) + alt = _altitude; + return _altitudeValid; + } + + /** + * @brief Get the year + * + * @return uint16_t year + */ + uint16_t getYear(void) const { + return _year; + } + + /** + * @brief Get the month (1 - 12 inclusive) + * + * @return uint8_t year + */ + uint8_t getMonth(void) const { + return _month; + } + + /** + * @brief Get the day of month (1 - 31 inclusive) + * + * @return uint8_t month + */ + uint8_t getDay(void) const { + return _day; + } + + /** + * @brief Get the hour + * + * @return uint8_t hour + */ + uint8_t getHour(void) const { + return _hour; + } + + /** + * @brief Get the minute + * + * @return uint8_t minute + */ + uint8_t getMinute(void) const { + return _minute; + } + + /** + * @brief Get the integer part of the second + * + * @return uint8_t second + */ + uint8_t getSecond(void) const { + return _second; + } + + /** + * @brief Get the hundredths part of the second + * + * @return uint8_t hundredths + */ + uint8_t getHundredths(void) const { + return _hundredths; + } + + /** + * @brief Get the speed + * + * @return uint8_t speed + */ + long getSpeed(void) const { + return _speed; + } + + /** + * @brief Get the direction of travel + * @return Direction in thousandths of a degree, clockwise from North + */ + long getCourse(void) const { + return _course; + } + + /** + * @brief Instruct MicroNMEA to process a character + * + * @param c Character to process + * @return true A complete non-empty sentence has been processed (may not be valid) + * @return false End of sentence not detected + */ + bool process(char c); + + /** + * @brief Register a handler to be called when bad checksums are detected + * + * @param handler pointer to handler function + */ + void setBadChecksumHandler(void (*handler)(MicroNMEA& nmea)) { + _badChecksumHandler = handler; + } + + /** + * @brief Register a handler to be called when an unknown NMEA sentence is detected + * + * @param handler pointer to handler function + */ + void setUnknownSentenceHandler(void (*handler)(MicroNMEA& nmea)) { + _unknownSentenceHandler = handler; + } + + /** + * @brief Get NMEA sentence + * + * @return const char* + */ + const char* getSentence(void) const { + return _buffer; + } + + // Talker ID for current MicroNMEA sentence + char getTalkerID(void) const { + return _talkerID; + } + + // Message ID for current MicroNMEA sentence + const char* getMessageID(void) const { + return (const char*)_messageID; + } + + +protected: + static inline bool isEndOfFields(char c) { + return c == '*' || c == '\0' || c == '\r' || c == '\n'; + } + + const char* parseTime(const char* s); + const char* parseDate(const char* s); + + bool processGGA(const char *s); + bool processRMC(const char* s); + +private: + // Sentence buffer and associated pointers + // static const uint8_t _bufferLen = 83; // 82 + NULL + // char _buffer[_bufferLen]; + uint8_t _bufferLen; + char* _buffer; + char *_ptr; + + // Information from current MicroNMEA sentence + char _talkerID; + char _messageID[6]; + + // Variables parsed and kept for user + char _navSystem; + bool _isValid; + long _latitude, _longitude; // In millionths of a degree + long _altitude; // In millimetres + bool _altitudeValid; + long _speed, _course; + uint16_t _year; + uint8_t _month, _day, _hour, _minute, _second, _hundredths; + uint8_t _numSat; + uint8_t _hdop; + + void (*_badChecksumHandler)(MicroNMEA &nmea); + void (*_unknownSentenceHandler)(MicroNMEA &nmea); + +}; + + +#endif diff --git a/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp b/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp index c2f11a66b..af12a9994 100644 --- a/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp +++ b/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp @@ -462,8 +462,9 @@ TEST(GpsClockFreshness, PartialOrNonNumericRmcClockFieldsCannotCount) { } } -TEST(GpsClockFreshness, CompleteRmcTimeAllowsSupportedDecimalSeconds) { - for (const char* time : {"120002.00", "120002.000", "120002.123456"}) { +TEST(GpsClockFreshness, CompleteRmcTimeAllowsIntegerAndDecimalSeconds) { + for (const char* time : {"120002", "120002.0", "120002.00", + "120002.000", "120002.123456"}) { SCOPED_TRACE(time); GpsRig rig; rig.window(pair("120000.00")); @@ -475,25 +476,22 @@ TEST(GpsClockFreshness, CompleteRmcTimeAllowsSupportedDecimalSeconds) { } } -TEST(GpsClockFreshness, PinnedParserShortTimeFractionsAreRejectedThenDecimalRecovers) { - for (const char* time : {"120002", "120002.0"}) { - SCOPED_TRACE(time); +TEST(GpsClockFreshness, GgaAndRmcTimePrecisionPreservesFixAndClockAcquisition) { + for (const char* fraction : {"", ".0", ".00", ".000", ".123456"}) { + SCOPED_TRACE(fraction); GpsRig rig; - rig.window(pair("120000.00")); - rig.window(pair("120001.00")); - // MicroNMEA 2.0.6 skips a fixed nine time characters before seeking the - // next field. No fraction or one fractional digit is not a provider fix. - // Reject these formats rather than reusing retained clock data; accepting - // them requires a separate parser/location compatibility fix. - rig.window(gga("120002.00") + rmc(time)); - EXPECT_FALSE(rig.gps.isValid()); + rig.window(pair(std::string("120000") + fraction)); + ASSERT_TRUE(rig.gps.isValid()); + EXPECT_EQ(8, rig.gps.satellitesCount()); + EXPECT_EQ(48117300L, rig.gps.getLatitude()); + EXPECT_EQ(11516666L, rig.gps.getLongitude()); + EXPECT_EQ(545400L, rig.gps.getAltitude()); + rig.window(pair(std::string("120001") + fraction)); EXPECT_TRUE(rig.rtc.writes.empty()); - rig.window(pair("120003.00")); - rig.window(pair("120004.00")); - EXPECT_TRUE(rig.rtc.writes.empty()); - rig.window(pair("120005.00")); + rig.window(pair(std::string("120002") + fraction)); ASSERT_EQ(1U, rig.rtc.writes.size()); - EXPECT_EQ(NOON_2026_10_17 + 5, rig.rtc.writes.back().after); + EXPECT_EQ(NOON_2026_10_17 + 2, rig.rtc.writes.back().after); + EXPECT_EQ(1U, rig.gps.getTimeSyncGeneration()); } } diff --git a/test/test_micronmea_time_fix.py b/test/test_micronmea_time_fix.py new file mode 100644 index 000000000..f163142be --- /dev/null +++ b/test/test_micronmea_time_fix.py @@ -0,0 +1,204 @@ +"""Exercise the reviewed complete GPS parser and its private build transform. + +The compiler uses the real MicroNMEA fixture with Arduino/Stream adapters. +No PlatformIO command, dependency download, serial port or hardware is used. +""" +from pathlib import Path +import importlib.util +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "test/fixtures/micronmea_time_fix" +SPEC = importlib.util.spec_from_file_location("micronmea_time_fix", ROOT / "scripts/micronmea_time_fix.py") +FIX = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FIX) +SOURCE = (FIXTURES / "src/MicroNMEA.cpp").read_text(encoding="ascii") +HEADER = (FIXTURES / "src/MicroNMEA.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, path): + return Path(path) + + def AddBuildMiddleware(self, callback, pattern): + self.registrations.append((callback, pattern)) + + +class TransformTests(unittest.TestCase): + def test_original_fixtures_are_exact_ascii_reviewed_and_attributed(self): + for path in FIXTURES.rglob("*"): + if path.is_file(): + path.read_text(encoding="ascii") + self.assertEqual(FIX.digest(SOURCE), FIX.SOURCE_SHA256) + self.assertEqual(FIX.digest(HEADER), FIX.HEADER_SHA256) + self.assertIn('#define MICRONMEA_VERSION "2.0.6"', HEADER) + self.assertIn("Steve Marple", (FIXTURES / "README.txt").read_text()) + license_text = (FIXTURES / "LICENSE").read_text(encoding="ascii") + self.assertIn("GNU LESSER GENERAL PUBLIC LICENSE", license_text) + self.assertIn("Version 2.1, February 1999", license_text) + + def test_transform_is_hash_bound_and_idempotent_with_lf_or_crlf(self): + patched = FIX.patched_source(SOURCE) + self.assertEqual(FIX.digest(patched), FIX.PATCHED_SHA256) + self.assertEqual(FIX.patched_source(SOURCE.replace("\n", "\r\n")), patched) + self.assertEqual(FIX.patched_source(patched), patched) + self.assertEqual(FIX.patched_source(patched.replace("\n", "\r\n")), patched) + self.assertIn(FIX.PATCH_NOTICE, patched) + self.assertEqual(patched.count(FIX.PATCHED_TIME), 1) + self.assertEqual(patched.count(FIX.PATCHED_DATE), 1) + + def test_unknown_changed_and_half_patched_sources_fail_closed(self): + for source in (SOURCE + "\n", SOURCE.replace("return r;", "return r + 1;", 1), + SOURCE.replace(FIX.ORIGINAL_TIME, FIX.PATCHED_TIME, 1), + SOURCE.replace(FIX.ORIGINAL_DATE, FIX.PATCHED_DATE, 1)): + with self.subTest(source=FIX.digest(source)): + with self.assertRaisesRegex(RuntimeError, "changed reviewed source"): + FIX.patched_source(source) + + def test_middleware_matches_only_the_expected_library_source(self): + env = BuildEnv("unused") + FIX.install(env) + self.assertEqual(env.registrations, [(FIX.replace_micronmea_source, "*MicroNMEA.cpp")]) + with tempfile.TemporaryDirectory() as temporary: + env = BuildEnv(Path(temporary) / "build") + for relative in ("src/unrelated.cpp", "other/MicroNMEA.cpp"): + node = Node(Path(temporary) / relative) + self.assertIs(FIX.replace_micronmea_source(env, node), node) + self.assertFalse(env.build.exists()) + + def test_private_pair_leaves_downloaded_files_and_unchanged_outputs_untouched(self): + for crlf in (False, True): + with self.subTest(crlf=crlf), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "libdeps/MicroNMEA/src/MicroNMEA.cpp" + source.parent.mkdir(parents=True) + original = SOURCE.replace("\n", "\r\n") if crlf else SOURCE + header = HEADER.replace("\n", "\r\n") if crlf else HEADER + source.write_bytes(original.encode("ascii")) + source.with_suffix(".h").write_bytes(header.encode("ascii")) + before = source.read_bytes(), source.with_suffix(".h").read_bytes() + env = BuildEnv(root / "build") + destination = FIX.replace_micronmea_source(env, Node(source)) + self.assertEqual(destination, env.build / "patched-micronmea/MicroNMEA.cpp") + self.assertEqual(destination.read_text(encoding="ascii"), FIX.patched_source(SOURCE)) + self.assertEqual(destination.with_suffix(".h").read_text(encoding="ascii"), HEADER) + times = destination.stat().st_mtime_ns, destination.with_suffix(".h").stat().st_mtime_ns + self.assertEqual(FIX.replace_micronmea_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_or_changed_header_fails_before_creating_private_output(self): + for header in (None, HEADER + "\n", "// unknown\n", "\u00b0"): + with self.subTest(missing=header is None), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "libdeps/MicroNMEA/src/MicroNMEA.cpp" + source.parent.mkdir(parents=True) + source.write_text(SOURCE, encoding="ascii") + if header is not None: + source.with_suffix(".h").write_text(header, encoding="utf-8") + env = BuildEnv(root / "build") + with self.assertRaisesRegex(RuntimeError, "reviewed header"): + FIX.replace_micronmea_source(env, Node(source)) + self.assertFalse(env.build.exists()) + + +class RuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise unittest.SkipTest("a host C++17 compiler is required") + temporary = tempfile.TemporaryDirectory(prefix="micronmea-time-") + cls.addClassCleanup(temporary.cleanup) + root = Path(temporary.name) + cls.binaries = {} + sanitize = [] if not sys.platform.startswith("linux") else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + for kind in ("original", "patched"): + source = root / kind / "src/MicroNMEA.cpp" + source.parent.mkdir(parents=True) + source.write_text(SOURCE, encoding="ascii") + source.with_suffix(".h").write_text(HEADER, encoding="ascii") + if kind == "patched": + source = FIX.replace_micronmea_source(BuildEnv(root / "private"), Node(source)) + binary = root / (kind + ".exe") + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *sanitize, "-I" + str(source.parent), + "-isystem", str(ROOT / "test/mocks"), str(FIXTURES / "runtime.cpp"), + str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + cls.binaries[kind] = binary + # Force a wrong header ahead of the reviewed private pair. Only the + # library implementation is compiled here; its quoted include must win. + poison = root / "poison" + poison.mkdir() + (poison / "MicroNMEA.h").write_text('#error "unpaired header"\n', encoding="ascii") + checked = subprocess.run([ + compiler, "-std=c++17", "-I" + str(poison), "-I" + str(ROOT / "test/mocks"), + "-c", str(root / "private/patched-micronmea/MicroNMEA.cpp"), + "-o", str(root / "private.o")], capture_output=True, text=True, timeout=30) + if checked.returncode: + raise AssertionError(checked.stdout + checked.stderr) + + def run_case(self, scenario, kind="patched", diagnostic=None): + result = subprocess.run([str(self.binaries[kind]), scenario], + capture_output=True, text=True, timeout=10) + if diagnostic: + self.assertEqual(result.returncode, 42, result.stdout + result.stderr) + self.assertIn("SEMANTIC: " + diagnostic, result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("AddressSanitizer:", result.stderr) + self.assertNotIn("runtime error:", result.stderr) + + def test_integer_and_variable_fraction_times_preserve_real_position_and_raw_sentences(self): + self.run_case("formats") + + def test_malformed_clock_fields_clear_utc_but_preserve_valid_position_and_recover(self): + self.run_case("invalid") + + def test_terminal_date_without_trailing_optional_fields_is_still_valid(self): + self.run_case("terminal-date") + + def test_bad_checksum_unknown_sentence_crlf_and_send_callbacks_are_unchanged(self): + self.run_case("callbacks") + self.run_case("callbacks", "original") + + def test_small_buffer_and_end_of_buffer_short_date_stay_in_bounds(self): + self.run_case("bounds") + self.run_case("near-buffer-date") + + def test_original_parser_fails_semantic_compatibility_and_stale_utc_oracles(self): + self.run_case("formats", "original", "RMC short time format lost validity") + self.run_case("invalid", "original", "incomplete date retained old UTC date") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_micronmea_time_fix_recipes.py b/test/test_micronmea_time_fix_recipes.py new file mode 100644 index 000000000..da62759d2 --- /dev/null +++ b/test/test_micronmea_time_fix_recipes.py @@ -0,0 +1,98 @@ +"""Resolve tracked GPS parser hooks, including real Full target synthesis. + +Only PlatformIO's configuration reader and the builder's JSON target mapper +run here. No PlatformIO CLI, dependency download, compilation or flashing. +Developer-local overlays are deliberately excluded from the tracked matrix. +""" +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/micronmea_time_fix.py' + + +class MicroNmeaTimeFixRecipeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + 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_micronmea(self, section): + return any('MicroNMEA' in dependency + for dependency in self.config.get(section, 'lib_deps', [])) + + def test_every_resolved_micronmea_recipe_loads_exactly_one_fix(self): + tested = [] + for section in self.config.sections(): + if not section.startswith('env:') or not self.has_micronmea(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), 500) + for target in ('native', 'RAK_4631_sensor', 't1000e_sensor', 'heltec_v4_sensor', + 't1000e_companion_radio_usb', 'heltec_v4_r8_tft_companion_radio_ble'): + self.assertIn('env:' + target, tested) + + def test_all_four_platform_bases_and_native_propagate_exactly_one_fix(self): + for section in ('esp32_base', 'nrf52_base', 'rp2040_base', 'stm32_base', 'env:native'): + with self.subTest(section=section): + self.assertEqual(self.config.get(section, 'extra_scripts', []).count(HOOK), 1) + + def test_native_compiles_the_reviewed_real_parser(self): + self.assertIn('stevemarple/MicroNMEA @ 2.0.6', + self.config.get('env:native', 'lib_deps')) + self.assertEqual(self.config.get('env:native', 'lib_compat_mode'), 'off') + + def test_actual_builder_synthesized_full_targets_retain_their_base_hook(self): + 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; } +platformio() { 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='micronmea-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, 'micronmea-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_micronmea(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), 70) + + 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_micronmea_time_fix.py -v', workflow) + self.assertIn('python3 -B test/test_micronmea_time_fix_recipes.py -v', workflow) + + +if __name__ == '__main__': + unittest.main()