commit d7b3fcc68cf22421c7b067ee3e8591c6d6b7b85a Author: i12bp8 Date: Mon Apr 6 03:09:39 2026 +0200 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f22ef0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Build artifacts +*.o +*.d +*.elf +*.fap +*.map +dist/ + +# ufbt +.ufbt/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Python bytecode +__pycache__/ +*.pyc + +# OS garbage +.DS_Store +Desktop.ini diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0423bd7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing to TagTinker + +Thanks for wanting to help out. Here's how to get involved. + +## Getting started + +1. Fork the repo +2. Create a feature branch: `git checkout -b my-feature` +3. Make your changes +4. Test on owned lab hardware if possible (or at minimum, ensure `ufbt build` passes) +5. Commit with a clear message +6. Open a pull request + +## Code style + +- C99, no C++ features +- 4-space indentation (no tabs) +- `snake_case` for functions and variables +- Keep functions short and focused +- Comment non-obvious logic, but don't over-comment + +## What we're looking for + +- Bug fixes (especially memory-related — the Flipper has very limited heap) +- New ESL tag compatibility (different sizes and display variants) +- Better compression or transmission reliability +- UI/UX improvements +- Documentation improvements + +## Project boundaries + +- Keep contributions aligned with educational use on ESL hardware the operator owns. +- Do not add documentation aimed at store use, retail deployment, or third-party systems. +- Prefer generic ESL wording over store- or brand-specific framing in user-facing docs. +- Preserve credit to furrtek's reverse-engineering research where relevant. + +## What to avoid + +- Don't break existing functionality without good reason +- Don't add external dependencies — this runs on a microcontroller with strict constraints +- Don't submit AI-generated code without reviewing and testing it +- Keep memory usage minimal — every byte counts on the Flipper Zero + +## Reporting issues + +Open a GitHub issue with: +- What you expected to happen +- What actually happened +- Your Flipper firmware version +- The ESL model you're using (if applicable) + +## Hardware safety + +Be careful with repeated refresh or LED test loops on your own tags. Power and display behavior vary by model, and long-running tests can have side effects. + +## License + +By contributing, you agree that your contributions will be licensed under GPL-3.0. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, 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 +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "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 PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM 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 PROGRAM (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 PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..41da872 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# TagTinker + +

+ Educational ESL research tools for Flipper Zero
+ Protocol study, signal analysis, and controlled display experiments on authorized lab hardware +

+ +

+ License: GPL-3.0 + Platform: Flipper Zero + Status: Research Project +

+ +--- + + +

Owner-authorized lab ESL display experiment

+ +## Overview + +TagTinker is an educational electronic shelf label (ESL) research project for Flipper Zero. + +It is intended solely for: + +- protocol study, +- signal analysis, +- interoperability research, +- and controlled display experiments on ESL hardware that you personally own or are explicitly authorized to test. + +This project is maintained as a home-lab and research tool. It is **not** intended for use against deployed retail systems, commercial infrastructure, or third-party equipment. + +--- + +## Important Notice + +Use this project **only** on hardware that: + +- you personally own, or +- you have explicit permission to study and test in a controlled environment. + +Do **not** use this project: + +- in stores, +- on deployed retail systems, +- on third-party equipment, +- on production infrastructure, +- to alter prices, product information, or operational displays, +- to interfere with commercial operations, +- to bypass pairing, authorization, access controls, or security measures, +- or in any manner that is unlawful, unauthorized, or harmful. + +The publication of this source code does **not** grant permission to access, modify, transmit to, or interfere with any third-party system or device. + +--- + +## Project Scope + +This repository is limited to research and lab use, including: + +- educational study of infrared ESL communication, +- display and protocol experiments on authorized test hardware, +- local tooling for preparing monochrome display assets, +- controlled diagnostics and signal observation for interoperability research. + +This README intentionally does **not** include installation, deployment, targeting, or operational instructions for use against deployed systems. + +--- + +## Features + +- Lab-only ESL protocol study tools +- Controlled display experiments for authorized test hardware +- Single-device research functions for text, image, test-pattern, and LED-response experiments +- Local image preparation utility in `tools/tagtinker.html` + +--- + +## Responsible Use + +TagTinker is published for legitimate educational, research, and interoperability purposes only. + +You are solely responsible for ensuring that any use of this project complies with: + +- applicable law, +- property rights, +- contractual restrictions, +- operator policies, +- and all required permissions and authorizations. + +If you do not own the hardware or do not have explicit authorization to test it, do not use this project on that hardware. + +--- + +## No Affiliation + +This project is an independent research effort. + +It is **not** affiliated with, endorsed by, authorized by, or sponsored by any ESL manufacturer, retailer, or system operator. + +Any brand names or product names referenced in the source tree or documentation are used only where necessary for descriptive, compatibility, or research purposes. + +--- + +## Security and Disclosure + +This repository is intended to support lawful research and interoperability work. + +If you are a vendor, operator, or security researcher and believe some part of this repository raises a concrete security concern, please open a responsible disclosure issue or contact the maintainer privately with sufficient detail. + +--- + +## Distribution Policy + +This repository contains source code for research purposes. + +To reduce the risk of misuse, this project does not provide: + +- hosted binaries, +- prebuilt application files, +- automated deployment packages, +- or step-by-step operational instructions for use on deployed systems. + +--- + +## Credits + +This project builds on public reverse-engineering research by [furrtek](https://github.com/furrtek/PrecIR) and related community work. + +Please see the upstream project for prior research context and historical protocol analysis. + +--- + +## License + +Licensed under **GPL-3.0**. +See [`LICENSE`](LICENSE) for details. + +If this project incorporates or adapts GPL-covered upstream work, the corresponding notices and licensing terms apply accordingly. + +--- + +## Warranty Disclaimer + +This project is provided **“as is”**, without warranty of any kind, express or implied, including but not limited to merchantability, fitness for a particular purpose, and noninfringement, to the maximum extent permitted by applicable law. + +--- + +## Maintainer Statement + +This project is maintained as a narrowly scoped educational and research repository. + +The maintainer does not authorize, encourage, or condone use against third-party devices, deployed retail systems, or any environment without explicit permission. diff --git a/application.fam b/application.fam new file mode 100644 index 0000000..28d36a5 --- /dev/null +++ b/application.fam @@ -0,0 +1,20 @@ +App( + appid="tagtinker", + name="TagTinker", + apptype=FlipperAppType.EXTERNAL, + entry_point="tagtinker_app_main", + requires=["gui", "notification", "dialogs", "storage"], + stack_size=4 * 1024, + fap_icon="tagtinker_10px.png", + fap_category="Infrared", + fap_author="i12bp8", + fap_description="Educational ESL study tool for owned hardware", + fap_version="1.1", + sources=[ + "tagtinker_app.c", + "scenes/*.c", + "protocol/*.c", + "ir/*.c", + "views/*.c", + ], +) diff --git a/demo.jpg b/demo.jpg new file mode 100644 index 0000000..897507e Binary files /dev/null and b/demo.jpg differ diff --git a/ir/tagtinker_ir.c b/ir/tagtinker_ir.c new file mode 100644 index 0000000..be92d64 --- /dev/null +++ b/ir/tagtinker_ir.c @@ -0,0 +1,328 @@ +/** + * TagTinker IR transmitter - implementation (v2) + * + * Drives the Flipper Zero's built-in IR LEDs at ~1.255 MHz carrier + * for the ESL pulse-position modulation (PPM) protocol used here. + * + * v2 changes from v1: + * - Removed TIM2 ISR approach (TIM2 conflicts with Flipper's IR RX subsystem!) + * - Uses DWT cycle counter for precise timing instead (zero timer conflicts) + * - Properly handles TIM1 bus state (was crashing if already enabled) + * - Non-blocking repeat loop with cancellation support + * + * Architecture: + * TIM1 Channel 3N: 1.255 MHz PWM carrier on built-in IR LED (PB9) + * DWT->CYCCNT: Cycle-accurate timing for PPM symbol encoding + * + * CRITICAL: The built-in IR LED is on PB9 = TIM1_CH3N (complementary output). + * NOT CH3! The carrier is gated by toggling OC3M bits in TIM1->CCMR2 + * (which control both CH3 and CH3N). The firmware uses PWM2 mode. + * For CH3N (complementary), Force Inactive = LED off, PWM2 = carrier on. + */ + +#include "tagtinker_ir.h" + +#include +#include +#include +#include +#include +#include + +#include + +/* ─── Carrier configuration ─── + * Target: 1.25 MHz carrier for ESL signaling + * Flipper: 64 MHz system clock, TIM1 PSC=0 + * ARR = 51-1 = 50 → 64MHz/51 = 1,254,901 Hz (+4.9 kHz off, within ±10kHz) + */ +#define CARRIER_TIM TIM1 +#define CARRIER_ARR (51 - 1) +#define CARRIER_CCR 25 /* ~50% duty cycle */ + +/* ─── PP4 timing in CPU cycles (64 MHz) ─── + * + * Protocol reference: ESL Blaster FW03 ir.c, furrtek.org ESL page + * Base period t = 1/32768 Hz = 30.518 µs + * + * Burst duration: ~40 µs = 50 carrier cycles at 1.25 MHz + * ESL Blaster: 4 ticks × 10.08µs = 40.32 µs + * Our value: 40 µs × 64 = 2560 cycles + * + * Symbol gap durations (index = 2-bit symbol value): + * From ir.c pp4_steps ordering: + * pp4_steps[0] = 5 ticks → sym 00: 6 × 10.08 = 60.48 µs + * pp4_steps[1] = 23 ticks → sym 01: 24 × 10.08 = 241.92 µs + * pp4_steps[2] = 11 ticks → sym 10: 12 × 10.08 = 120.96 µs + * pp4_steps[3] = 17 ticks → sym 11: 18 × 10.08 = 181.44 µs + * + * Converting to 64 MHz cycles: + */ +#define PP4_BURST_CYCLES 2581 /* 40.33 µs */ +static const uint32_t pp4_gap_cycles[4] = { + 3871, /* Symbol 00: 60.48 µs */ + 15483, /* Symbol 01: 241.92 µs */ + 7741, /* Symbol 10: 120.96 µs */ + 11612, /* Symbol 11: 181.44 µs */ +}; + +/* ─── PP16 timing in CPU cycles (64 MHz) ─── + * Derived from esl_blaster ir.c: TIM16 steps of 4us. + * Gap values (27us to 107us) mapped to 16 symbols. + * Burst: 21us * 64 = 1344 cycles. + */ +#define PP16_BURST_CYCLES 1344 +static const uint32_t pp16_gap_cycles[16] = { + 1728, // 0000: 27µs + 3264, // 0001: 51µs + 2240, // 0010: 35µs + 2752, // 0011: 43µs + 9408, // 0100: 147µs + 7872, // 0101: 123µs + 8896, // 0110: 139µs + 8384, // 0111: 131µs + 5312, // 1000: 83µs + 3776, // 1001: 59µs + 4800, // 1010: 75µs + 4288, // 1011: 67µs + 5824, // 1100: 91µs + 7360, // 1101: 115µs + 6336, // 1110: 99µs + 6848 // 1111: 107µs +}; + +/* ─── Module state ─── */ +static bool ir_initialized = false; +static volatile bool ir_stop_requested = false; + +/* ─── Carrier control (TIM1 OC3M register manipulation) ─── */ + +static inline void carrier_on(void) { + /* OC3M = PWM Mode 2 (111) — matching Flipper firmware. + * For CH3N (complementary): PWM2 inverts → CH3N gets active-low PWM = carrier burst. + * This matches INFRARED_TX_CCMR_HIGH in furi_hal_infrared.c */ + uint32_t ccmr2 = CARRIER_TIM->CCMR2; + ccmr2 &= ~(TIM_CCMR2_OC3M); + ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); /* PWM mode 2 */ + CARRIER_TIM->CCMR2 = ccmr2; +} + +static inline void carrier_off(void) { + /* OC3M = Force Inactive (100) — CH3N goes idle = LED off. + * This matches INFRARED_TX_CCMR_LOW in furi_hal_infrared.c */ + uint32_t ccmr2 = CARRIER_TIM->CCMR2; + ccmr2 &= ~(TIM_CCMR2_OC3M); + ccmr2 |= TIM_CCMR2_OC3M_2; + CARRIER_TIM->CCMR2 = ccmr2; +} + +/* ─── Cycle-accurate delay using DWT ─── */ + +static inline void delay_cycles(uint32_t cycles) { + uint32_t start = DWT->CYCCNT; + while((DWT->CYCCNT - start) < cycles) { + /* Busy wait — handles wraparound via unsigned subtraction */ + } +} + +/* ─── Send a single PP4-encoded frame ─── + * + * PPM on-air sequence for N symbols: + * [burst][gap₀][burst][gap₁]...[burst][gapₙ₋₁][burst] + * = N+1 bursts with N gaps, each gap encoding one 2-bit symbol. + * + * Data is sent LSB first, 2 bits at a time per byte (matching ir.c). + */ +static void send_frame_pp4(const uint8_t* data, size_t len) { + for(size_t byte_idx = 0; byte_idx < len; byte_idx++) { + uint8_t current_byte = data[byte_idx]; + + for(int sym = 0; sym < 4; sym++) { + uint8_t symbol = current_byte & 0x03; + current_byte >>= 2; + + /* Burst (carrier ON) */ + carrier_on(); + delay_cycles(PP4_BURST_CYCLES); + + /* Gap (carrier OFF) — duration encodes the symbol */ + carrier_off(); + delay_cycles(pp4_gap_cycles[symbol]); + } + } + + /* Final burst (N+1th burst, required by PPM protocol) */ + carrier_on(); + delay_cycles(PP4_BURST_CYCLES); + carrier_off(); +} + +/* ─── Send a single PP16-encoded frame ─── + * Encodes 4 bits per symbol, resulting in half the pulses of PP4. + */ +static void send_frame_pp16(const uint8_t* data, size_t len) { + for(size_t byte_idx = 0; byte_idx < len; byte_idx++) { + uint8_t current_byte = data[byte_idx]; + + for(int sym = 0; sym < 2; sym++) { + uint8_t symbol = current_byte & 0x0F; + current_byte >>= 4; + + /* Burst (carrier ON) */ + carrier_on(); + delay_cycles(PP16_BURST_CYCLES); + + /* Gap (carrier OFF) */ + carrier_off(); + delay_cycles(pp16_gap_cycles[symbol]); + } + } + + /* Final burst */ + carrier_on(); + delay_cycles(PP16_BURST_CYCLES); + carrier_off(); +} + +/* ─── Public API ─── */ + +void tagtinker_ir_init(void) { + if(ir_initialized) return; + + /* Safely claim TIM1: must handle case where it's already enabled + * by the firmware's IR subsystem. Bus enable asserts if already on! */ + if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) { + furi_hal_bus_disable(FuriHalBusTIM1); + } + furi_hal_bus_enable(FuriHalBusTIM1); + + /* Configure GPIO for IR TX (built-in IR LEDs) */ + furi_hal_gpio_init_ex( + &gpio_infrared_tx, + GpioModeAltFunctionPushPull, + GpioPullNo, + GpioSpeedVeryHigh, + GpioAltFn1TIM1); + + /* Configure TIM1 as 1.255 MHz carrier */ + LL_TIM_SetPrescaler(CARRIER_TIM, 0); + LL_TIM_SetAutoReload(CARRIER_TIM, CARRIER_ARR); + LL_TIM_SetCounter(CARRIER_TIM, 0); + + /* Channel 3 config — controls both CH3 and CH3N outputs. + * The IR LED is on CH3N (PB9), so we must enable CC3NE. + * PWM2 mode with preload, matching the firmware. */ + LL_TIM_OC_SetMode(CARRIER_TIM, LL_TIM_CHANNEL_CH3, LL_TIM_OCMODE_PWM2); + LL_TIM_OC_SetCompareCH3(CARRIER_TIM, CARRIER_CCR); + LL_TIM_OC_EnablePreload(CARRIER_TIM, LL_TIM_CHANNEL_CH3); + + /* CRITICAL: Enable CH3N (complementary output), NOT CH3! + * IR LED is on PB9 = TIM1_CH3N. Without this, no IR output at all. */ + LL_TIM_CC_EnableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N); + + /* Main output enable (required for TIM1 advanced timer) */ + LL_TIM_EnableAllOutputs(CARRIER_TIM); + + /* Start timer but force carrier OFF initially */ + carrier_off(); + LL_TIM_EnableCounter(CARRIER_TIM); + LL_TIM_GenerateEvent_UPDATE(CARRIER_TIM); + + ir_stop_requested = false; + ir_initialized = true; + + FURI_LOG_I("TagTinker", "IR TX initialized: carrier %.3f MHz", + (double)(64000000.0f / (CARRIER_ARR + 1) / 1000000.0f)); +} + +void tagtinker_ir_deinit(void) { + if(!ir_initialized) return; + + tagtinker_ir_stop(); + + /* Force carrier off and disable TIM1 */ + carrier_off(); + LL_TIM_DisableAllOutputs(CARRIER_TIM); + LL_TIM_CC_DisableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N); + LL_TIM_DisableCounter(CARRIER_TIM); + + /* Reset TIM1 bus so firmware can reclaim it for normal IR */ + if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) { + furi_hal_bus_disable(FuriHalBusTIM1); + } + + /* Restore GPIO to safe state */ + furi_hal_gpio_init(&gpio_infrared_tx, GpioModeAnalog, GpioPullNo, GpioSpeedLow); + + ir_initialized = false; + FURI_LOG_I("TagTinker", "IR TX deinitialized"); +} + +bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw, uint8_t delay) { + if(!ir_initialized) return false; + if(len == 0 || len > 255) return false; + + ir_stop_requested = false; + + // MSB of repeats indicates PP16 protocol! + bool is_pp16 = (repeats_raw & 0x8000) != 0; + uint32_t repeats = repeats_raw & 0x7FFF; + + FURI_LOG_I("TagTinker", "TX start: %zu bytes, %lu repeats (PP%d), %u delay", + len, repeats, is_pp16 ? 16 : 4, delay); + + /* Transmit frame with repeats. + * Between repeats we yield briefly so FreeRTOS stays happy + * and the user can cancel via tagtinker_ir_stop(). */ + for(uint32_t rep = 0; rep <= repeats; rep++) { + if(ir_stop_requested) { + FURI_LOG_I("TagTinker", "TX cancelled at repeat %lu", rep); + carrier_off(); + return false; + } + + /* Send one frame (interrupts stay enabled — DWT handles timing) */ + if(is_pp16) { + send_frame_pp16(data, len); + } else { + send_frame_pp4(data, len); + } + + /* Delay between repeats: delay × 500µs (matching ESL Blaster). + * ESL Blaster: TickCounter = RepeatDelay * 50 ticks, each ~10µs = 500µs per unit */ + if(rep < repeats) { + uint32_t delay_us = (uint32_t)delay * 500; + if(delay_us > 0) { + uint32_t delay_ms_yield = delay_us / 1000; + uint32_t delay_us_busy = delay_us % 1000; + + /* Yield to FreeRTOS for the bulk of the delay to allow the GUI to animate! */ + if(delay_ms_yield > 0) { + furi_delay_ms(delay_ms_yield); + } + + /* Busy loop only for the sub-millisecond remainder */ + if(delay_us_busy > 0) { + delay_cycles(delay_us_busy * 64); /* 64 cycles per µs */ + } + } + } + + /* Fallback yield to FreeRTOS every 10 repeats if the delay parameter was 0 or 1 */ + if(((uint32_t)delay * 500) < 1000 && (rep % 10) == 9) { + furi_delay_ms(1); + } + } + + FURI_LOG_I("TagTinker", "TX complete"); + return true; +} + +bool tagtinker_ir_is_busy(void) { + return false; /* Transmit is blocking in this version */ +} + +void tagtinker_ir_stop(void) { + ir_stop_requested = true; + carrier_off(); +} diff --git a/ir/tagtinker_ir.h b/ir/tagtinker_ir.h new file mode 100644 index 0000000..a54644c --- /dev/null +++ b/ir/tagtinker_ir.h @@ -0,0 +1,50 @@ +/** + * TagTinker IR transmitter - header + * + * Low-level IR transmitter for the ESL protocol used by this project. + * Drives the Flipper Zero's built-in IR LEDs at 1.255 MHz carrier + * by directly programming TIM1 registers, bypassing furi_hal_infrared. + * + * Uses DWT cycle counter for symbol timing (no timer conflicts). + * PP4 mode (2-bit symbols) for this POC. + */ + +#pragma once + +#include +#include +#include + +/** + * Initialize the IR transmitter. + * Claims TIM1 for carrier, configures GPIO for IR output. + */ +void tagtinker_ir_init(void); + +/** + * Deinitialize the IR transmitter. + * Releases TIM1, restores GPIO state. + */ +void tagtinker_ir_deinit(void); + +/** + * Transmit a frame using PP4 modulation. + * Blocking call — returns when all repeats complete or cancelled. + * + * @param data frame bytes + * @param len byte count + * @param repeats times to repeat (0 = send once) + * @param delay inter-repeat delay (×500µs, 10 = 5ms like ESL Blaster) + * @return true if completed, false if cancelled/error + */ +bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats, uint8_t delay); + +/** + * Check if transmitting. + */ +bool tagtinker_ir_is_busy(void); + +/** + * Stop any ongoing transmission. + */ +void tagtinker_ir_stop(void); diff --git a/protocol/tagtinker_proto.c b/protocol/tagtinker_proto.c new file mode 100644 index 0000000..c812179 --- /dev/null +++ b/protocol/tagtinker_proto.c @@ -0,0 +1,322 @@ +/* + * TagTinker - ESL protocol helpers (implementation) + * + * Ported from furrtek/TagTinker tools_python/pr.py and img2dm.py + * + * SPDX-License-Identifier: MIT + */ + +#include "tagtinker_proto.h" +#include "../tagtinker_app.h" +#include +#include + +/* ── Helpers ────────────────────────────────────────────────── */ + +static void append_word(uint8_t* buf, size_t* pos, uint16_t value) { + buf[(*pos)++] = (value >> 8) & 0xFF; + buf[(*pos)++] = value & 0xFF; +} + +static size_t terminate(uint8_t* buf, size_t len) { + uint16_t crc = tagtinker_crc16(buf, len); + buf[len] = crc & 0xFF; + buf[len + 1] = (crc >> 8) & 0xFF; + return len + 2; +} + +static size_t raw_frame(uint8_t* buf, uint8_t proto, + const uint8_t plid[4], uint8_t cmd) { + buf[0] = proto; + buf[1] = plid[3]; buf[2] = plid[2]; + buf[3] = plid[1]; buf[4] = plid[0]; + buf[5] = cmd; + return 6; +} + +static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) { + size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x34); + buf[p++] = 0x00; + buf[p++] = 0x00; + buf[p++] = 0x00; + buf[p++] = cmd; + return p; +} + +/* ── CRC-16 (poly 0x8408, init 0x8408) ─────────────────────── */ + +uint16_t tagtinker_crc16(const uint8_t* data, size_t len) { + uint16_t crc = 0x8408; + for(size_t i = 0; i < len; i++) { + crc ^= data[i]; + for(int b = 0; b < 8; b++) + crc = (crc & 1) ? (crc >> 1) ^ 0x8408 : crc >> 1; + } + return crc; +} + +/* ── Barcode → PLID ─────────────────────────────────────────── */ + +bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]) { + if(!barcode || strlen(barcode) != 17) return false; + for(int i = 2; i < 12; i++) + if(barcode[i] < '0' || barcode[i] > '9') return false; + + uint32_t lo = 0, hi = 0; + for(int i = 2; i < 7; i++) lo = lo * 10 + (barcode[i] - '0'); + for(int i = 7; i < 12; i++) hi = hi * 10 + (barcode[i] - '0'); + + uint32_t id = lo + (hi << 16); + plid[0] = (id >> 8) & 0xFF; + plid[1] = id & 0xFF; + plid[2] = (id >> 24) & 0xFF; + plid[3] = (id >> 16) & 0xFF; + return true; +} + +/* ── Frame builders ─────────────────────────────────────────── */ + +size_t tagtinker_build_broadcast_page_frame( + uint8_t* buf, uint8_t page, bool forever, uint16_t duration) { + + const uint8_t plid[4] = {0}; + size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06); + buf[p++] = ((page & 7) << 3) | 0x01 | (forever ? 0x80 : 0x00); + buf[p++] = 0x00; + buf[p++] = 0x00; + buf[p++] = forever ? 0x00 : ((duration >> 8) & 0xFF); + buf[p++] = forever ? 0x00 : (duration & 0xFF); + return terminate(buf, p); +} + +size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf) { + const uint8_t plid[4] = {0}; + size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06); + buf[p++] = 0xF1; + buf[p++] = 0x00; + buf[p++] = 0x00; + buf[p++] = 0x00; + buf[p++] = 0x0A; + return terminate(buf, p); +} + +size_t tagtinker_make_addressed_frame( + uint8_t* buf, const uint8_t plid[4], + const uint8_t* payload, size_t payload_len) { + + size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, payload[0]); + memcpy(&buf[p], payload + 1, payload_len - 1); + p += payload_len - 1; + return terminate(buf, p); +} + +size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]) { + size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x17); + buf[p++] = 0x01; + buf[p++] = 0x00; + buf[p++] = 0x00; + buf[p++] = 0x00; + for(int i = 0; i < 22; i++) buf[p++] = 0x01; + return terminate(buf, p); +} + +size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]) { + size_t p = mcu_frame(buf, plid, 0x01); + for(int i = 0; i < 22; i++) buf[p++] = 0x00; + return terminate(buf, p); +} + +size_t tagtinker_make_mcu_frame( + uint8_t* buf, const uint8_t plid[4], uint8_t cmd) { + return mcu_frame(buf, plid, cmd); +} + +static void record_run(uint8_t* out, size_t* pos, size_t cap, uint32_t run_count) { + uint8_t bits[32]; + int n = 0; + uint32_t v = run_count; + while(v) { bits[n++] = v & 1; v >>= 1; } + for(int i = 0; i < n / 2; i++) { + uint8_t t = bits[i]; bits[i] = bits[n - 1 - i]; bits[n - 1 - i] = t; + } + /* Prefix zeros (n-1 of them, skip leading 1) */ + for(int i = 1; i < n; i++) + if(*pos < cap) out[(*pos)++] = 0; + /* The bits themselves */ + for(int i = 0; i < n; i++) + if(*pos < cap) out[(*pos)++] = bits[i]; +} + +size_t tagtinker_rle_compress( + const uint8_t* pixels, size_t count, + uint8_t* out, size_t out_cap, uint8_t* comp_type) { + + if(count == 0) { *comp_type = 0; return 0; } + + size_t pos = 0; + if(pos < out_cap) out[pos++] = pixels[0]; + + uint8_t run_pixel = pixels[0]; + uint32_t run_count = 1; + + for(size_t i = 1; i < count; i++) { + if(pixels[i] == run_pixel) { + run_count++; + } else { + record_run(out, &pos, out_cap, run_count); + run_pixel = pixels[i]; + run_count = 1; + } + } + if(run_count > 1) record_run(out, &pos, out_cap, run_count); + + if(pos < count) { + *comp_type = 2; /* RLE */ + return pos; + } + /* Compression didn't help — use raw */ + memcpy(out, pixels, count < out_cap ? count : out_cap); + *comp_type = 0; + return count < out_cap ? count : out_cap; +} + +/* ── Image upload sequence builder ──────────────────────────── */ + +#define DATA_BYTES_PER_FRAME 20 +#define DATA_BITS_PER_FRAME (DATA_BYTES_PER_FRAME * 8) + +void tagtinker_build_image_sequence( + TagTinkerApp* app, + const uint8_t plid[4], + const uint8_t* pixels, + uint16_t width, uint16_t height, + uint8_t page, + uint16_t pos_x, uint16_t pos_y, + uint16_t wake_repeats) { + + size_t pixel_count = (size_t)width * height; + + /* Prepare pixel data — optionally add color-clear plane */ + size_t total_pixels; + uint8_t* combined = NULL; + const uint8_t* compress_src; + + if(app->color_clear) { + /* Two planes: BW + color-clear (removes red artifacts) */ + total_pixels = pixel_count * 2; + combined = malloc(total_pixels); + if(!combined) return; + memcpy(combined, pixels, pixel_count); + memset(combined + pixel_count, 1, pixel_count); /* 1 = Red Pigment OFF */ + compress_src = combined; + } else { + /* Single BW plane — faster, less data */ + total_pixels = pixel_count; + compress_src = pixels; + } + + /* Compress */ + size_t comp_cap = total_pixels + 256; + uint8_t* comp_bits = malloc(comp_cap); + if(!comp_bits) { if(combined) free(combined); return; } + + uint8_t comp_type; + size_t comp_len = tagtinker_rle_compress( + compress_src, total_pixels, comp_bits, comp_cap, &comp_type); + + const uint8_t* src_bits = (comp_type == 2) ? comp_bits : compress_src; + size_t src_len = (comp_type == 2) ? comp_len : total_pixels; + + /* Pack bits into bytes */ + size_t padding = (DATA_BITS_PER_FRAME - (src_len % DATA_BITS_PER_FRAME)) % DATA_BITS_PER_FRAME; + size_t padded_bits = src_len + padding; + size_t padded_bytes = padded_bits / 8; + + uint8_t* data_bytes = calloc(padded_bytes, 1); + if(!data_bytes) { free(comp_bits); free(combined); return; } + + for(size_t i = 0; i < src_len; i++) { + size_t byte_idx = i / 8; + size_t bit_idx = 7 - (i % 8); + if(byte_idx < padded_bytes && src_bits[i]) + data_bytes[byte_idx] |= (1 << bit_idx); + } + + size_t frame_count = padded_bytes / DATA_BYTES_PER_FRAME; + + FURI_LOG_I("TagTinker", "IMG %ux%u pg=%u comp=%u %zu->%zu frames=%zu", + width, height, page, comp_type, total_pixels, padded_bytes, frame_count); + + /* Free temp buffers */ + free(comp_bits); + if(combined) free(combined); + + /* Total: ping + params + N data + refresh */ + size_t total = 2 + frame_count + 1; + + app->frame_seq_count = total; + app->frame_sequence = malloc(sizeof(uint8_t*) * total); + app->frame_lengths = malloc(sizeof(size_t) * total); + app->frame_repeats = malloc(sizeof(uint16_t) * total); + + if(!app->frame_sequence || !app->frame_lengths || !app->frame_repeats) { + free(data_bytes); + app->frame_seq_count = 0; + return; + } + + size_t idx = 0; + + /* 1. Ping */ + app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); + app->frame_lengths[idx] = tagtinker_make_ping_frame(app->frame_sequence[idx], plid); + app->frame_repeats[idx] = wake_repeats; + idx++; + + /* 2. Parameters (cmd 0x05) */ + app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); + size_t p = mcu_frame(app->frame_sequence[idx], plid, 0x05); + append_word(app->frame_sequence[idx], &p, (uint16_t)padded_bytes); + app->frame_sequence[idx][p++] = 0x00; + app->frame_sequence[idx][p++] = comp_type; + app->frame_sequence[idx][p++] = page; + append_word(app->frame_sequence[idx], &p, width); + append_word(app->frame_sequence[idx], &p, height); + append_word(app->frame_sequence[idx], &p, pos_x); + append_word(app->frame_sequence[idx], &p, pos_y); + append_word(app->frame_sequence[idx], &p, 0x0000); + app->frame_sequence[idx][p++] = 0x88; + append_word(app->frame_sequence[idx], &p, 0x0000); + for(int i = 0; i < 4; i++) app->frame_sequence[idx][p++] = 0x00; + app->frame_lengths[idx] = terminate(app->frame_sequence[idx], p); + app->frame_repeats[idx] = 1; + idx++; + + /* 3..N+2. Data frames (cmd 0x20) */ + for(size_t fi = 0; fi < frame_count; fi++) { + app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); + p = mcu_frame(app->frame_sequence[idx], plid, 0x20); + append_word(app->frame_sequence[idx], &p, (uint16_t)fi); + size_t start = fi * DATA_BYTES_PER_FRAME; + memcpy(&app->frame_sequence[idx][p], &data_bytes[start], DATA_BYTES_PER_FRAME); + p += DATA_BYTES_PER_FRAME; + app->frame_lengths[idx] = terminate(app->frame_sequence[idx], p); + app->frame_repeats[idx] = 3; /* 3 repeats per data frame for reliability */ + idx++; + } + + /* N+3. Refresh */ + app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); + app->frame_lengths[idx] = tagtinker_make_refresh_frame(app->frame_sequence[idx], plid); + app->frame_repeats[idx] = 1; + + /* Copy first data frame for display in TX scene */ + if(app->frame_seq_count > 1) { + memcpy(app->frame_buf, app->frame_sequence[1], + app->frame_lengths[1] < TAGTINKER_MAX_FRAME_SIZE + ? app->frame_lengths[1] : TAGTINKER_MAX_FRAME_SIZE); + app->frame_len = app->frame_lengths[1]; + } + + free(data_bytes); +} diff --git a/protocol/tagtinker_proto.h b/protocol/tagtinker_proto.h new file mode 100644 index 0000000..265d213 --- /dev/null +++ b/protocol/tagtinker_proto.h @@ -0,0 +1,72 @@ +/* + * TagTinker - ESL protocol helpers + * + * Frame construction, CRC, and encoding for the infrared + * ESL protocol used by this project. Ported from furrtek/TagTinker. + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include +#include + +#define TAGTINKER_PROTO_DM 0x85 /* Dot-matrix / graphic ESLs */ +#define TAGTINKER_PROTO_SEG 0x84 /* 7-segment ESLs */ +#define TAGTINKER_MAX_FRAME_SIZE 96 + +/* Forward declaration for TagTinkerApp (avoids circular include) */ +typedef struct TagTinkerApp TagTinkerApp; + +/* ── CRC ────────────────────────────────────────────────────── */ + +uint16_t tagtinker_crc16(const uint8_t* data, size_t len); + +/* ── Barcode / PLID ─────────────────────────────────────────── */ + +bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]); + +/* ── Frame builders ─────────────────────────────────────────── */ + +/* Broadcast page-change (no barcode needed). */ +size_t tagtinker_build_broadcast_page_frame( + uint8_t* buf, uint8_t page, bool forever, uint16_t duration); + +/* Broadcast diagnostic page. */ +size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf); + +/* Addressed DM frame: wraps raw payload with protocol + PLID + CRC. */ +size_t tagtinker_make_addressed_frame( + uint8_t* buf, const uint8_t plid[4], + const uint8_t* payload, size_t payload_len); + +/* Wake-up ping (must be sent before most addressed commands). */ +size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]); + +/* Display refresh request. */ +size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]); + +/* MCU-level frame (used for image upload protocol). */ +size_t tagtinker_make_mcu_frame( + uint8_t* buf, const uint8_t plid[4], uint8_t cmd); + +/* ── Image upload helpers ───────────────────────────────────── */ + +/* RLE-compress a pixel array (0/1 values). + * Returns compressed bitstream length. comp_type is set to 0 (raw) or 2 (RLE). */ +size_t tagtinker_rle_compress( + const uint8_t* pixels, size_t count, + uint8_t* out, size_t out_cap, uint8_t* comp_type); + +/* Build a complete image-upload frame sequence and store it in app state. + * Allocates memory that the transmit scene frees on exit. */ +void tagtinker_build_image_sequence( + TagTinkerApp* app, + const uint8_t plid[4], + const uint8_t* pixels, + uint16_t width, uint16_t height, + uint8_t page, + uint16_t pos_x, uint16_t pos_y, + uint16_t wake_repeats); diff --git a/scenes/tagtinker_scene.c b/scenes/tagtinker_scene.c new file mode 100644 index 0000000..e92ef50 --- /dev/null +++ b/scenes/tagtinker_scene.c @@ -0,0 +1,57 @@ +/* + * Scene handler table + */ + +#include "tagtinker_scene.h" + +void(*const tagtinker_scene_on_enter_handlers[])(void*) = { + tagtinker_scene_main_menu_on_enter, + tagtinker_scene_broadcast_menu_on_enter, + tagtinker_scene_broadcast_on_enter, + tagtinker_scene_target_menu_on_enter, + tagtinker_scene_target_actions_on_enter, + tagtinker_scene_barcode_input_on_enter, + tagtinker_scene_text_input_on_enter, + tagtinker_scene_preset_list_on_enter, + tagtinker_scene_size_picker_on_enter, + tagtinker_scene_image_upload_on_enter, + tagtinker_scene_transmit_on_enter, + tagtinker_scene_about_on_enter, +}; + +bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = { + tagtinker_scene_main_menu_on_event, + tagtinker_scene_broadcast_menu_on_event, + tagtinker_scene_broadcast_on_event, + tagtinker_scene_target_menu_on_event, + tagtinker_scene_target_actions_on_event, + tagtinker_scene_barcode_input_on_event, + tagtinker_scene_text_input_on_event, + tagtinker_scene_preset_list_on_event, + tagtinker_scene_size_picker_on_event, + tagtinker_scene_image_upload_on_event, + tagtinker_scene_transmit_on_event, + tagtinker_scene_about_on_event, +}; + +void(*const tagtinker_scene_on_exit_handlers[])(void*) = { + tagtinker_scene_main_menu_on_exit, + tagtinker_scene_broadcast_menu_on_exit, + tagtinker_scene_broadcast_on_exit, + tagtinker_scene_target_menu_on_exit, + tagtinker_scene_target_actions_on_exit, + tagtinker_scene_barcode_input_on_exit, + tagtinker_scene_text_input_on_exit, + tagtinker_scene_preset_list_on_exit, + tagtinker_scene_size_picker_on_exit, + tagtinker_scene_image_upload_on_exit, + tagtinker_scene_transmit_on_exit, + tagtinker_scene_about_on_exit, +}; + +const SceneManagerHandlers tagtinker_scene_handlers = { + .on_enter_handlers = tagtinker_scene_on_enter_handlers, + .on_event_handlers = tagtinker_scene_on_event_handlers, + .on_exit_handlers = tagtinker_scene_on_exit_handlers, + .scene_num = TagTinkerSceneCount, +}; diff --git a/scenes/tagtinker_scene.h b/scenes/tagtinker_scene.h new file mode 100644 index 0000000..03a988e --- /dev/null +++ b/scenes/tagtinker_scene.h @@ -0,0 +1,72 @@ +/* + * TagTinker — Scene Definitions + */ + +#pragma once + +#include + +typedef enum { + TagTinkerSceneMainMenu, + TagTinkerSceneBroadcastMenu, + TagTinkerSceneBroadcast, + TagTinkerSceneTargetMenu, + TagTinkerSceneTargetActions, + TagTinkerSceneBarcodeInput, + TagTinkerSceneTextInput, + TagTinkerScenePresetList, + TagTinkerSceneSizePicker, + TagTinkerSceneImageUpload, + TagTinkerSceneTransmit, + TagTinkerSceneAbout, + TagTinkerSceneCount, +} TagTinkerScene; + +/* Scene handler declarations */ +void tagtinker_scene_main_menu_on_enter(void* ctx); +bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_main_menu_on_exit(void* ctx); + +void tagtinker_scene_broadcast_menu_on_enter(void* ctx); +bool tagtinker_scene_broadcast_menu_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_broadcast_menu_on_exit(void* ctx); + +void tagtinker_scene_broadcast_on_enter(void* ctx); +bool tagtinker_scene_broadcast_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_broadcast_on_exit(void* ctx); + +void tagtinker_scene_target_menu_on_enter(void* ctx); +bool tagtinker_scene_target_menu_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_target_menu_on_exit(void* ctx); + +void tagtinker_scene_target_actions_on_enter(void* ctx); +bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_target_actions_on_exit(void* ctx); + +void tagtinker_scene_barcode_input_on_enter(void* ctx); +bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_barcode_input_on_exit(void* ctx); + +void tagtinker_scene_text_input_on_enter(void* ctx); +bool tagtinker_scene_text_input_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_text_input_on_exit(void* ctx); + +void tagtinker_scene_size_picker_on_enter(void* ctx); +bool tagtinker_scene_size_picker_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_size_picker_on_exit(void* ctx); + +void tagtinker_scene_preset_list_on_enter(void* ctx); +bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_preset_list_on_exit(void* ctx); + +void tagtinker_scene_image_upload_on_enter(void* ctx); +bool tagtinker_scene_image_upload_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_image_upload_on_exit(void* ctx); + +void tagtinker_scene_transmit_on_enter(void* ctx); +bool tagtinker_scene_transmit_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_transmit_on_exit(void* ctx); + +void tagtinker_scene_about_on_enter(void* ctx); +bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_about_on_exit(void* ctx); diff --git a/scenes/tagtinker_scene_about.c b/scenes/tagtinker_scene_about.c new file mode 100644 index 0000000..b5588d4 --- /dev/null +++ b/scenes/tagtinker_scene_about.c @@ -0,0 +1,63 @@ +/* + * About — credits (state=0) or Android teaser (state=1) + */ + +#include "../tagtinker_app.h" + +typedef struct { + uint32_t mode; + uint32_t tick; +} AboutViewModel; + +static void about_draw_cb(Canvas* canvas, void* _model) { + AboutViewModel* model = _model; + + canvas_set_font(canvas, FontPrimary); + if(model->mode == 1) { + canvas_set_font(canvas, FontPrimary); + canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignCenter, "In Progress"); + } else { + canvas_draw_str_aligned( + canvas, 64, 10, AlignCenter, AlignTop, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION); + + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned(canvas, 64, 24, AlignCenter, AlignTop, "Ported by I12BP8"); + canvas_draw_str_aligned(canvas, 64, 34, AlignCenter, AlignTop, "Research by furrtek"); + canvas_draw_str_aligned(canvas, 64, 44, AlignCenter, AlignTop, "Owned ESLs only"); + canvas_draw_str_aligned(canvas, 64, 54, AlignCenter, AlignTop, "No store use"); + } +} + +void tagtinker_scene_about_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneAbout); + + if(!app->about_view_allocated) { + view_allocate_model(app->about_view, ViewModelTypeLockFree, sizeof(AboutViewModel)); + view_set_context(app->about_view, app); + view_set_draw_callback(app->about_view, about_draw_cb); + app->about_view_allocated = true; + } + + AboutViewModel* model = view_get_model(app->about_view); + model->mode = mode; + model->tick = 0; + view_commit_model(app->about_view, true); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewAbout); +} + +bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type == SceneManagerEventTypeTick) { + AboutViewModel* model = view_get_model(app->about_view); + model->tick++; + view_commit_model(app->about_view, true); + return true; + } + return false; +} + +void tagtinker_scene_about_on_exit(void* ctx) { + UNUSED(ctx); +} diff --git a/scenes/tagtinker_scene_barcode_input.c b/scenes/tagtinker_scene_barcode_input.c new file mode 100644 index 0000000..444ca32 --- /dev/null +++ b/scenes/tagtinker_scene_barcode_input.c @@ -0,0 +1,78 @@ +/* + * Barcode Input — custom numlock digit selector + * Auto-prefixes 'N', user enters 16 digits with arrow keys. + */ + +#include "../tagtinker_app.h" + +static void numlock_done(void* ctx, const char* barcode) { + TagTinkerApp* app = ctx; + /* Copy result to app barcode buffer */ + strncpy(app->barcode, barcode, TAGTINKER_BC_LEN); + app->barcode[TAGTINKER_BC_LEN] = '\0'; + view_dispatcher_send_custom_event(app->view_dispatcher, 0); +} + +void tagtinker_scene_barcode_input_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + numlock_input_reset(app->numlock); + numlock_input_set_callback(app->numlock, numlock_done, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewNumlock); +} + +bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + app->barcode_valid = tagtinker_barcode_to_plid(app->barcode, app->plid); + + if(!app->barcode_valid) { + FURI_LOG_W(TAGTINKER_TAG, "Invalid barcode: %s", app->barcode); + popup_reset(app->popup); + popup_set_header(app->popup, "Invalid Barcode", 64, 20, AlignCenter, AlignCenter); + popup_set_text(app->popup, + "Format: N + 4 + 14 digits", + 64, 40, AlignCenter, AlignCenter); + popup_set_timeout(app->popup, 2000); + popup_enable_timeout(app->popup); + popup_set_callback(app->popup, NULL); + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup); + return true; + } + + FURI_LOG_I(TAGTINKER_TAG, "Barcode: %s -> PLID %02X%02X%02X%02X", + app->barcode, app->plid[3], app->plid[2], app->plid[1], app->plid[0]); + + /* Auto-save target */ + bool exists = false; + for(uint8_t i = 0; i < app->target_count; i++) { + if(strcmp(app->targets[i].barcode, app->barcode) == 0) { + exists = true; + app->selected_target = i; + break; + } + } + if(!exists && app->target_count < TAGTINKER_MAX_TARGETS) { + TagTinkerTarget* t = &app->targets[app->target_count]; + memcpy(t->barcode, app->barcode, TAGTINKER_BC_LEN + 1); + memcpy(t->plid, app->plid, 4); + char suffix[7]; + memcpy(suffix, app->barcode + TAGTINKER_BC_LEN - 6, 6); + suffix[6] = '\0'; + snprintf(t->name, TAGTINKER_TARGET_NAME_LEN, "Tag ...%s", suffix); + app->selected_target = app->target_count; + app->target_count++; + } + + uint32_t target_scene = scene_manager_get_scene_state( + app->scene_manager, TagTinkerSceneBarcodeInput); + scene_manager_next_scene(app->scene_manager, target_scene); + return true; +} + +void tagtinker_scene_barcode_input_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + numlock_input_reset(app->numlock); +} diff --git a/scenes/tagtinker_scene_broadcast.c b/scenes/tagtinker_scene_broadcast.c new file mode 100644 index 0000000..0bc9b65 --- /dev/null +++ b/scenes/tagtinker_scene_broadcast.c @@ -0,0 +1,148 @@ +/** + * Broadcast page scene + * + * Sends either a page-select command or the diagnostic page command + * to all DM ESLs in range. + * No barcode needed - uses PLID 0 (broadcast). + * Settings: page (0-7), duration (2-120s), forever toggle. + */ + +#include "../tagtinker_app.h" + +enum { + BroadcastItemPage, + BroadcastItemDuration, + BroadcastItemForever, + BroadcastItemRepeats, + BroadcastItemSpam, +}; + +static const char* page_labels[] = {"0", "1", "2", "3", "4", "5", "6", "7"}; +static const char* forever_labels[] = {"Off", "On"}; +static const char* repeat_labels[] = {"50", "100", "200", "400", "800"}; +static const uint16_t repeat_values[] = {50, 100, 200, 400, 800}; +#define REPEAT_COUNT 5 + +static void page_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + variable_item_set_current_value_text(item, page_labels[idx]); + app->page = idx; +} + +static void duration_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + uint16_t durations[] = {2, 5, 10, 15, 30, 60, 120}; + const char* dur_labels[] = {"2s", "5s", "10s", "15s", "30s", "60s", "120s"}; + variable_item_set_current_value_text(item, dur_labels[idx]); + app->duration = durations[idx]; +} + +static void forever_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + variable_item_set_current_value_text(item, forever_labels[idx]); + app->forever = (idx == 1); +} + +static void spam_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + variable_item_set_current_value_text(item, forever_labels[idx]); + app->tx_spam = (idx == 1); +} + +static void repeats_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + variable_item_set_current_value_text(item, repeat_labels[idx]); + app->repeats = repeat_values[idx]; +} + +static void broadcast_enter_callback(void* context, uint32_t index) { + UNUSED(index); + TagTinkerApp* app = context; + view_dispatcher_send_custom_event(app->view_dispatcher, 0); +} + +void tagtinker_scene_broadcast_on_enter(void* context) { + TagTinkerApp* app = context; + VariableItemList* vil = app->var_item_list; + + variable_item_list_reset(vil); + + VariableItem* item; + + /* Build list conditionally */ + if(app->broadcast_type == TagTinkerBroadcastFlipPage) { + /* Page selector 0-7 */ + item = variable_item_list_add(vil, "Page", 8, page_changed, app); + variable_item_set_current_value_index(item, app->page); + variable_item_set_current_value_text(item, page_labels[app->page]); + + /* Duration */ + item = variable_item_list_add(vil, "Duration", 7, duration_changed, app); + /* Find closest index */ + uint16_t durations[] = {2, 5, 10, 15, 30, 60, 120}; + uint8_t dur_idx = 3; /* default 15s */ + for(uint8_t i = 0; i < 7; i++) { + if(durations[i] == app->duration) { dur_idx = i; break; } + } + const char* dur_labels[] = {"2s", "5s", "10s", "15s", "30s", "60s", "120s"}; + variable_item_set_current_value_index(item, dur_idx); + variable_item_set_current_value_text(item, dur_labels[dur_idx]); + + /* Forever toggle */ + item = variable_item_list_add(vil, "Forever", 2, forever_changed, app); + variable_item_set_current_value_index(item, app->forever ? 1 : 0); + variable_item_set_current_value_text(item, forever_labels[app->forever ? 1 : 0]); + } + + /* Repeats (for both) */ + item = variable_item_list_add(vil, "Repeats", REPEAT_COUNT, repeats_changed, app); + uint8_t rep_idx = 2; /* default 200 */ + for(uint8_t i = 0; i < REPEAT_COUNT; i++) { + if(repeat_values[i] == app->repeats) { rep_idx = i; break; } + } + variable_item_set_current_value_index(item, rep_idx); + variable_item_set_current_value_text(item, repeat_labels[rep_idx]); + + /* Continuous repeat toggle */ + item = variable_item_list_add(vil, "Repeat", 2, spam_changed, app); + variable_item_set_current_value_index(item, app->tx_spam ? 1 : 0); + variable_item_set_current_value_text(item, forever_labels[app->tx_spam ? 1 : 0]); + + /* OK press on any item → trigger transmit */ + variable_item_list_set_enter_callback(vil, broadcast_enter_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList); +} + +bool tagtinker_scene_broadcast_on_event(void* context, SceneManagerEvent event) { + TagTinkerApp* app = context; + bool consumed = false; + + if(event.type == SceneManagerEventTypeCustom) { + if(app->broadcast_type == TagTinkerBroadcastFlipPage) { + /* Build page broadcast frame and transmit */ + app->frame_len = tagtinker_build_broadcast_page_frame( + app->frame_buf, app->page, app->forever, app->duration); + } else { + /* Build debug broadcast frame */ + app->frame_len = tagtinker_build_broadcast_debug_frame(app->frame_buf); + } + + /* Set up single-frame TX */ + app->frame_seq_count = 0; /* Use single frame mode */ + + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); + consumed = true; + } + return consumed; +} + +void tagtinker_scene_broadcast_on_exit(void* context) { + TagTinkerApp* app = context; + variable_item_list_reset(app->var_item_list); +} diff --git a/scenes/tagtinker_scene_broadcast_menu.c b/scenes/tagtinker_scene_broadcast_menu.c new file mode 100644 index 0000000..53c2058 --- /dev/null +++ b/scenes/tagtinker_scene_broadcast_menu.c @@ -0,0 +1,50 @@ +/* + * Broadcast menu - page select / diagnostic page + */ + +#include "../tagtinker_app.h" + +static void broadcast_menu_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +void tagtinker_scene_broadcast_menu_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Broadcast Tag"); + + submenu_add_item(app->submenu, "Change Page", TagTinkerBroadcastFlipPage, broadcast_menu_cb, app); + submenu_add_item(app->submenu, "Show Debug Page", TagTinkerBroadcastDebugScreen, broadcast_menu_cb, app); + + submenu_set_selected_item( + app->submenu, + scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneBroadcastMenu)); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); +} + +bool tagtinker_scene_broadcast_menu_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneBroadcastMenu, event.event); + + switch(event.event) { + case TagTinkerBroadcastFlipPage: + app->broadcast_type = TagTinkerBroadcastFlipPage; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneBroadcast); + return true; + case TagTinkerBroadcastDebugScreen: + app->broadcast_type = TagTinkerBroadcastDebugScreen; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneBroadcast); + return true; + } + return false; +} + +void tagtinker_scene_broadcast_menu_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + submenu_reset(app->submenu); +} diff --git a/scenes/tagtinker_scene_image_upload.c b/scenes/tagtinker_scene_image_upload.c new file mode 100644 index 0000000..3eaef39 --- /dev/null +++ b/scenes/tagtinker_scene_image_upload.c @@ -0,0 +1,116 @@ +#include "../tagtinker_app.h" +#include +#include +#include + +static void show_error_dialog(TagTinkerApp* app, const char* text) { + DialogMessage* message = dialog_message_alloc(); + dialog_message_set_header(message, "Load Error", 64, 0, AlignCenter, AlignTop); + dialog_message_set_text(message, text, 64, 26, AlignCenter, AlignCenter); + dialog_message_set_buttons(message, "OK", NULL, NULL); + dialog_message_show(app->dialogs, message); + dialog_message_free(message); +} + +void tagtinker_scene_image_upload_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + TagTinkerTarget* target = &app->targets[app->selected_target]; + + FuriString* file_path = furi_string_alloc(); + furi_string_set(file_path, "/ext/apps_data/tagtinker"); + + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_simply_mkdir(storage, furi_string_get_cstr(file_path)); + + DialogsFileBrowserOptions browser_options; + memset(&browser_options, 0, sizeof(browser_options)); + dialog_file_browser_set_basic_options(&browser_options, ".bmp", NULL); + browser_options.hide_dot_files = true; + + if(dialog_file_browser_show(app->dialogs, file_path, file_path, &browser_options)) { + File* file = storage_file_alloc(storage); + if(storage_file_open(file, furi_string_get_cstr(file_path), FSAM_READ, FSOM_OPEN_EXISTING)) { + uint8_t header[54]; + if(storage_file_read(file, header, sizeof(header)) == sizeof(header)) { + if(header[0] == 'B' && header[1] == 'M') { + uint32_t data_offset = header[10] | (header[11] << 8) | (header[12] << 16) | (header[13] << 24); + int32_t bmp_w = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); + int32_t bmp_h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); + uint16_t bpp = header[28] | (header[29] << 8); + + bool top_down = false; + if(bmp_h < 0) { + top_down = true; + bmp_h = -bmp_h; + } + + if(bpp == 1) { + size_t w = (size_t)bmp_w; + size_t h = (size_t)bmp_h; + uint8_t* img_data = malloc(w * h); + memset(img_data, 0, w * h); + + uint32_t row_stride = ((bmp_w + 31) / 32) * 4; + uint8_t* row_buf = malloc(row_stride); + + storage_file_seek(file, data_offset, true); + + for(int32_t y = 0; y < bmp_h; y++) { + /* target_y is the logical row index 0..h-1 */ + int32_t target_y = top_down ? y : (bmp_h - 1 - y); + storage_file_read(file, row_buf, row_stride); + + if(target_y >= 0 && target_y < (int32_t)h) { + for(int32_t x = 0; x < bmp_w && x < (int32_t)w; x++) { + uint8_t byte = row_buf[x / 8]; + uint8_t bit = (byte >> (7 - (x % 8))) & 1; + img_data[target_y * w + x] = (bit == 0) ? 0 : 1; + } + } + } + free(row_buf); + + /* Force color-clear and use BMP's own dimensions */ + bool prev_clr = app->color_clear; + app->color_clear = true; + tagtinker_build_image_sequence(app, target->plid, img_data, (uint16_t)w, (uint16_t)h, 0, 0, 0, 250); + app->color_clear = prev_clr; + + memcpy(app->frame_buf, app->frame_sequence[0], app->frame_lengths[0]); + app->frame_len = app->frame_lengths[0]; + free(img_data); + + app->tx_spam = false; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); + } else { + show_error_dialog(app, "Must be 1-bit BMP"); + } + } else { + show_error_dialog(app, "Invalid BMP magic"); + } + } else { + show_error_dialog(app, "File too small"); + } + storage_file_close(file); + } else { + show_error_dialog(app, "Could not open file"); + } + storage_file_free(file); + } else { + /* User cancelled browser */ + scene_manager_previous_scene(app->scene_manager); + } + + furi_record_close(RECORD_STORAGE); + furi_string_free(file_path); +} + +bool tagtinker_scene_image_upload_on_event(void* ctx, SceneManagerEvent event) { + UNUSED(ctx); + UNUSED(event); + return false; +} + +void tagtinker_scene_image_upload_on_exit(void* ctx) { + UNUSED(ctx); +} diff --git a/scenes/tagtinker_scene_main_menu.c b/scenes/tagtinker_scene_main_menu.c new file mode 100644 index 0000000..a65d411 --- /dev/null +++ b/scenes/tagtinker_scene_main_menu.c @@ -0,0 +1,66 @@ +/* + * Main Menu + */ + +#include "../tagtinker_app.h" + +enum { + MainMenuBroadcast, + MainMenuTargetESL, + MainMenuAndroid, + MainMenuAbout, +}; + +static void main_menu_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +void tagtinker_scene_main_menu_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION); + + submenu_add_item(app->submenu, "Broadcast Tag", MainMenuBroadcast, main_menu_cb, app); + submenu_add_item(app->submenu, "Target Tag", MainMenuTargetESL, main_menu_cb, app); + submenu_add_item(app->submenu, "Android App", MainMenuAndroid, main_menu_cb, app); + submenu_add_item(app->submenu, "About", MainMenuAbout, main_menu_cb, app); + + submenu_set_selected_item( + app->submenu, + scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneMainMenu)); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); +} + +bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneMainMenu, event.event); + + switch(event.event) { + case MainMenuBroadcast: + scene_manager_next_scene(app->scene_manager, TagTinkerSceneBroadcastMenu); + return true; + case MainMenuTargetESL: + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTargetMenu); + return true; + case MainMenuAndroid: + /* state=1 tells About scene to show Android teaser */ + scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 1); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout); + return true; + case MainMenuAbout: + scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 0); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout); + return true; + } + return false; +} + +void tagtinker_scene_main_menu_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + submenu_reset(app->submenu); +} diff --git a/scenes/tagtinker_scene_preset_list.c b/scenes/tagtinker_scene_preset_list.c new file mode 100644 index 0000000..cf81f1b --- /dev/null +++ b/scenes/tagtinker_scene_preset_list.c @@ -0,0 +1,150 @@ +/* + * Preset List — first screen after "Push Text". + * + * Shows saved presets (click = instant transmit with saved text + settings). + * "[+] Add New Preset" goes to text input → size picker → save. + */ + +#include "../tagtinker_app.h" +#include "../views/tagtinker_font.h" +#include "../protocol/tagtinker_proto.h" + +#define EVT_ADD_NEW 200 +#define EVT_PRESET 0 + +/* Load presets from SD card */ +static void presets_load(TagTinkerApp* app) { + app->preset_count = 0; + + Storage* storage = furi_record_open(RECORD_STORAGE); + File* file = storage_file_alloc(storage); + + if(storage_file_open(file, APP_DATA_PATH("presets.txt"), + FSAM_READ, FSOM_OPEN_EXISTING)) { + char buf[512]; + uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1); + buf[read] = '\0'; + storage_file_close(file); + + char* line = buf; + while(line && *line && app->preset_count < TAGTINKER_MAX_PRESETS) { + char* nl = strchr(line, '\n'); + if(nl) *nl = '\0'; + + unsigned w, h, pg, inv, clr; + if(sscanf(line, "%u|%u|%u|%u|%u|", &w, &h, &pg, &inv, &clr) == 5) { + char* p = line; + int pipes = 0; + while(*p && pipes < 5) { if(*p == '|') pipes++; p++; } + + uint8_t idx = app->preset_count++; + app->presets[idx].width = (uint16_t)w; + app->presets[idx].height = (uint16_t)h; + app->presets[idx].page = (uint8_t)pg; + app->presets[idx].invert = (inv != 0); + app->presets[idx].color_clear = (clr != 0); + memset(app->presets[idx].text, 0, TAGTINKER_PRESET_TEXT_LEN); + if(pipes == 5) { + strncpy(app->presets[idx].text, p, TAGTINKER_PRESET_TEXT_LEN - 1); + } + } + line = nl ? nl + 1 : NULL; + } + } + storage_file_free(file); + furi_record_close(RECORD_STORAGE); +} + +static void preset_list_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +/* Static label storage */ +static char preset_labels[TAGTINKER_MAX_PRESETS][48]; + +void tagtinker_scene_preset_list_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + presets_load(app); + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Text Presets"); + + /* Add New Preset option first */ + submenu_add_item(app->submenu, "[+] New Preset", + EVT_ADD_NEW, preset_list_cb, app); + + /* Saved presets */ + for(uint8_t i = 0; i < app->preset_count; i++) { + snprintf(preset_labels[i], sizeof(preset_labels[i]), + "%ux%u \"%s\"", + app->presets[i].width, + app->presets[i].height, + app->presets[i].text); + submenu_add_item(app->submenu, preset_labels[i], + EVT_PRESET + i, preset_list_cb, app); + } + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); +} + +bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + if(event.event == EVT_ADD_NEW) { + /* Clear text buffer and go to text input */ + memset(app->text_input_buf, 0, sizeof(app->text_input_buf)); + scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneTextInput, 0); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextInput); + return true; + } + + /* Preset selected — load text + settings and transmit */ + uint32_t idx = event.event - EVT_PRESET; + if(idx < app->preset_count) { + /* Load preset into app state */ + app->esl_width = app->presets[idx].width; + app->esl_height = app->presets[idx].height; + app->img_page = app->presets[idx].page; + app->invert_text = app->presets[idx].invert; + app->color_clear = app->presets[idx].color_clear; + strncpy(app->text_input_buf, app->presets[idx].text, + sizeof(app->text_input_buf) - 1); + + FURI_LOG_I(TAGTINKER_TAG, "Preset %lu: %ux%u \"%s\"", + idx, app->esl_width, app->esl_height, app->text_input_buf); + + /* Render + transmit */ + size_t num_pixels = (size_t)app->esl_width * app->esl_height; + uint8_t* pixels = malloc(num_pixels); + if(!pixels) return false; + + if(app->invert_text) { + render_text_ex(pixels, app->esl_width, app->esl_height, + app->text_input_buf, 0, 1); + } else { + render_text(pixels, app->esl_width, app->esl_height, + app->text_input_buf); + } + + TagTinkerTarget* target = &app->targets[app->selected_target]; + tagtinker_build_image_sequence( + app, target->plid, pixels, + app->esl_width, app->esl_height, app->img_page, + 0, 0, 250); + + free(pixels); + app->tx_spam = false; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); + return true; + } + + return false; +} + +void tagtinker_scene_preset_list_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + submenu_reset(app->submenu); +} diff --git a/scenes/tagtinker_scene_size_picker.c b/scenes/tagtinker_scene_size_picker.c new file mode 100644 index 0000000..49c0064 --- /dev/null +++ b/scenes/tagtinker_scene_size_picker.c @@ -0,0 +1,239 @@ +/* + * Size Picker — custom W/H (5px steps), mode, page, color, transmit + */ + +#include "../tagtinker_app.h" +#include "../views/tagtinker_font.h" +#include "../protocol/tagtinker_proto.h" +#include + +/* Limits */ +#define MIN_W 48 +#define MAX_W 200 +#define STEP_W 8 +#define MIN_H 50 +#define MAX_H 90 +#define STEP_H 5 + +#define W_COUNT ((MAX_W - MIN_W) / STEP_W + 1) +#define H_COUNT ((MAX_H - MIN_H) / STEP_H + 1) + +/* Setting indices */ +enum { + SettingWidth, + SettingHeight, + SettingPage, + SettingColor, + SettingMode, + SettingSave, + SettingTransmit, +}; + +/* ── Callbacks ── */ + +static void width_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + app->esl_width = MIN_W + idx * STEP_W; + + char buf[8]; + snprintf(buf, sizeof(buf), "%u", app->esl_width); + variable_item_set_current_value_text(item, buf); +} + +static void height_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + uint8_t idx = variable_item_get_current_value_index(item); + app->esl_height = MIN_H + idx * STEP_H; + + char buf[8]; + snprintf(buf, sizeof(buf), "%u", app->esl_height); + variable_item_set_current_value_text(item, buf); +} + +static void page_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + app->img_page = variable_item_get_current_value_index(item); + + char buf[4]; + snprintf(buf, sizeof(buf), "%u", app->img_page); + variable_item_set_current_value_text(item, buf); +} + +static void color_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + app->invert_text = (variable_item_get_current_value_index(item) == 1); + + variable_item_set_current_value_text( + item, app->invert_text ? "W on B" : "B on W"); +} + +static void mode_changed(VariableItem* item) { + TagTinkerApp* app = variable_item_get_context(item); + app->color_clear = (variable_item_get_current_value_index(item) == 1); + + variable_item_set_current_value_text( + item, app->color_clear ? "BW+Clr" : "BW Fast"); +} + +static void save_presets_to_sd(TagTinkerApp* app) { + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_common_mkdir(storage, APP_DATA_PATH("")); + + File* file = storage_file_alloc(storage); + if(storage_file_open(file, APP_DATA_PATH("presets.txt"), + FSAM_WRITE, FSOM_CREATE_ALWAYS)) { + for(uint8_t i = 0; i < app->preset_count; i++) { + char line[80]; + int len = snprintf(line, sizeof(line), "%u|%u|%u|%d|%d|%s\n", + app->presets[i].width, app->presets[i].height, + app->presets[i].page, + app->presets[i].invert ? 1 : 0, + app->presets[i].color_clear ? 1 : 0, + app->presets[i].text); + storage_file_write(file, line, (uint16_t)len); + } + storage_file_close(file); + } + storage_file_free(file); + furi_record_close(RECORD_STORAGE); +} + +static void setting_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + + if(index == SettingSave) { + /* Save current settings as a preset */ + if(app->preset_count < TAGTINKER_MAX_PRESETS) { + uint8_t idx = app->preset_count++; + app->presets[idx].width = app->esl_width; + app->presets[idx].height = app->esl_height; + app->presets[idx].page = app->img_page; + app->presets[idx].invert = app->invert_text; + app->presets[idx].color_clear = app->color_clear; + strncpy(app->presets[idx].text, app->text_input_buf, + TAGTINKER_PRESET_TEXT_LEN - 1); + app->presets[idx].text[TAGTINKER_PRESET_TEXT_LEN - 1] = '\0'; + + save_presets_to_sd(app); + notification_message(app->notifications, &sequence_success); + FURI_LOG_I(TAGTINKER_TAG, "Preset saved: %ux%u", app->esl_width, app->esl_height); + } + return; + } + + if(index != SettingTransmit) return; + + FURI_LOG_I(TAGTINKER_TAG, "TX: %ux%u pg=%u inv=%d clr=%d", + app->esl_width, app->esl_height, app->img_page, + app->invert_text, app->color_clear); + + size_t num_pixels = (size_t)app->esl_width * app->esl_height; + uint8_t* pixels = malloc(num_pixels); + if(!pixels) return; + + if(app->invert_text) { + render_text_ex(pixels, app->esl_width, app->esl_height, app->text_input_buf, 0, 1); + } else { + render_text(pixels, app->esl_width, app->esl_height, app->text_input_buf); + } + + TagTinkerTarget* target = &app->targets[app->selected_target]; + + tagtinker_build_image_sequence( + app, target->plid, pixels, + app->esl_width, app->esl_height, app->img_page, + 0, 0, 250); + + free(pixels); + app->tx_spam = false; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); +} + +/* ── Scene handlers ── */ + +void tagtinker_scene_size_picker_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + variable_item_list_reset(app->var_item_list); + + /* Clamp to valid grid positions */ + if(app->esl_width < MIN_W) app->esl_width = MIN_W; + if(app->esl_width > MAX_W) app->esl_width = MAX_W; + if(app->esl_height < MIN_H) app->esl_height = MIN_H; + if(app->esl_height > MAX_H) app->esl_height = MAX_H; + + /* Snap to nearest step */ + uint8_t w_idx = (app->esl_width - MIN_W + STEP_W / 2) / STEP_W; + if(w_idx >= W_COUNT) w_idx = W_COUNT - 1; + app->esl_width = MIN_W + w_idx * STEP_W; + + uint8_t h_idx = (app->esl_height - MIN_H + STEP_H / 2) / STEP_H; + if(h_idx >= H_COUNT) h_idx = H_COUNT - 1; + app->esl_height = MIN_H + h_idx * STEP_H; + + /* Width */ + VariableItem* item_w = variable_item_list_add( + app->var_item_list, "Width", W_COUNT, width_changed, app); + variable_item_set_current_value_index(item_w, w_idx); + { + char buf[8]; + snprintf(buf, sizeof(buf), "%u", app->esl_width); + variable_item_set_current_value_text(item_w, buf); + } + + /* Height */ + VariableItem* item_h = variable_item_list_add( + app->var_item_list, "Height", H_COUNT, height_changed, app); + variable_item_set_current_value_index(item_h, h_idx); + { + char buf[8]; + snprintf(buf, sizeof(buf), "%u", app->esl_height); + variable_item_set_current_value_text(item_h, buf); + } + + /* Page */ + VariableItem* item_pg = variable_item_list_add( + app->var_item_list, "Page", 8, page_changed, app); + variable_item_set_current_value_index(item_pg, app->img_page); + { + char buf[4]; + snprintf(buf, sizeof(buf), "%u", app->img_page); + variable_item_set_current_value_text(item_pg, buf); + } + + /* Color (text polarity) */ + VariableItem* item_col = variable_item_list_add( + app->var_item_list, "Color", 2, color_changed, app); + variable_item_set_current_value_index(item_col, app->invert_text ? 1 : 0); + variable_item_set_current_value_text( + item_col, app->invert_text ? "W on B" : "B on W"); + + /* Mode (BW Fast vs BW+Color clear) */ + VariableItem* item_mode = variable_item_list_add( + app->var_item_list, "Mode", 2, mode_changed, app); + variable_item_set_current_value_index(item_mode, app->color_clear ? 1 : 0); + variable_item_set_current_value_text( + item_mode, app->color_clear ? "BW+Clr" : "BW Fast"); + + /* Save preset button */ + variable_item_list_add(app->var_item_list, "[*] Save Preset", 0, NULL, app); + + /* Transmit button */ + variable_item_list_add(app->var_item_list, ">> Send to Tag <<", 0, NULL, app); + + variable_item_list_set_enter_callback(app->var_item_list, setting_cb, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList); +} + +bool tagtinker_scene_size_picker_on_event(void* ctx, SceneManagerEvent event) { + UNUSED(ctx); + UNUSED(event); + return false; +} + +void tagtinker_scene_size_picker_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + variable_item_list_reset(app->var_item_list); +} diff --git a/scenes/tagtinker_scene_target_actions.c b/scenes/tagtinker_scene_target_actions.c new file mode 100644 index 0000000..379fdd8 --- /dev/null +++ b/scenes/tagtinker_scene_target_actions.c @@ -0,0 +1,78 @@ +/* + * Target Actions — what to do with a selected ESL + */ + +#include "../tagtinker_app.h" + +static void target_actions_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +void tagtinker_scene_target_actions_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + submenu_reset(app->submenu); + + char header[24]; + snprintf(header, sizeof(header), "Tag: %.8s...", app->barcode); + submenu_set_header(app->submenu, header); + submenu_add_item(app->submenu, "Show Text Preset", TagTinkerTargetPushText, target_actions_cb, app); + submenu_add_item(app->submenu, "Show Custom Image", TagTinkerTargetPushImage, target_actions_cb, app); + submenu_add_item(app->submenu, "LED Response Check", TagTinkerTargetPingFlash, target_actions_cb, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); +} + +bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + switch(event.event) { + case TagTinkerTargetPushText: + scene_manager_next_scene(app->scene_manager, TagTinkerScenePresetList); + return true; + case TagTinkerTargetPushImage: + scene_manager_next_scene(app->scene_manager, TagTinkerSceneImageUpload); + return true; + case TagTinkerTargetPingFlash: + { + TagTinkerTarget* target = &app->targets[app->selected_target]; + + /* The blink command actually requires two frames: + * 1. A wake ping (lots of repeats) + * 2. The green LED command payload (0x06 0xC9 0x00 0x00 0x00 0x00) + */ + app->frame_seq_count = 2; + app->frame_sequence = malloc(sizeof(uint8_t*) * 2); + app->frame_lengths = malloc(sizeof(size_t) * 2); + app->frame_repeats = malloc(sizeof(uint16_t) * 2); + + /* 1. Wake ping */ + app->frame_sequence[0] = malloc(TAGTINKER_MAX_FRAME_SIZE); + app->frame_lengths[0] = tagtinker_make_ping_frame(app->frame_sequence[0], target->plid); + app->frame_repeats[0] = 500; + + /* 2. LED command */ + app->frame_sequence[1] = malloc(TAGTINKER_MAX_FRAME_SIZE); + const uint8_t blink_payload[6] = {0x06, 0xC9, 0x00, 0x00, 0x00, 0x00}; + app->frame_lengths[1] = tagtinker_make_addressed_frame( + app->frame_sequence[1], target->plid, blink_payload, 6); + app->frame_repeats[1] = 100; + + /* Put the first frame in the preview buffer */ + memcpy(app->frame_buf, app->frame_sequence[0], app->frame_lengths[0]); + app->frame_len = app->frame_lengths[0]; + + app->tx_spam = false; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); + } + return true; + } + return false; +} + +void tagtinker_scene_target_actions_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + submenu_reset(app->submenu); +} diff --git a/scenes/tagtinker_scene_target_menu.c b/scenes/tagtinker_scene_target_menu.c new file mode 100644 index 0000000..89fd36b --- /dev/null +++ b/scenes/tagtinker_scene_target_menu.c @@ -0,0 +1,67 @@ +/* + * Saved tag menu - saved targets + add new + */ + +#include "../tagtinker_app.h" + +enum { + TargetMenuAddNew = 100, +}; + +static void target_menu_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +void tagtinker_scene_target_menu_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Target Tag"); + + /* Add new target */ + submenu_add_item(app->submenu, "+ Add Tag", TargetMenuAddNew, target_menu_cb, app); + + /* List saved targets */ + for(uint8_t i = 0; i < app->target_count; i++) { + /* Use index as event id (0..15) */ + submenu_add_item( + app->submenu, + app->targets[i].name[0] ? app->targets[i].name : app->targets[i].barcode, + i, + target_menu_cb, + app); + } + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); +} + +bool tagtinker_scene_target_menu_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + if(event.event == TargetMenuAddNew) { + /* Go to barcode input, then come back */ + scene_manager_set_scene_state( + app->scene_manager, TagTinkerSceneBarcodeInput, TagTinkerSceneTargetActions); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneBarcodeInput); + return true; + } + + /* Selected a saved target */ + if(event.event < app->target_count) { + app->selected_target = (int8_t)event.event; + memcpy(app->barcode, app->targets[event.event].barcode, TAGTINKER_BC_LEN + 1); + memcpy(app->plid, app->targets[event.event].plid, 4); + app->barcode_valid = true; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTargetActions); + return true; + } + + return false; +} + +void tagtinker_scene_target_menu_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + submenu_reset(app->submenu); +} diff --git a/scenes/tagtinker_scene_text_input.c b/scenes/tagtinker_scene_text_input.c new file mode 100644 index 0000000..c2e3691 --- /dev/null +++ b/scenes/tagtinker_scene_text_input.c @@ -0,0 +1,54 @@ +/* + * TagTinker — Text Input Scene + * + * Prompts user for a text string, then goes to Size Picker. + */ + +#include "../tagtinker_app.h" + +static void text_input_done_cb(void* ctx) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, 0); +} + +void tagtinker_scene_text_input_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + bool clear = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneTextInput) == 0; + + if(clear) { + memset(app->text_input_buf, 0, sizeof(app->text_input_buf)); + scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneTextInput, 1); + } + + text_input_reset(app->text_input); + text_input_set_header_text(app->text_input, "Text to display:"); + text_input_set_result_callback( + app->text_input, + text_input_done_cb, + app, + app->text_input_buf, + sizeof(app->text_input_buf), + clear); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput); +} + +bool tagtinker_scene_text_input_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + if(strlen(app->text_input_buf) == 0) { + scene_manager_search_and_switch_to_previous_scene( + app->scene_manager, TagTinkerSceneTargetActions); + return true; + } + + /* Configure settings (Add Preset flow) */ + scene_manager_next_scene(app->scene_manager, TagTinkerSceneSizePicker); + return true; +} + +void tagtinker_scene_text_input_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + text_input_reset(app->text_input); +} diff --git a/scenes/tagtinker_scene_transmit.c b/scenes/tagtinker_scene_transmit.c new file mode 100644 index 0000000..1550689 --- /dev/null +++ b/scenes/tagtinker_scene_transmit.c @@ -0,0 +1,262 @@ +/** + * Transmit Scene + * Displays cool animations while transmitting in a background thread. + */ + +#include "../tagtinker_app.h" +#include + +typedef struct { + TagTinkerApp* app; + uint32_t tick; + bool completed; + bool ok; +} TxViewModel; + +static int32_t tx_thread_callback(void* context) { + TagTinkerApp* app = context; + bool ok = true; + + tagtinker_ir_init(); + + do { + if(app->frame_seq_count > 0) { + for(size_t i = 0; i < app->frame_seq_count; i++) { + if(!app->tx_active) { ok = false; break; } + if(i > 0) furi_delay_ms(20); + ok = tagtinker_ir_transmit( + app->frame_sequence[i], app->frame_lengths[i], + app->frame_repeats[i], 10); + if(!ok) break; + } + } else { + ok = tagtinker_ir_transmit(app->frame_buf, app->frame_len, app->repeats, 10); + } + + if(app->tx_spam && app->tx_active) { + furi_delay_ms(50); + } + } while(app->tx_spam && app->tx_active); + + app->tx_active = false; + /* Use event payload to securely pass result instead of querying running thread! */ + view_dispatcher_send_custom_event(app->view_dispatcher, 101 + (ok ? 0 : 1)); + return 0; +} + +static void transmit_draw_cb(Canvas* canvas, void* _model) { + TxViewModel* model = _model; + TagTinkerApp* app = model->app; + uint32_t t = model->tick; + + /* Left: Top-Down Detailed Flipper Zero Vector Model */ + int f_x = -15; // Protruding from left screen edge + int f_y = 10; + int f_w = 40; + int f_h = 36; + /* Main casing */ + canvas_draw_rframe(canvas, f_x, f_y, f_w, f_h, 3); + /* Outer screen outline */ + canvas_draw_rframe(canvas, f_x + 10, f_y + 4, 20, 16, 2); + /* Screen inner solid */ + canvas_draw_box(canvas, f_x + 12, f_y + 6, 16, 12); + /* Flipper Screen highlight glow */ + canvas_set_color(canvas, ColorWhite); + canvas_draw_dot(canvas, f_x + 14, f_y + 8); + canvas_draw_dot(canvas, f_x + 15, f_y + 8); + canvas_set_color(canvas, ColorBlack); + /* D-Pad Matrix */ + canvas_draw_circle(canvas, f_x + 18, f_y + 27, 5); // Outer + canvas_draw_circle(canvas, f_x + 18, f_y + 27, 2); // Inner button + /* GPIO / Case Vents */ + for(int i=0; i<4; i++) { + canvas_draw_dot(canvas, f_x + 32, f_y + 8 + i*3); + canvas_draw_dot(canvas, f_x + 34, f_y + 8 + i*3); + } + /* IR Blaster window on the right tip */ + int ir_x = f_x + f_w; // 25 + int ir_y = f_y + 10; + canvas_draw_box(canvas, ir_x, ir_y, 4, 16); + + int center_y = f_y + 18; /* perfectly aligns with tag center */ + + if (app->tx_active) { + /* Aggressive Pulsing Data-Stream Chevrons with bits */ + uint8_t wave_phase = (t * 2) % 15; + for(int w=0; w<2; w++) { + int r = wave_phase + w*18; + if(r > 0 && r < 20) { + int wave_x = ir_x + 4 + r; + int wave_h = r/2 + 3; + /* 2px thick chevron pointing right */ + canvas_draw_line(canvas, wave_x, center_y, wave_x - wave_h/2, center_y - wave_h); + canvas_draw_line(canvas, wave_x, center_y, wave_x - wave_h/2, center_y + wave_h); + canvas_draw_line(canvas, wave_x+1, center_y, wave_x+1 - wave_h/2, center_y - wave_h); + canvas_draw_line(canvas, wave_x+1, center_y, wave_x+1 - wave_h/2, center_y + wave_h); + /* Data stream particles */ + if((t + w) % 2 == 0) { + canvas_draw_dot(canvas, wave_x + 3, center_y - wave_h/2); + canvas_draw_dot(canvas, wave_x - 3, center_y + wave_h/2 + 2); + } + } + } + } + + /* Right: Detailed ESL tag model */ + int tag_x = 52; + int tag_y = 8; + int tag_w = 75; + int tag_h = 40; + /* Outer casing bounding rect */ + canvas_draw_rframe(canvas, tag_x, tag_y, tag_w, tag_h, 2); + /* Internal 3D bezel shadow */ + canvas_draw_rframe(canvas, tag_x+1, tag_y+1, tag_w-2, tag_h-2, 1); + + /* True 32-bit encoded barcode representation on the rim */ + int bc_x = tag_x + 3; + int bc_w = 8; + int bc_y = tag_y + 4; + uint8_t bar_pattern[] = {0b10110100, 0b11010010, 0b10011011, 0b01101010}; + for(int i=0; i<32; i++) { + if( (bar_pattern[i/8] >> (7-(i%8))) & 1 ) { + canvas_draw_line(canvas, bc_x, bc_y + i, bc_x + bc_w - 1, bc_y + i); + } + } + + /* Screen display hardware border */ + int scr_x = tag_x + 14; + int scr_y = tag_y + 3; + int scr_w = tag_w - 17; + int scr_h = tag_h - 6; + canvas_draw_frame(canvas, scr_x - 1, scr_y - 1, scr_w + 2, scr_h + 2); + + /* E-Paper Sweep Logic */ + int cycle = t % 40; /* 2 second loop at 20fps */ + + if (app->tx_active) { + bool show_result = (cycle >= 20); + + if(!show_result) { + canvas_set_font(canvas, FontSecondary); + canvas_draw_str(canvas, scr_x + 2, scr_y + 11, "LAB NOTE"); + canvas_set_font(canvas, FontPrimary); + canvas_draw_str(canvas, scr_x + 5, scr_y + 26, "STUDY"); + } else { + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned( + canvas, scr_x + scr_w / 2, scr_y + scr_h / 2, AlignCenter, AlignCenter, "FLIPPED ;)"); + } + + /* Glitchy Matrix Wipe Transition */ + if(cycle >= 15 && cycle < 20) { + int wipe_h = ((cycle - 15) * scr_h) / 5; + canvas_draw_box(canvas, scr_x, scr_y, scr_w, wipe_h); + /* Algorithmic leading edge burn/glitch */ + for(int xx = 0; xx < scr_w; xx += 2) { + canvas_draw_dot(canvas, scr_x + xx, scr_y + wipe_h + ((xx+t)%4)); + } + } else if (cycle >= 20 && cycle < 25) { + int wipe_h = ((cycle - 20) * scr_h) / 5; + canvas_draw_box(canvas, scr_x, scr_y + wipe_h, scr_w, scr_h - wipe_h); + /* Algorithmic trailing edge burn/glitch */ + for(int xx = 0; xx < scr_w; xx += 2) { + canvas_draw_dot(canvas, scr_x + xx, scr_y + wipe_h - 1 - ((xx+t)%4)); + } + } + } else { + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned( + canvas, scr_x + scr_w / 2, scr_y + scr_h / 2, AlignCenter, AlignCenter, "FLIPPED ;)"); + } + + /* Submenu action hint decoupled from canvas models */ + canvas_set_font(canvas, FontSecondary); + if(app->tx_spam) { + canvas_draw_str_aligned(canvas, 64, 55, AlignCenter, AlignTop, "[<-] Stop Repeat"); + } else { + canvas_draw_str_aligned(canvas, 64, 55, AlignCenter, AlignTop, app->tx_active ? "[<-] Cancel" : "[<-] Back"); + } +} + +void tagtinker_scene_transmit_on_enter(void* context) { + TagTinkerApp* app = context; + + if(!app->transmit_view_allocated) { + view_allocate_model(app->transmit_view, ViewModelTypeLockFree, sizeof(TxViewModel)); + view_set_context(app->transmit_view, app); + view_set_draw_callback(app->transmit_view, transmit_draw_cb); + app->transmit_view_allocated = true; + } + + TxViewModel* model = view_get_model(app->transmit_view); + model->app = app; + model->tick = 0; + model->completed = false; + model->ok = true; + view_commit_model(app->transmit_view, true); + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTransmit); + + app->tx_active = true; + furi_thread_set_callback(app->tx_thread, tx_thread_callback); + furi_thread_start(app->tx_thread); +} + +bool tagtinker_scene_transmit_on_event(void* context, SceneManagerEvent event) { + TagTinkerApp* app = context; + + if(event.type == SceneManagerEventTypeBack) { + if(app->tx_active) { + app->tx_active = false; + tagtinker_ir_stop(); + return true; + } else { + if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneTargetActions)) { + if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcast)) { + if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcastMenu)) { + scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneMainMenu); + } + } + } + return true; + } + } else if(event.type == SceneManagerEventTypeTick) { + TxViewModel* model = view_get_model(app->transmit_view); + model->tick++; + view_commit_model(app->transmit_view, true); + return true; + } else if(event.type == SceneManagerEventTypeCustom) { + if(event.event == 101 || event.event == 102) { /* Thread Done */ + app->tx_active = false; + tagtinker_ir_deinit(); + TxViewModel* model = view_get_model(app->transmit_view); + model->completed = true; + model->ok = (event.event == 101); + view_commit_model(app->transmit_view, true); + notification_message(app->notifications, &sequence_success); + } + return true; + } + return false; +} + +void tagtinker_scene_transmit_on_exit(void* context) { + TagTinkerApp* app = context; + + app->tx_active = false; + tagtinker_ir_stop(); + furi_thread_join(app->tx_thread); + tagtinker_ir_deinit(); + + if(app->frame_sequence) { + for(size_t i = 0; i < app->frame_seq_count; i++) + free(app->frame_sequence[i]); + free(app->frame_sequence); + free(app->frame_lengths); + free(app->frame_repeats); + app->frame_sequence = NULL; + app->frame_lengths = NULL; + app->frame_repeats = NULL; + app->frame_seq_count = 0; + } +} diff --git a/tagtinker_10px.png b/tagtinker_10px.png new file mode 100644 index 0000000..c037bce Binary files /dev/null and b/tagtinker_10px.png differ diff --git a/tagtinker_app.c b/tagtinker_app.c new file mode 100644 index 0000000..c9b41c4 --- /dev/null +++ b/tagtinker_app.c @@ -0,0 +1,161 @@ +/* + * TagTinker - ESL Flipper Zero application + * + * Transmit infrared commands to supported ESL displays + * using the built-in IR LED at 1.255 MHz carrier. + * + * App by I12BP8 - github.com/i12bp8 + * Research by furrtek - github.com/furrtek + * + * SPDX-License-Identifier: MIT + */ + +#include "tagtinker_app.h" + +static bool navigation_cb(void* ctx) { + TagTinkerApp* app = ctx; + return scene_manager_handle_back_event(app->scene_manager); +} + +static void tick_cb(void* ctx) { + TagTinkerApp* app = ctx; + scene_manager_handle_tick_event(app->scene_manager); +} + +static bool custom_event_cb(void* ctx, uint32_t event) { + TagTinkerApp* app = ctx; + return scene_manager_handle_custom_event(app->scene_manager, event); +} + +extern const SceneManagerHandlers tagtinker_scene_handlers; + +static TagTinkerApp* app_alloc(void) { + TagTinkerApp* app = malloc(sizeof(TagTinkerApp)); + memset(app, 0, sizeof(TagTinkerApp)); + + /* Defaults */ + app->page = 0; + app->duration = 15; + app->repeats = 200; + app->draw_width = 48; + app->draw_height = 48; + app->img_page = 1; + app->esl_width = 200; + app->esl_height = 80; + app->color_clear = false; + app->invert_text = false; + strcpy(app->text_input_buf, "TagTinker"); + app->selected_target = -1; + + /* Scene manager */ + app->scene_manager = scene_manager_alloc(&tagtinker_scene_handlers, app); + + /* View dispatcher */ + app->view_dispatcher = view_dispatcher_alloc(); + view_dispatcher_set_event_callback_context(app->view_dispatcher, app); + view_dispatcher_set_navigation_event_callback(app->view_dispatcher, navigation_cb); + view_dispatcher_set_tick_event_callback(app->view_dispatcher, tick_cb, 50); + view_dispatcher_set_custom_event_callback(app->view_dispatcher, custom_event_cb); + + /* GUI */ + app->gui = furi_record_open(RECORD_GUI); + view_dispatcher_attach_to_gui(app->view_dispatcher, app->gui, ViewDispatcherTypeFullscreen); + + /* Notifications */ + app->notifications = furi_record_open(RECORD_NOTIFICATION); + + /* Views */ + app->submenu = submenu_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewSubmenu, submenu_get_view(app->submenu)); + + app->var_item_list = variable_item_list_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewVarItemList, + variable_item_list_get_view(app->var_item_list)); + + app->text_input = text_input_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewTextInput, text_input_get_view(app->text_input)); + + app->popup = popup_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewPopup, popup_get_view(app->popup)); + + app->widget = widget_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewWidget, widget_get_view(app->widget)); + + app->numlock = numlock_input_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewNumlock, numlock_input_get_view(app->numlock)); + + app->transmit_view = view_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewTransmit, app->transmit_view); + + app->about_view = view_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TagTinkerViewAbout, app->about_view); + + app->dialogs = furi_record_open(RECORD_DIALOGS); + + /* Allocate Thread and Timer for animations and TX */ + app->tx_thread = furi_thread_alloc(); + furi_thread_set_name(app->tx_thread, "TagTinkerTx"); + furi_thread_set_stack_size(app->tx_thread, 2048); + furi_thread_set_priority(app->tx_thread, FuriThreadPriorityHighest); + furi_thread_set_context(app->tx_thread, app); + + return app; +} + +static void app_free(TagTinkerApp* app) { + tagtinker_ir_deinit(); + + if(app->frame_sequence) { + for(size_t i = 0; i < app->frame_seq_count; i++) + free(app->frame_sequence[i]); + free(app->frame_sequence); + free(app->frame_lengths); + free(app->frame_repeats); + } + + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewSubmenu); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewVarItemList); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTextInput); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewPopup); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWidget); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewNumlock); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTransmit); + view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewAbout); + + submenu_free(app->submenu); + variable_item_list_free(app->var_item_list); + text_input_free(app->text_input); + popup_free(app->popup); + widget_free(app->widget); + numlock_input_free(app->numlock); + + view_dispatcher_free(app->view_dispatcher); + scene_manager_free(app->scene_manager); + + furi_record_close(RECORD_GUI); + furi_record_close(RECORD_NOTIFICATION); + furi_record_close(RECORD_DIALOGS); + + view_free(app->transmit_view); + view_free(app->about_view); + furi_thread_free(app->tx_thread); + + free(app); +} + +int32_t tagtinker_app_main(void* p) { + UNUSED(p); + TagTinkerApp* app = app_alloc(); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneMainMenu); + view_dispatcher_run(app->view_dispatcher); + app_free(app); + return 0; +} diff --git a/tagtinker_app.h b/tagtinker_app.h new file mode 100644 index 0000000..6e3cfbf --- /dev/null +++ b/tagtinker_app.h @@ -0,0 +1,157 @@ +/* + * TagTinker — App State + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "views/numlock_input.h" + +#include "scenes/tagtinker_scene.h" +#include "protocol/tagtinker_proto.h" +#include "ir/tagtinker_ir.h" + +#define TAGTINKER_TAG "TagTinker" +#define TAGTINKER_DISPLAY_NAME "TagTinker" +#define TAGTINKER_VERSION "1.1" +#define TAGTINKER_BC_LEN 17 +#define TAGTINKER_HEX_LEN 64 +#define TAGTINKER_MAX_TARGETS 16 +#define TAGTINKER_TARGET_NAME_LEN 16 +#define TAGTINKER_MAX_PRESETS 6 +#define TAGTINKER_PRESET_TEXT_LEN 32 + +/* Views */ +typedef enum { + TagTinkerViewSubmenu, + TagTinkerViewVarItemList, + TagTinkerViewTextInput, + TagTinkerViewPopup, + TagTinkerViewWidget, + TagTinkerViewNumlock, + TagTinkerViewTargetActions, + TagTinkerViewTransmit, + TagTinkerViewAbout, +} TagTinkerView; + +/* Saved ESL target */ +typedef struct { + char name[TAGTINKER_TARGET_NAME_LEN + 1]; + char barcode[TAGTINKER_BC_LEN + 1]; + uint8_t plid[4]; +} TagTinkerTarget; + +struct TagTinkerApp { + /* GUI */ + Gui* gui; + ViewDispatcher* view_dispatcher; + SceneManager* scene_manager; + NotificationApp* notifications; + DialogsApp* dialogs; + + /* Views */ + Submenu* submenu; + VariableItemList* var_item_list; + TextInput* text_input; + Popup* popup; + Widget* widget; + NumlockInput* numlock; + View* target_actions_view; + View* transmit_view; + View* about_view; + bool transmit_view_allocated; + bool about_view_allocated; + + /* TX state */ + bool tx_active; + FuriThread* tx_thread; + + /* Broadcast settings */ + uint8_t broadcast_type; + uint8_t page; + uint16_t duration; + uint16_t repeats; + bool forever; + bool tx_spam; + + /* Current target */ + char barcode[TAGTINKER_BC_LEN + 1]; + uint8_t plid[4]; + bool barcode_valid; + int8_t selected_target; /* -1 = none */ + + /* Saved targets */ + TagTinkerTarget targets[TAGTINKER_MAX_TARGETS]; + uint8_t target_count; + + /* Text to push */ + char text_input_buf[64]; + + /* ESL display size for current target */ + uint16_t esl_width; + uint16_t esl_height; + + /* Frame buffer */ + uint8_t frame_buf[TAGTINKER_MAX_FRAME_SIZE]; + size_t frame_len; + + /* Multi-frame sequence */ + uint8_t** frame_sequence; + size_t* frame_lengths; + uint16_t* frame_repeats; + size_t frame_seq_count; + bool invert_text; + bool color_clear; + + /* Saved presets */ + struct { + uint16_t width; + uint16_t height; + uint8_t page; + bool invert; + bool color_clear; + char text[TAGTINKER_PRESET_TEXT_LEN]; + } presets[TAGTINKER_MAX_PRESETS]; + uint8_t preset_count; + + /* Image settings */ + uint8_t img_page; + uint16_t draw_width; + uint16_t draw_height; + + /* Indicates which mode triggered raw cmd (0=broadcast, 1=targeted) */ + uint8_t raw_mode; +}; + +/* Main menu items */ +typedef enum { + TagTinkerMenuBroadcast, + TagTinkerMenuTargetESL, + TagTinkerMenuAbout, +} TagTinkerMainMenuItem; + +/* Broadcast menu items */ +typedef enum { + TagTinkerBroadcastFlipPage, + TagTinkerBroadcastDebugScreen, +} TagTinkerBroadcastMenuItem; + +/* Target action items */ +typedef enum { + TagTinkerTargetPushText, + TagTinkerTargetPushImage, + TagTinkerTargetPingFlash, +} TagTinkerTargetActionItem; diff --git a/tools/tagtinker.html b/tools/tagtinker.html new file mode 100644 index 0000000..b6b6fa8 --- /dev/null +++ b/tools/tagtinker.html @@ -0,0 +1,392 @@ + + + + + + TagTinker Image Converter + + + + +
+
+

TagTinker Image Converter

+

Prepare 1-bit BMP assets for educational ESL display experiments on hardware you own.

+
+ + + +
+
+ + +
+
+ + +
+
+ + + + +
+ + + + diff --git a/views/numlock_input.c b/views/numlock_input.c new file mode 100644 index 0000000..c3c9524 --- /dev/null +++ b/views/numlock_input.c @@ -0,0 +1,194 @@ +/* + * TagTinker — Number Lock Barcode Input + * + * Clean centered digit entry: N + 16 digits in groups of 4. + * UP/DOWN cycle, LEFT/RIGHT move, OK confirm, BACK cancel. + */ + +#include "numlock_input.h" +#include +#include +#include + +#define NUM_DIGITS 16 +#define CHAR_W 6 +#define GROUP_SIZE 4 +#define GROUP_GAP 5 +#define PREFIX_W 10 + +typedef struct { + uint8_t digits[NUM_DIGITS]; + uint8_t cursor; +} NumlockModel; + +static uint8_t digit_x(uint8_t i) { + uint8_t groups = i / GROUP_SIZE; + /* Spread across horizontal: Prefix(8) + 16 chars(6) + 3 gaps(5) = 119. Left margin = 4. */ + return 4 + 8 + i * CHAR_W + groups * GROUP_GAP; +} + +static void numlock_draw(Canvas* canvas, void* model_v) { + NumlockModel* m = model_v; + canvas_clear(canvas); + + /* 1. Header Bar — Inverted Tech Banner */ + canvas_set_color(canvas, ColorBlack); + canvas_draw_box(canvas, 0, 0, 128, 12); + canvas_set_color(canvas, ColorWhite); + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned(canvas, 64, 6, AlignCenter, AlignCenter, "SET BARCODE"); + canvas_set_color(canvas, ColorBlack); + + /* 2. Main Input Frame — Heavy industrial border */ + int frame_y = 19; + int frame_h = 24; + canvas_draw_rframe(canvas, 1, frame_y, 126, frame_h, 2); + /* Inner shadow/border detail */ + canvas_draw_line(canvas, 2, frame_y+1, 125, frame_y+1); + + const uint8_t baseline = 36; + canvas_set_font(canvas, FontPrimary); + + /* Fixed 'N' Prefix */ + canvas_draw_str(canvas, 4, baseline, "N"); + + /* Iterating Digits */ + for(uint8_t i = 0; i < NUM_DIGITS; i++) { + uint8_t x = digit_x(i); + char ch[2] = {'0' + m->digits[i], '\0'}; + + if(i == m->cursor) { + /* Selected digit bounding block */ + canvas_draw_box(canvas, x - 1, frame_y + 3, CHAR_W + 2, frame_h - 6); + canvas_set_color(canvas, ColorWhite); + canvas_draw_str(canvas, x, baseline, ch); + canvas_set_color(canvas, ColorBlack); + + /* Sharp Navigator Arrows pointing at selection */ + uint8_t cx = x + CHAR_W / 2 - 1; /* Center of char */ + + /* Up pointer */ + canvas_draw_line(canvas, cx, frame_y - 4, cx - 2, frame_y - 2); + canvas_draw_line(canvas, cx, frame_y - 4, cx + 2, frame_y - 2); + canvas_draw_line(canvas, cx, frame_y - 4, cx, frame_y - 1); + + /* Down pointer */ + canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx - 2, frame_y + frame_h + 1); + canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx + 2, frame_y + frame_h + 1); + canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx, frame_y + frame_h); + + } else { + /* Standard unselected digit */ + canvas_draw_str(canvas, x, baseline, ch); + } + } + + /* 3. Footer UI with Clean Button Callouts */ + canvas_set_font(canvas, FontSecondary); + + /* D-Pad Hint Icons */ + canvas_draw_str(canvas, 2, 59, "<\x12\x13> Sel"); + canvas_draw_str(canvas, 45, 59, "^\x18\x19v Set"); + + /* Thick OK Button */ + canvas_draw_rbox(canvas, 92, 48, 34, 14, 2); + canvas_set_color(canvas, ColorWhite); + canvas_draw_str_aligned(canvas, 109, 55, AlignCenter, AlignCenter, "Hold OK"); + canvas_set_color(canvas, ColorBlack); +} + +static bool numlock_input(InputEvent* input, void* ctx) { + NumlockInput* numlock = ctx; + if(input->type != InputTypePress && input->type != InputTypeRepeat) return false; + + bool consumed = false; + + with_view_model( + numlock->view, + NumlockModel * m, + { + switch(input->key) { + case InputKeyUp: + m->digits[m->cursor] = (m->digits[m->cursor] + 1) % 10; + consumed = true; + break; + case InputKeyDown: + m->digits[m->cursor] = (m->digits[m->cursor] + 9) % 10; + consumed = true; + break; + case InputKeyLeft: + if(m->cursor > 0) m->cursor--; + consumed = true; + break; + case InputKeyRight: + if(m->cursor < NUM_DIGITS - 1) m->cursor++; + consumed = true; + break; + case InputKeyOk: { + char barcode[18]; + barcode[0] = 'N'; + for(uint8_t i = 0; i < NUM_DIGITS; i++) + barcode[1 + i] = '0' + m->digits[i]; + barcode[17] = '\0'; + if(numlock->callback) + numlock->callback(numlock->callback_ctx, barcode); + consumed = true; + break; + } + default: + break; + } + }, + consumed); + + return consumed; +} + +NumlockInput* numlock_input_alloc(void) { + NumlockInput* numlock = malloc(sizeof(NumlockInput)); + numlock->view = view_alloc(); + view_allocate_model(numlock->view, ViewModelTypeLocking, sizeof(NumlockModel)); + view_set_draw_callback(numlock->view, numlock_draw); + view_set_input_callback(numlock->view, numlock_input); + view_set_context(numlock->view, numlock); + numlock->callback = NULL; + numlock->callback_ctx = NULL; + + with_view_model( + numlock->view, + NumlockModel * m, + { + memset(m->digits, 0, NUM_DIGITS); + m->digits[0] = 4; + m->cursor = 1; + }, + true); + + return numlock; +} + +void numlock_input_free(NumlockInput* numlock) { + view_free(numlock->view); + free(numlock); +} + +View* numlock_input_get_view(NumlockInput* numlock) { + return numlock->view; +} + +void numlock_input_set_callback(NumlockInput* numlock, NumlockCallback cb, void* ctx) { + numlock->callback = cb; + numlock->callback_ctx = ctx; +} + +void numlock_input_reset(NumlockInput* numlock) { + with_view_model( + numlock->view, + NumlockModel * m, + { + memset(m->digits, 0, NUM_DIGITS); + m->digits[0] = 4; + m->cursor = 1; + }, + true); +} diff --git a/views/numlock_input.h b/views/numlock_input.h new file mode 100644 index 0000000..86654a0 --- /dev/null +++ b/views/numlock_input.h @@ -0,0 +1,30 @@ +/* + * TagTinker - number lock barcode input view + * + * Custom view for entering a 16-digit ESL barcode. + * Format: N + 16 digits (auto-prefixed 'N'). + * + * Controls: + * UP/DOWN - cycle digit 0-9 + * LEFT/RIGHT - move cursor position + * OK - confirm barcode + * BACK - cancel + */ + +#pragma once + +#include + +typedef void (*NumlockCallback)(void* ctx, const char* barcode); + +typedef struct { + View* view; + NumlockCallback callback; + void* callback_ctx; +} NumlockInput; + +NumlockInput* numlock_input_alloc(void); +void numlock_input_free(NumlockInput* numlock); +View* numlock_input_get_view(NumlockInput* numlock); +void numlock_input_set_callback(NumlockInput* numlock, NumlockCallback cb, void* ctx); +void numlock_input_reset(NumlockInput* numlock); diff --git a/views/tagtinker_font.h b/views/tagtinker_font.h new file mode 100644 index 0000000..caf28e3 --- /dev/null +++ b/views/tagtinker_font.h @@ -0,0 +1,175 @@ +/* + * Helper to render simple 5x7 bitmap text onto the ESL image buffer. + */ + +#ifndef TAGTINKER_FONT_H +#define TAGTINKER_FONT_H + +#include +#include +#include + +#define FONT_CHAR_W 5 +#define FONT_CHAR_H 7 + +/* Standard 5x7 ASCII bitmap font (characters 32-127) */ +static const uint8_t font_5x7[][5] = { + {0x00, 0x00, 0x00, 0x00, 0x00}, /* */ + {0x00, 0x00, 0x5f, 0x00, 0x00}, /* ! */ + {0x00, 0x07, 0x00, 0x07, 0x00}, /* " */ + {0x14, 0x7f, 0x14, 0x7f, 0x14}, /* # */ + {0x24, 0x2a, 0x7f, 0x2a, 0x12}, /* $ */ + {0x23, 0x13, 0x08, 0x64, 0x62}, /* % */ + {0x36, 0x49, 0x55, 0x22, 0x50}, /* & */ + {0x00, 0x05, 0x03, 0x00, 0x00}, /* ' */ + {0x00, 0x1c, 0x22, 0x41, 0x00}, /* ( */ + {0x00, 0x41, 0x22, 0x1c, 0x00}, /* ) */ + {0x08, 0x2a, 0x1c, 0x2a, 0x08}, /* * */ + {0x08, 0x08, 0x3e, 0x08, 0x08}, /* + */ + {0x00, 0x50, 0x30, 0x00, 0x00}, /* , */ + {0x08, 0x08, 0x08, 0x08, 0x08}, /* - */ + {0x00, 0x60, 0x60, 0x00, 0x00}, /* . */ + {0x20, 0x10, 0x08, 0x04, 0x02}, /* / */ + {0x3e, 0x51, 0x49, 0x45, 0x3e}, /* 0 */ + {0x00, 0x42, 0x7f, 0x40, 0x00}, /* 1 */ + {0x42, 0x61, 0x51, 0x49, 0x46}, /* 2 */ + {0x21, 0x41, 0x45, 0x4b, 0x31}, /* 3 */ + {0x18, 0x14, 0x12, 0x7f, 0x10}, /* 4 */ + {0x27, 0x45, 0x45, 0x45, 0x39}, /* 5 */ + {0x3c, 0x4a, 0x49, 0x49, 0x30}, /* 6 */ + {0x01, 0x71, 0x09, 0x05, 0x03}, /* 7 */ + {0x36, 0x49, 0x49, 0x49, 0x36}, /* 8 */ + {0x06, 0x49, 0x49, 0x29, 0x1e}, /* 9 */ + {0x00, 0x36, 0x36, 0x00, 0x00}, /* : */ + {0x00, 0x56, 0x36, 0x00, 0x00}, /* ; */ + {0x00, 0x08, 0x14, 0x22, 0x41}, /* < */ + {0x14, 0x14, 0x14, 0x14, 0x14}, /* = */ + {0x41, 0x22, 0x14, 0x08, 0x00}, /* > */ + {0x02, 0x01, 0x51, 0x09, 0x06}, /* ? */ + {0x32, 0x49, 0x79, 0x41, 0x3e}, /* @ */ + {0x7e, 0x11, 0x11, 0x11, 0x7e}, /* A */ + {0x7f, 0x49, 0x49, 0x49, 0x36}, /* B */ + {0x3e, 0x41, 0x41, 0x41, 0x22}, /* C */ + {0x7f, 0x41, 0x41, 0x22, 0x1c}, /* D */ + {0x7f, 0x49, 0x49, 0x49, 0x41}, /* E */ + {0x7f, 0x09, 0x09, 0x01, 0x01}, /* F */ + {0x3e, 0x41, 0x41, 0x51, 0x32}, /* G */ + {0x7f, 0x08, 0x08, 0x08, 0x7f}, /* H */ + {0x00, 0x41, 0x7f, 0x41, 0x00}, /* I */ + {0x20, 0x40, 0x41, 0x3f, 0x01}, /* J */ + {0x7f, 0x08, 0x14, 0x22, 0x41}, /* K */ + {0x7f, 0x40, 0x40, 0x40, 0x40}, /* L */ + {0x7f, 0x02, 0x04, 0x02, 0x7f}, /* M */ + {0x7f, 0x04, 0x08, 0x10, 0x7f}, /* N */ + {0x3e, 0x41, 0x41, 0x41, 0x3e}, /* O */ + {0x7f, 0x09, 0x09, 0x09, 0x06}, /* P */ + {0x3e, 0x41, 0x51, 0x21, 0x5e}, /* Q */ + {0x7f, 0x09, 0x19, 0x29, 0x46}, /* R */ + {0x46, 0x49, 0x49, 0x49, 0x31}, /* S */ + {0x01, 0x01, 0x7f, 0x01, 0x01}, /* T */ + {0x3f, 0x40, 0x40, 0x40, 0x3f}, /* U */ + {0x1f, 0x20, 0x40, 0x20, 0x1f}, /* V */ + {0x7f, 0x20, 0x18, 0x20, 0x7f}, /* W */ + {0x63, 0x14, 0x08, 0x14, 0x63}, /* X */ + {0x03, 0x04, 0x78, 0x04, 0x03}, /* Y */ + {0x61, 0x51, 0x49, 0x45, 0x43}, /* Z */ + {0x00, 0x00, 0x7f, 0x41, 0x41}, /* [ */ + {0x02, 0x04, 0x08, 0x10, 0x20}, /* \ */ + {0x41, 0x41, 0x7f, 0x00, 0x00}, /* ] */ + {0x04, 0x02, 0x01, 0x02, 0x04}, /* ^ */ + {0x40, 0x40, 0x40, 0x40, 0x40}, /* _ */ + {0x00, 0x01, 0x02, 0x04, 0x00}, /* ` */ + {0x20, 0x54, 0x54, 0x54, 0x78}, /* a */ + {0x7f, 0x48, 0x44, 0x44, 0x38}, /* b */ + {0x38, 0x44, 0x44, 0x44, 0x20}, /* c */ + {0x38, 0x44, 0x44, 0x48, 0x7f}, /* d */ + {0x38, 0x54, 0x54, 0x54, 0x18}, /* e */ + {0x08, 0x7e, 0x09, 0x01, 0x02}, /* f */ + {0x08, 0x14, 0x54, 0x54, 0x3c}, /* g */ + {0x7f, 0x08, 0x04, 0x04, 0x78}, /* h */ + {0x00, 0x44, 0x7d, 0x40, 0x00}, /* i */ + {0x20, 0x40, 0x44, 0x3d, 0x00}, /* j */ + {0x00, 0x7f, 0x10, 0x28, 0x44}, /* k */ + {0x00, 0x41, 0x7f, 0x40, 0x00}, /* l */ + {0x7c, 0x04, 0x18, 0x04, 0x78}, /* m */ + {0x7c, 0x08, 0x04, 0x04, 0x78}, /* n */ + {0x38, 0x44, 0x44, 0x44, 0x38}, /* o */ + {0x7c, 0x14, 0x14, 0x14, 0x08}, /* p */ + {0x08, 0x14, 0x14, 0x18, 0x7c}, /* q */ + {0x7c, 0x08, 0x04, 0x04, 0x08}, /* r */ + {0x48, 0x54, 0x54, 0x54, 0x20}, /* s */ + {0x04, 0x3f, 0x44, 0x40, 0x20}, /* t */ + {0x3c, 0x40, 0x40, 0x20, 0x7c}, /* u */ + {0x1c, 0x20, 0x40, 0x20, 0x1c}, /* v */ + {0x3c, 0x40, 0x30, 0x40, 0x3c}, /* w */ + {0x44, 0x28, 0x10, 0x28, 0x44}, /* x */ + {0x0c, 0x50, 0x50, 0x50, 0x3c}, /* y */ + {0x44, 0x64, 0x54, 0x4c, 0x44}, /* z */ + {0x00, 0x08, 0x36, 0x41, 0x00}, /* { */ + {0x00, 0x00, 0x7f, 0x00, 0x00}, /* | */ + {0x00, 0x41, 0x36, 0x08, 0x00}, /* } */ + {0x08, 0x04, 0x08, 0x10, 0x08}, /* ~ */ +}; + +/* Sets a pixel in a 1-byte-per-pixel buffer */ +static inline void set_pixel(uint8_t* buf, uint16_t w, uint16_t h, uint16_t x, uint16_t y, uint8_t val) { + if(x < w && y < h) buf[y * w + x] = val; +} + +/* + * Render text to fill the entire buffer. + * bg_val/fg_val control polarity: + * Normal: bg=1 (white), fg=0 (black) + * Inverted: bg=0 (black), fg=1 (white) + * Zero margin — text fills edge to edge. + */ +static inline void render_text_ex(uint8_t* buf, uint16_t w, uint16_t h, + const char* text, uint8_t bg_val, uint8_t fg_val) { + memset(buf, bg_val, w * h); + + size_t len = strlen(text); + if(len == 0) return; + + uint16_t scale_w = w / (len * (FONT_CHAR_W + 1)); + uint16_t scale_h = h / FONT_CHAR_H; + + if(scale_w < 1) scale_w = 1; + if(scale_h < 1) scale_h = 1; + + /* Use uniform scale = min(w,h) for proportional text + better RLE */ + uint16_t scale = (scale_w < scale_h) ? scale_w : scale_h; + + uint16_t text_w = len * (FONT_CHAR_W + 1) * scale; + uint16_t text_h = FONT_CHAR_H * scale; + + uint16_t start_x = (w - text_w) / 2; + uint16_t start_y = (h - text_h) / 2; + + for(size_t i = 0; i < len; i++) { + char c = text[i]; + if(c < 32 || c > 126) c = '?'; + uint8_t glyph_idx = c - 32; + + for(uint8_t gx = 0; gx < FONT_CHAR_W; gx++) { + uint8_t col = font_5x7[glyph_idx][gx]; + for(uint8_t gy = 0; gy < FONT_CHAR_H; gy++) { + if(col & (1 << gy)) { + for(uint16_t sx = 0; sx < scale; sx++) { + for(uint16_t sy = 0; sy < scale; sy++) { + uint16_t px = start_x + (i * (FONT_CHAR_W + 1) + gx) * scale + sx; + uint16_t py = start_y + gy * scale + sy; + set_pixel(buf, w, h, px, py, fg_val); + } + } + } + } + } + } +} + +/* Convenience: normal polarity (white bg=1, black text=0) */ +static inline void render_text(uint8_t* buf, uint16_t w, uint16_t h, const char* text) { + render_text_ex(buf, w, h, text, 1, 0); +} + +#endif