initial commit
This commit is contained in:
1 parent
3f403dbca1
commit
1436174509
116 files changed
+15931
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go version *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
translate(0, 0, 0) cylinder(15, 6, 6);
|
||||||
|
cylinder(1.5, 8, 8);
|
||||||
|
};
|
||||||
|
union() {
|
||||||
|
translate([0, -2, 0]) {
|
||||||
|
cube([15, 4, 15]);
|
||||||
|
};
|
||||||
|
cylinder(20, 5, 5);
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
car-radio
|
||||||
|
Copyright (C) 2026 matthewkotras
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
car-radio Copyright (C) 2026 matthewkotras
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Car Radio Map</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#fbfaf7;--fg:#1d1d1b;--muted:#6b6a65;--line:#e3e0d8;--card:#fff;--live:#2f7d4f;--exp:#b06d12;--junk:#b23a3a;--test:#3b5fa8}
|
||||||
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#161615;--fg:#ecebe6;--muted:#9c9a92;--line:#33322e;--card:#1f1f1d;--live:#5cc28a;--exp:#e0a24a;--junk:#e57373;--test:#7f9fe0}}
|
||||||
|
body{background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,sans-serif;margin:0;padding:0 16px}
|
||||||
|
main{max-width:960px;margin:0 auto;padding:32px 0 64px}
|
||||||
|
h1{margin:0 0 4px;font-size:28px}h2{margin:40px 0 8px;font-size:20px;border-bottom:1px solid var(--line);padding-bottom:6px}
|
||||||
|
p.lead{color:var(--muted);margin:0 0 20px}
|
||||||
|
code{font:13px ui-monospace,monospace;background:var(--line);padding:1px 5px;border-radius:4px}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:8px 0;font-size:14px}
|
||||||
|
td{border-top:1px solid var(--line);padding:7px 8px;vertical-align:top}
|
||||||
|
td:first-child{white-space:nowrap;font-family:ui-monospace,monospace;font-size:13px;width:1%}
|
||||||
|
.tag{display:inline-block;font-size:11px;font-weight:600;padding:1px 7px;border-radius:10px;border:1px solid currentColor;margin-left:6px;white-space:nowrap}
|
||||||
|
.live{color:var(--live)}.exp{color:var(--exp)}.junk{color:var(--junk)}.test{color:var(--test)}
|
||||||
|
.legend span{margin-right:14px}
|
||||||
|
.box{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px 18px;margin:12px 0}
|
||||||
|
ul{padding-left:20px}
|
||||||
|
@media(max-width:640px){td:first-child{white-space:normal;width:auto}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<h1>Car Radio — project map</h1>
|
||||||
|
<p class="lead">A custom head unit for a Ford Fusion: a Raspberry Pi drives the factory display (FDIM) and button panel (FCIM) over CAN, and an ADAU1467 DSP over I²C. Generated from the folder contents on 2026-10-26.</p>
|
||||||
|
|
||||||
|
<p class="legend"><span class="tag live">ACTIVE</span> current, maintained code <span class="tag exp">EXPERIMENT</span> one-off reverse-engineering tools <span class="tag test">TEST</span> automated tests <span class="tag junk">CLUTTER</span> stale, duplicate or accidental</p>
|
||||||
|
|
||||||
|
<div class="box"><strong>TL;DR.</strong> The real software is <code>car-radio/fusion/</code>, plus <code>deploy/</code> to put it on the Pi. Everything under <code>code/</code> is the earlier CAN-cracking work. The protocol knowledge lives in <code>notes-and-knowledge/</code>, <code>FDIM/</code> and the <code>ccode/cmd/*</code> probes. Several files are leftovers (see the cleanup section).</div>
|
||||||
|
|
||||||
|
<h2>Outer folder: <code>projects/car/</code></h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>car-radio/</td><td>The git repo. Almost everything lives here.</td></tr>
|
||||||
|
<tr><td>start_can.sh</td><td>Finds a CANable USB adapter (candleLight or slcan firmware) and brings it up as a SocketCAN <code>canN</code> at 125 kbit/s (Ford MS-CAN). Re-runs itself with sudo/run0. It sits <em>outside</em> the repo, so it isn't version-controlled. <span class="tag live">ACTIVE</span></td></tr>
|
||||||
|
<tr><td>grommet.scad / .stl / .3mf</td><td>OpenSCAD source, the exported mesh and the slicer project for a small printed grommet (a 6 mm post on an 8 mm flange), probably for routing wires. Unrelated to the code. <span class="tag exp">HARDWARE</span></td></tr>
|
||||||
|
<tr><td>.claude/</td><td>Claude Code local permission settings.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Repo root: <code>car-radio/</code></h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>README.md, LICENSE</td><td>A one-line README ("Repo for everything involving… my custom car radio") and the license.</td></tr>
|
||||||
|
<tr><td>decode.py</td><td>The original Python (python-can) FCIM button decoder, including <code>send_wake_fcim</code>. It's the first working decoder from the initial commit. The Go code later ported it. <span class="tag exp">LEGACY</span></td></tr>
|
||||||
|
<tr><td>read-can.txt</td><td>A cheat sheet: slcand plus <code>candump</code> commands for sniffing the bus. <span class="tag exp">NOTES</span></td></tr>
|
||||||
|
<tr><td>write-can.txt</td><td>Pasted shell history (line numbers included) of <code>cansend</code>/<code>cangen</code> experiments. One line is a bad paste where two commands ran together. <span class="tag junk">CLUTTER</span></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><code>fusion/</code>: the main application <span class="tag live">ACTIVE</span></h2>
|
||||||
|
<p>This is a Go implementation of the "Fusion Audio UI & Software Specification". It has a pure UI core, and every piece of hardware sits behind an interface. Terminal emulators for the display and the buttons let the whole system run on a laptop. <code>README.md</code> documents how to run it.</p>
|
||||||
|
<table>
|
||||||
|
<tr><td>cmd/fusiond/</td><td>The daemon that wires the core to drivers (real CAN/I²C or simulated). <code>console.go</code> is a simulated-hardware REPL with commands like <code>connect Pixel</code>, <code>ring Alice</code> and <code>dsp fail</code>.</td></tr>
|
||||||
|
<tr><td>cmd/fdim/</td><td>A terminal emulator of the 2×20 front display. It listens on :7002.</td></tr>
|
||||||
|
<tr><td>cmd/fcim/</td><td>A terminal emulator of the button panel. Each keystroke is sent to fusiond as a button pulse on :7001.</td></tr>
|
||||||
|
<tr><td>core/</td><td>The UI state machine: pages and menus (<code>pages.go</code>), rendering, text, the clock editor and shared types. <code>script.go</code> runs the <code>.test</code> scenario files.</td></tr>
|
||||||
|
<tr><td>app/</td><td>Dependency injection (<code>app.Deps</code>) and the main loop that ties core and drivers together.</td></tr>
|
||||||
|
<tr><td>fdim/</td><td>The display driver. <code>can.go</code>/<code>canproto.go</code> drive the real FDIM over CAN, <code>net.go</code> drives the emulator, and there's an in-memory version for tests.</td></tr>
|
||||||
|
<tr><td>fcim/</td><td>The button source: the real FCIM over CAN, the emulator over the network, and <code>keymap.go</code>.</td></tr>
|
||||||
|
<tr><td>can/</td><td>The low-level SocketCAN wrapper (<code>can_linux.go</code>).</td></tr>
|
||||||
|
<tr><td>dsp/</td><td>The DSP interface, a simulator, and the real ADAU1467 driver over I²C (<code>i2c_linux.go</code>).</td></tr>
|
||||||
|
<tr><td>bt/</td><td>The Bluetooth module interface. It's simulated only so far.</td></tr>
|
||||||
|
<tr><td>platform/</td><td>Clock and persistent settings store.</td></tr>
|
||||||
|
<tr><td>termui/</td><td>Shared terminal-drawing helpers for the emulators.</td></tr>
|
||||||
|
<tr><td>tests/*.test</td><td>Seven scenario scripts written as <code>key X</code> / <code>expect …</code> steps: boot/standby, volume, menus/source, now playing, clock editor, Bluetooth, phone/health. Each one cites a spec section. <span class="tag test">TEST</span></td></tr>
|
||||||
|
<tr><td>*_test.go</td><td>Go unit tests in core, app, fdim, fcim, can, dsp, platform and fusiond/console. <span class="tag test">TEST</span></td></tr>
|
||||||
|
<tr><td>run-all.sh</td><td>Opens the three programs in separate terminals or in tmux panes. <code>--build</code> compiles them first.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><code>deploy/</code>: putting it in the car <span class="tag live">ACTIVE</span></h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>bootstrap.sh</td><td>One-time, idempotent setup of the Pi, run from the laptop (<code>deploy/bootstrap.sh main@fusion.local</code>).</td></tr>
|
||||||
|
<tr><td>99-canable.rules</td><td>A udev rule that identifies the CANable adapter.</td></tr>
|
||||||
|
<tr><td>slcan.service</td><td>A systemd unit that brings up the slcan CAN interface at boot.</td></tr>
|
||||||
|
<tr><td>fusion.service</td><td>A systemd unit that auto-starts <code>fusiond</code> (latest commit: "Make fusiond auto-boot").</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><code>code/</code>: earlier Go prototypes <span class="tag exp">EXPERIMENT</span></h2>
|
||||||
|
<p>This folder holds the work done before <code>fusion/</code> existed, and it's the messiest part of the repo. It has three layers, each a newer copy of the previous one. <code>ccode/</code> and <code>new/</code> are <strong>untracked in git</strong>.</p>
|
||||||
|
<table>
|
||||||
|
<tr><td>main.go, display.go, inputs.go, go.mod</td><td>The first Go radio prototype (May). It's been superseded: an updated copy lives at <code>ccode/cmd/radio/</code>. <span class="tag junk">STALE</span></td></tr>
|
||||||
|
<tr><td>new/</td><td>An intermediate snapshot: <code>fdimtext</code>, ISO-TP and layout code with tests, plus <code>Ford FDIM text protocol.md</code>, the most complete write-up of how FDIM text is encoded. <code>layout.go</code> and <code>isotp.go</code> are identical to the copies in <code>ccode/</code>. <span class="tag junk">DUPLICATE</span> (keep the .md)</td></tr>
|
||||||
|
<tr><td>ccode/</td><td>The newest prototype library: <code>isotp.go</code> (multi-frame CAN transport), <code>layout.go</code> (fitting text into 2×20), <code>clock.go</code>, <code>fcim.go</code>, each with a <code>_test.go</code>. <span class="tag test">TEST</span></td></tr>
|
||||||
|
<tr><td>ccode/cmd/radio</td><td>An updated version of the old radio prototype.</td></tr>
|
||||||
|
<tr><td>ccode/cmd/fcimread</td><td>Receive-only sniffer that decodes FCIM button presses.</td></tr>
|
||||||
|
<tr><td>ccode/cmd/fdimtext</td><td>Prints the cansend frames for a two-line message without touching the bus. Same purpose as <code>new/main.go</code>.</td></tr>
|
||||||
|
<tr><td>ccode/cmd/fdimtest</td><td>Cycles test patterns across the whole 2×20 text area.</td></tr>
|
||||||
|
<tr><td>ccode/cmd/fdimprobe</td><td>Steps through experiments on the source box, the volume bar and unmapped icons.</td></tr>
|
||||||
|
<tr><td>ccode/cmd/sweep</td><td>A fast blind search for the frames that drive the AM/FM/SYNC/AUX source box.</td></tr>
|
||||||
|
<tr><td>ccode/cmd/backlight</td><td>Isolates which frame turns on the FCIM backlight. The answer (the 0x3E8 baseline) went into fusion.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><code>FDIM/</code>: raw display experiments <span class="tag exp">EXPERIMENT</span></h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>send.sh</td><td>Replays a <code>cansend</code> script onto a given CAN interface.</td></tr>
|
||||||
|
<tr><td>display-text.py</td><td>An early Python generator for display text frames.</td></tr>
|
||||||
|
<tr><td>display-matthew.txt</td><td>A captured or hand-written frame sequence that shows a name on the display.</td></tr>
|
||||||
|
<tr><td>maybe-text.txt</td><td>A near-copy of the above that's slightly different. It was a guess, as the name says. <span class="tag junk">CLUTTER</span></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><code>notes-and-knowledge/</code>: research <span class="tag live">REFERENCE</span></h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>fdim.txt</td><td>A summary of the FDIM protocol (heartbeat, text) based on p1ne's fdim-controller.</td></tr>
|
||||||
|
<tr><td>keep-wake.txt</td><td>Notes on the Ford network-management "ring" that keeps modules awake.</td></tr>
|
||||||
|
<tr><td>wiring.txt</td><td>Links to ACM pinouts and wiring diagrams.</td></tr>
|
||||||
|
<tr><td>software-design.md, interface-design.md</td><td>Early design docs for the menu and UI that came before the formal spec.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><code>dsp/</code>: ADAU1467 SigmaStudio project</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>Design 1.dspproj</td><td>The SigmaStudio project (the audio signal chain).</td></tr>
|
||||||
|
<tr><td>Design 1_IC 1/</td><td>SigmaStudio's compiled export: <code>Program.bin/.dat</code>, the memory images, <code>ParamAddress.dat</code> (register addresses the Go driver needs) and the compiler log. It also contains a second copy of the <code>.dspproj</code> and of <code>bass_remapping.txt</code>.</td></tr>
|
||||||
|
<tr><td>generate_bass_remapping.py</td><td>Generates a sigmoid lookup curve for remapping bass levels. The output goes to <code>bass_remapping.txt</code>.</td></tr>
|
||||||
|
<tr><td>Settings/Design 1, Design 3</td><td>Empty folders. <span class="tag junk">CLUTTER</span></td></tr>
|
||||||
|
<tr><td>System Volume Information/, FOUND.000/</td><td>Windows filesystem debris from when this folder was on a USB stick or SD card. <code>FILE0000.CHK</code> is a chkdsk recovery fragment. These are committed to git by mistake. <span class="tag junk">CLUTTER</span></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Cleanup suggestions</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Remove from git:</strong> <code>dsp/System Volume Information/</code>, <code>dsp/FOUND.000/</code>, and the empty <code>dsp/Settings/</code>. Add them to a <code>.gitignore</code> (the repo doesn't have one).</li>
|
||||||
|
<li><strong>Collapse <code>code/</code>:</strong> keep <code>ccode/</code> and move <code>new/Ford FDIM text protocol.md</code> into <code>notes-and-knowledge/</code>. Then delete <code>new/</code> and the top-level <code>code/*.go</code>, which are older copies of <code>ccode/</code>. Commit <code>ccode/</code>; right now it's untracked.</li>
|
||||||
|
<li><strong>Move <code>start_can.sh</code> into the repo</strong> (for example <code>deploy/</code> or <code>tools/</code>) so it's versioned.</li>
|
||||||
|
<li>Merge <code>read-can.txt</code> and <code>write-can.txt</code> into one cleaned-up CAN cheat sheet in <code>notes-and-knowledge/</code>, and delete <code>FDIM/maybe-text.txt</code>.</li>
|
||||||
|
<li><code>git status</code> shows every file under <code>dsp/</code> as modified. That's probably just file-mode or line-ending noise from the FAT copy (try <code>git diff --stat</code> to check). Only one <code>.dspproj</code> or <code>bass_remapping.txt</code> copy needs tracking.</li>
|
||||||
|
<li>Expand the root README to point at <code>fusion/README.md</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</main></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Car Radio
|
||||||
|
|
||||||
|
Repo for everything involving the development of my custom car radio
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Text group 1
|
||||||
|
cansend can0 336#03010A01FE000000
|
||||||
|
sleep 0.050
|
||||||
|
cansend can0 324#0101020000000000
|
||||||
|
sleep 0.050
|
||||||
|
cansend can0 337#0620204020402000
|
||||||
|
|
||||||
|
# Text group 2
|
||||||
|
sleep 0.100
|
||||||
|
cansend can0 336#0301050303000000
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 324#0301020000000000
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#102B202020402020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#212048656C6C406F
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2220202020202020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2320202020204020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#242020466F726421
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2520202020202020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2620000000000000
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import time
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
CAN_INTERFACE = "can0"
|
||||||
|
|
||||||
|
# Message IDs
|
||||||
|
ID_336 = "336"
|
||||||
|
ID_324 = "324"
|
||||||
|
ID_337 = "337"
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
SHORT_DELAY = 0.025
|
||||||
|
LONG_DELAY = 0.100
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def chunk_string(s, size):
|
||||||
|
return [s[i:i + size] for i in range(0, len(s), size)]
|
||||||
|
|
||||||
|
|
||||||
|
def pad_line(line, length=16):
|
||||||
|
return line[:length].ljust(length)
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_hex(s):
|
||||||
|
return ''.join(f'{ord(c):02X}' for c in s)
|
||||||
|
|
||||||
|
|
||||||
|
def build_display_frames(line1, line2):
|
||||||
|
"""
|
||||||
|
Builds the 337 ISO-TP style frames.
|
||||||
|
|
||||||
|
The display appears to use:
|
||||||
|
- First frame: 10LL...
|
||||||
|
- Continuation frames: 21, 22, 23...
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 16 chars per line
|
||||||
|
line1 = pad_line(line1, 16)
|
||||||
|
line2 = pad_line(line2, 16)
|
||||||
|
|
||||||
|
payload = (line1 + line2).encode("ascii")
|
||||||
|
total_len = len(payload)
|
||||||
|
|
||||||
|
hex_payload = payload.hex().upper()
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# First frame
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# 102B202020402020
|
||||||
|
#
|
||||||
|
# 10 = first frame
|
||||||
|
# 2B = total payload length
|
||||||
|
#
|
||||||
|
# Then 6 bytes of payload
|
||||||
|
#
|
||||||
|
first_data = hex_payload[:12] # 6 bytes
|
||||||
|
hex_payload = hex_payload[12:]
|
||||||
|
|
||||||
|
first_frame = f"10{total_len:02X}{first_data}"
|
||||||
|
first_frame = first_frame.ljust(16, '0')
|
||||||
|
|
||||||
|
frames.append(first_frame)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Consecutive frames
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
seq = 1
|
||||||
|
|
||||||
|
while hex_payload:
|
||||||
|
chunk = hex_payload[:14] # 7 bytes
|
||||||
|
hex_payload = hex_payload[14:]
|
||||||
|
|
||||||
|
frame = f"2{seq:X}{chunk}"
|
||||||
|
frame = frame.ljust(16, '0')
|
||||||
|
|
||||||
|
frames.append(frame)
|
||||||
|
|
||||||
|
seq += 1
|
||||||
|
if seq > 15:
|
||||||
|
seq = 0
|
||||||
|
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def print_can_command(can_id, data):
|
||||||
|
print(f"cansend {CAN_INTERFACE} {can_id}#{data}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(line1, line2):
|
||||||
|
"""
|
||||||
|
Generates the full message sequence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Header/setup messages
|
||||||
|
print(f"sleep {LONG_DELAY:.3f}")
|
||||||
|
print_can_command(ID_336, "0301050303000000")
|
||||||
|
|
||||||
|
print(f"sleep {SHORT_DELAY:.3f}")
|
||||||
|
print_can_command(ID_324, "0301020000000000")
|
||||||
|
|
||||||
|
frames = build_display_frames(line1, line2)
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
print(f"sleep {SHORT_DELAY:.3f}")
|
||||||
|
print_can_command(ID_337, frame)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
line1 = "Mercury"
|
||||||
|
line2 = "Mariner"
|
||||||
|
|
||||||
|
build_message(line1, line2)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Text group 1
|
||||||
|
cansend can0 336#03010A01FE000000
|
||||||
|
sleep 0.050
|
||||||
|
cansend can0 324#0101020000000000
|
||||||
|
sleep 0.050
|
||||||
|
cansend can0 337#0620204020402000
|
||||||
|
|
||||||
|
# Text group 2
|
||||||
|
sleep 0.100
|
||||||
|
cansend can0 336#0301050303000000
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 324#0301020000000000
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#102B202020402020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#21204D6572634075
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2272792020202020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2320202020204020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2420204D6172696E
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2565722020202020
|
||||||
|
sleep 0.025
|
||||||
|
cansend can0 337#2620000000000000
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# First arg is the can interface usually can0
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IFACE="${1:-can0}"
|
||||||
|
|
||||||
|
send() {
|
||||||
|
cansend "$IFACE" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Startup sequence from p1ne FordMessages.h, aftermarket HU path.
|
||||||
|
# Heartbeat
|
||||||
|
# send 50C#010200
|
||||||
|
# top line and heartbeat
|
||||||
|
# send 3E8#0700000400000000
|
||||||
|
# Unknown init/heartbeat
|
||||||
|
# send 3EF#32323232030000
|
||||||
|
# Clock enabled init
|
||||||
|
send 3F2#1302FFFFFFF00000
|
||||||
|
# Uknown init
|
||||||
|
# send 3F1#F590000000000000
|
||||||
|
|
||||||
|
echo "Startup sent. Running cyclic FDIM keepalives on $IFACE. Ctrl-C to stop."
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# Heartbeat
|
||||||
|
# send 50C#0C0200
|
||||||
|
# top line and heartbeat
|
||||||
|
# send 3E8#0A00000400000000
|
||||||
|
# Unknown init/heartbeat
|
||||||
|
# send 3EF#32323232030020
|
||||||
|
# Clock heartbeat
|
||||||
|
# send 3F2#1302FFFFFFF00000
|
||||||
|
send 3F2#0355FFFFFFF00000
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeString(text string, bus *can.Bus, clear bool) {
|
||||||
|
if clear {
|
||||||
|
clearText(bus)
|
||||||
|
}
|
||||||
|
frames := []can.Frame{
|
||||||
|
{
|
||||||
|
ID: 0x336,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x324,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x03, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// lastSize := len(text) % 7
|
||||||
|
text = slices.Insert(text, 4, '@')
|
||||||
|
runes := []rune(text)
|
||||||
|
|
||||||
|
pageCount := math.Ceil(float64(len(runes)) / 7.0)
|
||||||
|
fmt.Printf("Printing %s len: %d pageCount: %d\n", text, len(text), int(pageCount))
|
||||||
|
|
||||||
|
currentLetter := 0
|
||||||
|
for pageIndex := 0; pageIndex < int(pageCount); pageIndex++ {
|
||||||
|
data := [8]uint8{}
|
||||||
|
elementsOnThisFrame := 7
|
||||||
|
if pageIndex == 0 {
|
||||||
|
elementsOnThisFrame = 6
|
||||||
|
}
|
||||||
|
data[0] = 0x20 + uint8(pageIndex)
|
||||||
|
if pageIndex == 0 {
|
||||||
|
data[0] = 0x10
|
||||||
|
}
|
||||||
|
insertStartingFrom := 8 - elementsOnThisFrame
|
||||||
|
for i := insertStartingFrom; i < 8; i++ {
|
||||||
|
if currentLetter >= len(text) {
|
||||||
|
data[i] = 0x00
|
||||||
|
} else {
|
||||||
|
data[i] = uint8(runes[currentLetter])
|
||||||
|
currentLetter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames = append(frames, can.Frame{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// for i := 0; i < int(pageCount); i++ {
|
||||||
|
// data := [8]uint8{}
|
||||||
|
// data[0] = 0x20 + uint8(i)
|
||||||
|
// if i == 0 {
|
||||||
|
// data[0] = 0x10
|
||||||
|
// }
|
||||||
|
// start := 1
|
||||||
|
// if i == 0 {
|
||||||
|
// start = 2
|
||||||
|
// }
|
||||||
|
// for j := start; j < 8; j++ {
|
||||||
|
// // Last page and out of bounds of remainder
|
||||||
|
// if i == int(pageCount)-1 && j+-1 > 7-lastSize {
|
||||||
|
// data[j] = 0x00
|
||||||
|
// } else {
|
||||||
|
// data[j] = uint8(runes[(i*8)+j])
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// frames = append(frames, can.Frame{
|
||||||
|
// ID: 0x337,
|
||||||
|
// Length: 8,
|
||||||
|
// Data: data,
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
frames[2].Data[1] = uint8(len(text))
|
||||||
|
|
||||||
|
for _, frame := range frames {
|
||||||
|
fmt.Println("writing frames", formatFrame(frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFrames(frames, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTest(bus *can.Bus) {
|
||||||
|
frames := []can.Frame{
|
||||||
|
{
|
||||||
|
ID: 0x336,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x324,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x03, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x10, 0x2B, '1', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x21, ' ', 'H', 'e', 'l', 'l', '@', 'o'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x22, ' ', ' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x23, ' ', ' ', ' ', ' ', ' ', '@', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x24, ' ', ' ', 'F', 'O', 'R', 'D', '!'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x25, ' ', ' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x26, '1', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeFrames(frames, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFrames(frames []can.Frame, bus *can.Bus) {
|
||||||
|
for _, frame := range frames {
|
||||||
|
fmt.Printf("Wrote %02X\n", frame.ID)
|
||||||
|
bus.Publish(frame)
|
||||||
|
time.Sleep(time.Millisecond * 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearText(bus *can.Bus) {
|
||||||
|
writeString(strings.Repeat(" ", 41), bus, false)
|
||||||
|
// cansend can0 336#0301050303000000
|
||||||
|
// sleep 0.050
|
||||||
|
// cansend can0 324#0301020000000000
|
||||||
|
// sleep 0.050
|
||||||
|
// cansend can0 337#100D202020202020
|
||||||
|
// sleep 0.050
|
||||||
|
// cansend can0 337#2120202020202020
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClock(bus *can.Bus) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
hour := now.Hour()
|
||||||
|
minute := now.Minute()
|
||||||
|
|
||||||
|
bus.Publish(can.Frame{
|
||||||
|
ID: 0x3F2,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{uint8(hour), uint8(minute), 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFrame(frame can.Frame) string {
|
||||||
|
out := strings.Builder{}
|
||||||
|
out.WriteString(fmt.Sprintf("%02X", frame.ID))
|
||||||
|
out.WriteString("#")
|
||||||
|
for i := 0; i < int(frame.Length); i++ {
|
||||||
|
out.WriteString(fmt.Sprintf("%02X", frame.Data[i]))
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module car-radio
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require github.com/brutella/can v0.0.2
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06 // indirect
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/brutella/can v0.0.2 h1:8TyjZrBZSwQwSr5x3U9KtKzGW8HNE/NpUgsNcYDAVIM=
|
||||||
|
github.com/brutella/can v0.0.2/go.mod h1:NYDxbQito3w4+4DcjWs/fpQ3xyaFdpXw/KYqtZFU98k=
|
||||||
|
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06 h1:0oC8rFnE+74kEmuHZ46F6KHsMr5Gx2gUQPuNz28iQZM=
|
||||||
|
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type FCIMButton uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
LOAD FCIMButton = 0x83
|
||||||
|
EJECT FCIMButton = 0x82
|
||||||
|
|
||||||
|
VOLUME_DOWN FCIMButton = 0x2C
|
||||||
|
VOLUME_UP FCIMButton = 0x2B
|
||||||
|
|
||||||
|
NUMBER_1 FCIMButton = 0x31
|
||||||
|
NUMBER_2 FCIMButton = 0x32
|
||||||
|
NUMBER_3 FCIMButton = 0x33
|
||||||
|
NUMBER_4 FCIMButton = 0x34
|
||||||
|
NUMBER_5 FCIMButton = 0x35
|
||||||
|
NUMBER_6 FCIMButton = 0x36
|
||||||
|
NUMBER_7 FCIMButton = 0x37
|
||||||
|
NUMBER_8 FCIMButton = 0x38
|
||||||
|
NUMBER_9 FCIMButton = 0x39
|
||||||
|
NUMBER_0 FCIMButton = 0x30
|
||||||
|
|
||||||
|
PHONE FCIMButton = 0x84
|
||||||
|
SCAN FCIMButton = 0x21
|
||||||
|
CAT_FOLD FCIMButton = 0x08
|
||||||
|
|
||||||
|
SEEK_LEFT FCIMButton = 0x25
|
||||||
|
SEEK_RIGHT FCIMButton = 0x24
|
||||||
|
SEEK_UP FCIMButton = 0x86
|
||||||
|
SEEK_DOWN FCIMButton = 0x87
|
||||||
|
|
||||||
|
SOUND FCIMButton = 0x77
|
||||||
|
MENU FCIMButton = 0x03
|
||||||
|
TEXT FCIMButton = 0x43
|
||||||
|
|
||||||
|
POWER FCIMButton = 0x01
|
||||||
|
|
||||||
|
TUNE_LEFT FCIMButton = 0x2A
|
||||||
|
TUNE_RIGHT FCIMButton = 0x29
|
||||||
|
OK_PLAY_PAUSE FCIMButton = 0x02
|
||||||
|
|
||||||
|
AM_FM FCIMButton = 0x80
|
||||||
|
CD FCIMButton = 0x81
|
||||||
|
AUX FCIMButton = 0x78
|
||||||
|
SIRIUS FCIMButton = 0x79
|
||||||
|
|
||||||
|
DIRECT FCIMButton = 0x76
|
||||||
|
CLOCK FCIMButton = 0x0F
|
||||||
|
)
|
||||||
|
|
||||||
|
func ButtonToString(button FCIMButton) string {
|
||||||
|
switch button {
|
||||||
|
case LOAD:
|
||||||
|
return "LOAD"
|
||||||
|
case EJECT:
|
||||||
|
return "EJECT"
|
||||||
|
case VOLUME_DOWN:
|
||||||
|
return "VOLUME_DOWN"
|
||||||
|
case VOLUME_UP:
|
||||||
|
return "VOLUME_UP"
|
||||||
|
|
||||||
|
case NUMBER_1:
|
||||||
|
return "NUMBER_1"
|
||||||
|
case NUMBER_2:
|
||||||
|
return "NUMBER_2"
|
||||||
|
case NUMBER_3:
|
||||||
|
return "NUMBER_3"
|
||||||
|
case NUMBER_4:
|
||||||
|
return "NUMBER_4"
|
||||||
|
case NUMBER_5:
|
||||||
|
return "NUMBER_5"
|
||||||
|
case NUMBER_6:
|
||||||
|
return "NUMBER_6"
|
||||||
|
case NUMBER_7:
|
||||||
|
return "NUMBER_7"
|
||||||
|
case NUMBER_8:
|
||||||
|
return "NUMBER_8"
|
||||||
|
case NUMBER_9:
|
||||||
|
return "NUMBER_9"
|
||||||
|
case NUMBER_0:
|
||||||
|
return "NUMBER_0"
|
||||||
|
|
||||||
|
case PHONE:
|
||||||
|
return "PHONE"
|
||||||
|
case SCAN:
|
||||||
|
return "SCAN"
|
||||||
|
case CAT_FOLD:
|
||||||
|
return "CAT_FOLD"
|
||||||
|
|
||||||
|
case SEEK_LEFT:
|
||||||
|
return "SEEK_LEFT"
|
||||||
|
case SEEK_RIGHT:
|
||||||
|
return "SEEK_RIGHT"
|
||||||
|
case SEEK_UP:
|
||||||
|
return "SEEK_UP"
|
||||||
|
case SEEK_DOWN:
|
||||||
|
return "SEEK_DOWN"
|
||||||
|
|
||||||
|
case SOUND:
|
||||||
|
return "SOUND"
|
||||||
|
case MENU:
|
||||||
|
return "MENU"
|
||||||
|
case TEXT:
|
||||||
|
return "TEXT"
|
||||||
|
|
||||||
|
case POWER:
|
||||||
|
return "POWER"
|
||||||
|
|
||||||
|
case TUNE_LEFT:
|
||||||
|
return "TUNE_LEFT"
|
||||||
|
case TUNE_RIGHT:
|
||||||
|
return "TUNE_RIGHT"
|
||||||
|
case OK_PLAY_PAUSE:
|
||||||
|
return "OK_PLAY_PAUSE"
|
||||||
|
|
||||||
|
case AM_FM:
|
||||||
|
return "AM_FM"
|
||||||
|
case CD:
|
||||||
|
return "CD"
|
||||||
|
case AUX:
|
||||||
|
return "AUX"
|
||||||
|
case SIRIUS:
|
||||||
|
return "SIRIUS"
|
||||||
|
|
||||||
|
case DIRECT:
|
||||||
|
return "DIRECT"
|
||||||
|
case CLOCK:
|
||||||
|
return "CLOCK"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Hello world")
|
||||||
|
bus, err := can.NewBusForInterfaceWithName("can0")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bus.Disconnect()
|
||||||
|
|
||||||
|
bus.SubscribeFunc(func(frame can.Frame) {
|
||||||
|
// fmt.Println(frame, frame.ID)
|
||||||
|
fmt.Printf("%v %X\n", frame, frame.ID)
|
||||||
|
if frame.ID == 0x2E0 && frame.Data[1] == 0x88 {
|
||||||
|
data := FCIMButton(frame.Data[0])
|
||||||
|
fmt.Printf("Found input %v %v\n", data, ButtonToString(data))
|
||||||
|
writeString(ButtonToString(data), bus, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
writeTest(bus)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for true {
|
||||||
|
runClock(bus)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bus.ConnectAndPublish()
|
||||||
|
|
||||||
|
for true {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import can
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Define known button/data map
|
||||||
|
# ----------------------------
|
||||||
|
CAN_MAP = {
|
||||||
|
(0x2E0, (0x83, 0x88)): "EJECT",
|
||||||
|
(0x2E0, (0x82, 0x88)): "LOAD",
|
||||||
|
|
||||||
|
(0x2E0, (0x2C, 0x88)): "VOL_DOWN",
|
||||||
|
(0x2E0, (0x2B, 0x88)): "VOL_UP",
|
||||||
|
|
||||||
|
(0x2E0, (0x31, 0x88)): "1",
|
||||||
|
(0x2E0, (0x32, 0x88)): "2",
|
||||||
|
(0x2E0, (0x33, 0x88)): "3",
|
||||||
|
(0x2E0, (0x34, 0x88)): "4",
|
||||||
|
(0x2E0, (0x35, 0x88)): "5",
|
||||||
|
(0x2E0, (0x36, 0x88)): "6",
|
||||||
|
(0x2E0, (0x37, 0x88)): "7",
|
||||||
|
(0x2E0, (0x38, 0x88)): "8",
|
||||||
|
(0x2E0, (0x39, 0x88)): "9",
|
||||||
|
(0x2E0, (0x30, 0x88)): "0",
|
||||||
|
|
||||||
|
(0x2E0, (0x84, 0x88)): "PHONE",
|
||||||
|
(0x2E0, (0x21, 0x88)): "SCAN",
|
||||||
|
(0x2E0, (0x08, 0x88)): "CAT/FOLD",
|
||||||
|
(0x2E0, (0x30, 0x88)): "0",
|
||||||
|
|
||||||
|
(0x2E0, (0x25, 0x88)): "SEEK/TRACK_LEFT",
|
||||||
|
(0x2E0, (0x24, 0x88)): "SEEK/TRACK_RIGHT",
|
||||||
|
(0x2E0, (0x86, 0x88)): "SEEK/TRACK_UP",
|
||||||
|
(0x2E0, (0x87, 0x88)): "SEEK/TRACK_DOWN",
|
||||||
|
|
||||||
|
(0x2E0, (0x77, 0x88)): "SOUND",
|
||||||
|
(0x2E0, (0x03, 0x88)): "MENU",
|
||||||
|
(0x2E0, (0x43, 0x88)): "TEXT",
|
||||||
|
|
||||||
|
(0x2E0, (0x01, 0x88)): "POWER",
|
||||||
|
(0x2E0, (0x2A, 0x88)): "TUNE_LEFT",
|
||||||
|
(0x2E0, (0x29, 0x88)): "TUNE_RIGHT",
|
||||||
|
(0x2E0, (0x02, 0x88)): "TUNE_OK",
|
||||||
|
|
||||||
|
(0x2E0, (0x80, 0x88)): "AM/FM",
|
||||||
|
(0x2E0, (0x81, 0x88)): "CD",
|
||||||
|
(0x2E0, (0x78, 0x88)): "AUX",
|
||||||
|
(0x2E0, (0x79, 0x88)): "SIRIUS",
|
||||||
|
|
||||||
|
(0x2E0, (0x76, 0x88)): "DIRECT",
|
||||||
|
(0x2E0, (0x0F, 0x88)): "CLOCK",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def match_message(arbitration_id, data):
|
||||||
|
"""
|
||||||
|
Try to match known CAN patterns.
|
||||||
|
Only compares prefix of data.
|
||||||
|
"""
|
||||||
|
for (msg_id, pattern), label in CAN_MAP.items():
|
||||||
|
if arbitration_id == msg_id:
|
||||||
|
if tuple(data[:len(pattern)]) == pattern:
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Setup CAN interface
|
||||||
|
# ----------------------------
|
||||||
|
bus = can.interface.Bus(channel='can0', bustype='socketcan')
|
||||||
|
|
||||||
|
print("Listening on can0...")
|
||||||
|
|
||||||
|
def send_wake_fcim():
|
||||||
|
bus.send(can.Message(
|
||||||
|
arbitration_id=0x50C,
|
||||||
|
data=[0x0C, 0x02, 0x00],
|
||||||
|
is_extended_id=False
|
||||||
|
))
|
||||||
|
print("Sent 50C")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Main loop
|
||||||
|
# ----------------------------
|
||||||
|
send_wake_fcim()
|
||||||
|
for msg in bus:
|
||||||
|
data_bytes = tuple(msg.data)
|
||||||
|
|
||||||
|
# standard log line
|
||||||
|
#log_line = f"can0 {hex(msg.arbitration_id)} [{len(msg.data)}] " \
|
||||||
|
# f"{' '.join(f'{b:02X}' for b in msg.data)}"
|
||||||
|
|
||||||
|
# print(log_line)
|
||||||
|
|
||||||
|
# check mapping
|
||||||
|
match = match_message(msg.arbitration_id, msg.data)
|
||||||
|
|
||||||
|
if msg.arbitration_id == 0x55D:
|
||||||
|
print("Got 55D")
|
||||||
|
send_wake_fcim()
|
||||||
|
if match:
|
||||||
|
print(f"\tFOUND {match}")
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Find the CANable adapter and bring it up as a SocketCAN interface.
|
||||||
|
#
|
||||||
|
# Handles both CANable firmwares:
|
||||||
|
# - candleLight (gs_usb): already a native canN interface, just set the bitrate
|
||||||
|
# - slcan: a /dev/ttyACM* serial device, attached with slcand
|
||||||
|
# If no CANable is plugged in yet, waits for one.
|
||||||
|
#
|
||||||
|
# Usage: ./start_can.sh [bitrate] [slcan-interface-name]
|
||||||
|
# bitrate defaults to 125000 (Ford MS-CAN, where the FDIM lives)
|
||||||
|
# slcan-interface-name defaults to the first free canN
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BITRATE="${1:-125000}"
|
||||||
|
SLCAN_IFACE="${2:-}"
|
||||||
|
POLL_INTERVAL=0.5
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
self="$(readlink -f "$0")"
|
||||||
|
# sudo is setuid, which the kernel blocks when "no new privileges" is set (e.g. when
|
||||||
|
# launched from a sandboxed app). run0 asks systemd/polkit instead, so it still works.
|
||||||
|
if grep -q '^NoNewPrivs:[[:space:]]*1' /proc/self/status && command -v run0 >/dev/null; then
|
||||||
|
exec run0 "$self" "$@"
|
||||||
|
fi
|
||||||
|
exec sudo "$self" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prints the first native CAN interface driven by gs_usb (CANable with candleLight firmware)
|
||||||
|
find_native_canable() {
|
||||||
|
local iface driver
|
||||||
|
for iface in $(ip -o link show type can 2>/dev/null | awk -F': ' '{print $2}' | cut -d@ -f1); do
|
||||||
|
driver="$(basename "$(readlink -f "/sys/class/net/$iface/device/driver" 2>/dev/null)" 2>/dev/null || true)"
|
||||||
|
if [[ "$driver" == "gs_usb" ]]; then
|
||||||
|
echo "$iface"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints the first serial device that identifies itself as a CANable (slcan firmware)
|
||||||
|
find_slcan_canable() {
|
||||||
|
local dev
|
||||||
|
for dev in /dev/ttyACM* /dev/ttyUSB*; do
|
||||||
|
[[ -e "$dev" ]] || continue
|
||||||
|
if udevadm info -q property -n "$dev" 2>/dev/null | grep -qiE '^ID_MODEL=.*canable|^ID_VENDOR=Openlight'; then
|
||||||
|
echo "$dev"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
slcan_speed_code() {
|
||||||
|
case "$1" in
|
||||||
|
10000) echo 0 ;;
|
||||||
|
20000) echo 1 ;;
|
||||||
|
50000) echo 2 ;;
|
||||||
|
100000) echo 3 ;;
|
||||||
|
125000) echo 4 ;;
|
||||||
|
250000) echo 5 ;;
|
||||||
|
500000) echo 6 ;;
|
||||||
|
800000) echo 7 ;;
|
||||||
|
1000000) echo 8 ;;
|
||||||
|
*) echo "Bitrate $1 is not supported by slcan" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
next_free_can_name() {
|
||||||
|
local n=0
|
||||||
|
while ip link show "can$n" &>/dev/null; do
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
echo "can$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_native() {
|
||||||
|
local iface="$1"
|
||||||
|
echo "Configuring native CAN interface $iface at $BITRATE bit/s"
|
||||||
|
ip link set "$iface" down 2>/dev/null || true
|
||||||
|
ip link set "$iface" type can bitrate "$BITRATE" restart-ms 100
|
||||||
|
ip link set "$iface" txqueuelen 1000
|
||||||
|
ip link set "$iface" up
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_slcan() {
|
||||||
|
local tty="$1"
|
||||||
|
local code iface
|
||||||
|
code="$(slcan_speed_code "$BITRATE")"
|
||||||
|
|
||||||
|
command -v slcand >/dev/null || { echo "slcand not found, install can-utils" >&2; exit 1; }
|
||||||
|
modprobe slcan 2>/dev/null || true
|
||||||
|
|
||||||
|
# Drop any slcand already attached to this tty so it can be reopened at the new bitrate
|
||||||
|
if pkill -f "slcand.* $tty( |$)"; then
|
||||||
|
echo "Stopped existing slcand on $tty"
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
iface="${SLCAN_IFACE:-$(next_free_can_name)}"
|
||||||
|
echo "Configuring slcan device $tty as $iface at $BITRATE bit/s"
|
||||||
|
# -o open, -c close on exit, -s<N> bitrate
|
||||||
|
slcand -o -c -s"$code" "$tty" "$iface"
|
||||||
|
|
||||||
|
# slcand creates the interface asynchronously
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
ip link show "$iface" &>/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
ip link set "$iface" txqueuelen 1000
|
||||||
|
ip link set "$iface" up
|
||||||
|
}
|
||||||
|
|
||||||
|
modprobe can 2>/dev/null || true
|
||||||
|
modprobe can_raw 2>/dev/null || true
|
||||||
|
|
||||||
|
waiting=0
|
||||||
|
while true; do
|
||||||
|
iface="$(find_native_canable)"
|
||||||
|
if [[ -n "$iface" ]]; then
|
||||||
|
setup_native "$iface"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
tty="$(find_slcan_canable)"
|
||||||
|
if [[ -n "$tty" ]]; then
|
||||||
|
echo "Found CANable on $tty"
|
||||||
|
setup_slcan "$tty"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $waiting -eq 0 ]]; then
|
||||||
|
echo "No CANable found, waiting for one to be connected..."
|
||||||
|
waiting=1
|
||||||
|
fi
|
||||||
|
sleep "$POLL_INTERVAL"
|
||||||
|
done
|
||||||
|
|
||||||
|
ip -details link show type can
|
||||||
|
echo "CAN is up."
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ClockID is the CAN ID the FDIM reads the clock from.
|
||||||
|
const ClockID = 0x3F2
|
||||||
|
|
||||||
|
// ClockFrame builds the 0x3F2 clock frame for t. The FDIM reads hour and
|
||||||
|
// minute as BCD (0x45 shows "45"); plain binary (45 = 0x2D) shows a blank
|
||||||
|
// digit. With h24 false the hour is sent as 1-12.
|
||||||
|
func ClockFrame(t time.Time, h24 bool) Frame {
|
||||||
|
h := t.Hour()
|
||||||
|
if !h24 {
|
||||||
|
h %= 12
|
||||||
|
if h == 0 {
|
||||||
|
h = 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Frame{
|
||||||
|
ID: ClockID,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{toBCD(h), toBCD(t.Minute()), 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBCD(n int) uint8 {
|
||||||
|
return uint8(n/10<<4 | n%10)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClockFrame(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
hour, min int
|
||||||
|
h24 bool
|
||||||
|
wantH, wantM uint8
|
||||||
|
}{
|
||||||
|
{18, 45, false, 0x06, 0x45},
|
||||||
|
{18, 45, true, 0x18, 0x45},
|
||||||
|
{0, 5, false, 0x12, 0x05},
|
||||||
|
{0, 5, true, 0x00, 0x05},
|
||||||
|
{12, 59, false, 0x12, 0x59},
|
||||||
|
{9, 30, false, 0x09, 0x30},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
f := ClockFrame(time.Date(2026, 10, 21, c.hour, c.min, 0, 0, time.Local), c.h24)
|
||||||
|
if f.ID != ClockID || f.Data[0] != c.wantH || f.Data[1] != c.wantM || f.Data[5] != 0xF0 {
|
||||||
|
t.Errorf("%02d:%02d h24=%v: got % X, want %02X %02X ...", c.hour, c.min, c.h24, f.Data, c.wantH, c.wantM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// backlight isolates which frame turns on the FCIM backlight. Press Enter to
|
||||||
|
// step through conditions; each one sends a single candidate (or one piece of
|
||||||
|
// 0x3E8) on top of the safe base that is known not to light it:
|
||||||
|
// 0x50C keep-alive on every 0x55D, the BCD clock on 0x3F2, and the label text.
|
||||||
|
//
|
||||||
|
// The FDIM shows the active condition (e.g. "COND 3/12" / "3E8 BASELINE").
|
||||||
|
// The FCIM may hold the backlight on for a while after a frame stops; if it
|
||||||
|
// stays lit, Ctrl-C, wait for it to go dark, and restart with -start N.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/backlight
|
||||||
|
// go run ./cmd/backlight -start 5
|
||||||
|
// go run ./cmd/backlight -only 5 # hold condition 5 until Ctrl-C
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
type frame struct {
|
||||||
|
id uint32
|
||||||
|
n uint8
|
||||||
|
data [8]uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
init3F1 = frame{0x3F1, 2, [8]uint8{0xF5, 0x90}}
|
||||||
|
base3E8 = frame{0x3E8, 8, [8]uint8{0x07, 0x00, 0x10, 0x04, 0, 0, 0, 0}}
|
||||||
|
base3EF = frame{0x3EF, 8, [8]uint8{0x32, 0x32, 0x32, 0x32, 0x03, 0x00, 0x00, 0x20}}
|
||||||
|
keepWake = frame{0x50C, 3, [8]uint8{0x0C, 0x02, 0x00}}
|
||||||
|
)
|
||||||
|
|
||||||
|
func e8(b0, b1, b2, b3 uint8) frame {
|
||||||
|
return frame{0x3E8, 8, [8]uint8{b0, b1, b2, b3, 0, 0, 0, 0}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// condition: once is sent when the condition starts, repeat every -refresh.
|
||||||
|
type condition struct {
|
||||||
|
label string // FDIM row 2, max 20 chars
|
||||||
|
desc string
|
||||||
|
once []frame
|
||||||
|
repeat []frame
|
||||||
|
}
|
||||||
|
|
||||||
|
var conditions = []condition{
|
||||||
|
{label: "CONTROL", desc: "base only (keep-alive, clock, text) - expect OFF"},
|
||||||
|
{label: "3F1 ONCE", desc: "0x3F1 F5 90 sent once", once: []frame{init3F1}},
|
||||||
|
{label: "3F1 REPEAT", desc: "0x3F1 F5 90 every refresh", repeat: []frame{init3F1}},
|
||||||
|
{label: "3EF BASELINE", desc: "0x3EF 32 32 32 32 03 00 00 20 every refresh", repeat: []frame{base3EF}},
|
||||||
|
{label: "3E8 BASELINE", desc: "0x3E8 07 00 10 04 00 00 00 00 every refresh", repeat: []frame{base3E8}},
|
||||||
|
{label: "3E8 ALL ZERO", desc: "0x3E8 00 00 00 00 00 00 00 00", repeat: []frame{e8(0, 0, 0, 0)}},
|
||||||
|
{label: "3E8 B0=07 SOURCE", desc: "0x3E8 07 00 00 00 ... (source AUX only)", repeat: []frame{e8(0x07, 0, 0, 0)}},
|
||||||
|
{label: "3E8 B2=10 VOLUME", desc: "0x3E8 00 00 10 00 ... (volume only)", repeat: []frame{e8(0, 0, 0x10, 0)}},
|
||||||
|
{label: "3E8 B3=04 MODE", desc: "0x3E8 00 00 00 04 ... (byte3 bit 0x04 only)", repeat: []frame{e8(0, 0, 0, 0x04)}},
|
||||||
|
{label: "3E8 NO B3", desc: "0x3E8 07 00 10 00 ... (baseline with byte3 cleared)", repeat: []frame{e8(0x07, 0, 0x10, 0)}},
|
||||||
|
{label: "ALL THREE", desc: "3F1 once + 3E8 + 3EF baselines (what fdimprobe sends) - expect ON",
|
||||||
|
once: []frame{init3F1}, repeat: []frame{base3E8, base3EF}},
|
||||||
|
{label: "CONTROL AGAIN", desc: "base only - does the backlight go back off?"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iface := flag.String("if", "can0", "CAN interface")
|
||||||
|
start := flag.Int("start", 1, "condition number to start at")
|
||||||
|
only := flag.Int("only", 0, "run just this condition number and hold it until Ctrl-C")
|
||||||
|
refresh := flag.Duration("refresh", 500*time.Millisecond, "resend interval")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *only != 0 {
|
||||||
|
*start = *only
|
||||||
|
}
|
||||||
|
if *start < 1 || *start > len(conditions) {
|
||||||
|
fmt.Fprintf(os.Stderr, "-start/-only must be 1..%d\n", len(conditions))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus, err := can.NewBusForInterfaceWithName(*iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
bus.SubscribeFunc(func(f can.Frame) {
|
||||||
|
if f.ID == 0x55D {
|
||||||
|
send(bus, keepWake)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
if err := bus.ConnectAndPublish(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sig
|
||||||
|
bus.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
cur = *start - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
send(bus, keepWake)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
f := fdim.ClockFrame(time.Now(), false)
|
||||||
|
send(bus, frame{f.ID, f.Length, f.Data})
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
i := cur
|
||||||
|
mu.Unlock()
|
||||||
|
c := conditions[i]
|
||||||
|
for _, f := range c.repeat {
|
||||||
|
send(bus, f)
|
||||||
|
}
|
||||||
|
sendText(bus, fmt.Sprintf("COND %d/%d", i+1, len(conditions)), c.label)
|
||||||
|
time.Sleep(*refresh)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if *only != 0 {
|
||||||
|
c := conditions[*only-1]
|
||||||
|
for _, f := range c.once {
|
||||||
|
send(bus, f)
|
||||||
|
}
|
||||||
|
fmt.Printf("[%d/%d] %s\n %s\n holding, Ctrl-C to quit\n", *only, len(conditions), c.label, c.desc)
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Watch the FCIM backlight. Enter = next condition, Ctrl-C = quit.")
|
||||||
|
in := bufio.NewReader(os.Stdin)
|
||||||
|
for i := *start - 1; i < len(conditions); i++ {
|
||||||
|
c := conditions[i]
|
||||||
|
mu.Lock()
|
||||||
|
cur = i
|
||||||
|
mu.Unlock()
|
||||||
|
for _, f := range c.once {
|
||||||
|
send(bus, f)
|
||||||
|
}
|
||||||
|
fmt.Printf("[%d/%d] %s\n %s\n Enter for next ", i+1, len(conditions), c.label, c.desc)
|
||||||
|
in.ReadString('\n')
|
||||||
|
}
|
||||||
|
fmt.Println("done")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendText(bus *can.Bus, line1, line2 string) {
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, fdim.BuildTwoLine(line1, line2))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encode:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range append([]fdim.Frame{fdim.PairB336, fdim.PairB324}, body...) {
|
||||||
|
send(bus, frame{f.ID, f.Length, f.Data})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(bus *can.Bus, f frame) {
|
||||||
|
bus.Publish(can.Frame{ID: f.id, Length: f.n, Data: f.data})
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// fcimread listens on a CAN interface, prints every frame, and decodes FCIM
|
||||||
|
// button presses. It is receive-only: it never publishes anything to the bus.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/fcimread # listens on can0
|
||||||
|
// go run ./cmd/fcimread -if can1
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iface := flag.String("if", "can0", "CAN interface to listen on")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
bus, err := can.NewBusForInterfaceWithName(*iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.SubscribeFunc(func(frame can.Frame) {
|
||||||
|
fmt.Printf("%v %X\n", frame, frame.ID)
|
||||||
|
f := fdim.Frame{ID: frame.ID, Length: frame.Length, Data: frame.Data}
|
||||||
|
if b, ok := fdim.ParseFCIMButton(f); ok {
|
||||||
|
fmt.Printf("Found input %v %v\n", b, fdim.ButtonToString(b))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sig
|
||||||
|
// Disconnect alone doesn't unblock the pending socket read, so exit here.
|
||||||
|
bus.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Printf("listening on %s (receive only, Ctrl-C to stop)\n", *iface)
|
||||||
|
if err := bus.ConnectAndPublish(); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// fdimprobe steps the FDIM through experiments aimed at the parts of the
|
||||||
|
// display that aren't the two text rows: the source box, the volume bar and
|
||||||
|
// any unmapped icons/segments. Each step is held on the bus while you watch
|
||||||
|
// the display; the text rows show which step is active so the display itself
|
||||||
|
// tells you what you're looking at.
|
||||||
|
//
|
||||||
|
// Probes (pick with -test, comma separated; default runs all in order):
|
||||||
|
//
|
||||||
|
// source 0x3E8 byte 0 = 00..1F (FoDiMoCo source table: 01 AM, 02 FM1, ... 0F clock-only)
|
||||||
|
// volume 0x3E8 byte 2 sweep (should pop the volume bar)
|
||||||
|
// mode 0x3E8 byte 3 bits (0x04 = "Multimedia_System" in opendbc)
|
||||||
|
// 3e8bits 0x3E8 bytes 1, 4-7, one bit at a time (unmapped; likely icons)
|
||||||
|
// 3ef 0x3EF baseline 32 32 32 32 03 00 00 20, one bit flipped at a time
|
||||||
|
// topfield pair-A 3-character top field
|
||||||
|
// glyphs raw text bytes 0x80-0xFF, 40 per page across both rows
|
||||||
|
//
|
||||||
|
// While running it keeps the bench alive like cmd/radio (0x50C keep-alive on
|
||||||
|
// every 0x55D, BCD clock on 0x3F2), sends 0x3F1 F5 90 once at start, and
|
||||||
|
// resends the current 0x3E8, 0x3EF and text every -refresh.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/fdimprobe -list
|
||||||
|
// go run ./cmd/fdimprobe -test source -hold 3s
|
||||||
|
// go run ./cmd/fdimprobe -test 3e8bits,3ef -manual # Enter advances each step
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Baselines: AUX source, mid volume, Multimedia_System bit set, everything else 0.
|
||||||
|
var (
|
||||||
|
base3E8 = [8]uint8{0x07, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00}
|
||||||
|
base3EF = [8]uint8{0x32, 0x32, 0x32, 0x32, 0x03, 0x00, 0x00, 0x20}
|
||||||
|
)
|
||||||
|
|
||||||
|
// step is one state held on the bus.
|
||||||
|
type step struct {
|
||||||
|
note string // printed to the terminal
|
||||||
|
l1, l2 string // text rows (ignored when raw is set)
|
||||||
|
raw []byte // explicit 43-byte 0x337 payload, for glyph pages
|
||||||
|
top string // pair-A top field; empty = don't send
|
||||||
|
e8 [8]uint8 // 0x3E8
|
||||||
|
ef [8]uint8 // 0x3EF
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseStep(note, l1, l2 string) step {
|
||||||
|
return step{note: note, l1: l1, l2: l2, e8: base3E8, ef: base3EF}
|
||||||
|
}
|
||||||
|
|
||||||
|
type probe struct {
|
||||||
|
name, desc string
|
||||||
|
steps func() []step
|
||||||
|
}
|
||||||
|
|
||||||
|
var probes = []probe{
|
||||||
|
{"source", "0x3E8 byte 0 (source box) = 00..1F", func() []step {
|
||||||
|
var out []step
|
||||||
|
for s := 0; s < 0x20; s++ {
|
||||||
|
st := baseStep(fmt.Sprintf("3E8 byte0 (source) = %02X", s), "SOURCE", fmt.Sprintf("3E8 B0=%02X", s))
|
||||||
|
st.e8[0] = uint8(s)
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
{"volume", "0x3E8 byte 2 (volume) sweep", func() []step {
|
||||||
|
var out []step
|
||||||
|
vals := []int{}
|
||||||
|
for v := 0; v <= 0x40; v += 4 {
|
||||||
|
vals = append(vals, v)
|
||||||
|
}
|
||||||
|
vals = append(vals, 0x7F, 0xFF)
|
||||||
|
for _, v := range vals {
|
||||||
|
st := baseStep(fmt.Sprintf("3E8 byte2 (volume) = %02X (%d)", v, v), "VOLUME", fmt.Sprintf("3E8 B2=%02X (%d)", v, v))
|
||||||
|
st.e8[2] = uint8(v)
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
{"mode", "0x3E8 byte 3: 00, then each single bit", func() []step {
|
||||||
|
var out []step
|
||||||
|
vals := []int{0x00}
|
||||||
|
for bit := 0; bit < 8; bit++ {
|
||||||
|
vals = append(vals, 1<<bit)
|
||||||
|
}
|
||||||
|
for _, v := range vals {
|
||||||
|
st := baseStep(fmt.Sprintf("3E8 byte3 = %02X", v), "MODE BYTE", fmt.Sprintf("3E8 B3=%02X", v))
|
||||||
|
st.e8[3] = uint8(v)
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
{"3e8bits", "0x3E8 bytes 1, 4-7: one bit set at a time", func() []step {
|
||||||
|
var out []step
|
||||||
|
for _, b := range []int{1, 4, 5, 6, 7} {
|
||||||
|
for bit := 0; bit < 8; bit++ {
|
||||||
|
v := uint8(1 << bit)
|
||||||
|
st := baseStep(fmt.Sprintf("3E8 byte%d = %02X", b, v), "3E8 BITS", fmt.Sprintf("3E8 B%d=%02X", b, v))
|
||||||
|
st.e8[b] = v
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
{"3ef", "0x3EF: one bit flipped from baseline at a time", func() []step {
|
||||||
|
var out []step
|
||||||
|
for b := 0; b < 8; b++ {
|
||||||
|
for bit := 0; bit < 8; bit++ {
|
||||||
|
st := baseStep("", "3EF BITS", "")
|
||||||
|
st.ef[b] ^= 1 << bit
|
||||||
|
st.note = fmt.Sprintf("3EF byte%d %02X -> %02X", b, base3EF[b], st.ef[b])
|
||||||
|
st.l2 = fmt.Sprintf("3EF B%d=%02X (%02X)", b, st.ef[b], base3EF[b])
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
{"topfield", "pair-A 3-character top field", func() []step {
|
||||||
|
var out []step
|
||||||
|
for _, s := range []string{"ABC", "123", "AM ", "SAT", " "} {
|
||||||
|
st := baseStep(fmt.Sprintf("top field %q", s), "TOP FIELD", fmt.Sprintf("TOP=%q", s))
|
||||||
|
st.top = s
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
{"glyphs", "raw text bytes 0x80-0xFF, 40 per page", func() []step {
|
||||||
|
var out []step
|
||||||
|
for start := 0x80; start <= 0xFF; start += 40 {
|
||||||
|
p := fdim.BuildTwoLine("", "")
|
||||||
|
for k, idx := range fdim.MainSlots {
|
||||||
|
if c := start + k; c <= 0xFF {
|
||||||
|
p[idx] = uint8(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := min(start+39, 0xFF)
|
||||||
|
st := baseStep(fmt.Sprintf("glyphs %02X-%02X: row 1 starts %02X, row 2 starts %02X (left to right)", start, end, start, start+20), "", "")
|
||||||
|
st.raw = p
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iface := flag.String("if", "can0", "CAN interface")
|
||||||
|
which := flag.String("test", "", "comma-separated probes to run (default: all)")
|
||||||
|
hold := flag.Duration("hold", 3*time.Second, "how long each step is held")
|
||||||
|
manual := flag.Bool("manual", false, "wait for Enter between steps instead of -hold")
|
||||||
|
refresh := flag.Duration("refresh", 500*time.Millisecond, "resend interval for 0x3E8/0x3EF/text")
|
||||||
|
h24 := flag.Bool("24h", false, "send the clock as 24-hour")
|
||||||
|
list := flag.Bool("list", false, "list probes and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *list {
|
||||||
|
for _, p := range probes {
|
||||||
|
fmt.Printf("%-9s %3d steps %s\n", p.name, len(p.steps()), p.desc)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := selectProbes(*which)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus, err := can.NewBusForInterfaceWithName(*iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
bus.SubscribeFunc(func(frame can.Frame) {
|
||||||
|
if frame.ID == 0x55D {
|
||||||
|
send(bus, 0x50C, 3, [8]uint8{0x0C, 0x02, 0x00})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
if err := bus.ConnectAndPublish(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sig
|
||||||
|
bus.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
cur = baseStep("baseline", "FDIM PROBE", "STARTING")
|
||||||
|
)
|
||||||
|
|
||||||
|
send(bus, 0x50C, 3, [8]uint8{0x0C, 0x02, 0x00})
|
||||||
|
send(bus, 0x3F1, 2, [8]uint8{0xF5, 0x90}) // init message: only the two bytes fdim.txt lists
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
f := fdim.ClockFrame(time.Now(), *h24)
|
||||||
|
send(bus, f.ID, f.Length, f.Data)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
s := cur
|
||||||
|
mu.Unlock()
|
||||||
|
sendStep(bus, s)
|
||||||
|
time.Sleep(*refresh)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Let the NM ring settle with the baseline up.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
in := bufio.NewReader(os.Stdin)
|
||||||
|
for _, p := range selected {
|
||||||
|
steps := p.steps()
|
||||||
|
fmt.Printf("== %s: %s (%d steps)\n", p.name, p.desc, len(steps))
|
||||||
|
for i, st := range steps {
|
||||||
|
mu.Lock()
|
||||||
|
cur = st
|
||||||
|
mu.Unlock()
|
||||||
|
fmt.Printf("[%s %d/%d] %s\n", p.name, i+1, len(steps), st.note)
|
||||||
|
if *manual {
|
||||||
|
fmt.Print(" Enter for next ")
|
||||||
|
in.ReadString('\n')
|
||||||
|
} else {
|
||||||
|
time.Sleep(*hold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
cur = baseStep("baseline", "FDIM PROBE", "DONE")
|
||||||
|
mu.Unlock()
|
||||||
|
fmt.Println("done")
|
||||||
|
time.Sleep(2 * *refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectProbes(which string) ([]probe, error) {
|
||||||
|
if which == "" {
|
||||||
|
return probes, nil
|
||||||
|
}
|
||||||
|
var out []probe
|
||||||
|
for _, name := range strings.Split(which, ",") {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
found := false
|
||||||
|
for _, p := range probes {
|
||||||
|
if p.name == name {
|
||||||
|
out = append(out, p)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("unknown probe %q (see -list)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendStep sends one refresh of a step: 0x3E8, 0x3EF, the main text, and the
|
||||||
|
// pair-A top field when set.
|
||||||
|
func sendStep(bus *can.Bus, s step) {
|
||||||
|
send(bus, 0x3E8, 8, s.e8)
|
||||||
|
send(bus, 0x3EF, 8, s.ef)
|
||||||
|
|
||||||
|
payload := s.raw
|
||||||
|
if payload == nil {
|
||||||
|
payload = fdim.BuildTwoLine(s.l1, s.l2)
|
||||||
|
}
|
||||||
|
sendMessage(bus, fdim.PairB336, fdim.PairB324, payload)
|
||||||
|
|
||||||
|
if s.top != "" {
|
||||||
|
sendMessage(bus, fdim.PairA336, fdim.PairA324, fdim.BuildTopField(s.top))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage sends a 0x336/0x324 header pair followed by the payload on 0x337.
|
||||||
|
func sendMessage(bus *can.Bus, h336, h324 fdim.Frame, payload []byte) {
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encode:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range append([]fdim.Frame{h336, h324}, body...) {
|
||||||
|
send(bus, f.ID, f.Length, f.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(bus *can.Bus, id uint32, length uint8, data [8]uint8) {
|
||||||
|
bus.Publish(can.Frame{ID: id, Length: length, Data: data})
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// fdimtest cycles the FDIM through a series of text tests to exercise the
|
||||||
|
// whole 2x20 text area:
|
||||||
|
//
|
||||||
|
// 1. a single character
|
||||||
|
// 2. half a row (10 chars)
|
||||||
|
// 3. a full row (20 chars)
|
||||||
|
// 4. both rows (40 chars)
|
||||||
|
// 5. random characters spammed across the entire grid
|
||||||
|
//
|
||||||
|
// It does what cmd/radio does to keep the bench alive: answers every 0x55D
|
||||||
|
// with the 0x50C keep-alive and sends the 0x3F2 clock once a second.
|
||||||
|
// Text is resent every -refresh because the FDIM only holds it about a second.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/fdimtest # loop through the tests forever
|
||||||
|
// go run ./cmd/fdimtest -once # run each test once, then exit
|
||||||
|
// go run ./cmd/fdimtest -hold 8s -v # longer per test, print every frame
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
type test struct {
|
||||||
|
name string
|
||||||
|
spam bool // resend at the -spam rate instead of -refresh
|
||||||
|
lines func() (string, string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tests = []test{
|
||||||
|
{name: "single character", lines: func() (string, string) {
|
||||||
|
return "A", ""
|
||||||
|
}},
|
||||||
|
{name: "half row", lines: func() (string, string) {
|
||||||
|
return "ABCDEFGHIJ", ""
|
||||||
|
}},
|
||||||
|
{name: "full row", lines: func() (string, string) {
|
||||||
|
return "ABCDEFGHIJKLMNOPQRST", ""
|
||||||
|
}},
|
||||||
|
{name: "both rows", lines: func() (string, string) {
|
||||||
|
return "ABCDEFGHIJKLMNOPQRST", "01234567890123456789"
|
||||||
|
}},
|
||||||
|
{name: "random spam", spam: true, lines: func() (string, string) {
|
||||||
|
return randomLine(), randomLine()
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iface := flag.String("if", "can0", "CAN interface")
|
||||||
|
hold := flag.Duration("hold", 5*time.Second, "how long each test stays up")
|
||||||
|
refresh := flag.Duration("refresh", 500*time.Millisecond, "resend interval for static tests")
|
||||||
|
spam := flag.Duration("spam", 150*time.Millisecond, "resend interval for the random test")
|
||||||
|
once := flag.Bool("once", false, "run each test once, then exit")
|
||||||
|
verbose := flag.Bool("v", false, "print every frame sent")
|
||||||
|
h24 := flag.Bool("24h", false, "send the clock as 24-hour instead of 12-hour")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
bus, err := can.NewBusForInterfaceWithName(*iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.SubscribeFunc(func(frame can.Frame) {
|
||||||
|
if frame.ID == 0x55D {
|
||||||
|
sendKeepAlive(bus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
if err := bus.ConnectAndPublish(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sig
|
||||||
|
bus.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
sendKeepAlive(bus)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
sendClock(bus, *h24)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the NM ring a moment to settle before the first text.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
for i, t := range tests {
|
||||||
|
fmt.Printf("== test %d/%d: %s\n", i+1, len(tests), t.name)
|
||||||
|
interval := *refresh
|
||||||
|
if t.spam {
|
||||||
|
interval = *spam
|
||||||
|
}
|
||||||
|
for end := time.Now().Add(*hold); time.Now().Before(end); {
|
||||||
|
l1, l2 := t.lines()
|
||||||
|
fmt.Printf(" |%-20s|\n |%-20s|\n", l1, l2)
|
||||||
|
showText(bus, l1, l2, *verbose)
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *once {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showText sends both lines as one 43-byte 0x337 message behind the pair-B headers.
|
||||||
|
func showText(bus *can.Bus, line1, line2 string, verbose bool) {
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, fdim.BuildTwoLine(line1, line2))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encode:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range append([]fdim.Frame{fdim.PairB336, fdim.PairB324}, body...) {
|
||||||
|
if verbose {
|
||||||
|
fmt.Println(" ", formatFrame(f))
|
||||||
|
}
|
||||||
|
bus.Publish(can.Frame{ID: f.ID, Length: f.Length, Data: f.Data})
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendKeepAlive announces the radio (node 0x0C) in the NM ring, as decode.py does.
|
||||||
|
func sendKeepAlive(bus *can.Bus) {
|
||||||
|
bus.Publish(can.Frame{ID: 0x50C, Length: 3, Data: [8]uint8{0x0C, 0x02, 0x00}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendClock sends the current time on 0x3F2 (BCD, see fdim.ClockFrame).
|
||||||
|
func sendClock(bus *can.Bus, h24 bool) {
|
||||||
|
f := fdim.ClockFrame(time.Now(), h24)
|
||||||
|
bus.Publish(can.Frame{ID: f.ID, Length: f.Length, Data: f.Data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomLine returns 20 random printable characters, excluding '@' (the separator).
|
||||||
|
func randomLine() string {
|
||||||
|
const charset = "!\"#$%&'()*+,-./0123456789:;<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
|
||||||
|
b := make([]byte, 20)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[rand.IntN(len(charset))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFrame(f fdim.Frame) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "%03X#", f.ID)
|
||||||
|
for i := 0; i < int(f.Length); i++ {
|
||||||
|
fmt.Fprintf(&sb, "%02X", f.Data[i])
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// fdimtext prints the CAN frames for a two-line FDIM text message in
|
||||||
|
// cansend format (ID#DATA), one per line. It never touches the bus itself,
|
||||||
|
// so you can inspect the output first and then pipe it to cansend.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/fdimtext "line one" "line two"
|
||||||
|
// go run ./cmd/fdimtext -top "ABC"
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
top := flag.String("top", "", "send the small 3-char top field (pair A) instead of the two main lines")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var frames []fdim.Frame
|
||||||
|
if *top != "" {
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, fdim.BuildTopField(*top))
|
||||||
|
check(err)
|
||||||
|
frames = append([]fdim.Frame{fdim.PairA336, fdim.PairA324}, body...)
|
||||||
|
} else {
|
||||||
|
args := flag.Args()
|
||||||
|
if len(args) < 1 || len(args) > 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, `usage: fdimtext "line one" ["line two"] or fdimtext -top "ABC"`)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
line2 := ""
|
||||||
|
if len(args) == 2 {
|
||||||
|
line2 = args[1]
|
||||||
|
}
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, fdim.BuildTwoLine(args[0], line2))
|
||||||
|
check(err)
|
||||||
|
frames = append([]fdim.Frame{fdim.PairB336, fdim.PairB324}, body...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range frames {
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "%03X#", f.ID)
|
||||||
|
for i := 0; i < int(f.Length); i++ {
|
||||||
|
fmt.Fprintf(&sb, "%02X", f.Data[i])
|
||||||
|
}
|
||||||
|
fmt.Println(sb.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func check(err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeString shows text on the first FDIM line (second line blank).
|
||||||
|
// It uses the fdim package's fixed 43-byte layout ('@' at 3, 11, 25), so
|
||||||
|
// every write fills all 40 characters and `clear` is no longer needed.
|
||||||
|
func writeString(text string, bus *can.Bus, clear bool) {
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, fdim.BuildTwoLine(text, ""))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("encode:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Printing %q\n", text)
|
||||||
|
|
||||||
|
var frames []can.Frame
|
||||||
|
for _, f := range append([]fdim.Frame{fdim.PairB336, fdim.PairB324}, body...) {
|
||||||
|
frames = append(frames, can.Frame{ID: f.ID, Length: f.Length, Data: f.Data})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, frame := range frames {
|
||||||
|
fmt.Println("writing frames", formatFrame(frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFrames(frames, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTest(bus *can.Bus) {
|
||||||
|
frames := []can.Frame{
|
||||||
|
{
|
||||||
|
ID: 0x336,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x324,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x03, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x10, 0x2B, '1', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x21, ' ', 'H', 'e', 'l', 'l', '@', 'o'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x22, ' ', ' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x23, ' ', ' ', ' ', ' ', ' ', '@', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x24, ' ', ' ', 'F', 'O', 'R', 'D', '!'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x25, ' ', ' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 0x337,
|
||||||
|
Length: 8,
|
||||||
|
Data: [8]uint8{0x26, '1', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeFrames(frames, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFrames(frames []can.Frame, bus *can.Bus) {
|
||||||
|
for _, frame := range frames {
|
||||||
|
fmt.Printf("Wrote %02X\n", frame.ID)
|
||||||
|
bus.Publish(frame)
|
||||||
|
time.Sleep(time.Millisecond * 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearText(bus *can.Bus) {
|
||||||
|
writeString(strings.Repeat(" ", 41), bus, false)
|
||||||
|
// cansend can0 336#0301050303000000
|
||||||
|
// sleep 0.050
|
||||||
|
// cansend can0 324#0301020000000000
|
||||||
|
// sleep 0.050
|
||||||
|
// cansend can0 337#100D202020202020
|
||||||
|
// sleep 0.050
|
||||||
|
// cansend can0 337#2120202020202020
|
||||||
|
}
|
||||||
|
|
||||||
|
// runClock sends the current time on 0x3F2 as BCD (12-hour); plain binary
|
||||||
|
// minutes showed a blank digit, e.g. 45 = 0x2D displayed as "2_".
|
||||||
|
func runClock(bus *can.Bus) {
|
||||||
|
f := fdim.ClockFrame(time.Now(), false)
|
||||||
|
bus.Publish(can.Frame{ID: f.ID, Length: f.Length, Data: f.Data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFrame(frame can.Frame) string {
|
||||||
|
out := strings.Builder{}
|
||||||
|
out.WriteString(fmt.Sprintf("%02X", frame.ID))
|
||||||
|
out.WriteString("#")
|
||||||
|
for i := 0; i < int(frame.Length); i++ {
|
||||||
|
out.WriteString(fmt.Sprintf("%02X", frame.Data[i]))
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type FCIMButton uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
LOAD FCIMButton = 0x82
|
||||||
|
EJECT FCIMButton = 0x83
|
||||||
|
|
||||||
|
VOLUME_DOWN FCIMButton = 0x2C
|
||||||
|
VOLUME_UP FCIMButton = 0x2B
|
||||||
|
|
||||||
|
NUMBER_1 FCIMButton = 0x31
|
||||||
|
NUMBER_2 FCIMButton = 0x32
|
||||||
|
NUMBER_3 FCIMButton = 0x33
|
||||||
|
NUMBER_4 FCIMButton = 0x34
|
||||||
|
NUMBER_5 FCIMButton = 0x35
|
||||||
|
NUMBER_6 FCIMButton = 0x36
|
||||||
|
NUMBER_7 FCIMButton = 0x37
|
||||||
|
NUMBER_8 FCIMButton = 0x38
|
||||||
|
NUMBER_9 FCIMButton = 0x39
|
||||||
|
NUMBER_0 FCIMButton = 0x30
|
||||||
|
|
||||||
|
PHONE FCIMButton = 0x84
|
||||||
|
SCAN FCIMButton = 0x21
|
||||||
|
CAT_FOLD FCIMButton = 0x08
|
||||||
|
|
||||||
|
SEEK_LEFT FCIMButton = 0x25
|
||||||
|
SEEK_RIGHT FCIMButton = 0x24
|
||||||
|
SEEK_UP FCIMButton = 0x86
|
||||||
|
SEEK_DOWN FCIMButton = 0x87
|
||||||
|
|
||||||
|
SOUND FCIMButton = 0x77
|
||||||
|
MENU FCIMButton = 0x03
|
||||||
|
TEXT FCIMButton = 0x43
|
||||||
|
|
||||||
|
POWER FCIMButton = 0x01
|
||||||
|
|
||||||
|
TUNE_LEFT FCIMButton = 0x2A
|
||||||
|
TUNE_RIGHT FCIMButton = 0x29
|
||||||
|
OK_PLAY_PAUSE FCIMButton = 0x02
|
||||||
|
|
||||||
|
AM_FM FCIMButton = 0x80
|
||||||
|
CD FCIMButton = 0x81
|
||||||
|
AUX FCIMButton = 0x78
|
||||||
|
SIRIUS FCIMButton = 0x79
|
||||||
|
|
||||||
|
DIRECT FCIMButton = 0x76
|
||||||
|
CLOCK FCIMButton = 0x0F
|
||||||
|
)
|
||||||
|
|
||||||
|
func ButtonToString(button FCIMButton) string {
|
||||||
|
switch button {
|
||||||
|
case LOAD:
|
||||||
|
return "LOAD"
|
||||||
|
case EJECT:
|
||||||
|
return "EJECT"
|
||||||
|
case VOLUME_DOWN:
|
||||||
|
return "VOLUME_DOWN"
|
||||||
|
case VOLUME_UP:
|
||||||
|
return "VOLUME_UP"
|
||||||
|
|
||||||
|
case NUMBER_1:
|
||||||
|
return "NUMBER_1"
|
||||||
|
case NUMBER_2:
|
||||||
|
return "NUMBER_2"
|
||||||
|
case NUMBER_3:
|
||||||
|
return "NUMBER_3"
|
||||||
|
case NUMBER_4:
|
||||||
|
return "NUMBER_4"
|
||||||
|
case NUMBER_5:
|
||||||
|
return "NUMBER_5"
|
||||||
|
case NUMBER_6:
|
||||||
|
return "NUMBER_6"
|
||||||
|
case NUMBER_7:
|
||||||
|
return "NUMBER_7"
|
||||||
|
case NUMBER_8:
|
||||||
|
return "NUMBER_8"
|
||||||
|
case NUMBER_9:
|
||||||
|
return "NUMBER_9"
|
||||||
|
case NUMBER_0:
|
||||||
|
return "NUMBER_0"
|
||||||
|
|
||||||
|
case PHONE:
|
||||||
|
return "PHONE"
|
||||||
|
case SCAN:
|
||||||
|
return "SCAN"
|
||||||
|
case CAT_FOLD:
|
||||||
|
return "CAT_FOLD"
|
||||||
|
|
||||||
|
case SEEK_LEFT:
|
||||||
|
return "SEEK_LEFT"
|
||||||
|
case SEEK_RIGHT:
|
||||||
|
return "SEEK_RIGHT"
|
||||||
|
case SEEK_UP:
|
||||||
|
return "SEEK_UP"
|
||||||
|
case SEEK_DOWN:
|
||||||
|
return "SEEK_DOWN"
|
||||||
|
|
||||||
|
case SOUND:
|
||||||
|
return "SOUND"
|
||||||
|
case MENU:
|
||||||
|
return "MENU"
|
||||||
|
case TEXT:
|
||||||
|
return "TEXT"
|
||||||
|
|
||||||
|
case POWER:
|
||||||
|
return "POWER"
|
||||||
|
|
||||||
|
case TUNE_LEFT:
|
||||||
|
return "TUNE_LEFT"
|
||||||
|
case TUNE_RIGHT:
|
||||||
|
return "TUNE_RIGHT"
|
||||||
|
case OK_PLAY_PAUSE:
|
||||||
|
return "OK_PLAY_PAUSE"
|
||||||
|
|
||||||
|
case AM_FM:
|
||||||
|
return "AM_FM"
|
||||||
|
case CD:
|
||||||
|
return "CD"
|
||||||
|
case AUX:
|
||||||
|
return "AUX"
|
||||||
|
case SIRIUS:
|
||||||
|
return "SIRIUS"
|
||||||
|
|
||||||
|
case DIRECT:
|
||||||
|
return "DIRECT"
|
||||||
|
case CLOCK:
|
||||||
|
return "CLOCK"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Hello world")
|
||||||
|
bus, err := can.NewBusForInterfaceWithName("can0")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bus.Disconnect()
|
||||||
|
|
||||||
|
bus.SubscribeFunc(func(frame can.Frame) {
|
||||||
|
// fmt.Println(frame, frame.ID)
|
||||||
|
fmt.Printf("%v %X\n", frame, frame.ID)
|
||||||
|
if frame.ID == 0x55D {
|
||||||
|
// The FCIM only reports buttons while the radio (0x50C) is in the NM ring.
|
||||||
|
sendWakeFCIM(bus)
|
||||||
|
}
|
||||||
|
if frame.ID == 0x2E0 && frame.Data[1] == 0x88 {
|
||||||
|
data := FCIMButton(frame.Data[0])
|
||||||
|
fmt.Printf("Found input %v %v\n", data, ButtonToString(data))
|
||||||
|
writeString(ButtonToString(data), bus, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sendWakeFCIM(bus)
|
||||||
|
writeTest(bus)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for true {
|
||||||
|
runClock(bus)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bus.ConnectAndPublish()
|
||||||
|
|
||||||
|
for true {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWakeFCIM is decode.py's send_wake_fcim: announce the radio as node 0x0C
|
||||||
|
// in the network-management ring so the FCIM stays awake and sends 0x2E0.
|
||||||
|
func sendWakeFCIM(bus *can.Bus) {
|
||||||
|
bus.Publish(can.Frame{
|
||||||
|
ID: 0x50C,
|
||||||
|
Length: 3,
|
||||||
|
Data: [8]uint8{0x0C, 0x02, 0x00},
|
||||||
|
})
|
||||||
|
fmt.Println("Sent 50C")
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// sweep is a fast blind search for whatever drives the FDIM source box
|
||||||
|
// (AM/FM/SYNC/AUX) and other unmapped segments. Each step lasts -step
|
||||||
|
// (default 250ms); the candidate frame is repeated every -burst within it.
|
||||||
|
// The FDIM text rows show the current step, and the terminal prints it, so
|
||||||
|
// when something lights up you can tell which step did it.
|
||||||
|
//
|
||||||
|
// Sweeps (-sweep, comma separated; default "ids,headers"):
|
||||||
|
//
|
||||||
|
// ids send -payload on every ID 0x200-0x4FF, skipping the IDs we
|
||||||
|
// already drive (0x2E0 buttons, 0x324/0x336/0x337 text, 0x3F2 clock).
|
||||||
|
// 0x500-0x5FF (network management ring) and 0x7xx (diagnostics)
|
||||||
|
// are never touched.
|
||||||
|
// headers 0x336 header 03 01 X Y with X = 00-1F, Y = 00-07, followed by
|
||||||
|
// a 3-char top-field style payload ("SYN"), to look for other
|
||||||
|
// display regions addressed through the text protocol.
|
||||||
|
//
|
||||||
|
// Press Enter at any time to pause/resume; the paused step keeps being sent.
|
||||||
|
// Restart from a step with -start N (step numbers are printed).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/sweep
|
||||||
|
// go run ./cmd/sweep -sweep ids -payload FFFFFFFFFFFFFFFF
|
||||||
|
// go run ./cmd/sweep -sweep headers -step 500ms
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brutella/can"
|
||||||
|
|
||||||
|
"fdim"
|
||||||
|
)
|
||||||
|
|
||||||
|
// step is one candidate held on the bus for -step.
|
||||||
|
type step struct {
|
||||||
|
group string
|
||||||
|
note string // terminal
|
||||||
|
label string // FDIM row 2
|
||||||
|
send func(bus *can.Bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipIDs are IDs other parts of this program (or the FCIM) already use.
|
||||||
|
var skipIDs = map[uint32]bool{0x2E0: true, 0x324: true, 0x336: true, 0x337: true, 0x3F2: true}
|
||||||
|
|
||||||
|
func idSteps(payload [8]uint8, n uint8) []step {
|
||||||
|
var out []step
|
||||||
|
for id := uint32(0x200); id <= 0x4FF; id++ {
|
||||||
|
if skipIDs[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := id
|
||||||
|
out = append(out, step{
|
||||||
|
group: "ids",
|
||||||
|
note: fmt.Sprintf("ID %03X <- % X", id, payload[:n]),
|
||||||
|
label: fmt.Sprintf("ID %03X", id),
|
||||||
|
send: func(bus *can.Bus) {
|
||||||
|
send(bus, id, n, payload)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerSteps() []step {
|
||||||
|
var out []step
|
||||||
|
top := fdim.BuildTopField("SYN")
|
||||||
|
for x := 0; x <= 0x1F; x++ {
|
||||||
|
for y := 0; y <= 0x07; y++ {
|
||||||
|
h336 := fdim.Frame{ID: 0x336, Length: 8, Data: [8]uint8{0x03, 0x01, uint8(x), uint8(y)}}
|
||||||
|
out = append(out, step{
|
||||||
|
group: "headers",
|
||||||
|
note: fmt.Sprintf("336 03 01 %02X %02X + top-field \"SYN\"", x, y),
|
||||||
|
label: fmt.Sprintf("336 %02X %02X", x, y),
|
||||||
|
send: func(bus *can.Bus) {
|
||||||
|
sendMessage(bus, h336, fdim.PairB324, top)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iface := flag.String("if", "can0", "CAN interface")
|
||||||
|
which := flag.String("sweep", "ids,headers", "sweeps to run: ids, headers")
|
||||||
|
payloadHex := flag.String("payload", "0500100400000000", "payload for the ids sweep (hex, 1-8 bytes)")
|
||||||
|
stepDur := flag.Duration("step", 250*time.Millisecond, "time per step")
|
||||||
|
burst := flag.Duration("burst", 50*time.Millisecond, "repeat interval of the candidate within a step")
|
||||||
|
start := flag.Int("start", 1, "step number to start at")
|
||||||
|
no3e8 := flag.Bool("no3e8", false, "don't send the 0x3E8 baseline (backlight on) once a second")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
raw, err := hex.DecodeString(*payloadHex)
|
||||||
|
if err != nil || len(raw) < 1 || len(raw) > 8 {
|
||||||
|
fmt.Fprintln(os.Stderr, "-payload must be 1-8 bytes of hex")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
var payload [8]uint8
|
||||||
|
copy(payload[:], raw)
|
||||||
|
|
||||||
|
var steps []step
|
||||||
|
for _, name := range strings.Split(*which, ",") {
|
||||||
|
switch strings.TrimSpace(name) {
|
||||||
|
case "ids":
|
||||||
|
steps = append(steps, idSteps(payload, uint8(len(raw)))...)
|
||||||
|
case "headers":
|
||||||
|
steps = append(steps, headerSteps()...)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown sweep %q (ids, headers)\n", name)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *start < 1 || *start > len(steps) {
|
||||||
|
fmt.Fprintf(os.Stderr, "-start must be 1..%d\n", len(steps))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus, err := can.NewBusForInterfaceWithName(*iface)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
bus.SubscribeFunc(func(f can.Frame) {
|
||||||
|
if f.ID == 0x55D {
|
||||||
|
send(bus, 0x50C, 3, [8]uint8{0x0C, 0x02, 0x00})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
if err := bus.ConnectAndPublish(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sig
|
||||||
|
bus.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Base: keep-alive, BCD clock, and (unless -no3e8) the 0x3E8 baseline
|
||||||
|
// with byte 3 = 0x04, so the system looks "on" while we sweep.
|
||||||
|
send(bus, 0x50C, 3, [8]uint8{0x0C, 0x02, 0x00})
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
f := fdim.ClockFrame(time.Now(), false)
|
||||||
|
send(bus, f.ID, f.Length, f.Data)
|
||||||
|
if !*no3e8 {
|
||||||
|
send(bus, 0x3E8, 8, [8]uint8{0x07, 0x00, 0x10, 0x04})
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var paused atomic.Bool
|
||||||
|
go func() {
|
||||||
|
in := bufio.NewReader(os.Stdin)
|
||||||
|
for {
|
||||||
|
in.ReadString('\n')
|
||||||
|
paused.Store(!paused.Load())
|
||||||
|
if paused.Load() {
|
||||||
|
fmt.Println(" -- PAUSED (Enter to resume) --")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" -- resumed --")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
fmt.Printf("%d steps, %v each (~%v). Enter = pause/resume, Ctrl-C = quit.\n",
|
||||||
|
len(steps), *stepDur, (time.Duration(len(steps)) * *stepDur).Round(time.Second))
|
||||||
|
|
||||||
|
for i := *start - 1; i < len(steps); i++ {
|
||||||
|
s := steps[i]
|
||||||
|
fmt.Printf("[%d/%d] %s\n", i+1, len(steps), s.note)
|
||||||
|
end := time.Now().Add(*stepDur)
|
||||||
|
var lastLabel time.Time
|
||||||
|
for time.Now().Before(end) || paused.Load() {
|
||||||
|
// The FDIM drops text after ~1s, so keep the label fresh (matters while paused).
|
||||||
|
if time.Since(lastLabel) >= 500*time.Millisecond {
|
||||||
|
sendText(bus, fmt.Sprintf("SWEEP %d/%d", i+1, len(steps)), s.label)
|
||||||
|
lastLabel = time.Now()
|
||||||
|
}
|
||||||
|
s.send(bus)
|
||||||
|
time.Sleep(*burst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("done")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendText(bus *can.Bus, line1, line2 string) {
|
||||||
|
sendMessage(bus, fdim.PairB336, fdim.PairB324, fdim.BuildTwoLine(line1, line2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(bus *can.Bus, h336, h324 fdim.Frame, payload []byte) {
|
||||||
|
body, err := fdim.EncodeISOTP(0x337, payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encode:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range append([]fdim.Frame{h336, h324}, body...) {
|
||||||
|
send(bus, f.ID, f.Length, f.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(bus *can.Bus, id uint32, n uint8, data [8]uint8) {
|
||||||
|
bus.Publish(can.Frame{ID: id, Length: n, Data: data})
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
type FCIMButton uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
LOAD FCIMButton = 0x82
|
||||||
|
EJECT FCIMButton = 0x83
|
||||||
|
|
||||||
|
VOLUME_DOWN FCIMButton = 0x2C
|
||||||
|
VOLUME_UP FCIMButton = 0x2B
|
||||||
|
|
||||||
|
NUMBER_1 FCIMButton = 0x31
|
||||||
|
NUMBER_2 FCIMButton = 0x32
|
||||||
|
NUMBER_3 FCIMButton = 0x33
|
||||||
|
NUMBER_4 FCIMButton = 0x34
|
||||||
|
NUMBER_5 FCIMButton = 0x35
|
||||||
|
NUMBER_6 FCIMButton = 0x36
|
||||||
|
NUMBER_7 FCIMButton = 0x37
|
||||||
|
NUMBER_8 FCIMButton = 0x38
|
||||||
|
NUMBER_9 FCIMButton = 0x39
|
||||||
|
NUMBER_0 FCIMButton = 0x30
|
||||||
|
|
||||||
|
PHONE FCIMButton = 0x84
|
||||||
|
SCAN FCIMButton = 0x21
|
||||||
|
CAT_FOLD FCIMButton = 0x08
|
||||||
|
|
||||||
|
SEEK_LEFT FCIMButton = 0x25
|
||||||
|
SEEK_RIGHT FCIMButton = 0x24
|
||||||
|
SEEK_UP FCIMButton = 0x86
|
||||||
|
SEEK_DOWN FCIMButton = 0x87
|
||||||
|
|
||||||
|
SOUND FCIMButton = 0x77
|
||||||
|
MENU FCIMButton = 0x03
|
||||||
|
TEXT FCIMButton = 0x43
|
||||||
|
|
||||||
|
POWER FCIMButton = 0x01
|
||||||
|
|
||||||
|
TUNE_LEFT FCIMButton = 0x2A
|
||||||
|
TUNE_RIGHT FCIMButton = 0x29
|
||||||
|
OK_PLAY_PAUSE FCIMButton = 0x02
|
||||||
|
|
||||||
|
AM_FM FCIMButton = 0x80
|
||||||
|
CD FCIMButton = 0x81
|
||||||
|
AUX FCIMButton = 0x78
|
||||||
|
SIRIUS FCIMButton = 0x79
|
||||||
|
|
||||||
|
DIRECT FCIMButton = 0x76
|
||||||
|
CLOCK FCIMButton = 0x0F
|
||||||
|
)
|
||||||
|
|
||||||
|
func ButtonToString(button FCIMButton) string {
|
||||||
|
switch button {
|
||||||
|
case LOAD:
|
||||||
|
return "LOAD"
|
||||||
|
case EJECT:
|
||||||
|
return "EJECT"
|
||||||
|
case VOLUME_DOWN:
|
||||||
|
return "VOLUME_DOWN"
|
||||||
|
case VOLUME_UP:
|
||||||
|
return "VOLUME_UP"
|
||||||
|
|
||||||
|
case NUMBER_1:
|
||||||
|
return "NUMBER_1"
|
||||||
|
case NUMBER_2:
|
||||||
|
return "NUMBER_2"
|
||||||
|
case NUMBER_3:
|
||||||
|
return "NUMBER_3"
|
||||||
|
case NUMBER_4:
|
||||||
|
return "NUMBER_4"
|
||||||
|
case NUMBER_5:
|
||||||
|
return "NUMBER_5"
|
||||||
|
case NUMBER_6:
|
||||||
|
return "NUMBER_6"
|
||||||
|
case NUMBER_7:
|
||||||
|
return "NUMBER_7"
|
||||||
|
case NUMBER_8:
|
||||||
|
return "NUMBER_8"
|
||||||
|
case NUMBER_9:
|
||||||
|
return "NUMBER_9"
|
||||||
|
case NUMBER_0:
|
||||||
|
return "NUMBER_0"
|
||||||
|
|
||||||
|
case PHONE:
|
||||||
|
return "PHONE"
|
||||||
|
case SCAN:
|
||||||
|
return "SCAN"
|
||||||
|
case CAT_FOLD:
|
||||||
|
return "CAT_FOLD"
|
||||||
|
|
||||||
|
case SEEK_LEFT:
|
||||||
|
return "SEEK_LEFT"
|
||||||
|
case SEEK_RIGHT:
|
||||||
|
return "SEEK_RIGHT"
|
||||||
|
case SEEK_UP:
|
||||||
|
return "SEEK_UP"
|
||||||
|
case SEEK_DOWN:
|
||||||
|
return "SEEK_DOWN"
|
||||||
|
|
||||||
|
case SOUND:
|
||||||
|
return "SOUND"
|
||||||
|
case MENU:
|
||||||
|
return "MENU"
|
||||||
|
case TEXT:
|
||||||
|
return "TEXT"
|
||||||
|
|
||||||
|
case POWER:
|
||||||
|
return "POWER"
|
||||||
|
|
||||||
|
case TUNE_LEFT:
|
||||||
|
return "TUNE_LEFT"
|
||||||
|
case TUNE_RIGHT:
|
||||||
|
return "TUNE_RIGHT"
|
||||||
|
case OK_PLAY_PAUSE:
|
||||||
|
return "OK_PLAY_PAUSE"
|
||||||
|
|
||||||
|
case AM_FM:
|
||||||
|
return "AM_FM"
|
||||||
|
case CD:
|
||||||
|
return "CD"
|
||||||
|
case AUX:
|
||||||
|
return "AUX"
|
||||||
|
case SIRIUS:
|
||||||
|
return "SIRIUS"
|
||||||
|
|
||||||
|
case DIRECT:
|
||||||
|
return "DIRECT"
|
||||||
|
case CLOCK:
|
||||||
|
return "CLOCK"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FCIMButtonID is the CAN ID the FCIM reports button events on.
|
||||||
|
const FCIMButtonID = 0x2E0
|
||||||
|
|
||||||
|
// ParseFCIMButton reports the button in an FCIM button-press frame
|
||||||
|
// (ID 0x2E0, Data[1] == 0x88), matching the check in the original main.go.
|
||||||
|
func ParseFCIMButton(f Frame) (FCIMButton, bool) {
|
||||||
|
if f.ID != FCIMButtonID || f.Data[1] != 0x88 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return FCIMButton(f.Data[0]), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseFCIMButton(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
f Frame
|
||||||
|
want FCIMButton
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{Frame{ID: 0x2E0, Length: 8, Data: [8]uint8{0x2B, 0x88}}, VOLUME_UP, true},
|
||||||
|
{Frame{ID: 0x2E0, Length: 8, Data: [8]uint8{0x01, 0x88}}, POWER, true},
|
||||||
|
{Frame{ID: 0x2E0, Length: 8, Data: [8]uint8{0x2B, 0x00}}, 0, false},
|
||||||
|
{Frame{ID: 0x2E1, Length: 8, Data: [8]uint8{0x2B, 0x88}}, 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := ParseFCIMButton(c.f)
|
||||||
|
if got != c.want || ok != c.ok {
|
||||||
|
t.Errorf("ParseFCIMButton(%X % X) = %v,%v want %v,%v", c.f.ID, c.f.Data, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := ButtonToString(SEEK_UP); s != "SEEK_UP" {
|
||||||
|
t.Errorf("ButtonToString(SEEK_UP) = %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module fdim
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/brutella/can v0.0.2
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06 // indirect
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/brutella/can v0.0.2 h1:8TyjZrBZSwQwSr5x3U9KtKzGW8HNE/NpUgsNcYDAVIM=
|
||||||
|
github.com/brutella/can v0.0.2/go.mod h1:NYDxbQito3w4+4DcjWs/fpQ3xyaFdpXw/KYqtZFU98k=
|
||||||
|
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06 h1:0oC8rFnE+74kEmuHZ46F6KHsMr5Gx2gUQPuNz28iQZM=
|
||||||
|
golang.org/x/sys v0.0.0-20181213200352-4d1cda033e06/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frame mirrors brutella/can.Frame closely enough to convert 1:1.
|
||||||
|
type Frame struct {
|
||||||
|
ID uint32
|
||||||
|
Length uint8
|
||||||
|
Data [8]uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
const PadByte = 0x00 // writeTest pads with 0x00; keep identical until proven otherwise.
|
||||||
|
|
||||||
|
var ErrTooLong = errors.New("isotp: payload exceeds 4095 bytes")
|
||||||
|
|
||||||
|
// EncodeISOTP splits payload into ISO 15765-2 (classic CAN, normal addressing) frames.
|
||||||
|
// Pure function: no I/O, no timing, no flow control.
|
||||||
|
func EncodeISOTP(id uint32, payload []byte) ([]Frame, error) {
|
||||||
|
n := len(payload)
|
||||||
|
if n > 0xFFF {
|
||||||
|
return nil, ErrTooLong
|
||||||
|
}
|
||||||
|
pad := func(d *[8]uint8, from int) {
|
||||||
|
for i := from; i < 8; i++ {
|
||||||
|
d[i] = PadByte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n <= 7 { // Single Frame
|
||||||
|
var d [8]uint8
|
||||||
|
d[0] = uint8(n)
|
||||||
|
copy(d[1:], payload)
|
||||||
|
pad(&d, 1+n)
|
||||||
|
return []Frame{{id, 8, d}}, nil
|
||||||
|
}
|
||||||
|
var out []Frame
|
||||||
|
var d [8]uint8
|
||||||
|
d[0] = 0x10 | uint8(n>>8) // First Frame, 12-bit length
|
||||||
|
d[1] = uint8(n)
|
||||||
|
copy(d[2:], payload[:6])
|
||||||
|
out = append(out, Frame{id, 8, d})
|
||||||
|
sn := uint8(1)
|
||||||
|
for off := 6; off < n; off += 7 {
|
||||||
|
var c [8]uint8
|
||||||
|
c[0] = 0x20 | (sn & 0x0F) // wraps 0x2F -> 0x20
|
||||||
|
k := copy(c[1:], payload[off:])
|
||||||
|
pad(&c, 1+k)
|
||||||
|
out = append(out, Frame{id, 8, c})
|
||||||
|
sn++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTextPayload reproduces the layout of the known-good writeTest() message:
|
||||||
|
// '1' | f0 | '@' | f1 | '@' | f2 | '1' | 0x00
|
||||||
|
// Field widths are taken from writeTest (10, 13, 15). THIS IS A HYPOTHESIS:
|
||||||
|
// it reproduces the one working capture byte-for-byte, nothing more.
|
||||||
|
var TestFieldWidths = []int{10, 13, 15}
|
||||||
|
|
||||||
|
func BuildTextPayload(widths []int, fields ...string) []byte {
|
||||||
|
var b []byte
|
||||||
|
b = append(b, '1')
|
||||||
|
for i, w := range widths {
|
||||||
|
if i > 0 {
|
||||||
|
b = append(b, '@')
|
||||||
|
}
|
||||||
|
f := ""
|
||||||
|
if i < len(fields) {
|
||||||
|
f = fields[i]
|
||||||
|
}
|
||||||
|
f = toDisplayASCII(f)
|
||||||
|
if len(f) > w {
|
||||||
|
f = f[:w]
|
||||||
|
}
|
||||||
|
b = append(b, f...)
|
||||||
|
b = append(b, strings.Repeat(" ", w-len(f))...)
|
||||||
|
}
|
||||||
|
b = append(b, '1', 0x00)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDisplayASCII maps anything outside printable ASCII to '?', and
|
||||||
|
// replaces '@' (apparently a control/marker byte) so user text can't inject it.
|
||||||
|
func toDisplayASCII(s string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if r < 0x20 || r > 0x7E || r == '@' {
|
||||||
|
r = '?'
|
||||||
|
}
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FDIMHeaders are the two single frames writeString/writeTest send before 0x337.
|
||||||
|
// Note: byte0=0x03 reads like an ISO-TP Single-Frame PCI (3 data bytes).
|
||||||
|
func FDIMHeaders() []Frame {
|
||||||
|
return []Frame{
|
||||||
|
{0x336, 8, [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03, 0, 0, 0}},
|
||||||
|
{0x324, 8, [8]uint8{0x03, 0x01, 0x02, 0, 0, 0, 0, 0}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Golden: the exact 0x337 frames from display.go writeTest() (lines 108-142).
|
||||||
|
var golden337 = [][8]uint8{
|
||||||
|
{0x10, 0x2B, '1', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
{0x21, ' ', 'H', 'e', 'l', 'l', '@', 'o'},
|
||||||
|
{0x22, ' ', ' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
{0x23, ' ', ' ', ' ', ' ', ' ', '@', ' '},
|
||||||
|
{0x24, ' ', ' ', 'F', 'O', 'R', 'D', '!'},
|
||||||
|
{0x25, ' ', ' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
{0x26, '1', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTestGolden(t *testing.T) {
|
||||||
|
p := BuildTextPayload(TestFieldWidths, " Hell", "o", " FORD!")
|
||||||
|
if len(p) != 0x2B {
|
||||||
|
t.Fatalf("payload len %d want 43", len(p))
|
||||||
|
}
|
||||||
|
fr, err := EncodeISOTP(0x337, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fr) != len(golden337) {
|
||||||
|
t.Fatalf("frames %d want %d", len(fr), len(golden337))
|
||||||
|
}
|
||||||
|
for i := range fr {
|
||||||
|
if fr[i].Data != golden337[i] {
|
||||||
|
t.Errorf("frame %d: got % X want % X", i, fr[i].Data, golden337[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reassemble(t *testing.T, fr []Frame) []byte {
|
||||||
|
if fr[0].Data[0]>>4 == 0 {
|
||||||
|
return fr[0].Data[1 : 1+fr[0].Data[0]]
|
||||||
|
}
|
||||||
|
n := int(fr[0].Data[0]&0x0F)<<8 | int(fr[0].Data[1])
|
||||||
|
b := append([]byte{}, fr[0].Data[2:]...)
|
||||||
|
for i, f := range fr[1:] {
|
||||||
|
if want := uint8(0x20 | ((i + 1) & 0x0F)); f.Data[0] != want {
|
||||||
|
t.Fatalf("SN %02X want %02X", f.Data[0], want)
|
||||||
|
}
|
||||||
|
b = append(b, f.Data[1:]...)
|
||||||
|
}
|
||||||
|
return b[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTripAllLengths(t *testing.T) {
|
||||||
|
for n := 0; n <= 300; n++ {
|
||||||
|
p := make([]byte, n)
|
||||||
|
for i := range p {
|
||||||
|
p[i] = byte(i*7 + 1)
|
||||||
|
}
|
||||||
|
fr, err := EncodeISOTP(0x337, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := reassemble(t, fr); !bytes.Equal(got, p) {
|
||||||
|
t.Fatalf("n=%d mismatch", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongLengthNibbleAndWrap(t *testing.T) {
|
||||||
|
fr, _ := EncodeISOTP(0x337, make([]byte, 300))
|
||||||
|
if fr[0].Data[0] != 0x11 || fr[0].Data[1] != 0x2C {
|
||||||
|
t.Fatalf("FF PCI % X want 11 2C", fr[0].Data[:2])
|
||||||
|
}
|
||||||
|
if fr[16].Data[0] != 0x20 { // 16th CF wraps
|
||||||
|
t.Fatalf("CF16 SN %02X want 20", fr[16].Data[0])
|
||||||
|
}
|
||||||
|
if _, err := EncodeISOTP(0x337, make([]byte, 4096)); err == nil {
|
||||||
|
t.Fatal("want ErrTooLong")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
// FoDiMoCo (p1ne/fdim-controller) 0x337 layout for pair B (0x336 03 01 05 03 03 / 0x324 03 01 02):
|
||||||
|
// 43-byte payload, '@' at fixed indices 3, 11, 25; the other 40 bytes are two 20-char lines.
|
||||||
|
// line 1 = idx 0-2, 4-10, 12-21 line 2 = idx 22-24, 26-42
|
||||||
|
const MainLen = 43
|
||||||
|
|
||||||
|
var sepIdx = map[int]bool{3: true, 11: true, 25: true}
|
||||||
|
|
||||||
|
// MainSlots lists, in display order, the payload index of every visible character (40 entries).
|
||||||
|
var MainSlots = func() []int {
|
||||||
|
s := make([]int, 0, 40)
|
||||||
|
for i := 0; i < MainLen; i++ {
|
||||||
|
if !sepIdx[i] {
|
||||||
|
s = append(s, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}()
|
||||||
|
|
||||||
|
// BuildTwoLine returns the 43-byte payload for two 20-column lines.
|
||||||
|
// Text is sanitised (toDisplayASCII), truncated to 20, right-padded with spaces.
|
||||||
|
func BuildTwoLine(line1, line2 string) []byte {
|
||||||
|
p := make([]byte, MainLen)
|
||||||
|
for i := range sepIdx {
|
||||||
|
p[i] = '@'
|
||||||
|
}
|
||||||
|
text := fit(line1, 20) + fit(line2, 20)
|
||||||
|
for k, idx := range MainSlots {
|
||||||
|
p[idx] = text[k]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTopField returns the 6-byte pair-A single-frame payload: 20 c0 '@' c1 '@' c2.
|
||||||
|
func BuildTopField(s string) []byte {
|
||||||
|
f := fit(s, 3)
|
||||||
|
return []byte{0x20, f[0], '@', f[1], '@', f[2]}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fit(s string, w int) string {
|
||||||
|
s = toDisplayASCII(s)
|
||||||
|
if len(s) > w {
|
||||||
|
s = s[:w]
|
||||||
|
}
|
||||||
|
for len(s) < w {
|
||||||
|
s += " "
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centre pads s with spaces to centre it in w columns (protocol does no centring).
|
||||||
|
func Centre(s string, w int) string {
|
||||||
|
if len(s) >= w {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
l := (w - len(s)) / 2
|
||||||
|
out := ""
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
out += " "
|
||||||
|
}
|
||||||
|
return out + s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header pairs exactly as FoDiMoCo replays them.
|
||||||
|
var (
|
||||||
|
PairA336 = Frame{0x336, 8, [8]uint8{0x03, 0x01, 0x0A, 0x01, 0xFE, 0, 0, 0}}
|
||||||
|
PairA324 = Frame{0x324, 8, [8]uint8{0x01, 0x01, 0x02, 0, 0, 0, 0, 0}}
|
||||||
|
PairB336 = Frame{0x336, 8, [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03, 0, 0, 0}}
|
||||||
|
PairB324 = Frame{0x324, 8, [8]uint8{0x03, 0x01, 0x02, 0, 0, 0, 0, 0}}
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FoDiMoCo FordMessages.h frames for "Mercury"/"Mariner".
|
||||||
|
var fodimoco337 = [][8]uint8{
|
||||||
|
{0x10, 0x2B, ' ', ' ', ' ', '@', ' ', ' '},
|
||||||
|
{0x21, ' ', 'M', 'e', 'r', 'c', '@', 'u'},
|
||||||
|
{0x22, 'r', 'y', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
{0x23, ' ', ' ', ' ', ' ', ' ', '@', ' '},
|
||||||
|
{0x24, ' ', ' ', 'M', 'a', 'r', 'i', 'n'},
|
||||||
|
{0x25, 'e', 'r', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
{0x26, ' ', ' ', 0, 0, 0, 0, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoDiMoCoGolden(t *testing.T) {
|
||||||
|
p := BuildTwoLine(" Mercury", " Mariner")
|
||||||
|
fr, err := EncodeISOTP(0x337, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fr) != len(fodimoco337) {
|
||||||
|
t.Fatalf("frames %d", len(fr))
|
||||||
|
}
|
||||||
|
for i := range fr {
|
||||||
|
if fr[i].Data != fodimoco337[i] {
|
||||||
|
t.Errorf("frame %d: got % X want % X", i, fr[i].Data, fodimoco337[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopField(t *testing.T) {
|
||||||
|
fr, _ := EncodeISOTP(0x337, BuildTopField(" "))
|
||||||
|
want := [8]uint8{0x06, 0x20, ' ', '@', ' ', '@', ' ', 0x00}
|
||||||
|
if fr[0].Data != want {
|
||||||
|
t.Fatalf("got % X", fr[0].Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the user's writeTest payload under the FoDiMoCo slot model.
|
||||||
|
func TestUserPayloadUnderFoDiMoCoModel(t *testing.T) {
|
||||||
|
p := []byte("1 Hell@o @ FORD! 1\x00")
|
||||||
|
if len(p) != 43 {
|
||||||
|
t.Fatal(len(p))
|
||||||
|
}
|
||||||
|
var vis []byte
|
||||||
|
for _, i := range MainSlots {
|
||||||
|
c := p[i]
|
||||||
|
if c == 0 {
|
||||||
|
c = '~'
|
||||||
|
}
|
||||||
|
vis = append(vis, c)
|
||||||
|
}
|
||||||
|
t.Logf("line1=%q line2=%q idx3=%q", vis[:20], vis[20:], p[3])
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# CANable2 (slcan firmware, 16d0:117e) -> stable /dev/canable symlink.
|
||||||
|
# Also pulls in slcan.service whenever the adapter appears (boot or replug).
|
||||||
|
SUBSYSTEM=="tty", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="117e", SYMLINK+="canable", TAG+="systemd", ENV{SYSTEMD_WANTS}+="slcan.service"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# deploy
|
||||||
|
|
||||||
|
Files that turn a Raspberry Pi into the radio: the CANable adapter comes up as
|
||||||
|
`can0` whenever it's plugged in, and `fusiond` starts at boot and drives the real
|
||||||
|
FDIM/FCIM over CAN and the ADAU1467 over I²C.
|
||||||
|
|
||||||
|
| File | Installed to | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `bootstrap.sh` | — (run from the laptop) | One-time, idempotent Pi provisioning |
|
||||||
|
| `99-canable.rules` | `/etc/udev/rules.d/` | Gives the CANable2 (`16d0:117e`, slcan firmware) a stable `/dev/canable` symlink and starts `slcan.service` when it appears |
|
||||||
|
| `slcan.service` | `/etc/systemd/system/` | Runs `slcand` to bridge `/dev/canable` → `can0` at 125 kbit/s (Ford MS-CAN) and brings the link up |
|
||||||
|
| `fusion.service` | `/etc/systemd/system/` | Runs `/usr/local/bin/fusion` against `can0` and `/dev/i2c-1`, restarting forever |
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
From the laptop, with SSH access to the Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/bootstrap.sh main@fusion.local
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs `can-utils` and `i2c-tools`, enables I²C and SPI via
|
||||||
|
`raspi-config`, installs the udev rule and both units, and enables them. It's safe
|
||||||
|
to re-run after editing any of the files here.
|
||||||
|
|
||||||
|
## Installing / updating the binary
|
||||||
|
|
||||||
|
`bootstrap.sh` does **not** install the program. `fusion.service` has
|
||||||
|
`ConditionPathExists=/usr/local/bin/fusion`, so it silently does nothing until
|
||||||
|
the binary is there. Cross-compile and copy it (use `GOARCH=arm GOARM=7` on a
|
||||||
|
32-bit OS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fusion
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o bin/fusion ./cmd/fusiond
|
||||||
|
scp bin/fusion main@fusion.local:/tmp/fusion
|
||||||
|
ssh main@fusion.local 'sudo install -m 755 /tmp/fusion /usr/local/bin/fusion && sudo systemctl restart fusion'
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it behaves
|
||||||
|
|
||||||
|
- **Hot-plug:** `slcan.service` is bound to the adapter (`BindsTo=dev-canable.device`),
|
||||||
|
so unplugging stops it and replugging starts it again via udev.
|
||||||
|
- **No hard dependency on CAN:** `fusion.service` only *wants* `slcan.service`. If
|
||||||
|
`can0` is missing, `fusiond` exits and systemd retries every 2 s with no limit
|
||||||
|
(`StartLimitIntervalSec=0`).
|
||||||
|
- **Settings** persist in `/var/lib/fusion/settings.json` (`StateDirectory=fusion`).
|
||||||
|
The service runs as a `DynamicUser` in the `i2c` and `gpio` groups.
|
||||||
|
- Bitrate is fixed at 125 kbit/s (`-s4`) in `slcan.service`. Change both that and
|
||||||
|
any manual setup if you move to HS-CAN.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u fusion -f
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status slcan fusion
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /dev/canable && ip -details link show can0
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
candump can0
|
||||||
|
```
|
||||||
|
|
||||||
|
For manual bring-up on a laptop (no udev rule), use `../../start_can.sh`, which
|
||||||
|
handles both the slcan and candleLight firmwares.
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time (idempotent) Pi provisioning. Run from the laptop:
|
||||||
|
# deploy/bootstrap.sh main@fusion.local
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
host="${1:?usage: $0 user@host}"
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
stage=/tmp/fusion-bootstrap
|
||||||
|
|
||||||
|
ssh "$host" "rm -rf $stage && mkdir -p $stage"
|
||||||
|
scp "$here/99-canable.rules" "$here/slcan.service" "$here/fusion.service" "$host:$stage/"
|
||||||
|
|
||||||
|
ssh -t "$host" "sudo bash -euo pipefail -s" <<EOF
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y can-utils i2c-tools
|
||||||
|
|
||||||
|
raspi-config nonint do_i2c 0 # 0 = enable
|
||||||
|
raspi-config nonint do_spi 0
|
||||||
|
|
||||||
|
install -m 644 $stage/99-canable.rules /etc/udev/rules.d/
|
||||||
|
install -m 644 $stage/slcan.service $stage/fusion.service /etc/systemd/system/
|
||||||
|
install -d -m 755 /etc/fusion
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
udevadm control --reload
|
||||||
|
udevadm trigger --subsystem-match=tty --action=add
|
||||||
|
|
||||||
|
systemctl enable slcan.service fusion.service
|
||||||
|
rm -rf $stage
|
||||||
|
echo "bootstrap done"
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Fusion audio controller (FCIM/FDIM over CAN, DSP over I2C)
|
||||||
|
# Start after CAN when it's present, but don't depend on it: fusiond exits if
|
||||||
|
# can0 isn't up (or disappears) and is restarted until it is.
|
||||||
|
Wants=slcan.service
|
||||||
|
After=slcan.service
|
||||||
|
# Never give up restarting: a slow boot or a CANable replug can take a while.
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
ConditionPathExists=/usr/local/bin/fusion
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/fusion -can can0 -dsp /dev/i2c-1 -settings /var/lib/fusion/settings.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
DynamicUser=yes
|
||||||
|
# /var/lib/fusion, writable by the dynamic user, survives restarts and reboots.
|
||||||
|
StateDirectory=fusion
|
||||||
|
SupplementaryGroups=i2c gpio
|
||||||
|
StandardInput=null
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ls /dev/ttyA*
|
||||||
|
|
||||||
|
sudo slcand -o -c -s4 /dev/ttyACM3 can0
|
||||||
|
sudo ip link set can0 up
|
||||||
|
candump can0
|
||||||
|
|
||||||
|
ls /dev/ttyA* | fzf | xargs sudo slcand -o -c -s4 /dev/ttyACM3 can0
|
||||||
|
sudo ip link set can0 up
|
||||||
|
|
||||||
|
|
||||||
|
sudo slcand -o -c -s4 $(ls /dev/ttyA* | fzf) can0;
|
||||||
|
sudo ip link set can0 up;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CANable slcan bridge -> can0 (125 kbit/s MS-CAN)
|
||||||
|
# Stop when the adapter is unplugged; udev starts us again on replug.
|
||||||
|
BindsTo=dev-canable.device
|
||||||
|
After=dev-canable.device
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# -o open, -c close on exit, -s4 = 125 kbit/s, -F stay in foreground
|
||||||
|
ExecStart=/usr/bin/slcand -o -c -s4 -F /dev/canable can0
|
||||||
|
# slcand creates can0 asynchronously; wait for it (max 5 s) then bring it up.
|
||||||
|
ExecStartPost=/bin/sh -c 'for i in $(seq 50); do ip link show can0 >/dev/null 2>&1 && break; sleep 0.1; done; ip link set can0 txqueuelen 1000 up'
|
||||||
|
ExecStopPost=-/usr/sbin/ip link set can0 down
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
71 cansend can0 123#1122334455667788
|
||||||
|
72 cansend can0 123#1122334455667788cangen can0 -g 5 -I 100 -L 8
|
||||||
|
73 cangen can0 -g 5 -I 100 -L 8
|
||||||
|
74 cansend can0 200#0000000000000000
|
||||||
|
75 cangen can0 -g 20 -I 100 -L 8
|
||||||
Binary file not shown.
@@ -0,0 +1,214 @@
|
|||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
000002D8000002D80000002400000000
|
||||||
|
000002FC000002FC0000001800000000
|
||||||
|
0000006000000060000000CC00000000
|
||||||
|
0000012C0000012C0000003C00000000
|
||||||
|
010000000000000000000000000007FF
|
||||||
|
FFFFFFFF00355555000000000000C000
|
||||||
|
0000C000000008000100000000000000
|
||||||
|
00000000000007FFFFFFFFFF000AAAAB
|
||||||
|
000000000000C0000000C00000000800
|
||||||
|
00804DCE0000000000000000000007FF
|
||||||
|
FFFFFFFF00006D3A000000000000C000
|
||||||
|
0000C0000000080000009D61000001E0
|
||||||
|
00000E39000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000054000000000000000300000057
|
||||||
|
0000005A0000005D0000006000000063
|
||||||
|
00000066000000690000006C0000006F
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
0100000000FFD26500FFA4D300FF7748
|
||||||
|
00FF49C600FF1C4C00FEEED900FEC16F
|
||||||
|
00FE940D00FE66B300FE396200FE0C18
|
||||||
|
00FDDED600FDB19D00FD846B00FD5742
|
||||||
|
00FD2A2000FCFD0700FCCFF500FCA2EC
|
||||||
|
00FC75EB00FC48F100FC1C0000FBEF17
|
||||||
|
00FBC23500FB955C00FB688B00FB3BC1
|
||||||
|
00FB0F0000FAE24700FAB59500FA88EC
|
||||||
|
00FA5C4A00FA2FB100FA031F00F9D696
|
||||||
|
00F9AA1400F97D9A00F9512800F924BE
|
||||||
|
00F8F85C00F8CC0200F89FB000F87366
|
||||||
|
00F8472300F81AE900F7EEB600F7C28B
|
||||||
|
00F7966800F76A4D00F73E3A00F7122E
|
||||||
|
00F6E62B00F6BA2F039B62A70384A300
|
||||||
|
036E72D30358CE950343B2D5032F1C35
|
||||||
|
031B076E0307714E02F456B502E1B498
|
||||||
|
02CF87FF02BDCE0602AC83D9029BA6B7
|
||||||
|
028B33F1027B28E7026B830C025C3FE1
|
||||||
|
024D5CF8023ED7F20230AE7F0222DE5C
|
||||||
|
021565580208414C01FB702101EEEFCB
|
||||||
|
01E2BE4E01D6D9B701CB402201BFEFB5
|
||||||
|
01B4E6A401AA232D019FA39701956638
|
||||||
|
018B696D0181ABA001782B43016EE6D2
|
||||||
|
0165DCD4015D0BD801547276014C0F50
|
||||||
|
0143E110013BE66801341E12012C86D2
|
||||||
|
01251F72011DE6C40116DBA1010FFCEA
|
||||||
|
010949870102C06700FC607E00F628CB
|
||||||
|
0485FBAE04697475044DA12804327D59
|
||||||
|
041804B403FE330303E5042803CC7420
|
||||||
|
03B47F02039D20FD0386565703701B70
|
||||||
|
035A6CBC034546C80330A635031C87B9
|
||||||
|
0308E82102F5C44C02E3192D02D0E3CC
|
||||||
|
02BF214102ADCEB8029CE970028C6EB6
|
||||||
|
027C5BEB026CAE80025D63F4024E79D9
|
||||||
|
023FEDCF0231BD830223E6B30216672C
|
||||||
|
02093CC601FC656801EFDF0801E3A7A5
|
||||||
|
01D7BD4F01CC1E1F01C0C83A01B5B9D4
|
||||||
|
01AAF12801A06C7F01962A2D018C288F
|
||||||
|
0182660C0178E117016F982B016689CE
|
||||||
|
015DB48F01551706014CAFD201447D9D
|
||||||
|
013C7F1A0134B302039B62A703CD8B9C
|
||||||
|
04026E1C043A300B0474F95E04B2F432
|
||||||
|
04F44CF00539326B0581D5FF05CE6BB9
|
||||||
|
061F2A7806744C1606CE0D93072CAF3D
|
||||||
|
079074E307F9A60108688DF608DD7C38
|
||||||
|
0958C49009DABF520A63C9A00AF445AA
|
||||||
|
0B8C9AF60C2D36AA0CD68BD80D8913D5
|
||||||
|
0E1C5C700E20189B0E20189B0E20189B
|
||||||
|
0E20189B0E20189B0E20189B0E20189B
|
||||||
|
0E20189B0E20189B0E20189B0E20189B
|
||||||
|
0E20189B0E20189B0E20189B0E20189B
|
||||||
|
0E20189B0E20189B0E20189B0E20189B
|
||||||
|
0E20189B0E20189B0E20189B0E20189B
|
||||||
|
0E20189B0E20189B0E20189B0E20189B
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
000005D300000010000057F0000025F0
|
||||||
|
0000002400000D80000025F000000024
|
||||||
|
00000D80000025F00000002400000D80
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000092000000060000000D00000095
|
||||||
|
00000098000000140000001B0000009B
|
||||||
|
0013E9FD0027D3F90013E9FDFF9E27E7
|
||||||
|
011230270013E9FD0027D3F90013E9FD
|
||||||
|
FF9E27E70112302700235128FFB95DAF
|
||||||
|
00235128FFBCDD9BFF4A223C00235128
|
||||||
|
FFB95DAF00235128FFBCDD9BFF4A223C
|
||||||
|
003C6D970078DB2D003C6D97FFD185EE
|
||||||
|
003CC3B8003C6D970078DB2D003C6D97
|
||||||
|
FFD185EE003CC3B800739799FF18D0CF
|
||||||
|
00739799FFC4EF7A00934DDD00739799
|
||||||
|
FF18D0CF00739799FFC4EF7A00934DDD
|
||||||
|
00000000000000000000000000000000
|
||||||
|
0100000000C66666FF39999A01000000
|
||||||
|
0093B646FF6C49BA010000000060C49C
|
||||||
|
FF9F3B6400009D61000001E000000E39
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000007
|
||||||
|
0000000800000009000000090000000A
|
||||||
|
0000000A0000000B0000000B00000005
|
||||||
|
0000000500009D61000001E000000E39
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000FF3628
|
||||||
|
FE0193B100FF3628FF01901C01FE68BA
|
||||||
|
00FD6806FE052FF300FD6806FF05293A
|
||||||
|
01FAC953000000000000000001000000
|
||||||
|
000000000000000000B922ABFE8DBAAB
|
||||||
|
00B922ABFF799942015E23EC00009D61
|
||||||
|
000001E000000E390000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
0000000000834AE20000000000000000
|
||||||
|
00834AE20000000000000000006FBF80
|
||||||
|
0000000000000000006FBF8000000000
|
||||||
|
0000000000834AE20000000000000000
|
||||||
|
00834AE2000000000000000000000000
|
||||||
|
00000000010000000000000000000000
|
||||||
|
0000208A0000208A0000208A0000208A
|
||||||
|
0000208A0000208A00009D61000001E0
|
||||||
|
00000E39000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00009D61000001E000000E3900000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
000000000000000000009D61000001E0
|
||||||
|
00000E39000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00009D61000001E000000E3900000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
0000000000000000FFFFFC05FFFFF80A
|
||||||
|
FFFFFC05FF07C49801F82B7B000003FE
|
||||||
|
000007FB000003FEFF0699C101F95649
|
||||||
|
000004020000080400000402FF046DE8
|
||||||
|
01FB8210000004080000080F00000408
|
||||||
|
FF01906C01FE5F75000001C900000391
|
||||||
|
000001C9FF03C38E01FC354F00009D61
|
||||||
|
000001E000000E390000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
01000000017ECE44017B46990175901F
|
||||||
|
016DE7630164984E0159F751014E5AC3
|
||||||
|
0142154D013571D70128B103011C081C
|
||||||
|
010FA12201039B9A00F80DD300ED065E
|
||||||
|
01000000010000000100000001000000
|
||||||
|
01000000010000000100000001000000
|
||||||
|
01000000010000000100000001000000
|
||||||
|
01000000010000000100000001000000
|
||||||
|
0000000000000000006D345100000000
|
||||||
|
0000000000009D61000001E000000E39
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000009D61
|
||||||
|
000001E000000E390000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
0000000000009D61000001E000000E39
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000001000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000A310900FFEF56000010AA
|
||||||
|
01000000000000000000000000000000
|
||||||
|
01000000000000000000000000000000
|
||||||
|
00000000010000000000000000000000
|
||||||
|
01000000000000000000000000000000
|
||||||
|
01000000000000000000000000000000
|
||||||
|
01000000000000000000000000000000
|
||||||
|
003E76C9200000000000000001C73D52
|
||||||
|
00000000010000000080000001000000
|
||||||
|
000003C600080000000003C600080000
|
||||||
|
000003C600080000000003C600080000
|
||||||
|
000003C600080000000003C600080000
|
||||||
|
00000000000007DC00002EE000000004
|
||||||
|
00000000000000000000000000000000
|
||||||
|
1F00000000000000000003C600080000
|
||||||
|
00000001000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
009D0210FEC5FBE0009D0210FF9E27E7
|
||||||
|
01123027009D0210FEC5FBE0009D0210
|
||||||
|
FF9E27E701123027003A66250074CC4A
|
||||||
|
003A6625FFD0B74D0045B01F003A6625
|
||||||
|
0074CC4A003A6625FFD0B74D0045B01F
|
||||||
|
005D3E34FF458397005D3E34FFD0B74D
|
||||||
|
0045B01F005D3E34FF458397005D3E34
|
||||||
|
FFD0B74D0045B01F007E400A00FC8014
|
||||||
|
007E400AFFBCDD9BFF4A223C007E400A
|
||||||
|
00FC8014007E400AFFBCDD9BFF4A223C
|
||||||
|
00C8EAD3FE6E2A5B00C8EAD3FF622C4B
|
||||||
|
0185D79500C8EAD3FE6E2A5B00C8EAD3
|
||||||
|
FF622C4B0185D79500B838C201707185
|
||||||
|
00B838C2FF7B04EAFEA4180D00B838C2
|
||||||
|
0170718500B838C2FF7B04EAFEA4180D
|
||||||
|
009EB8D4FEC28E58009EB8D4FF9C2697
|
||||||
|
011709E8009EB8D4FEC28E58009EB8D4
|
||||||
|
FF9C2697011709E801FF4C3A03FE9874
|
||||||
|
01FF4C3AFB274D51FDDB81C801FF4C3A
|
||||||
|
03FE987401FF4C3AFB274D51FDDB81C8
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000020800000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
Binary file not shown.
Whitespace-only changes.
@@ -0,0 +1,500 @@
|
|||||||
|
__STACK__.__STACK__ 0
|
||||||
|
MP1_DM0 16
|
||||||
|
MP2_DM0 20
|
||||||
|
MP1_DM1 24
|
||||||
|
MP2_DM1 28
|
||||||
|
SinePhaseGainAlgS3001.gain_0 32
|
||||||
|
SinePhaseGainAlgS3001.index_fp_hi_0 33
|
||||||
|
SinePhaseGainAlgS3001.index_fp_lo_0 34
|
||||||
|
SinePhaseGainAlgS3001.mask 35
|
||||||
|
SinePhaseGainAlgS3001.inc 37
|
||||||
|
SinePhaseGainAlgS3001.sinTable 39
|
||||||
|
SinePhaseGainAlgS3002.gain_0 42
|
||||||
|
SinePhaseGainAlgS3002.index_fp_hi_0 43
|
||||||
|
SinePhaseGainAlgS3002.index_fp_lo_0 44
|
||||||
|
SinePhaseGainAlgS3002.mask 45
|
||||||
|
SinePhaseGainAlgS3002.inc 47
|
||||||
|
SinePhaseGainAlgS3002.sinTable 49
|
||||||
|
SinePhaseGainAlgS3003.gain_0 52
|
||||||
|
SinePhaseGainAlgS3003.index_fp_hi_0 53
|
||||||
|
SinePhaseGainAlgS3003.index_fp_lo_0 54
|
||||||
|
SinePhaseGainAlgS3003.mask 55
|
||||||
|
SinePhaseGainAlgS3003.inc 57
|
||||||
|
SinePhaseGainAlgS3003.sinTable 59
|
||||||
|
SingleBandLevelDetS3008.TCONST 62
|
||||||
|
SingleBandLevelDetS3008.hold 63
|
||||||
|
SingleBandLevelDetS3008.decay 64
|
||||||
|
SingleBandLevelDetS3008.rms 65
|
||||||
|
SingleBandLevelDetS3008.held_db 67
|
||||||
|
SingleBandLevelDetS3008.hold_count 68
|
||||||
|
SingleBandLevelDetS3008.x_sq 69
|
||||||
|
SingleBandLevelDetS3008.dbout 71
|
||||||
|
SingleBandLevelDetS3008.X 72
|
||||||
|
SingleBandLevelDetS3008.X_lo 73
|
||||||
|
SingleBandLevelDetS3008.LinX 74
|
||||||
|
SingleBandLevelDetS3008.LinX_lo 75
|
||||||
|
RMS4BandCompS300Alg1.XoverInputs 76
|
||||||
|
RMS4BandCompS300Alg1.XoverOutputs 80
|
||||||
|
RMS4BandCompS300Alg1.OverlapFiltOut 84
|
||||||
|
RMS4BandCompS300Alg1.CompressorIn 88
|
||||||
|
RMS4BandCompS300Alg1.CompressorOut 92
|
||||||
|
RMS4BandCompS300Alg1.points_0 96
|
||||||
|
RMS4BandCompS300Alg1.points_1 150
|
||||||
|
RMS4BandCompS300Alg1.points_2 204
|
||||||
|
RMS4BandCompS300Alg1.points_3 258
|
||||||
|
RMS4BandCompS300Alg1.rms_000 312
|
||||||
|
RMS4BandCompS300Alg1.rms_001 314
|
||||||
|
RMS4BandCompS300Alg1.rms_002 316
|
||||||
|
RMS4BandCompS300Alg1.rms_003 318
|
||||||
|
RMS4BandCompS300Alg1.rms_010 320
|
||||||
|
RMS4BandCompS300Alg1.rms_011 322
|
||||||
|
RMS4BandCompS300Alg1.rms_012 324
|
||||||
|
RMS4BandCompS300Alg1.rms_013 326
|
||||||
|
RMS4BandCompS300Alg1.rms_020 328
|
||||||
|
RMS4BandCompS300Alg1.rms_021 330
|
||||||
|
RMS4BandCompS300Alg1.rms_022 332
|
||||||
|
RMS4BandCompS300Alg1.rms_023 334
|
||||||
|
RMS4BandCompS300Alg1.rms_030 336
|
||||||
|
RMS4BandCompS300Alg1.rms_031 338
|
||||||
|
RMS4BandCompS300Alg1.rms_032 340
|
||||||
|
RMS4BandCompS300Alg1.rms_033 342
|
||||||
|
RMS4BandCompS300Alg1.hold_count_00 344
|
||||||
|
RMS4BandCompS300Alg1.helddB_00 345
|
||||||
|
RMS4BandCompS300Alg1.hold_count_01 346
|
||||||
|
RMS4BandCompS300Alg1.helddB_01 347
|
||||||
|
RMS4BandCompS300Alg1.hold_count_02 348
|
||||||
|
RMS4BandCompS300Alg1.helddB_02 349
|
||||||
|
RMS4BandCompS300Alg1.hold_count_03 350
|
||||||
|
RMS4BandCompS300Alg1.helddB_03 351
|
||||||
|
RMS4BandCompS300Alg1.tc_0 352
|
||||||
|
RMS4BandCompS300Alg1.decay_0 353
|
||||||
|
RMS4BandCompS300Alg1.hold_0 354
|
||||||
|
RMS4BandCompS300Alg1.tc_1 355
|
||||||
|
RMS4BandCompS300Alg1.decay_1 356
|
||||||
|
RMS4BandCompS300Alg1.hold_1 357
|
||||||
|
RMS4BandCompS300Alg1.tc_2 358
|
||||||
|
RMS4BandCompS300Alg1.decay_2 359
|
||||||
|
RMS4BandCompS300Alg1.hold_2 360
|
||||||
|
RMS4BandCompS300Alg1.tc_3 361
|
||||||
|
RMS4BandCompS300Alg1.decay_3 362
|
||||||
|
RMS4BandCompS300Alg1.hold_3 363
|
||||||
|
RMS4BandCompS300Alg1.IpLevel_00 364
|
||||||
|
RMS4BandCompS300Alg1.IpLevelLo_00 365
|
||||||
|
RMS4BandCompS300Alg1.IpLevel_01 366
|
||||||
|
RMS4BandCompS300Alg1.IpLevelLo_01 367
|
||||||
|
RMS4BandCompS300Alg1.IpLevel_02 368
|
||||||
|
RMS4BandCompS300Alg1.IpLevelLo_02 369
|
||||||
|
RMS4BandCompS300Alg1.IpLevel_03 370
|
||||||
|
RMS4BandCompS300Alg1.IpLevelLo_03 371
|
||||||
|
RMS4BandCompS300Alg1.Compression_00 372
|
||||||
|
RMS4BandCompS300Alg1.Compression_01 373
|
||||||
|
RMS4BandCompS300Alg1.Compression_02 374
|
||||||
|
RMS4BandCompS300Alg1.Compression_03 375
|
||||||
|
RMS4BandCompS300Alg1.IpRms 376
|
||||||
|
RMS4BandCompS300Alg1.OpLevel_00 384
|
||||||
|
RMS4BandCompS300Alg1.OpLevel_01 385
|
||||||
|
RMS4BandCompS300Alg1.OpLevel_02 386
|
||||||
|
RMS4BandCompS300Alg1.OpLevel_03 387
|
||||||
|
RMS4BandCompS300Alg1.states 388
|
||||||
|
RMS4BandCompS300Alg1.overlapStates 392
|
||||||
|
RMS4BandCompS300Alg1.B2_0 396
|
||||||
|
RMS4BandCompS300Alg1.B1_0 397
|
||||||
|
RMS4BandCompS300Alg1.B0_0 398
|
||||||
|
RMS4BandCompS300Alg1.A2_0 399
|
||||||
|
RMS4BandCompS300Alg1.A1_0 400
|
||||||
|
RMS4BandCompS300Alg1.B2_1 401
|
||||||
|
RMS4BandCompS300Alg1.B1_1 402
|
||||||
|
RMS4BandCompS300Alg1.B0_1 403
|
||||||
|
RMS4BandCompS300Alg1.A2_1 404
|
||||||
|
RMS4BandCompS300Alg1.A1_1 405
|
||||||
|
RMS4BandCompS300Alg1.B2_10 406
|
||||||
|
RMS4BandCompS300Alg1.B1_10 407
|
||||||
|
RMS4BandCompS300Alg1.B0_10 408
|
||||||
|
RMS4BandCompS300Alg1.A2_10 409
|
||||||
|
RMS4BandCompS300Alg1.A1_10 410
|
||||||
|
RMS4BandCompS300Alg1.B2_11 411
|
||||||
|
RMS4BandCompS300Alg1.B1_11 412
|
||||||
|
RMS4BandCompS300Alg1.B0_11 413
|
||||||
|
RMS4BandCompS300Alg1.A2_11 414
|
||||||
|
RMS4BandCompS300Alg1.A1_11 415
|
||||||
|
RMS4BandCompS300Alg1.B2_12 416
|
||||||
|
RMS4BandCompS300Alg1.B1_12 417
|
||||||
|
RMS4BandCompS300Alg1.B0_12 418
|
||||||
|
RMS4BandCompS300Alg1.A2_12 419
|
||||||
|
RMS4BandCompS300Alg1.A1_12 420
|
||||||
|
RMS4BandCompS300Alg1.B2_13 421
|
||||||
|
RMS4BandCompS300Alg1.B1_13 422
|
||||||
|
RMS4BandCompS300Alg1.B0_13 423
|
||||||
|
RMS4BandCompS300Alg1.A2_13 424
|
||||||
|
RMS4BandCompS300Alg1.A1_13 425
|
||||||
|
RMS4BandCompS300Alg1.B2_22 426
|
||||||
|
RMS4BandCompS300Alg1.B1_22 427
|
||||||
|
RMS4BandCompS300Alg1.B0_22 428
|
||||||
|
RMS4BandCompS300Alg1.A2_22 429
|
||||||
|
RMS4BandCompS300Alg1.A1_22 430
|
||||||
|
RMS4BandCompS300Alg1.B2_23 431
|
||||||
|
RMS4BandCompS300Alg1.B1_23 432
|
||||||
|
RMS4BandCompS300Alg1.B0_23 433
|
||||||
|
RMS4BandCompS300Alg1.A2_23 434
|
||||||
|
RMS4BandCompS300Alg1.A1_23 435
|
||||||
|
RMS4BandCompS300Alg1.CompressorGains 436
|
||||||
|
RMS4BandCompS300Alg1.AllPass_B1_1 440
|
||||||
|
SingleBandLevelDetS3009.TCONST 449
|
||||||
|
SingleBandLevelDetS3009.hold 450
|
||||||
|
SingleBandLevelDetS3009.decay 451
|
||||||
|
SingleBandLevelDetS3009.rms 452
|
||||||
|
SingleBandLevelDetS3009.held_db 454
|
||||||
|
SingleBandLevelDetS3009.hold_count 455
|
||||||
|
SingleBandLevelDetS3009.x_sq 456
|
||||||
|
SingleBandLevelDetS3009.dbout 458
|
||||||
|
SingleBandLevelDetS3009.X 459
|
||||||
|
SingleBandLevelDetS3009.X_lo 460
|
||||||
|
SingleBandLevelDetS3009.LinX 461
|
||||||
|
SingleBandLevelDetS3009.LinX_lo 462
|
||||||
|
stereomuxSigma300ns1.inputs 463
|
||||||
|
SingleBandLevelDetS3003.TCONST 473
|
||||||
|
SingleBandLevelDetS3003.hold 474
|
||||||
|
SingleBandLevelDetS3003.decay 475
|
||||||
|
SingleBandLevelDetS3003.rms 476
|
||||||
|
SingleBandLevelDetS3003.held_db 478
|
||||||
|
SingleBandLevelDetS3003.hold_count 479
|
||||||
|
SingleBandLevelDetS3003.x_sq 480
|
||||||
|
SingleBandLevelDetS3003.dbout 482
|
||||||
|
SingleBandLevelDetS3003.X 483
|
||||||
|
SingleBandLevelDetS3003.X_lo 484
|
||||||
|
SingleBandLevelDetS3003.LinX 485
|
||||||
|
SingleBandLevelDetS3003.LinX_lo 486
|
||||||
|
EQGenFilterDPS300Alg2.B2_1 487
|
||||||
|
EQGenFilterDPS300Alg2.B1_1 488
|
||||||
|
EQGenFilterDPS300Alg2.B0_1 489
|
||||||
|
EQGenFilterDPS300Alg2.A2_1 490
|
||||||
|
EQGenFilterDPS300Alg2.A1_1 491
|
||||||
|
EQGenFilterDPS300Alg3.B2_1 492
|
||||||
|
EQGenFilterDPS300Alg3.B1_1 493
|
||||||
|
EQGenFilterDPS300Alg3.B0_1 494
|
||||||
|
EQGenFilterDPS300Alg3.A2_1 495
|
||||||
|
EQGenFilterDPS300Alg3.A1_1 496
|
||||||
|
EQGenFilterDPS300Alg4.B2_1 497
|
||||||
|
EQGenFilterDPS300Alg4.B1_1 498
|
||||||
|
EQGenFilterDPS300Alg4.B0_1 499
|
||||||
|
EQGenFilterDPS300Alg4.A2_1 500
|
||||||
|
EQGenFilterDPS300Alg4.A1_1 501
|
||||||
|
EQGenFilterDPS300Alg5.B2_1 502
|
||||||
|
EQGenFilterDPS300Alg5.B1_1 503
|
||||||
|
EQGenFilterDPS300Alg5.B0_1 504
|
||||||
|
EQGenFilterDPS300Alg5.A2_1 505
|
||||||
|
EQGenFilterDPS300Alg5.A1_1 506
|
||||||
|
SingleBandLevelDetS3001.TCONST 507
|
||||||
|
SingleBandLevelDetS3001.hold 508
|
||||||
|
SingleBandLevelDetS3001.decay 509
|
||||||
|
SingleBandLevelDetS3001.rms 510
|
||||||
|
SingleBandLevelDetS3001.held_db 512
|
||||||
|
SingleBandLevelDetS3001.hold_count 513
|
||||||
|
SingleBandLevelDetS3001.x_sq 514
|
||||||
|
SingleBandLevelDetS3001.dbout 516
|
||||||
|
SingleBandLevelDetS3001.X 517
|
||||||
|
SingleBandLevelDetS3001.X_lo 518
|
||||||
|
SingleBandLevelDetS3001.LinX 519
|
||||||
|
SingleBandLevelDetS3001.LinX_lo 520
|
||||||
|
limiterAlgS3005.threshold 521
|
||||||
|
limiterAlgS3005.mean 522
|
||||||
|
limiterAlgS3004.threshold 524
|
||||||
|
limiterAlgS3004.mean 525
|
||||||
|
limiterAlgS3007.threshold 527
|
||||||
|
limiterAlgS3007.mean 528
|
||||||
|
limiterAlgS3006.threshold 530
|
||||||
|
limiterAlgS3006.mean 531
|
||||||
|
limiterAlgS3003.threshold 533
|
||||||
|
limiterAlgS3003.mean 534
|
||||||
|
limiterAlgS3002.threshold 536
|
||||||
|
limiterAlgS3002.mean 537
|
||||||
|
EQGenFilterDPS300Alg6.B2_1 539
|
||||||
|
EQGenFilterDPS300Alg6.B1_1 540
|
||||||
|
EQGenFilterDPS300Alg6.B0_1 541
|
||||||
|
EQGenFilterDPS300Alg6.A2_1 542
|
||||||
|
EQGenFilterDPS300Alg6.A1_1 543
|
||||||
|
monoMuxS300Slew1.slew_mode 544
|
||||||
|
monoMuxS300Slew2.slew_mode 545
|
||||||
|
monoMuxS300Slew3.slew_mode 546
|
||||||
|
monoMuxS300Slew4.slew_mode 547
|
||||||
|
monoMuxS300Slew5.slew_mode 548
|
||||||
|
monoMuxS300Slew6.slew_mode 549
|
||||||
|
SingleBandLevelDetS3004.TCONST 550
|
||||||
|
SingleBandLevelDetS3004.hold 551
|
||||||
|
SingleBandLevelDetS3004.decay 552
|
||||||
|
SingleBandLevelDetS3004.rms 553
|
||||||
|
SingleBandLevelDetS3004.held_db 555
|
||||||
|
SingleBandLevelDetS3004.hold_count 556
|
||||||
|
SingleBandLevelDetS3004.x_sq 557
|
||||||
|
SingleBandLevelDetS3004.dbout 559
|
||||||
|
SingleBandLevelDetS3004.X 560
|
||||||
|
SingleBandLevelDetS3004.X_lo 561
|
||||||
|
SingleBandLevelDetS3004.LinX 562
|
||||||
|
SingleBandLevelDetS3004.LinX_lo 563
|
||||||
|
SingleBandLevelDetS30010.TCONST 564
|
||||||
|
SingleBandLevelDetS30010.hold 565
|
||||||
|
SingleBandLevelDetS30010.decay 566
|
||||||
|
SingleBandLevelDetS30010.rms 567
|
||||||
|
SingleBandLevelDetS30010.held_db 569
|
||||||
|
SingleBandLevelDetS30010.hold_count 570
|
||||||
|
SingleBandLevelDetS30010.x_sq 571
|
||||||
|
SingleBandLevelDetS30010.dbout 573
|
||||||
|
SingleBandLevelDetS30010.X 574
|
||||||
|
SingleBandLevelDetS30010.X_lo 575
|
||||||
|
SingleBandLevelDetS30010.LinX 576
|
||||||
|
SingleBandLevelDetS30010.LinX_lo 577
|
||||||
|
SingleBandLevelDetS3005.TCONST 578
|
||||||
|
SingleBandLevelDetS3005.hold 579
|
||||||
|
SingleBandLevelDetS3005.decay 580
|
||||||
|
SingleBandLevelDetS3005.rms 581
|
||||||
|
SingleBandLevelDetS3005.held_db 583
|
||||||
|
SingleBandLevelDetS3005.hold_count 584
|
||||||
|
SingleBandLevelDetS3005.x_sq 585
|
||||||
|
SingleBandLevelDetS3005.dbout 587
|
||||||
|
SingleBandLevelDetS3005.X 588
|
||||||
|
SingleBandLevelDetS3005.X_lo 589
|
||||||
|
SingleBandLevelDetS3005.LinX 590
|
||||||
|
SingleBandLevelDetS3005.LinX_lo 591
|
||||||
|
SingleBandLevelDetS30011.TCONST 592
|
||||||
|
SingleBandLevelDetS30011.hold 593
|
||||||
|
SingleBandLevelDetS30011.decay 594
|
||||||
|
SingleBandLevelDetS30011.rms 595
|
||||||
|
SingleBandLevelDetS30011.held_db 597
|
||||||
|
SingleBandLevelDetS30011.hold_count 598
|
||||||
|
SingleBandLevelDetS30011.x_sq 599
|
||||||
|
SingleBandLevelDetS30011.dbout 601
|
||||||
|
SingleBandLevelDetS30011.X 602
|
||||||
|
SingleBandLevelDetS30011.X_lo 603
|
||||||
|
SingleBandLevelDetS30011.LinX 604
|
||||||
|
SingleBandLevelDetS30011.LinX_lo 605
|
||||||
|
NthOrderDoubleS3001B2_0 606
|
||||||
|
NthOrderDoubleS3001B1_0 607
|
||||||
|
NthOrderDoubleS3001B0_0 608
|
||||||
|
NthOrderDoubleS3001A2_0 609
|
||||||
|
NthOrderDoubleS3001A1_0 610
|
||||||
|
NthOrderDoubleS3001B2_1 611
|
||||||
|
NthOrderDoubleS3001B1_1 612
|
||||||
|
NthOrderDoubleS3001B0_1 613
|
||||||
|
NthOrderDoubleS3001A2_1 614
|
||||||
|
NthOrderDoubleS3001A1_1 615
|
||||||
|
NthOrderDoubleS3001B2_2 616
|
||||||
|
NthOrderDoubleS3001B1_2 617
|
||||||
|
NthOrderDoubleS3001B0_2 618
|
||||||
|
NthOrderDoubleS3001A2_2 619
|
||||||
|
NthOrderDoubleS3001A1_2 620
|
||||||
|
NthOrderDoubleS3001B2_3 621
|
||||||
|
NthOrderDoubleS3001B1_3 622
|
||||||
|
NthOrderDoubleS3001B0_3 623
|
||||||
|
NthOrderDoubleS3001A2_3 624
|
||||||
|
NthOrderDoubleS3001A1_3 625
|
||||||
|
EQGenFilterDPS300Alg1.B2_1 626
|
||||||
|
EQGenFilterDPS300Alg1.B1_1 627
|
||||||
|
EQGenFilterDPS300Alg1.B0_1 628
|
||||||
|
EQGenFilterDPS300Alg1.A2_1 629
|
||||||
|
EQGenFilterDPS300Alg1.A1_1 630
|
||||||
|
SingleBandLevelDetS30012.TCONST 631
|
||||||
|
SingleBandLevelDetS30012.hold 632
|
||||||
|
SingleBandLevelDetS30012.decay 633
|
||||||
|
SingleBandLevelDetS30012.rms 634
|
||||||
|
SingleBandLevelDetS30012.held_db 636
|
||||||
|
SingleBandLevelDetS30012.hold_count 637
|
||||||
|
SingleBandLevelDetS30012.x_sq 638
|
||||||
|
SingleBandLevelDetS30012.dbout 640
|
||||||
|
SingleBandLevelDetS30012.X 641
|
||||||
|
SingleBandLevelDetS30012.X_lo 642
|
||||||
|
SingleBandLevelDetS30012.LinX 643
|
||||||
|
SingleBandLevelDetS30012.LinX_lo 644
|
||||||
|
ReadBackAlgNewSigma3003.Value 645
|
||||||
|
ReadBackAlgNewSigma3004.Value 646
|
||||||
|
ReadBackAlgNewSigma3005.Value 647
|
||||||
|
LookupTableGrowAlg300LPConfigFormat1.table 648
|
||||||
|
ReadBackAlgNewSigma3001.Value 680
|
||||||
|
ReadBackAlgNewSigma3002.Value 681
|
||||||
|
limiterAlgS3001.threshold 682
|
||||||
|
limiterAlgS3001.mean 683
|
||||||
|
SingleBandLevelDetS3007.TCONST 685
|
||||||
|
SingleBandLevelDetS3007.hold 686
|
||||||
|
SingleBandLevelDetS3007.decay 687
|
||||||
|
SingleBandLevelDetS3007.rms 688
|
||||||
|
SingleBandLevelDetS3007.held_db 690
|
||||||
|
SingleBandLevelDetS3007.hold_count 691
|
||||||
|
SingleBandLevelDetS3007.x_sq 692
|
||||||
|
SingleBandLevelDetS3007.dbout 694
|
||||||
|
SingleBandLevelDetS3007.X 695
|
||||||
|
SingleBandLevelDetS3007.X_lo 696
|
||||||
|
SingleBandLevelDetS3007.LinX 697
|
||||||
|
SingleBandLevelDetS3007.LinX_lo 698
|
||||||
|
SingleBandLevelDetS3006.TCONST 699
|
||||||
|
SingleBandLevelDetS3006.hold 700
|
||||||
|
SingleBandLevelDetS3006.decay 701
|
||||||
|
SingleBandLevelDetS3006.rms 702
|
||||||
|
SingleBandLevelDetS3006.held_db 704
|
||||||
|
SingleBandLevelDetS3006.hold_count 705
|
||||||
|
SingleBandLevelDetS3006.x_sq 706
|
||||||
|
SingleBandLevelDetS3006.dbout 708
|
||||||
|
SingleBandLevelDetS3006.X 709
|
||||||
|
SingleBandLevelDetS3006.X_lo 710
|
||||||
|
SingleBandLevelDetS3006.LinX 711
|
||||||
|
SingleBandLevelDetS3006.LinX_lo 712
|
||||||
|
SingleBandLevelDetS3002.TCONST 713
|
||||||
|
SingleBandLevelDetS3002.hold 714
|
||||||
|
SingleBandLevelDetS3002.decay 715
|
||||||
|
SingleBandLevelDetS3002.rms 716
|
||||||
|
SingleBandLevelDetS3002.held_db 718
|
||||||
|
SingleBandLevelDetS3002.hold_count 719
|
||||||
|
SingleBandLevelDetS3002.x_sq 720
|
||||||
|
SingleBandLevelDetS3002.dbout 722
|
||||||
|
SingleBandLevelDetS3002.X 723
|
||||||
|
SingleBandLevelDetS3002.X_lo 724
|
||||||
|
SingleBandLevelDetS3002.LinX 725
|
||||||
|
SingleBandLevelDetS3002.LinX_lo 726
|
||||||
|
DCInpAlg145X5.value 727
|
||||||
|
SWGainOptADAU145XAlg1.gain 788
|
||||||
|
SWGainOptADAU145XAlg1.target 789
|
||||||
|
SWGainOptADAU145XAlg1.alpha 790
|
||||||
|
SWGainOptADAU145XAlg1.om_alpha 791
|
||||||
|
monoMuxS300Slew1.vol0 792
|
||||||
|
monoMuxS300Slew1.vol1 793
|
||||||
|
monoMuxS300Slew1.cur0 794
|
||||||
|
monoMuxS300Slew1.cur1 795
|
||||||
|
monoMuxS300Slew2.vol0 796
|
||||||
|
monoMuxS300Slew2.vol1 797
|
||||||
|
monoMuxS300Slew2.cur0 798
|
||||||
|
monoMuxS300Slew2.cur1 799
|
||||||
|
monoMuxS300Slew3.vol0 800
|
||||||
|
monoMuxS300Slew3.vol1 801
|
||||||
|
monoMuxS300Slew3.cur0 802
|
||||||
|
monoMuxS300Slew3.cur1 803
|
||||||
|
monoMuxS300Slew4.vol0 804
|
||||||
|
monoMuxS300Slew4.vol1 805
|
||||||
|
monoMuxS300Slew4.cur0 806
|
||||||
|
monoMuxS300Slew4.cur1 807
|
||||||
|
monoMuxS300Slew5.vol0 808
|
||||||
|
monoMuxS300Slew5.vol1 809
|
||||||
|
monoMuxS300Slew5.cur0 810
|
||||||
|
monoMuxS300Slew5.cur1 811
|
||||||
|
monoMuxS300Slew6.vol0 812
|
||||||
|
monoMuxS300Slew6.vol1 813
|
||||||
|
monoMuxS300Slew6.cur0 814
|
||||||
|
monoMuxS300Slew6.cur1 815
|
||||||
|
DCInpAlg145X6.value 816
|
||||||
|
DCInpAlg145X8.value 817
|
||||||
|
DCInpAlg145X1.value 818
|
||||||
|
GainAlgNS145X2.gain 819
|
||||||
|
stereomuxSigma300ns1.index 820
|
||||||
|
GainAlgNS145X4.gain 821
|
||||||
|
GainAlgNS145X1.gain 822
|
||||||
|
GainAlgNS145X3.gain 823
|
||||||
|
limiterAlgS3005.rmstc 824
|
||||||
|
limiterAlgS3005.decay 825
|
||||||
|
limiterAlgS3004.rmstc 826
|
||||||
|
limiterAlgS3004.decay 827
|
||||||
|
limiterAlgS3007.rmstc 828
|
||||||
|
limiterAlgS3007.decay 829
|
||||||
|
limiterAlgS3006.rmstc 830
|
||||||
|
limiterAlgS3006.decay 831
|
||||||
|
limiterAlgS3003.rmstc 832
|
||||||
|
limiterAlgS3003.decay 833
|
||||||
|
limiterAlgS3002.rmstc 834
|
||||||
|
limiterAlgS3002.decay 835
|
||||||
|
monomuxSigma300ns3.index 836
|
||||||
|
MonoEnvelopeDetectAlgS3002.RMS 837
|
||||||
|
MonoEnvelopeDetectAlgS3002.hold 838
|
||||||
|
MonoEnvelopeDetectAlgS3002.decay 839
|
||||||
|
MonoEnvelopeDetectAlgS3002.rms 840
|
||||||
|
MonoEnvelopeDetectAlgS3002.held_val 842
|
||||||
|
MonoEnvelopeDetectAlgS3002.hold_count 843
|
||||||
|
LookupTableGrowAlg300LPConfigFormat1.maximum2 844
|
||||||
|
monomuxSigma300ns2.index 845
|
||||||
|
limiterAlgS3001.rmstc 846
|
||||||
|
limiterAlgS3001.decay 847
|
||||||
|
monomuxSigma300ns1.index 848
|
||||||
|
__DM0_PADDING__.__DM0_PADDING__ 849
|
||||||
|
__SafeLoad_Module__.data_SafeLoad 24576
|
||||||
|
__SafeLoad_Module__.address_SafeLoad 24581
|
||||||
|
__SafeLoad_Module__.num_SafeLoad_lower 24582
|
||||||
|
__SafeLoad_Module__.num_SafeLoad_upper 24583
|
||||||
|
RMS4BandCompS300Alg1.B2_2 24584
|
||||||
|
RMS4BandCompS300Alg1.B1_2 24585
|
||||||
|
RMS4BandCompS300Alg1.B0_2 24586
|
||||||
|
RMS4BandCompS300Alg1.A2_2 24587
|
||||||
|
RMS4BandCompS300Alg1.A1_2 24588
|
||||||
|
RMS4BandCompS300Alg1.B2_3 24589
|
||||||
|
RMS4BandCompS300Alg1.B1_3 24590
|
||||||
|
RMS4BandCompS300Alg1.B0_3 24591
|
||||||
|
RMS4BandCompS300Alg1.A2_3 24592
|
||||||
|
RMS4BandCompS300Alg1.A1_3 24593
|
||||||
|
RMS4BandCompS300Alg1.B2_4 24594
|
||||||
|
RMS4BandCompS300Alg1.B1_4 24595
|
||||||
|
RMS4BandCompS300Alg1.B0_4 24596
|
||||||
|
RMS4BandCompS300Alg1.A2_4 24597
|
||||||
|
RMS4BandCompS300Alg1.A1_4 24598
|
||||||
|
RMS4BandCompS300Alg1.B2_5 24599
|
||||||
|
RMS4BandCompS300Alg1.B1_5 24600
|
||||||
|
RMS4BandCompS300Alg1.B0_5 24601
|
||||||
|
RMS4BandCompS300Alg1.A2_5 24602
|
||||||
|
RMS4BandCompS300Alg1.A1_5 24603
|
||||||
|
RMS4BandCompS300Alg1.B2_6 24604
|
||||||
|
RMS4BandCompS300Alg1.B1_6 24605
|
||||||
|
RMS4BandCompS300Alg1.B0_6 24606
|
||||||
|
RMS4BandCompS300Alg1.A2_6 24607
|
||||||
|
RMS4BandCompS300Alg1.A1_6 24608
|
||||||
|
RMS4BandCompS300Alg1.B2_7 24609
|
||||||
|
RMS4BandCompS300Alg1.B1_7 24610
|
||||||
|
RMS4BandCompS300Alg1.B0_7 24611
|
||||||
|
RMS4BandCompS300Alg1.A2_7 24612
|
||||||
|
RMS4BandCompS300Alg1.A1_7 24613
|
||||||
|
RMS4BandCompS300Alg1.B2_8 24614
|
||||||
|
RMS4BandCompS300Alg1.B1_8 24615
|
||||||
|
RMS4BandCompS300Alg1.B0_8 24616
|
||||||
|
RMS4BandCompS300Alg1.A2_8 24617
|
||||||
|
RMS4BandCompS300Alg1.A1_8 24618
|
||||||
|
RMS4BandCompS300Alg1.B2_9 24619
|
||||||
|
RMS4BandCompS300Alg1.B1_9 24620
|
||||||
|
RMS4BandCompS300Alg1.B0_9 24621
|
||||||
|
RMS4BandCompS300Alg1.A2_9 24622
|
||||||
|
RMS4BandCompS300Alg1.A1_9 24623
|
||||||
|
RMS4BandCompS300Alg1.B2_14 24624
|
||||||
|
RMS4BandCompS300Alg1.B1_14 24625
|
||||||
|
RMS4BandCompS300Alg1.B0_14 24626
|
||||||
|
RMS4BandCompS300Alg1.A2_14 24627
|
||||||
|
RMS4BandCompS300Alg1.A1_14 24628
|
||||||
|
RMS4BandCompS300Alg1.B2_15 24629
|
||||||
|
RMS4BandCompS300Alg1.B1_15 24630
|
||||||
|
RMS4BandCompS300Alg1.B0_15 24631
|
||||||
|
RMS4BandCompS300Alg1.A2_15 24632
|
||||||
|
RMS4BandCompS300Alg1.A1_15 24633
|
||||||
|
RMS4BandCompS300Alg1.B2_16 24634
|
||||||
|
RMS4BandCompS300Alg1.B1_16 24635
|
||||||
|
RMS4BandCompS300Alg1.B0_16 24636
|
||||||
|
RMS4BandCompS300Alg1.A2_16 24637
|
||||||
|
RMS4BandCompS300Alg1.A1_16 24638
|
||||||
|
RMS4BandCompS300Alg1.B2_17 24639
|
||||||
|
RMS4BandCompS300Alg1.B1_17 24640
|
||||||
|
RMS4BandCompS300Alg1.B0_17 24641
|
||||||
|
RMS4BandCompS300Alg1.A2_17 24642
|
||||||
|
RMS4BandCompS300Alg1.A1_17 24643
|
||||||
|
RMS4BandCompS300Alg1.B2_18 24644
|
||||||
|
RMS4BandCompS300Alg1.B1_18 24645
|
||||||
|
RMS4BandCompS300Alg1.B0_18 24646
|
||||||
|
RMS4BandCompS300Alg1.A2_18 24647
|
||||||
|
RMS4BandCompS300Alg1.A1_18 24648
|
||||||
|
RMS4BandCompS300Alg1.B2_19 24649
|
||||||
|
RMS4BandCompS300Alg1.B1_19 24650
|
||||||
|
RMS4BandCompS300Alg1.B0_19 24651
|
||||||
|
RMS4BandCompS300Alg1.A2_19 24652
|
||||||
|
RMS4BandCompS300Alg1.A1_19 24653
|
||||||
|
RMS4BandCompS300Alg1.B2_20 24654
|
||||||
|
RMS4BandCompS300Alg1.B1_20 24655
|
||||||
|
RMS4BandCompS300Alg1.B0_20 24656
|
||||||
|
RMS4BandCompS300Alg1.A2_20 24657
|
||||||
|
RMS4BandCompS300Alg1.A1_20 24658
|
||||||
|
RMS4BandCompS300Alg1.B2_21 24659
|
||||||
|
RMS4BandCompS300Alg1.B1_21 24660
|
||||||
|
RMS4BandCompS300Alg1.B0_21 24661
|
||||||
|
RMS4BandCompS300Alg1.A2_21 24662
|
||||||
|
RMS4BandCompS300Alg1.A1_21 24663
|
||||||
|
RMS4BandCompS300Alg1.linkEnable 24664
|
||||||
|
ExternalGainAlgSlew145X1.current 24668
|
||||||
|
ExternalGainAlgSlew145X1.slew_mode 24669
|
||||||
|
__DM1_PADDING__.__DM1_PADDING__ 24936
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
|||||||
|
1
|
||||||
|
1.49533485304928
|
||||||
|
1.4815459838783847
|
||||||
|
1.4592303923871972
|
||||||
|
1.4293119123567155
|
||||||
|
1.3929489712928942
|
||||||
|
1.3514299862615653
|
||||||
|
1.3060723912532848
|
||||||
|
1.2581375435834459
|
||||||
|
1.20876833030867
|
||||||
|
1.158950996148176
|
||||||
|
1.1094987428671819
|
||||||
|
1.0610524274624933
|
||||||
|
1.0140930282796843
|
||||||
|
0.96896097043364
|
||||||
|
0.9258783843994246
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
ADAU145X Assembler, Analog Devices Inc.
|
||||||
|
Version: 4.7.0,1827 (built 4/5/2022)
|
||||||
|
|
||||||
|
##### Compile Started: Sunday, October 25, 2026 6:00:01 PM #####
|
||||||
|
ADAU145X Assembler, Analog Devices Inc.
|
||||||
|
Version: 4.7.0,1827 (built 4/5/2022)
|
||||||
|
|
||||||
|
##### Compile Started: Sunday, October 25, 2026 6:00:01 PM #####
|
||||||
|
##### Compile succeeded: 0 errors, 0 warnings #####
|
||||||
|
|
||||||
|
##### Summary #####
|
||||||
|
DM0 RAM used: 00851 (of 40960)
|
||||||
|
DM1 RAM used: 00360 (of 40960)
|
||||||
|
PM RAM used(Data): 00000 (of 24576)
|
||||||
|
PM RAM used(Inst): 01410 (of 24576) 1124 instructions (32bit: 838 64bit: 286)
|
||||||
|
Total PM used: 01410 (of 24576)
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
STREAM Inst Inst DM0 DM1 PM
|
||||||
|
Instance (cycles) (size)
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
STACK - - 16 0 0
|
||||||
|
Compressor1_3 566 417 407 159 0
|
||||||
|
NFilter1 30 58 20 37 0
|
||||||
|
FRAMEWORK 70 52 16 8 0
|
||||||
|
BR_RMS_LIMIT 28 33 5 5 0
|
||||||
|
BL_RMS_LIMIT 28 33 5 5 0
|
||||||
|
FL_RMS_2 28 33 5 5 0
|
||||||
|
FL_RMS 28 33 5 5 0
|
||||||
|
Limiter 1 28 32 5 5 0
|
||||||
|
FR_RMS_LIMIT_2 28 32 5 5 0
|
||||||
|
FR_RMS_LIMIT 28 32 5 5 0
|
||||||
|
FRONT_HI_HPF 14 26 11 14 0
|
||||||
|
FRONT_MIDBASS_LPF 14 26 11 14 0
|
||||||
|
FRONT_MIDBASS_HPF 14 26 11 12 0
|
||||||
|
REAR_HPF 14 26 11 8 0
|
||||||
|
MSEnv1_2 23 23 7 1 0
|
||||||
|
Tone1 18 18 10 1 0
|
||||||
|
Tone1_3 18 18 10 1 0
|
||||||
|
Tone1_2 18 18 10 1 0
|
||||||
|
FRONT_MIDBASS_LPF_2 9 16 5 10 0
|
||||||
|
SUB_HPF 9 16 5 9 0
|
||||||
|
x/y 1_2 13 15 0 1 0
|
||||||
|
Level1_10 13 14 14 1 0
|
||||||
|
Level1_3 13 14 14 1 0
|
||||||
|
Level1_7 13 14 14 1 0
|
||||||
|
Level1_5 13 14 14 1 0
|
||||||
|
Level1_11 13 14 14 1 0
|
||||||
|
Level1_12 13 14 14 1 0
|
||||||
|
Level1_6 13 13 14 1 0
|
||||||
|
Level1_9 13 13 14 1 0
|
||||||
|
FL_MONITOR 13 13 14 1 0
|
||||||
|
FL_MONITOR_2 13 13 14 1 0
|
||||||
|
Level1_8 13 13 14 1 0
|
||||||
|
MONO_MONITOR 13 13 14 0 0
|
||||||
|
Lookup Table1 11 12 33 1 0
|
||||||
|
Nx2-1 10 12 11 2 0
|
||||||
|
BYPASS_REAR_RMS_R 9 12 5 1 0
|
||||||
|
BYPASS_REAR_RMS_L 9 12 5 1 0
|
||||||
|
BYPASS_FL_RMS 9 12 5 1 0
|
||||||
|
BYPASS_FL_HI_RMS 9 12 5 1 0
|
||||||
|
BYPASS_FR_RMS 9 12 5 1 0
|
||||||
|
BYPASS_FR_HI_RMS 9 12 5 1 0
|
||||||
|
Slew vol 1 9 11 0 3 0
|
||||||
|
SW vol 1 9 9 4 0 0
|
||||||
|
REAR_GAIN 7 7 1 2 0
|
||||||
|
INPUT_NORMALIZER 7 7 1 2 0
|
||||||
|
REAR_GAIN_2 7 7 1 0 0
|
||||||
|
MONO_NORMALIZE 5 5 1 1 0
|
||||||
|
BYPASS_BASS_RMS_LIMITER 5 5 1 1 0
|
||||||
|
COMPRESSOR_BYPASS 5 5 1 1 0
|
||||||
|
BYPASS_BASS_RMS_LIMITER_2 5 5 1 1 0
|
||||||
|
X2 5 5 0 1 0
|
||||||
|
X1_2 5 5 0 1 0
|
||||||
|
ReadBack1 3 4 1 1 0
|
||||||
|
Input1 4 4 0 2 0
|
||||||
|
MONO_ADD 4 4 0 1 0
|
||||||
|
ReadBack1_4 3 3 1 1 0
|
||||||
|
ReadBack1_5 3 3 1 1 0
|
||||||
|
ReadBack1_6 3 3 1 1 0
|
||||||
|
ReadBack1_3 3 3 1 1 0
|
||||||
|
FRONT-TWEETER-OUT2 3 3 0 0 0
|
||||||
|
FRONT-WOOFER-OUT1 3 3 0 0 0
|
||||||
|
REAR-OUT3 3 3 0 0 0
|
||||||
|
BASS-OUT4 3 3 0 0 0
|
||||||
|
DC1 2 2 1 1 0
|
||||||
|
DC2_7 2 2 1 1 0
|
||||||
|
DC2_5 2 2 1 1 0
|
||||||
|
DC2_4 2 2 1 1 0
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
Total 1369 1356 847 356 0
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
(% Fs=48.0kHz) 23% 6% 3% 1% 0%
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pointer_setup__, MP1_DM0, 16, 728, 728, 36, 0
|
||||||
|
__pointer_setup__, MP2_DM0, 20, 764, 764, 24, 0
|
||||||
|
__pointer_setup__, MP1_DM1, 24, 96, 96, 204, 0
|
||||||
|
__pointer_setup__, MP2_DM1, 28, 300, 300, 60, 0
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
|||||||
|
1
|
||||||
|
1.49533485304928
|
||||||
|
1.4815459838783847
|
||||||
|
1.4592303923871972
|
||||||
|
1.4293119123567155
|
||||||
|
1.3929489712928942
|
||||||
|
1.3514299862615653
|
||||||
|
1.3060723912532848
|
||||||
|
1.2581375435834459
|
||||||
|
1.20876833030867
|
||||||
|
1.158950996148176
|
||||||
|
1.1094987428671819
|
||||||
|
1.0610524274624933
|
||||||
|
1.0140930282796843
|
||||||
|
0.96896097043364
|
||||||
|
0.9258783843994246
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
|
1.0
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from math import *
|
||||||
|
def sigmoid_factory(k):
|
||||||
|
return lambda t: 2* ((1 / (1+exp(2 * -k * t))) - 0.5)
|
||||||
|
|
||||||
|
# Paste into desmos to visualize
|
||||||
|
# f\left(x\right)=2\left(\frac{1}{1+e^{-kx}}-0.5\right)
|
||||||
|
|
||||||
|
sig = sigmoid_factory(1.5)
|
||||||
|
|
||||||
|
goalPercents = []
|
||||||
|
|
||||||
|
for i in range(32):
|
||||||
|
x = 2*i/31
|
||||||
|
y = sig(x)/2
|
||||||
|
print(x, y)
|
||||||
|
goalPercents.append(y)
|
||||||
|
|
||||||
|
for i in range(16, 32):
|
||||||
|
x = i/31
|
||||||
|
print(i, x)
|
||||||
|
goalPercents[i] = x
|
||||||
|
|
||||||
|
for i in range(32):
|
||||||
|
x = i/31
|
||||||
|
if goalPercents[i] == 0:
|
||||||
|
goalPercents[i] = 1
|
||||||
|
continue
|
||||||
|
print(i, x, goalPercents[i])
|
||||||
|
goalPercents[i] = goalPercents[i] / x
|
||||||
|
|
||||||
|
print(goalPercents)
|
||||||
|
|
||||||
|
with open('bass_remapping.txt', 'w') as f:
|
||||||
|
for i in range(32):
|
||||||
|
f.write(str(goalPercents[i]))
|
||||||
|
f.write("\n")
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Fusion Audio — controller software and hardware emulator
|
||||||
|
|
||||||
|
Implements the *Fusion Audio UI & Software Specification* (draft v0.2) in Go:
|
||||||
|
a pure UI core, drivers behind generic interfaces, and two terminal emulators
|
||||||
|
(FDIM display, FCIM buttons) so the whole system runs on a laptop.
|
||||||
|
|
||||||
|
> **Status:** written without compiling or running anything. First thing to do:
|
||||||
|
> `go vet ./... && go test ./...` and fix whatever the compiler finds.
|
||||||
|
|
||||||
|
## Three terminals
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 – the front display (listens on :7002)
|
||||||
|
go run ./cmd/fdim
|
||||||
|
|
||||||
|
# terminal 2 – the core software (listens for buttons on :7001, dials the display)
|
||||||
|
go run ./cmd/fusiond
|
||||||
|
|
||||||
|
# terminal 3 – the buttons (raw keyboard; `-line` for typed button names)
|
||||||
|
go run ./cmd/fcim
|
||||||
|
```
|
||||||
|
|
||||||
|
Start order does not matter: the core reconnects to the display with backoff
|
||||||
|
(0.5 s → 10 s) and replays its full state when the link comes back.
|
||||||
|
|
||||||
|
FCIM keys: `[`/`]` or `-`/`=` or ↑/↓ volume · ←/→ tune · Enter/Space OK ·
|
||||||
|
`m` MENU · `p` POWER · `c` CLOCK · `a` AUX · `s` SIRIUS · `h` PHONE · `o` SOUND ·
|
||||||
|
`,`/`.` seek · `0`–`9` digits · `l e d n f t r v u j` the unused buttons · `q` quit.
|
||||||
|
|
||||||
|
The core terminal also takes simulated-hardware commands (`help` lists them):
|
||||||
|
`connect Pixel`, `media Title|Artist|Album`, `ring Alice`, `pair X`, `dsp fail`, …
|
||||||
|
Pair a phone: Menu → Bluetooth → Pair New, then type `pair MyPhone`.
|
||||||
|
|
||||||
|
## Generic hardware APIs (dependency injection)
|
||||||
|
|
||||||
|
Everything the core talks to is injected through `app.Deps`:
|
||||||
|
|
||||||
|
| Peripheral | Interface | Implementations |
|
||||||
|
|---|---|---|
|
||||||
|
| FDIM | `fdim.Display` – `DisplayText(row1,row2)`, `SetSourceLabel`, `SetClock`, `KeepAlive` | `fdim.NetDisplay` (to the emulator), `fdim.Memory` (tests) |
|
||||||
|
| FCIM | `fcim.Source` – `Pulses() <-chan string` (button names) | `fcim.NetSource` (from the emulator), `fcim.Chan` (tests) |
|
||||||
|
| DSP | `dsp.Device` | `dsp.Sim` |
|
||||||
|
| Bluetooth | `bt.Module` | `bt.Sim` |
|
||||||
|
| Clock / settings | `platform.Clock`, `platform.Store` | `SysClock`, `FileStore`, `MemStore` |
|
||||||
|
|
||||||
|
A real CAN-backed FDIM/FCIM only needs to implement those two small interfaces.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
core/ pure UI core: handle(state,event) -> commands + frame (no I/O, no clock)
|
||||||
|
script.go plain-text test-script runner (shared acceptance suite, SDS §14.11)
|
||||||
|
tests/ *.test scripts run by core/script_test.go
|
||||||
|
fdim/ fcim/ dsp/ bt/ platform/ drivers, fakes, simulators
|
||||||
|
app/ wiring + main loop (events in, commands/frames out, DSP health probing)
|
||||||
|
cmd/ fdim, fcim, fusiond binaries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`go test ./...` runs: the script suite (`tests/*.test`, covering
|
||||||
|
boot, standby, volume, menus, source select, Now Playing paging, Bluetooth,
|
||||||
|
pairing, clock editor, phone, health, reboot), unit tests for paging, character
|
||||||
|
mapping, loudness table vs. the `10·log2(p)` formula, the frame-diff driver and
|
||||||
|
keep-alive, the network links, key mapping, settings store, and a 100 000-step
|
||||||
|
randomised invariant test of the core.
|
||||||
|
|
||||||
|
## Decisions where the SDS was open or ambiguous
|
||||||
|
|
||||||
|
* Items tagged *Proposed* are implemented (wrapping lists, restoring the
|
||||||
|
selection on back, Bluetooth pairing 120 s, reboot prompt, phone overlay).
|
||||||
|
* Open character-set question: the core emits `› · █ ░`; `fdim.Charset`
|
||||||
|
maps them to `> - # -` for ASCII-only displays (`fusiond -ascii`).
|
||||||
|
* Boot volume cap (open question 3): `core.Config.BootVolumeCap`, default off.
|
||||||
|
* Minute first digit 6–9: ignored (the SDS's safe default).
|
||||||
|
* When the two Now Playing lines have different page counts the shorter one
|
||||||
|
holds its last page while the other continues.
|
||||||
|
* A page break at a space renders `word ›`; a break at column 19 or inside a
|
||||||
|
word renders `word›` (so every page is ≤ 20 columns including the marker).
|
||||||
|
* Not built (per SDS "Future"): Sound menu, S/PDIF, System Info.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Package app wires the pure core to its drivers. All dependencies are
|
||||||
|
// injected through Deps: swap the FDIM/FCIM implementations (emulator over
|
||||||
|
// TCP, in-memory fakes, a real CAN driver) without touching the core.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/bt"
|
||||||
|
"fusion/core"
|
||||||
|
"fusion/dsp"
|
||||||
|
"fusion/fcim"
|
||||||
|
"fusion/fdim"
|
||||||
|
"fusion/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps are the injected hardware and platform services.
|
||||||
|
type Deps struct {
|
||||||
|
Display fdim.Display // FDIM (required)
|
||||||
|
Charset fdim.Charset // character set the display supports
|
||||||
|
Buttons fcim.Source // FCIM (may be nil: no buttons)
|
||||||
|
DSP dsp.Device // required
|
||||||
|
BT bt.Module // may be nil
|
||||||
|
Clock platform.Clock // may be nil
|
||||||
|
Store platform.Store // may be nil: defaults, nothing saved
|
||||||
|
Config core.Config
|
||||||
|
|
||||||
|
// Reboot is called on SYSTEM_REBOOT. If nil the app just restarts the core
|
||||||
|
// (what the emulator wants).
|
||||||
|
Reboot func()
|
||||||
|
// Log receives human-readable lines (commands, frames, faults). May be nil.
|
||||||
|
Log func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App owns the core and the driver glue. The core is only ever touched from
|
||||||
|
// the goroutine that calls Run/Step/Tick, so it needs no locks.
|
||||||
|
type App struct {
|
||||||
|
d Deps
|
||||||
|
core *core.State
|
||||||
|
ctl *dsp.Controller
|
||||||
|
fd *fdim.Driver
|
||||||
|
out core.Out
|
||||||
|
events chan core.Event
|
||||||
|
pend []core.Event
|
||||||
|
|
||||||
|
dspDown bool
|
||||||
|
healthMs uint32
|
||||||
|
clockSent bool
|
||||||
|
lastClock int
|
||||||
|
rebootReq bool
|
||||||
|
lastFrameLn string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds an App. Call Run (or Start plus Step/Tick for tests).
|
||||||
|
func New(d Deps) *App {
|
||||||
|
if d.Log == nil {
|
||||||
|
d.Log = func(string) {}
|
||||||
|
}
|
||||||
|
return &App{
|
||||||
|
d: d,
|
||||||
|
ctl: dsp.NewController(d.DSP),
|
||||||
|
fd: fdim.NewDriver(d.Display, d.Charset),
|
||||||
|
events: make(chan core.Event, 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core exposes the core state for inspection (read it only from the loop
|
||||||
|
// goroutine, or when the loop is not running).
|
||||||
|
func (a *App) Core() *core.State { return a.core }
|
||||||
|
|
||||||
|
// Post queues an event from any goroutine. It never blocks; if the queue is
|
||||||
|
// full the event is dropped and logged.
|
||||||
|
func (a *App) Post(ev core.Event) {
|
||||||
|
select {
|
||||||
|
case a.events <- ev:
|
||||||
|
default:
|
||||||
|
a.d.Log(fmt.Sprintf("event queue full, dropped %T", ev))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start loads settings, creates the core and runs the boot sequence.
|
||||||
|
func (a *App) Start() {
|
||||||
|
set := core.DefaultSettings()
|
||||||
|
if a.d.Store != nil {
|
||||||
|
s, err := a.d.Store.Load()
|
||||||
|
if err != nil {
|
||||||
|
a.d.Log("settings: " + err.Error() + " (using defaults)")
|
||||||
|
}
|
||||||
|
set = s
|
||||||
|
}
|
||||||
|
a.core = core.New(a.d.Config, set)
|
||||||
|
a.out.Reset()
|
||||||
|
a.core.Start(&a.out)
|
||||||
|
a.apply()
|
||||||
|
a.dspDown = false
|
||||||
|
if err := a.d.DSP.Ping(); err != nil {
|
||||||
|
a.dspDown = true
|
||||||
|
a.d.Log("dsp: " + err.Error())
|
||||||
|
a.Step(core.HealthEvent{Device: core.DevDSP, Status: core.HealthDown})
|
||||||
|
} else {
|
||||||
|
a.Step(core.HealthEvent{Device: core.DevDSP, Status: core.HealthOK})
|
||||||
|
}
|
||||||
|
a.clockSent = false
|
||||||
|
a.pollClock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step feeds one event to the core and applies everything it produced.
|
||||||
|
// Events produced while applying (health changes) are processed too.
|
||||||
|
func (a *App) Step(ev core.Event) {
|
||||||
|
a.pend = append(a.pend, ev)
|
||||||
|
for len(a.pend) > 0 {
|
||||||
|
e := a.pend[0]
|
||||||
|
a.pend = a.pend[1:]
|
||||||
|
a.out.Reset()
|
||||||
|
a.core.Handle(e, &a.out)
|
||||||
|
a.apply()
|
||||||
|
}
|
||||||
|
if a.rebootReq {
|
||||||
|
a.rebootReq = false
|
||||||
|
if a.d.Reboot != nil {
|
||||||
|
a.d.Reboot()
|
||||||
|
} else {
|
||||||
|
a.d.Log("rebooting")
|
||||||
|
a.Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick advances time by dtMs: core timers, FDIM keep-alive, wall clock and
|
||||||
|
// DSP health probing.
|
||||||
|
func (a *App) Tick(dtMs uint32) {
|
||||||
|
a.Step(core.TickEvent{DtMs: dtMs})
|
||||||
|
if err := a.fd.Tick(dtMs); err != nil {
|
||||||
|
a.d.Log("fdim keep-alive: " + err.Error())
|
||||||
|
}
|
||||||
|
a.pollClock()
|
||||||
|
a.healthMs += dtMs
|
||||||
|
if a.healthMs >= 1000 {
|
||||||
|
a.healthMs = 0
|
||||||
|
if a.dspDown && a.d.DSP.Ping() == nil {
|
||||||
|
a.dspDown = false
|
||||||
|
a.d.Log("dsp: recovered")
|
||||||
|
a.Step(core.HealthEvent{Device: core.DevDSP, Status: core.HealthOK})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) pollClock() {
|
||||||
|
if a.d.Clock == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, m := a.d.Clock.Now()
|
||||||
|
if a.clockSent && h*60+m == a.lastClock {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.clockSent, a.lastClock = true, h*60+m
|
||||||
|
a.Step(core.ClockEvent{Hour: h, Minute: m})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the drivers' pumps and runs the main loop until ctx is done.
|
||||||
|
func (a *App) Run(ctx context.Context, tick <-chan time.Time) error {
|
||||||
|
a.Start()
|
||||||
|
if a.d.Buttons != nil {
|
||||||
|
go fcim.Run(ctx, a.d.Buttons, a.Post, func(n string) { a.d.Log("unknown button " + n) })
|
||||||
|
}
|
||||||
|
if a.d.BT != nil {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ev, ok := <-a.d.BT.Events():
|
||||||
|
if !ok {
|
||||||
|
a.Post(core.HealthEvent{Device: core.DevBT, Status: core.HealthDown})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.Post(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if tick == nil {
|
||||||
|
tk := time.NewTicker(core.TickMs * time.Millisecond)
|
||||||
|
defer tk.Stop()
|
||||||
|
tick = tk.C
|
||||||
|
}
|
||||||
|
last := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case ev := <-a.events:
|
||||||
|
a.Step(ev)
|
||||||
|
case now := <-tick:
|
||||||
|
dt := uint32(now.Sub(last).Milliseconds())
|
||||||
|
last = now
|
||||||
|
if dt == 0 {
|
||||||
|
dt = 1
|
||||||
|
}
|
||||||
|
a.Tick(dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- applying output
|
||||||
|
|
||||||
|
func (a *App) apply() {
|
||||||
|
f := a.out.Frame
|
||||||
|
if err := a.fd.Apply(f); err != nil {
|
||||||
|
a.d.Log("fdim: " + err.Error()) // UI degrades, audio continues
|
||||||
|
}
|
||||||
|
if ln := frameLine(f); ln != a.lastFrameLn {
|
||||||
|
a.lastFrameLn = ln
|
||||||
|
a.d.Log("FDIM " + ln)
|
||||||
|
}
|
||||||
|
for _, c := range a.out.Cmds {
|
||||||
|
a.d.Log("CMD " + c.String())
|
||||||
|
a.exec(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameLine(f core.Frame) string {
|
||||||
|
label, clock := f.Label, ""
|
||||||
|
if f.ClockOn {
|
||||||
|
clock = f.ClockText
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[%-4s %8s] |%s|%s|", label, clock,
|
||||||
|
strings.TrimRight(f.Row(0), " "), strings.TrimRight(f.Row(1), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dspResult(err error) {
|
||||||
|
if err == nil || a.dspDown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dspDown = true
|
||||||
|
a.d.Log("dsp: " + err.Error())
|
||||||
|
a.pend = append(a.pend, core.HealthEvent{Device: core.DevDSP, Status: core.HealthDown})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) btDo(f func(bt.Module) error) {
|
||||||
|
if a.d.BT == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := f(a.d.BT); err != nil {
|
||||||
|
a.d.Log("bt: " + err.Error())
|
||||||
|
a.pend = append(a.pend, core.HealthEvent{Device: core.DevBT, Status: core.HealthDown})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) exec(c core.Cmd) {
|
||||||
|
switch c := c.(type) {
|
||||||
|
case core.DSPSetVolume:
|
||||||
|
a.dspResult(a.ctl.SetVolume(c.Pct))
|
||||||
|
case core.DSPMute:
|
||||||
|
a.dspResult(a.ctl.Mute(c.On))
|
||||||
|
case core.DSPSetSource:
|
||||||
|
a.dspResult(a.ctl.SetSource(c.Src.String()))
|
||||||
|
case core.DSPDACEnable:
|
||||||
|
a.dspResult(a.ctl.EnableDAC(c.On))
|
||||||
|
case core.DSPRampTo:
|
||||||
|
a.dspResult(a.ctl.RampTo(c.Pct, c.Ms))
|
||||||
|
case core.BTPlayPause:
|
||||||
|
a.btDo(bt.Module.PlayPause)
|
||||||
|
case core.BTPlay:
|
||||||
|
a.btDo(bt.Module.Play)
|
||||||
|
case core.BTPause:
|
||||||
|
a.btDo(bt.Module.Pause)
|
||||||
|
case core.BTNext:
|
||||||
|
a.btDo(bt.Module.Next)
|
||||||
|
case core.BTPrev:
|
||||||
|
a.btDo(bt.Module.Prev)
|
||||||
|
case core.BTConnect:
|
||||||
|
a.btDo(func(m bt.Module) error { return m.Connect(c.Index) })
|
||||||
|
case core.BTDisconnect:
|
||||||
|
a.btDo(bt.Module.Disconnect)
|
||||||
|
case core.BTPairing:
|
||||||
|
a.btDo(func(m bt.Module) error { return m.SetPairing(c.On) })
|
||||||
|
case core.BTAnswer:
|
||||||
|
a.btDo(bt.Module.Answer)
|
||||||
|
case core.BTHangup:
|
||||||
|
a.btDo(bt.Module.Hangup)
|
||||||
|
case core.ClockSet:
|
||||||
|
if a.d.Clock != nil {
|
||||||
|
if err := a.d.Clock.SetTime(c.Hour, c.Minute); err != nil {
|
||||||
|
a.d.Log("clock: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case core.SettingsSave:
|
||||||
|
if a.d.Store != nil {
|
||||||
|
if err := a.d.Store.Save(core.Settings{Volume: c.Volume, Source: c.Source}); err != nil {
|
||||||
|
a.d.Log("settings: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case core.SystemReboot:
|
||||||
|
a.rebootReq = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/bt"
|
||||||
|
"fusion/core"
|
||||||
|
"fusion/dsp"
|
||||||
|
"fusion/fcim"
|
||||||
|
"fusion/fdim"
|
||||||
|
"fusion/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rig struct {
|
||||||
|
app *App
|
||||||
|
disp *fdim.Memory
|
||||||
|
dsp *dsp.Sim
|
||||||
|
bt *bt.Sim
|
||||||
|
store *platform.MemStore
|
||||||
|
clock *platform.SysClock
|
||||||
|
btns *fcim.Chan
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRig(saved *core.Settings) *rig {
|
||||||
|
r := &rig{
|
||||||
|
disp: &fdim.Memory{},
|
||||||
|
dsp: dsp.NewSim(),
|
||||||
|
bt: bt.NewSim("Pixel", "iPhone"),
|
||||||
|
store: &platform.MemStore{},
|
||||||
|
btns: fcim.NewChan(),
|
||||||
|
clock: &platform.SysClock{NowFn: func() time.Time {
|
||||||
|
return time.Date(2026, 10, 23, 16, 7, 0, 0, time.UTC)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
r.bt.Delay = time.Millisecond
|
||||||
|
if saved != nil {
|
||||||
|
r.store.Save(*saved)
|
||||||
|
r.store.Saves = 0
|
||||||
|
}
|
||||||
|
r.app = New(Deps{
|
||||||
|
Display: r.disp, Charset: fdim.ASCII, Buttons: r.btns, DSP: r.dsp,
|
||||||
|
BT: r.bt, Clock: r.clock, Store: r.store,
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rig) key(names ...string) {
|
||||||
|
for _, n := range names {
|
||||||
|
k, ok := core.ParseKey(n)
|
||||||
|
if !ok {
|
||||||
|
panic("bad key " + n)
|
||||||
|
}
|
||||||
|
r.app.Step(core.KeyEvent{Key: k})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rig) row1() string { a, _, _, _ := r.disp.Snapshot(); return strings.TrimRight(a, " ") }
|
||||||
|
func (r *rig) row2() string { _, b, _, _ := r.disp.Snapshot(); return strings.TrimRight(b, " ") }
|
||||||
|
|
||||||
|
func TestBootBringsUpAudioAndDisplay(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
if got := r.row1(); got != "Aux Input" {
|
||||||
|
t.Errorf("row1 = %q", got)
|
||||||
|
}
|
||||||
|
_, _, label, clock := r.disp.Snapshot()
|
||||||
|
if label != "AUX" || clock != "4:07 PM" {
|
||||||
|
t.Errorf("label %q clock %q", label, clock)
|
||||||
|
}
|
||||||
|
st := r.dsp.State()
|
||||||
|
if st.Input != "AUX" || !st.DAC || st.Muted || st.GainTenths != -174 || st.RampMs != 1000 {
|
||||||
|
t.Errorf("dsp = %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavedSettingsAreRestored(t *testing.T) {
|
||||||
|
r := newRig(&core.Settings{Volume: 45, Source: core.SourceBT})
|
||||||
|
r.app.Start()
|
||||||
|
st := r.dsp.State()
|
||||||
|
if st.Input != "BT" || st.GainTenths != -115 {
|
||||||
|
t.Errorf("dsp = %+v", st)
|
||||||
|
}
|
||||||
|
if _, _, label, _ := r.disp.Snapshot(); label != "SYNC" {
|
||||||
|
t.Errorf("label = %q", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVolumeOverlayAndPersistence(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
r.key("VOLUME_RIGHT")
|
||||||
|
if st := r.dsp.State(); st.GainTenths != -151 {
|
||||||
|
t.Errorf("gain = %d", st.GainTenths)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.row1(), "Volume") || !strings.HasSuffix(r.row1(), "35%") {
|
||||||
|
t.Errorf("row1 = %q", r.row1())
|
||||||
|
}
|
||||||
|
if want := strings.Repeat("#", 7) + strings.Repeat("-", 13); r.row2() != want {
|
||||||
|
t.Errorf("bar = %q, want %q", r.row2(), want)
|
||||||
|
}
|
||||||
|
if r.store.Saves != 0 {
|
||||||
|
t.Error("saved before the knob settled")
|
||||||
|
}
|
||||||
|
r.app.Tick(5000)
|
||||||
|
got, err := r.store.Load()
|
||||||
|
if err != nil || got.Volume != 35 || got.Source != core.SourceAux {
|
||||||
|
t.Errorf("stored = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
if r.row1() != "Aux Input" {
|
||||||
|
t.Errorf("overlay not closed: %q", r.row1())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceSwitchDrivesDSP(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
r.key("SIRIUS")
|
||||||
|
if st := r.dsp.State(); st.Input != "BT" || st.Muted {
|
||||||
|
t.Errorf("dsp = %+v", st)
|
||||||
|
}
|
||||||
|
if r.row1() != "Source: Bluetooth" {
|
||||||
|
t.Errorf("row1 = %q", r.row1())
|
||||||
|
}
|
||||||
|
if got, _ := r.store.Load(); got.Source != core.SourceBT {
|
||||||
|
t.Errorf("source not persisted: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDSPFailureAndRecovery(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
r.dsp.SetFailing(true)
|
||||||
|
r.app.Tick(5000) // close nothing; just let time pass
|
||||||
|
r.key("VOLUME_RIGHT")
|
||||||
|
if r.app.Core().Health[core.DevDSP] != core.HealthDown {
|
||||||
|
t.Fatal("DSP failure not detected")
|
||||||
|
}
|
||||||
|
r.app.Tick(5000) // overlay closes -> Now Playing shows the fault
|
||||||
|
if r.row2() != "DSP not responding" {
|
||||||
|
t.Errorf("row2 = %q", r.row2())
|
||||||
|
}
|
||||||
|
r.dsp.SetFailing(false)
|
||||||
|
r.app.Tick(1000)
|
||||||
|
if r.app.Core().Health[core.DevDSP] != core.HealthOK {
|
||||||
|
t.Fatal("DSP recovery not detected")
|
||||||
|
}
|
||||||
|
st := r.dsp.State()
|
||||||
|
if st.Input != "AUX" || st.GainTenths != -151 {
|
||||||
|
t.Errorf("settings not resent after recovery: %+v", st)
|
||||||
|
}
|
||||||
|
if r.row2() != "" {
|
||||||
|
t.Errorf("fault still shown: %q", r.row2())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayFaultNeverInterruptsAudio(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
r.disp.SetFailing(true)
|
||||||
|
r.key("VOLUME_RIGHT")
|
||||||
|
if st := r.dsp.State(); st.GainTenths != -151 {
|
||||||
|
t.Errorf("audio stopped following the UI: %+v", st)
|
||||||
|
}
|
||||||
|
r.disp.SetFailing(false)
|
||||||
|
r.app.Tick(50) // next apply resends what failed
|
||||||
|
if !strings.HasPrefix(r.row1(), "Volume") {
|
||||||
|
t.Errorf("display not recovered: %q", r.row1())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepAliveWhenIdle(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
before := r.disp.KeepAlives
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
r.app.Tick(50)
|
||||||
|
}
|
||||||
|
if r.disp.KeepAlives <= before {
|
||||||
|
t.Error("no keep-alive sent while idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClockEditorSetsTheSystemClock(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
r.key("CLOCK", "TUNE_RIGHT", "OK", "OK", "OK")
|
||||||
|
if h, m := r.clock.Now(); h != 17 || m != 7 {
|
||||||
|
t.Errorf("system time = %d:%02d", h, m)
|
||||||
|
}
|
||||||
|
if _, _, _, c := r.disp.Snapshot(); c != "5:07 PM" {
|
||||||
|
t.Errorf("info-area clock = %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebootRestartsAndRestoresSettings(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
r.app.Start()
|
||||||
|
r.key("VOLUME_RIGHT")
|
||||||
|
r.key("MENU", "TUNE_LEFT", "OK", "OK", "OK")
|
||||||
|
if got, _ := r.store.Load(); got.Volume != 35 {
|
||||||
|
t.Errorf("volume not saved before reboot: %+v", got)
|
||||||
|
}
|
||||||
|
if r.app.Core().Mode != core.ModeActive {
|
||||||
|
t.Errorf("mode after reboot = %v", r.app.Core().Mode)
|
||||||
|
}
|
||||||
|
if r.app.Core().Volume != 35 {
|
||||||
|
t.Errorf("volume after reboot = %d", r.app.Core().Volume)
|
||||||
|
}
|
||||||
|
if r.row1() != "Aux Input" {
|
||||||
|
t.Errorf("row1 = %q", r.row1())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectedRebootHook(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
called := false
|
||||||
|
r.app = New(Deps{Display: r.disp, DSP: r.dsp, Store: r.store, Reboot: func() { called = true }})
|
||||||
|
r.app.Start()
|
||||||
|
r.key("MENU", "TUNE_LEFT", "OK", "OK", "OK")
|
||||||
|
if !called {
|
||||||
|
t.Error("Reboot hook not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end through the injected FCIM and Bluetooth module, with the real
|
||||||
|
// event loop running.
|
||||||
|
func TestRunLoopEndToEnd(t *testing.T) {
|
||||||
|
r := newRig(nil)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { r.app.Run(ctx, nil); close(done) }()
|
||||||
|
defer func() { cancel(); <-done }()
|
||||||
|
|
||||||
|
waitFor := func(what string, ok func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if ok() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("timeout waiting for %s (row1=%q row2=%q)", what, r.row1(), r.row2())
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor("boot", func() bool { return r.row1() == "Aux Input" })
|
||||||
|
r.btns.Press("MENU")
|
||||||
|
waitFor("menu", func() bool { return strings.HasPrefix(r.row1(), "Menu") })
|
||||||
|
r.btns.Press("MENU")
|
||||||
|
waitFor("home", func() bool { return r.row1() == "Aux Input" })
|
||||||
|
|
||||||
|
r.btns.Press("SIRIUS")
|
||||||
|
waitFor("confirmation", func() bool { return r.row1() == "Source: Bluetooth" })
|
||||||
|
r.btns.Press("OK") // skip the confirmation
|
||||||
|
r.bt.SimConnect("Pixel")
|
||||||
|
r.bt.SimMedia("Song", "Artist", "Album", true)
|
||||||
|
waitFor("track", func() bool { return r.row1() == "Song" && r.row2() == "Artist - Album" })
|
||||||
|
r.btns.Press("SEEK_RIGHT")
|
||||||
|
waitFor("next track", func() bool { return r.row1() != "Song" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoBluetoothModule is the car hardware today: BT is nil. Every button
|
||||||
|
// must be safe, including the Bluetooth shortcut (SIRIUS) and the menus.
|
||||||
|
func TestNoBluetoothModule(t *testing.T) {
|
||||||
|
disp := &fdim.Memory{}
|
||||||
|
a := New(Deps{Display: disp, Charset: fdim.ASCII, DSP: dsp.NewSim(), Store: &platform.MemStore{}})
|
||||||
|
a.Start()
|
||||||
|
for _, n := range []string{"VOLUME_RIGHT", "SIRIUS", "OK", "AUX", "MENU", "TUNE_RIGHT", "OK", "TUNE_RIGHT", "OK", "MENU", "MENU", "POWER", "POWER"} {
|
||||||
|
k, _ := core.ParseKey(n)
|
||||||
|
a.Step(core.KeyEvent{Key: k})
|
||||||
|
a.Tick(100)
|
||||||
|
}
|
||||||
|
if r1, _, _, _ := disp.Snapshot(); r1 == "" {
|
||||||
|
t.Error("display blank after key sequence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Package bt is the Bluetooth module driver (Feasycom BT1026D in hardware):
|
||||||
|
// a generic Module API and a simulated phone-plus-module for the emulator.
|
||||||
|
package bt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module is the generic Bluetooth API. Commands are fire-and-forget; results
|
||||||
|
// come back as core events on Events().
|
||||||
|
type Module interface {
|
||||||
|
Events() <-chan core.Event
|
||||||
|
PlayPause() error
|
||||||
|
Play() error
|
||||||
|
Pause() error
|
||||||
|
Next() error
|
||||||
|
Prev() error
|
||||||
|
Connect(index int) error
|
||||||
|
Disconnect() error
|
||||||
|
SetPairing(on bool) error
|
||||||
|
Answer() error
|
||||||
|
Hangup() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type track struct{ title, artist, album string }
|
||||||
|
|
||||||
|
var playlist = []track{
|
||||||
|
{"Midnight City", "M83", "Hurry Up, We're Dreaming"},
|
||||||
|
{"Blue in Green", "Miles Davis", "Kind of Blue"},
|
||||||
|
{"Café del Mar", "Energy 52", "Single"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sim is a simulated Bluetooth module with phones that can be paired.
|
||||||
|
type Sim struct {
|
||||||
|
// Delay is how long a connect takes (default 300 ms).
|
||||||
|
Delay time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
ev chan core.Event
|
||||||
|
paired []string
|
||||||
|
connected string
|
||||||
|
playing bool
|
||||||
|
track int
|
||||||
|
pairing bool
|
||||||
|
inCall bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSim creates a module with the given paired devices.
|
||||||
|
func NewSim(paired ...string) *Sim {
|
||||||
|
s := &Sim{Delay: 300 * time.Millisecond, ev: make(chan core.Event, 64), paired: append([]string(nil), paired...)}
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTDeviceList, Devices: s.paired})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) emitLocked(e core.Event) {
|
||||||
|
select {
|
||||||
|
case s.ev <- e:
|
||||||
|
default: // never block the caller; a full queue drops the oldest news
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Events() <-chan core.Event { return s.ev }
|
||||||
|
|
||||||
|
func (s *Sim) mediaLocked() {
|
||||||
|
t := playlist[s.track%len(playlist)]
|
||||||
|
s.emitLocked(core.MediaEvent{Title: t.title, Artist: t.artist, Album: t.album, Playing: s.playing})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) PlayPause() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.connected != "" {
|
||||||
|
s.playing = !s.playing
|
||||||
|
s.mediaLocked()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) setPlaying(on bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.connected != "" && s.playing != on {
|
||||||
|
s.playing = on
|
||||||
|
s.mediaLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Play() error { s.setPlaying(true); return nil }
|
||||||
|
func (s *Sim) Pause() error { s.setPlaying(false); return nil }
|
||||||
|
|
||||||
|
func (s *Sim) step(d int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.connected != "" {
|
||||||
|
s.track = (s.track + d + len(playlist)) % len(playlist)
|
||||||
|
s.mediaLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Next() error { s.step(1); return nil }
|
||||||
|
func (s *Sim) Prev() error { s.step(-1); return nil }
|
||||||
|
|
||||||
|
// Connect connects the paired device at index after Delay.
|
||||||
|
func (s *Sim) Connect(index int) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
var name string
|
||||||
|
ok := index >= 0 && index < len(s.paired)
|
||||||
|
if ok {
|
||||||
|
name = s.paired[index]
|
||||||
|
}
|
||||||
|
delay := s.Delay
|
||||||
|
s.mu.Unlock()
|
||||||
|
time.AfterFunc(delay, func() {
|
||||||
|
if !ok {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTConnectFailed})
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.SimConnect(name)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Disconnect() error { s.SimDisconnect(); return nil }
|
||||||
|
|
||||||
|
func (s *Sim) SetPairing(on bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.pairing = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Answer() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.inCall = true
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTCallActive})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Hangup() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.inCall = false
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTCallEnded})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- phone side
|
||||||
|
|
||||||
|
// SimConnect makes a phone connect (adding it to the paired list if new). It
|
||||||
|
// starts streaming the first track, paused.
|
||||||
|
func (s *Sim) SimConnect(name string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
known := false
|
||||||
|
for _, p := range s.paired {
|
||||||
|
if p == name {
|
||||||
|
known = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
s.paired = append(s.paired, name)
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTDeviceList, Devices: append([]string(nil), s.paired...)})
|
||||||
|
}
|
||||||
|
s.connected = name
|
||||||
|
s.pairing = false
|
||||||
|
s.playing = false
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTConnected, Device: name})
|
||||||
|
s.mediaLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimDisconnect drops the current phone.
|
||||||
|
func (s *Sim) SimDisconnect() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.connected == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.connected, s.playing = "", false
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTDisconnected})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimPair emulates a phone pairing while the module is discoverable. It
|
||||||
|
// returns false if the module is not in pairing mode.
|
||||||
|
func (s *Sim) SimPair(name string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
ok := s.pairing
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
s.SimConnect(name)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimMedia pushes custom metadata (the phone changed track).
|
||||||
|
func (s *Sim) SimMedia(title, artist, album string, playing bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.playing = playing
|
||||||
|
s.emitLocked(core.MediaEvent{Title: title, Artist: artist, Album: album, Playing: playing})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimRing emulates an incoming call.
|
||||||
|
func (s *Sim) SimRing(caller string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTCallRinging, Caller: caller})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimCallEnd emulates the remote party hanging up.
|
||||||
|
func (s *Sim) SimCallEnd() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.inCall = false
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTCallEnded})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimPairingEnded emulates the module leaving discoverable mode.
|
||||||
|
func (s *Sim) SimPairingEnded() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.pairing = false
|
||||||
|
s.emitLocked(core.BTEvent{State: core.BTPairingEnded})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing reports whether the module is currently discoverable.
|
||||||
|
func (s *Sim) Pairing() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.pairing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected returns the connected device name ("" if none).
|
||||||
|
func (s *Sim) Connected() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing reports the phone's playback state.
|
||||||
|
func (s *Sim) Playing() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.playing
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Package can is a minimal SocketCAN (raw, classic CAN) bus: one reader
|
||||||
|
// goroutine fanning frames out to subscribers, and serialised sends.
|
||||||
|
package can
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frame is one classic CAN frame with an 11-bit (standard) ID.
|
||||||
|
type Frame struct {
|
||||||
|
ID uint32
|
||||||
|
Len uint8
|
||||||
|
Data [8]uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Frame) String() string {
|
||||||
|
return fmt.Sprintf("%03X#% X", f.ID, f.Data[:f.Len])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender is anything that can put a frame on the bus (the Bus, or a test fake).
|
||||||
|
type Sender interface {
|
||||||
|
Send(Frame) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux struct can_frame flags and size (include/uapi/linux/can.h).
|
||||||
|
const (
|
||||||
|
effFlag = 0x80000000
|
||||||
|
rtrFlag = 0x40000000
|
||||||
|
errFlag = 0x20000000
|
||||||
|
sffMask = 0x7FF
|
||||||
|
frameSize = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// encode packs f as a struct can_frame.
|
||||||
|
func encode(f Frame) []byte {
|
||||||
|
b := make([]byte, frameSize)
|
||||||
|
binary.LittleEndian.PutUint32(b, f.ID&sffMask)
|
||||||
|
b[4] = f.Len
|
||||||
|
copy(b[8:], f.Data[:])
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode unpacks a struct can_frame; ok is false for error, remote and
|
||||||
|
// extended-ID frames, which nothing on this bus uses.
|
||||||
|
func decode(b []byte) (f Frame, ok bool) {
|
||||||
|
id := binary.LittleEndian.Uint32(b)
|
||||||
|
if id&(effFlag|rtrFlag|errFlag) != 0 || b[4] > 8 {
|
||||||
|
return Frame{}, false
|
||||||
|
}
|
||||||
|
f.ID, f.Len = id&sffMask, b[4]
|
||||||
|
copy(f.Data[:], b[8:16])
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bus is an open CAN interface.
|
||||||
|
type Bus struct {
|
||||||
|
rw io.ReadWriteCloser
|
||||||
|
wmu sync.Mutex
|
||||||
|
smu sync.Mutex
|
||||||
|
subs []func(Frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers fn for every received frame. fn runs on the reader
|
||||||
|
// goroutine, so it must not block.
|
||||||
|
func (b *Bus) Subscribe(fn func(Frame)) {
|
||||||
|
b.smu.Lock()
|
||||||
|
b.subs = append(b.subs, fn)
|
||||||
|
b.smu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bus) Send(f Frame) error {
|
||||||
|
b.wmu.Lock()
|
||||||
|
defer b.wmu.Unlock()
|
||||||
|
_, err := b.rw.Write(encode(f))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run reads frames until the interface fails or the bus is closed.
|
||||||
|
func (b *Bus) Run() error {
|
||||||
|
buf := make([]byte, frameSize)
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(b.rw, buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, ok := decode(buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.smu.Lock()
|
||||||
|
subs := b.subs
|
||||||
|
b.smu.Unlock()
|
||||||
|
for _, fn := range subs {
|
||||||
|
fn(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bus) Close() error { return b.rw.Close() }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package can
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sockaddrCAN mirrors struct sockaddr_can (24 bytes with the j1939 union).
|
||||||
|
type sockaddrCAN struct {
|
||||||
|
family uint16
|
||||||
|
_ uint16
|
||||||
|
ifindex int32
|
||||||
|
_ [16]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRaw = 1 // CAN_RAW
|
||||||
|
|
||||||
|
// Open binds a raw CAN socket to iface (e.g. "can0", brought up by slcan.service).
|
||||||
|
func Open(iface string) (*Bus, error) {
|
||||||
|
ifi, err := net.InterfaceByName(iface)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fd, err := syscall.Socket(syscall.AF_CAN, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, canRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("can socket: %w", err)
|
||||||
|
}
|
||||||
|
sa := sockaddrCAN{family: syscall.AF_CAN, ifindex: int32(ifi.Index)}
|
||||||
|
if _, _, errno := syscall.Syscall(syscall.SYS_BIND, uintptr(fd), uintptr(unsafe.Pointer(&sa)), unsafe.Sizeof(sa)); errno != 0 {
|
||||||
|
syscall.Close(fd)
|
||||||
|
return nil, fmt.Errorf("can bind %s: %w", iface, errno)
|
||||||
|
}
|
||||||
|
// Non-blocking so os.File uses the poller and Close unblocks Run.
|
||||||
|
if err := syscall.SetNonblock(fd, true); err != nil {
|
||||||
|
syscall.Close(fd)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Bus{rw: os.NewFile(uintptr(fd), iface)}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package can
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncodeDecode(t *testing.T) {
|
||||||
|
f := Frame{ID: 0x50C, Len: 3, Data: [8]uint8{0x0C, 0x02}}
|
||||||
|
b := encode(f)
|
||||||
|
want := []byte{0x0C, 0x05, 0, 0, 3, 0, 0, 0, 0x0C, 0x02, 0, 0, 0, 0, 0, 0}
|
||||||
|
if !bytes.Equal(b, want) {
|
||||||
|
t.Fatalf("encode = % X, want % X", b, want)
|
||||||
|
}
|
||||||
|
if got, ok := decode(b); !ok || got != f {
|
||||||
|
t.Errorf("decode = %v %v, want %v", got, ok, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSkipsSpecialFrames(t *testing.T) {
|
||||||
|
for _, id := range []uint32{0x80000123, 0x40000123, 0x20000004} {
|
||||||
|
b := encode(Frame{Len: 1})
|
||||||
|
b[0], b[1], b[2], b[3] = byte(id), byte(id>>8), byte(id>>16), byte(id>>24)
|
||||||
|
if _, ok := decode(b); ok {
|
||||||
|
t.Errorf("frame with id %08X accepted", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipeRW feeds Run from a buffer and records writes.
|
||||||
|
type pipeRW struct {
|
||||||
|
r io.Reader
|
||||||
|
w bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pipeRW) Read(b []byte) (int, error) { return p.r.Read(b) }
|
||||||
|
func (p *pipeRW) Write(b []byte) (int, error) { return p.w.Write(b) }
|
||||||
|
func (p *pipeRW) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestRunFansOut(t *testing.T) {
|
||||||
|
in := append(encode(Frame{ID: 0x55D, Len: 2}), encode(Frame{ID: 0x2E0, Len: 8, Data: [8]uint8{0x2B, 0x88}})...)
|
||||||
|
rw := &pipeRW{r: bytes.NewReader(in)}
|
||||||
|
b := &Bus{rw: rw}
|
||||||
|
var got []uint32
|
||||||
|
b.Subscribe(func(f Frame) { got = append(got, f.ID) })
|
||||||
|
if err := b.Run(); err != io.EOF {
|
||||||
|
t.Fatalf("Run = %v, want EOF", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != 0x55D || got[1] != 0x2E0 {
|
||||||
|
t.Errorf("got %X", got)
|
||||||
|
}
|
||||||
|
if err := b.Send(Frame{ID: 0x50C, Len: 3, Data: [8]uint8{0x0C, 0x02}}); err != nil || rw.w.Len() != frameSize {
|
||||||
|
t.Errorf("Send wrote %d bytes, err %v", rw.w.Len(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Command fcim is the front control input module (FCIM) emulator: the
|
||||||
|
// buttons and knobs. Run it in its own terminal; every keystroke becomes one
|
||||||
|
// button pulse sent to the core (fusiond).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fusion/fcim"
|
||||||
|
"fusion/termui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
coreAddr := flag.String("core", "127.0.0.1:7001", "address of the core's FCIM port")
|
||||||
|
line := flag.Bool("line", false, "line mode: read button names from stdin instead of raw keystrokes")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
sender := fcim.NewSender(*coreAddr)
|
||||||
|
defer sender.Close()
|
||||||
|
|
||||||
|
if *line {
|
||||||
|
runLine(sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restore, err := termui.RawMode()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fcim: stdin is not a terminal (", err, ") - use -line")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
fmt.Print(termui.Clear, "FCIM emulator - every key is one pulse\r\n\r\n")
|
||||||
|
for _, l := range fcim.Legend {
|
||||||
|
fmt.Print(l, "\r\n")
|
||||||
|
}
|
||||||
|
fmt.Print("\r\n")
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := buf[:n]
|
||||||
|
for len(data) > 0 {
|
||||||
|
name, used := fcim.MapKeys(data)
|
||||||
|
if used == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data = data[used:]
|
||||||
|
switch name {
|
||||||
|
case "QUIT":
|
||||||
|
fmt.Print("\r\n")
|
||||||
|
return
|
||||||
|
case "":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sender.Press(name); err != nil {
|
||||||
|
fmt.Print(termui.EraseLine, "pressed ", name, " (core not reachable: ", err, ")")
|
||||||
|
} else {
|
||||||
|
fmt.Print(termui.EraseLine, "pressed ", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLine(sender *fcim.Sender) {
|
||||||
|
fmt.Println("FCIM line mode: type button names (POWER, VOLUME_RIGHT, TUNE_LEFT, OK, MENU, 5, ...), blank line or Ctrl-D quits")
|
||||||
|
sc := bufio.NewScanner(os.Stdin)
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range fields {
|
||||||
|
if err := sender.Press(strings.ToUpper(f)); err != nil {
|
||||||
|
fmt.Println("core not reachable:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("pressed", strings.ToUpper(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Command fdim is the front display (FDIM) emulator. Run it in its own
|
||||||
|
// terminal; the core (fusiond) connects to it and drives the display.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/fdim"
|
||||||
|
"fusion/termui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("listen", "127.0.0.1:7002", "address to listen on for the core")
|
||||||
|
plain := flag.Bool("plain", false, "no colours, no screen clearing (prints every change)")
|
||||||
|
wake := flag.Duration("wake", fdim.DefaultWakeTimeout, "blank the display if no wake signal (keep-alive) arrives for this long; 0 disables")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", *listen)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fdim:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
cur := fdim.EmuState{R1: "", R2: ""}
|
||||||
|
draw := func() {
|
||||||
|
mu.Lock()
|
||||||
|
st := cur
|
||||||
|
mu.Unlock()
|
||||||
|
status := fmt.Sprintf("waiting for core on %s ...", *listen)
|
||||||
|
if st.Asleep {
|
||||||
|
status = fmt.Sprintf("NO WAKE SIGNAL for %.1fs - display off, contents lost", time.Since(st.LastMsg).Seconds())
|
||||||
|
} else if st.Connected {
|
||||||
|
status = fmt.Sprintf("core connected - last message %.1fs ago", time.Since(st.LastMsg).Seconds())
|
||||||
|
} else if !st.LastMsg.IsZero() {
|
||||||
|
status = "core disconnected - waiting to reconnect ..."
|
||||||
|
}
|
||||||
|
if *plain {
|
||||||
|
fmt.Print(fdim.RenderBox(st, false), status, "\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(termui.Clear, termui.HideCursor, "FDIM emulator (2 x 20 VFD)\n\n",
|
||||||
|
fdim.RenderBox(st, true), "\n", status, "\n\nCtrl-C to quit\n")
|
||||||
|
}
|
||||||
|
onChange := func(st fdim.EmuState) {
|
||||||
|
mu.Lock()
|
||||||
|
cur = st
|
||||||
|
mu.Unlock()
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
if !*plain {
|
||||||
|
defer fmt.Print(termui.ShowCursor)
|
||||||
|
go func() { // keep the "last message" age fresh
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if err := fdim.ServeTimeout(ctx, ln, *wake, onChange); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fdim:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fusion/bt"
|
||||||
|
"fusion/core"
|
||||||
|
"fusion/dsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// env is what the operator console can poke at: the simulated phone and
|
||||||
|
// Bluetooth module, the simulated DSP, and the core's event queue.
|
||||||
|
type env struct {
|
||||||
|
bt *bt.Sim
|
||||||
|
dsp *dsp.Sim
|
||||||
|
post func(core.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleHelp = `console commands (simulated hardware outside the FDIM/FCIM):
|
||||||
|
key NAME inject a button pulse locally (same names as the FCIM)
|
||||||
|
connect NAME a phone connects over Bluetooth (starts paused)
|
||||||
|
disconnect the phone drops
|
||||||
|
pair NAME a phone pairs while "Pair New" is active
|
||||||
|
media TITLE|ARTIST|ALBUM the phone changes track (playing)
|
||||||
|
play | pause the phone starts / stops playback
|
||||||
|
ring NAME incoming call from NAME
|
||||||
|
hangup the remote party ends the call
|
||||||
|
pairend the module leaves discoverable mode
|
||||||
|
dsp fail | dsp ok kill / revive the DSP (shows the fault screen)
|
||||||
|
status print simulated BT/DSP state
|
||||||
|
help | quit`
|
||||||
|
|
||||||
|
// btCommands need the simulated Bluetooth module.
|
||||||
|
var btCommands = map[string]bool{
|
||||||
|
"connect": true, "disconnect": true, "pair": true, "media": true, "play": true,
|
||||||
|
"pause": true, "ring": true, "hangup": true, "pairend": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle runs one console line and returns the text to print.
|
||||||
|
func (e *env) handle(line string) (reply string, quit bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cmd, rest, _ := strings.Cut(line, " ")
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
cmd = strings.ToLower(cmd)
|
||||||
|
if e.bt == nil && btCommands[cmd] {
|
||||||
|
return "no Bluetooth module in hardware mode", false
|
||||||
|
}
|
||||||
|
switch cmd {
|
||||||
|
case "help", "?":
|
||||||
|
return consoleHelp, false
|
||||||
|
case "quit", "exit":
|
||||||
|
return "", true
|
||||||
|
case "key":
|
||||||
|
k, ok := core.ParseKey(rest)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Sprintf("unknown key %q", rest), false
|
||||||
|
}
|
||||||
|
e.post(core.KeyEvent{Key: k})
|
||||||
|
case "connect":
|
||||||
|
if rest == "" {
|
||||||
|
return "usage: connect NAME", false
|
||||||
|
}
|
||||||
|
e.bt.SimConnect(rest)
|
||||||
|
case "disconnect":
|
||||||
|
e.bt.SimDisconnect()
|
||||||
|
case "pair":
|
||||||
|
if rest == "" {
|
||||||
|
return "usage: pair NAME", false
|
||||||
|
}
|
||||||
|
if !e.bt.SimPair(rest) {
|
||||||
|
return "module is not discoverable (open Menu > Bluetooth > Pair New first)", false
|
||||||
|
}
|
||||||
|
case "media":
|
||||||
|
parts := strings.SplitN(rest, "|", 3)
|
||||||
|
for len(parts) < 3 {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
|
e.bt.SimMedia(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true)
|
||||||
|
case "play":
|
||||||
|
e.bt.Play()
|
||||||
|
case "pause":
|
||||||
|
e.bt.Pause()
|
||||||
|
case "ring":
|
||||||
|
if rest == "" {
|
||||||
|
rest = "Unknown caller"
|
||||||
|
}
|
||||||
|
e.bt.SimRing(rest)
|
||||||
|
case "hangup":
|
||||||
|
e.bt.SimCallEnd()
|
||||||
|
case "pairend":
|
||||||
|
e.bt.SimPairingEnded()
|
||||||
|
case "dsp":
|
||||||
|
if e.dsp == nil {
|
||||||
|
return "the real DSP is in use; nothing to simulate", false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(rest) {
|
||||||
|
case "fail":
|
||||||
|
e.dsp.SetFailing(true)
|
||||||
|
case "ok":
|
||||||
|
e.dsp.SetFailing(false)
|
||||||
|
default:
|
||||||
|
return "usage: dsp fail | dsp ok", false
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
bts := "bt: none"
|
||||||
|
if e.bt != nil {
|
||||||
|
bts = fmt.Sprintf("bt: connected=%q discoverable=%v playing=%v", e.bt.Connected(), e.bt.Pairing(), e.bt.Playing())
|
||||||
|
}
|
||||||
|
if e.dsp == nil {
|
||||||
|
return bts + " | dsp: hardware", false
|
||||||
|
}
|
||||||
|
st := e.dsp.State()
|
||||||
|
return fmt.Sprintf("%s | dsp: input=%s gain=%.1fdB muted=%v dac=%v ping=%v",
|
||||||
|
bts, st.Input, float64(st.GainTenths)/10, st.Muted, st.DAC, e.dsp.Ping() == nil), false
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown command %q (try help)", cmd), false
|
||||||
|
}
|
||||||
|
return "ok", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fusion/bt"
|
||||||
|
"fusion/core"
|
||||||
|
"fusion/dsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEnv() (*env, *[]core.Event) {
|
||||||
|
var got []core.Event
|
||||||
|
return &env{bt: bt.NewSim("A"), dsp: dsp.NewSim(), post: func(e core.Event) { got = append(got, e) }}, &got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleKey(t *testing.T) {
|
||||||
|
e, got := newEnv()
|
||||||
|
if r, _ := e.handle("key VOLUME_RIGHT"); r != "ok" {
|
||||||
|
t.Errorf("reply = %q", r)
|
||||||
|
}
|
||||||
|
if len(*got) != 1 || (*got)[0] != (core.KeyEvent{Key: core.KeyVolRight}) {
|
||||||
|
t.Errorf("events = %#v", *got)
|
||||||
|
}
|
||||||
|
if r, _ := e.handle("key NOPE"); !strings.Contains(r, "unknown key") {
|
||||||
|
t.Errorf("reply = %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleBluetooth(t *testing.T) {
|
||||||
|
e, _ := newEnv()
|
||||||
|
e.handle("connect Pixel")
|
||||||
|
if e.bt.Connected() != "Pixel" {
|
||||||
|
t.Error("connect failed")
|
||||||
|
}
|
||||||
|
e.handle("media Song | Artist | Album")
|
||||||
|
if !e.bt.Playing() {
|
||||||
|
t.Error("media should be playing")
|
||||||
|
}
|
||||||
|
e.handle("pause")
|
||||||
|
if e.bt.Playing() {
|
||||||
|
t.Error("pause failed")
|
||||||
|
}
|
||||||
|
e.handle("disconnect")
|
||||||
|
if e.bt.Connected() != "" {
|
||||||
|
t.Error("disconnect failed")
|
||||||
|
}
|
||||||
|
if r, _ := e.handle("pair X"); !strings.Contains(r, "not discoverable") {
|
||||||
|
t.Errorf("reply = %q", r)
|
||||||
|
}
|
||||||
|
e.bt.SetPairing(true)
|
||||||
|
if r, _ := e.handle("pair Nokia"); r != "ok" || e.bt.Connected() != "Nokia" {
|
||||||
|
t.Errorf("pair: %q connected=%q", r, e.bt.Connected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleDSPAndQuit(t *testing.T) {
|
||||||
|
e, _ := newEnv()
|
||||||
|
e.handle("dsp fail")
|
||||||
|
if e.dsp.Ping() == nil {
|
||||||
|
t.Error("dsp should be failing")
|
||||||
|
}
|
||||||
|
e.handle("dsp ok")
|
||||||
|
if e.dsp.Ping() != nil {
|
||||||
|
t.Error("dsp should be back")
|
||||||
|
}
|
||||||
|
if _, quit := e.handle("quit"); !quit {
|
||||||
|
t.Error("quit not honoured")
|
||||||
|
}
|
||||||
|
if r, _ := e.handle("bogus"); !strings.Contains(r, "unknown command") {
|
||||||
|
t.Errorf("reply = %q", r)
|
||||||
|
}
|
||||||
|
if r, _ := e.handle("status"); !strings.Contains(r, "dsp:") {
|
||||||
|
t.Errorf("status = %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleHardwareMode(t *testing.T) {
|
||||||
|
e := &env{post: func(core.Event) {}}
|
||||||
|
for _, line := range []string{"connect X", "ring Bob", "dsp fail", "status"} {
|
||||||
|
if r, _ := e.handle(line); r == "" || r == "ok" {
|
||||||
|
t.Errorf("%q in hardware mode = %q", line, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Command fusiond is the core software: the UI core plus drivers. By default
|
||||||
|
// it listens for the FCIM emulator, dials the FDIM emulator, and simulates the
|
||||||
|
// DSP and Bluetooth module; type "help" for the simulated-hardware console.
|
||||||
|
//
|
||||||
|
// Dependency injection: every peripheral is passed to app.New as an
|
||||||
|
// interface (see app.Deps). On the car hardware:
|
||||||
|
//
|
||||||
|
// fusiond -can can0 -dsp /dev/i2c-1
|
||||||
|
//
|
||||||
|
// uses the real FDIM/FCIM over MS-CAN and the real ADAU1467 over I2C.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/app"
|
||||||
|
"fusion/bt"
|
||||||
|
"fusion/can"
|
||||||
|
"fusion/core"
|
||||||
|
"fusion/dsp"
|
||||||
|
"fusion/fcim"
|
||||||
|
"fusion/fdim"
|
||||||
|
"fusion/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fdimAddr := flag.String("fdim", "127.0.0.1:7002", "address of the FDIM emulator (the core dials it)")
|
||||||
|
fcimAddr := flag.String("fcim", "127.0.0.1:7001", "address the core listens on for the FCIM emulator")
|
||||||
|
settings := flag.String("settings", defaultSettingsPath(), "settings file")
|
||||||
|
ascii := flag.Bool("ascii", false, "restrict the display to plain ASCII (fallback glyphs)")
|
||||||
|
dspDev := flag.String("dsp", "sim", `DSP: "sim", or an i2c-dev path for the real ADAU1467 (e.g. `+dsp.ADAU1467Device+")")
|
||||||
|
canIf := flag.String("can", "", `CAN interface for the real FDIM/FCIM (e.g. "can0"); empty uses the emulators`)
|
||||||
|
traceOn := flag.Bool("trace", false, "log timing for every button press, DSP write and FDIM text send")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logf := func(s string) { fmt.Printf("%s %s\n", time.Now().Format("15:04:05.000"), s) }
|
||||||
|
var trace func(string) // nil unless -trace
|
||||||
|
if *traceOn {
|
||||||
|
trace = logf
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
charset := fdim.Extended
|
||||||
|
if *ascii {
|
||||||
|
charset = fdim.ASCII
|
||||||
|
}
|
||||||
|
var disp fdim.Display
|
||||||
|
var buttons fcim.Source
|
||||||
|
canErr := make(chan error, 1)
|
||||||
|
if *canIf != "" {
|
||||||
|
bus, err := can.Open(*canIf)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fusiond: can:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer bus.Close()
|
||||||
|
src := fcim.NewCANSource(bus, trace)
|
||||||
|
bus.Subscribe(src.Handle)
|
||||||
|
disp, buttons = fdim.NewCANDisplay(bus, trace), src
|
||||||
|
charset = fdim.ASCII // the FDIM's glyphs beyond ASCII are unknown
|
||||||
|
// If can0 goes away (adapter unplugged) exit, and let systemd restart
|
||||||
|
// us once slcan.service brings it back.
|
||||||
|
go func() {
|
||||||
|
canErr <- bus.Run()
|
||||||
|
stop()
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
disp = fdim.DialDisplay(*fdimAddr)
|
||||||
|
var err error
|
||||||
|
if buttons, err = fcim.Listen(*fcimAddr); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fusiond:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer disp.Close()
|
||||||
|
defer buttons.Close()
|
||||||
|
var dspDevice dsp.Device
|
||||||
|
var dspSim *dsp.Sim // nil with the real DSP; the console's dsp commands need it
|
||||||
|
if *dspDev == "sim" {
|
||||||
|
dspSim = dsp.NewSim()
|
||||||
|
dspDevice = dspSim
|
||||||
|
} else {
|
||||||
|
bus, err := dsp.OpenDevBus(*dspDev, dsp.ADAU1467Addr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fusiond: dsp:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer bus.Close()
|
||||||
|
adau := dsp.NewADAU1467(bus, trace)
|
||||||
|
defer adau.Close()
|
||||||
|
dspDevice = adau
|
||||||
|
}
|
||||||
|
// No Bluetooth module on the car hardware yet; simulate one only with the
|
||||||
|
// emulators. btModule must stay a nil interface, not a nil *bt.Sim.
|
||||||
|
var btSim *bt.Sim
|
||||||
|
var btModule bt.Module
|
||||||
|
if *canIf == "" {
|
||||||
|
btSim = bt.NewSim("Pixel 8", "iPhone 15")
|
||||||
|
btModule = btSim
|
||||||
|
}
|
||||||
|
|
||||||
|
a := app.New(app.Deps{
|
||||||
|
Display: disp,
|
||||||
|
Charset: charset,
|
||||||
|
Buttons: buttons,
|
||||||
|
DSP: dspDevice,
|
||||||
|
BT: btModule,
|
||||||
|
Clock: &platform.SysClock{},
|
||||||
|
Store: platform.FileStore{Path: *settings},
|
||||||
|
Log: logf,
|
||||||
|
Config: core.Config{SplashMs: 3000}, // keep the splash readable
|
||||||
|
})
|
||||||
|
|
||||||
|
if *canIf != "" {
|
||||||
|
fmt.Printf("fusiond: FDIM/FCIM on %s, DSP %s, settings %s\n", *canIf, *dspDev, *settings)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("fusiond: FCIM on %s, FDIM at %s, DSP %s, settings %s\n", *fcimAddr, *fdimAddr, *dspDev, *settings)
|
||||||
|
}
|
||||||
|
fmt.Println(consoleHelp)
|
||||||
|
|
||||||
|
e := &env{bt: btSim, dsp: dspSim, post: a.Post}
|
||||||
|
go func() {
|
||||||
|
sc := bufio.NewScanner(os.Stdin)
|
||||||
|
for sc.Scan() {
|
||||||
|
reply, quit := e.handle(sc.Text())
|
||||||
|
if reply != "" {
|
||||||
|
fmt.Println(reply)
|
||||||
|
}
|
||||||
|
if quit {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.Run(ctx, nil); err != nil && ctx.Err() == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fusiond:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-canErr:
|
||||||
|
fmt.Fprintln(os.Stderr, "fusiond: can:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSettingsPath() string {
|
||||||
|
if dir, err := os.UserConfigDir(); err == nil {
|
||||||
|
return filepath.Join(dir, "fusion", "settings.json")
|
||||||
|
}
|
||||||
|
return "fusion-settings.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Clock editor stages (SDS §10).
|
||||||
|
const (
|
||||||
|
stageHour = iota
|
||||||
|
stageMinute
|
||||||
|
stageAMPM
|
||||||
|
)
|
||||||
|
|
||||||
|
// clockEdit is the editor's working copy. hour is 0..12 (0 only transiently,
|
||||||
|
// displayed "00", saved as 12); minute is 0..59.
|
||||||
|
type clockEdit struct {
|
||||||
|
stage, pos int
|
||||||
|
hour, minute int
|
||||||
|
pm bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) loadEdit() clockEdit {
|
||||||
|
e := clockEdit{minute: s.minute, pm: s.hour >= 12}
|
||||||
|
e.hour = s.hour % 12
|
||||||
|
if e.hour == 0 {
|
||||||
|
e.hour = 12
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// openClock opens the modal editor on the hour stage. From Active the editor
|
||||||
|
// sits on top of Now Playing; in Standby it sits on top of the hidden stack.
|
||||||
|
func (s *State) openClock(out *Out) {
|
||||||
|
s.confirm = nil
|
||||||
|
s.edit = s.loadEdit()
|
||||||
|
s.blinkEpoch = s.nowMs
|
||||||
|
s.setStack(out, []PageInst{{ID: PgNow}, {ID: PgClock}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) closeClock(out *Out) { s.pop(out) }
|
||||||
|
|
||||||
|
func (s *State) saveClock(out *Out) {
|
||||||
|
h := s.edit.hour
|
||||||
|
if h == 0 {
|
||||||
|
h = 12
|
||||||
|
}
|
||||||
|
h24 := h % 12
|
||||||
|
if s.edit.pm {
|
||||||
|
h24 += 12
|
||||||
|
}
|
||||||
|
out.add(ClockSet{h24, s.edit.minute})
|
||||||
|
s.hour, s.minute, s.clockKnown = h24, s.edit.minute, true
|
||||||
|
s.closeClock(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clockKey(s *State, k Key, out *Out) {
|
||||||
|
e := &s.edit
|
||||||
|
changed := true
|
||||||
|
switch {
|
||||||
|
case k == KeyMenu:
|
||||||
|
s.closeClock(out)
|
||||||
|
return
|
||||||
|
case k == KeyClock:
|
||||||
|
s.edit = s.loadEdit()
|
||||||
|
case k == KeyOK:
|
||||||
|
switch e.stage {
|
||||||
|
case stageHour:
|
||||||
|
e.stage, e.pos = stageMinute, 0
|
||||||
|
case stageMinute:
|
||||||
|
e.stage, e.pos = stageAMPM, 0
|
||||||
|
default:
|
||||||
|
s.saveClock(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case k == KeyTuneLeft || k == KeyTuneRight:
|
||||||
|
dir := 1
|
||||||
|
if k == KeyTuneLeft {
|
||||||
|
dir = -1
|
||||||
|
}
|
||||||
|
e.tune(dir)
|
||||||
|
case k.IsDigit():
|
||||||
|
changed = e.digit(k.Digit())
|
||||||
|
default:
|
||||||
|
changed = false
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
s.blinkEpoch = s.nowMs // show the field solid right after an edit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *clockEdit) tune(dir int) {
|
||||||
|
e.pos = 0
|
||||||
|
switch e.stage {
|
||||||
|
case stageHour:
|
||||||
|
e.hour += dir
|
||||||
|
if e.hour > 12 {
|
||||||
|
e.hour = 1
|
||||||
|
}
|
||||||
|
if e.hour < 1 {
|
||||||
|
e.hour = 12
|
||||||
|
}
|
||||||
|
case stageMinute:
|
||||||
|
e.minute = (e.minute + dir + 60) % 60
|
||||||
|
default:
|
||||||
|
e.pm = !e.pm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// digit applies a number key; it returns false if the key was ignored.
|
||||||
|
func (e *clockEdit) digit(d int) bool {
|
||||||
|
switch e.stage {
|
||||||
|
case stageHour:
|
||||||
|
if e.pos == 0 {
|
||||||
|
switch {
|
||||||
|
case d == 0:
|
||||||
|
e.hour = e.hour % 10
|
||||||
|
e.pos = 1
|
||||||
|
case d == 1:
|
||||||
|
u := e.hour % 10
|
||||||
|
if u >= 3 {
|
||||||
|
u = 0
|
||||||
|
}
|
||||||
|
e.hour = 10 + u
|
||||||
|
e.pos = 1
|
||||||
|
default:
|
||||||
|
e.hour = d
|
||||||
|
e.stage, e.pos = stageMinute, 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.hour/10 == 1 {
|
||||||
|
if d > 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e.hour = 10 + d
|
||||||
|
} else {
|
||||||
|
e.hour = d
|
||||||
|
}
|
||||||
|
e.stage, e.pos = stageMinute, 0
|
||||||
|
return true
|
||||||
|
case stageMinute:
|
||||||
|
if e.pos == 0 {
|
||||||
|
if d > 5 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e.minute = d*10 + e.minute%10
|
||||||
|
e.pos = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
e.minute = e.minute/10*10 + d
|
||||||
|
e.stage, e.pos = stageAMPM, 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderClock(s *State, f *Frame) {
|
||||||
|
e := s.edit
|
||||||
|
ap := "AM"
|
||||||
|
if e.pm {
|
||||||
|
ap = "PM"
|
||||||
|
}
|
||||||
|
put(&f.Line[0], "Set Clock")
|
||||||
|
put(&f.Line[1], fmt.Sprintf("%02d:%02d %s", e.hour, e.minute, ap))
|
||||||
|
lo, hi := 0, 1
|
||||||
|
switch e.stage {
|
||||||
|
case stageMinute:
|
||||||
|
lo, hi = 3, 4
|
||||||
|
case stageAMPM:
|
||||||
|
lo, hi = 6, 7
|
||||||
|
}
|
||||||
|
off := ((s.nowMs-s.blinkEpoch)/BlinkMs)%2 == 1
|
||||||
|
for i := lo; i <= hi; i++ {
|
||||||
|
f.Blink[1][i] = true
|
||||||
|
if off {
|
||||||
|
f.Line[1][i] = ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// Timing constants (SDS §6, §12).
|
||||||
|
const (
|
||||||
|
TickMs = 50
|
||||||
|
VolStep = 5
|
||||||
|
OverlayMs = 5000
|
||||||
|
ConfirmMs = 5000
|
||||||
|
MenuTimeoutMs = 10000
|
||||||
|
ClockTimeoutMs = 60000
|
||||||
|
PairTimeoutMs = 120000
|
||||||
|
RebootTimeoutMs = 10000
|
||||||
|
BootTimeoutMs = 5000
|
||||||
|
FirstPageMs = 5000
|
||||||
|
NextPageMs = 2500
|
||||||
|
BlinkMs = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode is the system mode (SDS §3).
|
||||||
|
type Mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeBoot Mode = iota
|
||||||
|
ModeActive
|
||||||
|
ModeStandby
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Mode) String() string {
|
||||||
|
switch m {
|
||||||
|
case ModeBoot:
|
||||||
|
return "boot"
|
||||||
|
case ModeActive:
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
return "standby"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallState is the Bluetooth call state.
|
||||||
|
type CallState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CallNone CallState = iota
|
||||||
|
CallRinging
|
||||||
|
CallActive
|
||||||
|
)
|
||||||
|
|
||||||
|
// BTInfo is what the core knows about the Bluetooth module.
|
||||||
|
type BTInfo struct {
|
||||||
|
Connected bool
|
||||||
|
Device string
|
||||||
|
Pairing bool
|
||||||
|
Connecting bool
|
||||||
|
Paired []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds static configuration.
|
||||||
|
type Config struct {
|
||||||
|
BTName string // name the car advertises while pairing
|
||||||
|
BootVolumeCap int // 0 = restore saved volume unchanged
|
||||||
|
SplashMs uint32 // minimum time the boot splash stays up (0 = leave as soon as ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings is the persisted state (SDS §13).
|
||||||
|
type Settings struct {
|
||||||
|
Volume int
|
||||||
|
Source Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSettings are used when nothing can be loaded.
|
||||||
|
func DefaultSettings() Settings { return Settings{Volume: 30, Source: SourceAux} }
|
||||||
|
|
||||||
|
type confirmMsg struct {
|
||||||
|
l1, l2 string
|
||||||
|
deadline uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the single owner of all UI and system state.
|
||||||
|
type State struct {
|
||||||
|
Cfg Config
|
||||||
|
Mode Mode
|
||||||
|
Stack []PageInst
|
||||||
|
Volume int
|
||||||
|
Source Source
|
||||||
|
Media MediaEvent
|
||||||
|
BT BTInfo
|
||||||
|
Call CallState
|
||||||
|
Caller string
|
||||||
|
Health [deviceCount]Health
|
||||||
|
|
||||||
|
nowMs uint64
|
||||||
|
bootDeadline uint64
|
||||||
|
splashDeadline uint64 // earliest time boot may finish
|
||||||
|
pageDeadline uint64 // 0 = none
|
||||||
|
overlayOn bool
|
||||||
|
overlayDeadline uint64
|
||||||
|
volDirty bool
|
||||||
|
confirm *confirmMsg
|
||||||
|
hour, minute int
|
||||||
|
clockKnown bool
|
||||||
|
edit clockEdit
|
||||||
|
blinkEpoch uint64
|
||||||
|
npPage int
|
||||||
|
npElapsed uint32
|
||||||
|
npKey string
|
||||||
|
wasPlaying bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a core in Boot mode with the given persisted settings.
|
||||||
|
func New(cfg Config, set Settings) *State {
|
||||||
|
if cfg.BTName == "" {
|
||||||
|
cfg.BTName = "Fusion Audio"
|
||||||
|
}
|
||||||
|
v := set.Volume
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 100 {
|
||||||
|
v = 100
|
||||||
|
}
|
||||||
|
v = v / VolStep * VolStep
|
||||||
|
src := SourceAux
|
||||||
|
if set.Source == SourceBT {
|
||||||
|
src = SourceBT
|
||||||
|
}
|
||||||
|
s := &State{
|
||||||
|
Cfg: cfg,
|
||||||
|
Mode: ModeBoot,
|
||||||
|
Stack: []PageInst{{ID: PgNow}},
|
||||||
|
Volume: v,
|
||||||
|
Source: src,
|
||||||
|
}
|
||||||
|
s.armBoot()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// armBoot sets the boot deadlines relative to now (initial boot and reboot).
|
||||||
|
func (s *State) armBoot() {
|
||||||
|
s.splashDeadline = s.nowMs + uint64(s.Cfg.SplashMs)
|
||||||
|
s.bootDeadline = s.nowMs + BootTimeoutMs
|
||||||
|
if s.bootDeadline < s.splashDeadline {
|
||||||
|
s.bootDeadline = s.splashDeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start emits the initial muted state and the boot frame.
|
||||||
|
func (s *State) Start(out *Out) {
|
||||||
|
out.add(DSPMute{true})
|
||||||
|
s.render(&out.Frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowMs is the core's accumulated time.
|
||||||
|
func (s *State) NowMs() uint64 { return s.nowMs }
|
||||||
|
|
||||||
|
// OverlayVisible reports whether the volume overlay is showing.
|
||||||
|
func (s *State) OverlayVisible() bool { return s.overlayOn }
|
||||||
|
|
||||||
|
// ConfirmVisible reports whether a confirmation message is showing.
|
||||||
|
func (s *State) ConfirmVisible() bool { return s.confirm != nil }
|
||||||
|
|
||||||
|
// Handle is the whole core: handle(state, event) -> commands + frame.
|
||||||
|
func (s *State) Handle(ev Event, out *Out) {
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case KeyEvent:
|
||||||
|
s.onKey(e.Key, out)
|
||||||
|
case TickEvent:
|
||||||
|
s.onTick(e.DtMs, out)
|
||||||
|
case ClockEvent:
|
||||||
|
s.hour = ((e.Hour % 24) + 24) % 24
|
||||||
|
s.minute = ((e.Minute % 60) + 60) % 60
|
||||||
|
s.clockKnown = true
|
||||||
|
case MediaEvent:
|
||||||
|
s.Media = e
|
||||||
|
case BTEvent:
|
||||||
|
s.onBT(e, out)
|
||||||
|
case HealthEvent:
|
||||||
|
s.onHealth(e, out)
|
||||||
|
}
|
||||||
|
s.syncNP()
|
||||||
|
s.render(&out.Frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- keys
|
||||||
|
|
||||||
|
// onKey implements the routing order of SDS §4.
|
||||||
|
func (s *State) onKey(k Key, out *Out) {
|
||||||
|
switch s.Mode {
|
||||||
|
case ModeBoot:
|
||||||
|
return
|
||||||
|
case ModeStandby:
|
||||||
|
s.standbyKey(k, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k == KeyPower {
|
||||||
|
s.enterStandby(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k == KeyVolLeft || k == KeyVolRight {
|
||||||
|
d := 1
|
||||||
|
if k == KeyVolLeft {
|
||||||
|
d = -1
|
||||||
|
}
|
||||||
|
s.stepVolume(d, out)
|
||||||
|
s.resetPageTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.overlayOn && (k == KeyClock || k == KeyMenu || k == KeyOK) {
|
||||||
|
s.hideOverlay(out)
|
||||||
|
}
|
||||||
|
s.resetPageTimer()
|
||||||
|
def := s.topDef()
|
||||||
|
if def.modal {
|
||||||
|
if k == KeyPhone && s.Call != CallNone {
|
||||||
|
s.phoneKey(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
def.key(s, k, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.globalKey(k, out) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.confirm != nil {
|
||||||
|
if k == KeyOK {
|
||||||
|
s.goHome(out)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
def.key(s, k, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) globalKey(k Key, out *Out) bool {
|
||||||
|
switch k {
|
||||||
|
case KeyClock:
|
||||||
|
s.openClock(out)
|
||||||
|
return true
|
||||||
|
case KeyAux:
|
||||||
|
s.selectSource(SourceAux, out)
|
||||||
|
return true
|
||||||
|
case KeySirius:
|
||||||
|
s.selectSource(SourceBT, out)
|
||||||
|
return true
|
||||||
|
case KeyPhone:
|
||||||
|
if s.Call != CallNone {
|
||||||
|
s.phoneKey(out)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case KeySeekLeft, KeySeekRight:
|
||||||
|
if s.supportsMedia() {
|
||||||
|
s.confirm = nil
|
||||||
|
if k == KeySeekLeft {
|
||||||
|
out.add(BTPrev{})
|
||||||
|
} else {
|
||||||
|
out.add(BTNext{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) phoneKey(out *Out) {
|
||||||
|
switch s.Call {
|
||||||
|
case CallRinging:
|
||||||
|
out.add(BTAnswer{})
|
||||||
|
s.Call = CallActive
|
||||||
|
case CallActive:
|
||||||
|
out.add(BTHangup{})
|
||||||
|
s.Call = CallNone
|
||||||
|
s.Caller = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportsMedia: the current source has play/pause/seek control.
|
||||||
|
func (s *State) supportsMedia() bool { return s.Source == SourceBT && s.BT.Connected }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- volume
|
||||||
|
|
||||||
|
func (s *State) stepVolume(dir int, out *Out) {
|
||||||
|
nv := s.Volume + dir*VolStep
|
||||||
|
if nv < 0 {
|
||||||
|
nv = 0
|
||||||
|
}
|
||||||
|
if nv > 100 {
|
||||||
|
nv = 100
|
||||||
|
}
|
||||||
|
if nv != s.Volume {
|
||||||
|
s.Volume = nv
|
||||||
|
out.add(DSPSetVolume{nv})
|
||||||
|
s.volDirty = true
|
||||||
|
}
|
||||||
|
s.overlayOn = true
|
||||||
|
s.overlayDeadline = s.nowMs + OverlayMs
|
||||||
|
}
|
||||||
|
|
||||||
|
// dismissOverlay hides the overlay without persisting.
|
||||||
|
func (s *State) dismissOverlay() { s.overlayOn = false }
|
||||||
|
|
||||||
|
// hideOverlay hides the overlay and saves a settled volume.
|
||||||
|
func (s *State) hideOverlay(out *Out) {
|
||||||
|
s.overlayOn = false
|
||||||
|
if s.volDirty {
|
||||||
|
s.volDirty = false
|
||||||
|
out.add(SettingsSave{s.Volume, s.Source})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- source
|
||||||
|
|
||||||
|
func (s *State) applySource(src Source, out *Out) {
|
||||||
|
if src == s.Source {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Source = src
|
||||||
|
out.add(DSPMute{true})
|
||||||
|
out.add(DSPSetSource{src})
|
||||||
|
out.add(DSPMute{false})
|
||||||
|
out.add(SettingsSave{s.Volume, s.Source})
|
||||||
|
s.volDirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectSource switches source and shows the confirmation. A press for the
|
||||||
|
// source already playing still shows the confirmation (SDS §5).
|
||||||
|
func (s *State) selectSource(src Source, out *Out) {
|
||||||
|
s.applySource(src, out)
|
||||||
|
s.setConfirm("Source: "+src.Name(), "", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- stack
|
||||||
|
|
||||||
|
func (s *State) top() *PageInst { return &s.Stack[len(s.Stack)-1] }
|
||||||
|
|
||||||
|
func (s *State) topDef() *pageDef { return &pageDefs[s.top().ID] }
|
||||||
|
|
||||||
|
func (s *State) onPage(id PageID) bool {
|
||||||
|
for _, p := range s.Stack {
|
||||||
|
if p.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page returns the id of the top page.
|
||||||
|
func (s *State) Page() PageID { return s.top().ID }
|
||||||
|
|
||||||
|
func (s *State) setStack(out *Out, st []PageInst) {
|
||||||
|
s.Stack = st
|
||||||
|
s.stackChanged(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) push(out *Out, p PageInst) {
|
||||||
|
s.Stack = append(s.Stack, p)
|
||||||
|
s.stackChanged(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) pop(out *Out) {
|
||||||
|
if len(s.Stack) > 1 {
|
||||||
|
s.Stack = s.Stack[:len(s.Stack)-1]
|
||||||
|
}
|
||||||
|
s.stackChanged(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) stackChanged(out *Out) {
|
||||||
|
if s.BT.Pairing && !s.onPage(PgBTPair) {
|
||||||
|
s.BT.Pairing = false
|
||||||
|
out.add(BTPairing{false})
|
||||||
|
}
|
||||||
|
s.armPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) goHome(out *Out) {
|
||||||
|
s.confirm = nil
|
||||||
|
s.setStack(out, []PageInst{{ID: PgNow}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) setConfirm(l1, l2 string, out *Out) {
|
||||||
|
s.setStack(out, []PageInst{{ID: PgNow}})
|
||||||
|
s.confirm = &confirmMsg{l1: l1, l2: l2, deadline: s.nowMs + ConfirmMs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) armPage() {
|
||||||
|
d := s.topDef()
|
||||||
|
if d.timeoutMs > 0 {
|
||||||
|
s.pageDeadline = s.nowMs + d.timeoutMs
|
||||||
|
} else {
|
||||||
|
s.pageDeadline = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPageTimer implements the timer rule of SDS §4.
|
||||||
|
func (s *State) resetPageTimer() {
|
||||||
|
d := s.topDef()
|
||||||
|
if d.timeoutMs > 0 && !d.noReset {
|
||||||
|
s.pageDeadline = s.nowMs + d.timeoutMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- tick
|
||||||
|
|
||||||
|
func (s *State) onTick(dt uint32, out *Out) {
|
||||||
|
s.nowMs += uint64(dt)
|
||||||
|
if s.Mode == ModeBoot {
|
||||||
|
if s.Health[DevDSP] == HealthOK && s.nowMs >= s.splashDeadline {
|
||||||
|
s.finishBoot(out)
|
||||||
|
} else if s.nowMs >= s.bootDeadline {
|
||||||
|
if s.Health[DevDSP] != HealthOK {
|
||||||
|
s.Health[DevDSP] = HealthDown
|
||||||
|
}
|
||||||
|
s.finishBoot(out)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.overlayOn && s.nowMs >= s.overlayDeadline {
|
||||||
|
s.hideOverlay(out)
|
||||||
|
}
|
||||||
|
if s.confirm != nil && s.nowMs >= s.confirm.deadline {
|
||||||
|
s.goHome(out)
|
||||||
|
}
|
||||||
|
if s.pageDeadline != 0 && s.nowMs >= s.pageDeadline {
|
||||||
|
s.pageDeadline = 0
|
||||||
|
if f := s.topDef().onTimeout; f != nil {
|
||||||
|
f(s, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Mode == ModeActive {
|
||||||
|
s.advanceNP(dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- boot / power
|
||||||
|
|
||||||
|
func (s *State) finishBoot(out *Out) {
|
||||||
|
s.Mode = ModeActive
|
||||||
|
s.confirm = nil
|
||||||
|
s.dismissOverlay()
|
||||||
|
s.setStack(out, []PageInst{{ID: PgNow}})
|
||||||
|
if c := s.Cfg.BootVolumeCap; c > 0 && s.Volume > c {
|
||||||
|
s.Volume = c / VolStep * VolStep
|
||||||
|
}
|
||||||
|
out.add(DSPSetSource{s.Source})
|
||||||
|
out.add(DSPDACEnable{true})
|
||||||
|
out.add(DSPRampTo{s.Volume, 1000})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) enterStandby(out *Out) {
|
||||||
|
s.dismissOverlay()
|
||||||
|
s.volDirty = false
|
||||||
|
s.confirm = nil
|
||||||
|
s.setStack(out, []PageInst{{ID: PgNow}})
|
||||||
|
if s.supportsMedia() && s.Media.Playing {
|
||||||
|
s.wasPlaying = true
|
||||||
|
out.add(BTPause{})
|
||||||
|
} else {
|
||||||
|
s.wasPlaying = false
|
||||||
|
}
|
||||||
|
out.add(DSPRampTo{0, 200})
|
||||||
|
out.add(DSPDACEnable{false})
|
||||||
|
out.add(SettingsSave{s.Volume, s.Source})
|
||||||
|
s.Mode = ModeStandby
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) wake(out *Out) {
|
||||||
|
s.setStack(out, []PageInst{{ID: PgNow}})
|
||||||
|
s.Mode = ModeActive
|
||||||
|
out.add(DSPDACEnable{true})
|
||||||
|
if s.wasPlaying && s.supportsMedia() {
|
||||||
|
out.add(BTPlay{})
|
||||||
|
}
|
||||||
|
s.wasPlaying = false
|
||||||
|
out.add(DSPRampTo{s.Volume, 500})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) standbyKey(k Key, out *Out) {
|
||||||
|
if k == KeyPower {
|
||||||
|
s.wake(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.top().ID == PgClock {
|
||||||
|
if k == KeyVolLeft || k == KeyVolRight {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.resetPageTimer()
|
||||||
|
clockKey(s, k, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k == KeyClock {
|
||||||
|
s.openClock(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- other events
|
||||||
|
|
||||||
|
func (s *State) onHealth(e HealthEvent, out *Out) {
|
||||||
|
if e.Device < 0 || e.Device >= deviceCount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := s.Health[e.Device]
|
||||||
|
s.Health[e.Device] = e.Status
|
||||||
|
if e.Device != DevDSP || e.Status != HealthOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch s.Mode {
|
||||||
|
case ModeBoot:
|
||||||
|
if s.nowMs >= s.splashDeadline {
|
||||||
|
s.finishBoot(out)
|
||||||
|
} // else: onTick finishes boot once the splash has been up long enough
|
||||||
|
case ModeActive:
|
||||||
|
if prev != HealthOK {
|
||||||
|
// DSP returned: resend the latest source and volume.
|
||||||
|
out.add(DSPSetSource{s.Source})
|
||||||
|
out.add(DSPSetVolume{s.Volume})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) onBT(e BTEvent, out *Out) {
|
||||||
|
active := s.Mode == ModeActive
|
||||||
|
switch e.State {
|
||||||
|
case BTConnected:
|
||||||
|
initiated := s.BT.Connecting || s.BT.Pairing
|
||||||
|
s.BT.Connected = true
|
||||||
|
s.BT.Device = e.Device
|
||||||
|
s.BT.Connecting = false
|
||||||
|
s.BT.Pairing = false
|
||||||
|
if initiated && active {
|
||||||
|
s.setConfirm("Connected: "+clean(e.Device), "", out)
|
||||||
|
}
|
||||||
|
case BTDisconnected:
|
||||||
|
s.BT.Connected = false
|
||||||
|
s.BT.Device = ""
|
||||||
|
s.BT.Connecting = false
|
||||||
|
s.Media = MediaEvent{}
|
||||||
|
case BTConnectFailed:
|
||||||
|
was := s.BT.Connecting
|
||||||
|
s.BT.Connecting = false
|
||||||
|
if was && active {
|
||||||
|
s.setConfirm("Connect failed", "", out)
|
||||||
|
}
|
||||||
|
case BTPairingEnded:
|
||||||
|
if s.BT.Pairing {
|
||||||
|
s.BT.Pairing = false
|
||||||
|
if active && s.onPage(PgBTPair) {
|
||||||
|
s.setConfirm("Pairing ended", "", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case BTDeviceList:
|
||||||
|
s.BT.Paired = append([]string(nil), e.Devices...)
|
||||||
|
case BTCallRinging:
|
||||||
|
s.Call = CallRinging
|
||||||
|
s.Caller = e.Caller
|
||||||
|
case BTCallActive:
|
||||||
|
s.Call = CallActive
|
||||||
|
if e.Caller != "" {
|
||||||
|
s.Caller = e.Caller
|
||||||
|
}
|
||||||
|
case BTCallEnded:
|
||||||
|
s.Call = CallNone
|
||||||
|
s.Caller = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaginate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"", []string{""}},
|
||||||
|
{"Midnight City", []string{"Midnight City"}},
|
||||||
|
{"12345678901234567890", []string{"12345678901234567890"}},
|
||||||
|
{"M83 · Hurry Up, We're Dreaming", []string{"M83 · Hurry Up, ›", "We're Dreaming"}},
|
||||||
|
{"A Very Long Song Title Indeed", []string{"A Very Long Song ›", "Title Indeed"}},
|
||||||
|
// single word longer than 20 is hard split
|
||||||
|
{"ABCDEFGHIJKLMNOPQRSTUVWXYZ", []string{"ABCDEFGHIJKLMNOPQRS›", "TUVWXYZ"}},
|
||||||
|
// break exactly at column 19: marker has no space
|
||||||
|
{"ABCDEFGHIJKLMNOPQRS TUVWXYZ", []string{"ABCDEFGHIJKLMNOPQRS›", "TUVWXYZ"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := paginate(c.in)
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("paginate(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
for _, p := range got {
|
||||||
|
if n := len([]rune(p)); n > LineW {
|
||||||
|
t.Errorf("page %q is %d wide", p, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateWidthProperty(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(1))
|
||||||
|
letters := "abcdefghij "
|
||||||
|
for i := 0; i < 2000; i++ {
|
||||||
|
n := rng.Intn(120)
|
||||||
|
b := make([]byte, n)
|
||||||
|
for j := range b {
|
||||||
|
b[j] = letters[rng.Intn(len(letters))]
|
||||||
|
}
|
||||||
|
pages := paginate(string(b))
|
||||||
|
for j, p := range pages {
|
||||||
|
if len([]rune(p)) > LineW {
|
||||||
|
t.Fatalf("page %d of %q too wide: %q", j, b, p)
|
||||||
|
}
|
||||||
|
more := strings.HasSuffix(p, string(GlyphMore))
|
||||||
|
if more != (j < len(pages)-1) {
|
||||||
|
t.Fatalf("marker mismatch on page %d of %q: %q", j, b, pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitize(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Café Ñandú": "Cafe Nandu",
|
||||||
|
"日本": "??",
|
||||||
|
"a\tb": "a?b",
|
||||||
|
"Straße": "Strasse",
|
||||||
|
"M83 · x": "M83 · x",
|
||||||
|
"plain ASCII": "plain ASCII",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := sanitize(in); got != want {
|
||||||
|
t.Errorf("sanitize(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFmt12(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
h, m int
|
||||||
|
want string
|
||||||
|
}{{0, 5, "12:05 AM"}, {12, 0, "12:00 PM"}, {16, 7, "4:07 PM"}, {23, 59, "11:59 PM"}, {9, 30, "9:30 AM"}}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := fmt12(c.h, c.m); got != c.want {
|
||||||
|
t.Errorf("fmt12(%d,%d) = %q, want %q", c.h, c.m, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeftRight(t *testing.T) {
|
||||||
|
l := leftRight("Select Source", "1/2")
|
||||||
|
if got := string(l[:]); got != "Select Source 1/2" {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
l = leftRight("A very long title indeed", "12/13")
|
||||||
|
got := string(l[:])
|
||||||
|
if len([]rune(got)) != LineW || !strings.HasSuffix(got, "12/13") {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got, "A very lon") {
|
||||||
|
t.Errorf("title not truncated first: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKey(t *testing.T) {
|
||||||
|
for _, n := range []string{"POWER", "volume_left", "VOL_RIGHT", "tune_left", "OK", "PLAY", "0", "9", "SEEK_UP", "CAT_FOLD"} {
|
||||||
|
if _, ok := ParseKey(n); !ok {
|
||||||
|
t.Errorf("ParseKey(%q) failed", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := ParseKey("NOPE"); ok {
|
||||||
|
t.Error("ParseKey accepted NOPE")
|
||||||
|
}
|
||||||
|
if _, ok := ParseKey("NONE"); ok {
|
||||||
|
t.Error("ParseKey accepted NONE")
|
||||||
|
}
|
||||||
|
k, _ := ParseKey("7")
|
||||||
|
if !k.IsDigit() || k.Digit() != 7 {
|
||||||
|
t.Errorf("digit parse: %v", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenize(t *testing.T) {
|
||||||
|
got, err := tokenize(`media "A # B" "" x # trailing`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"media", "A # B", "", "x"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if _, err := tokenize(`key "oops`); err == nil {
|
||||||
|
t.Error("expected unterminated quote error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFuzzInvariants throws random events at the core and checks the
|
||||||
|
// structural invariants that must hold at all times.
|
||||||
|
func TestFuzzInvariants(t *testing.T) {
|
||||||
|
for seed := int64(0); seed < 20; seed++ {
|
||||||
|
rng := rand.New(rand.NewSource(seed))
|
||||||
|
s := New(Config{}, DefaultSettings())
|
||||||
|
var out Out
|
||||||
|
s.Start(&out)
|
||||||
|
s.Handle(HealthEvent{DevDSP, HealthOK}, &out)
|
||||||
|
s.Handle(ClockEvent{16, 7}, &out)
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
var ev Event
|
||||||
|
switch rng.Intn(10) {
|
||||||
|
case 0, 1, 2, 3, 4, 5:
|
||||||
|
ev = KeyEvent{Key(1 + rng.Intn(int(Key9)))}
|
||||||
|
case 6, 7:
|
||||||
|
ev = TickEvent{uint32(rng.Intn(3000))}
|
||||||
|
case 8:
|
||||||
|
kinds := []BTEvent{
|
||||||
|
{State: BTConnected, Device: "Phone"},
|
||||||
|
{State: BTDisconnected},
|
||||||
|
{State: BTConnectFailed},
|
||||||
|
{State: BTPairingEnded},
|
||||||
|
{State: BTDeviceList, Devices: []string{"A", "B"}},
|
||||||
|
{State: BTCallRinging, Caller: "Bob"},
|
||||||
|
{State: BTCallActive},
|
||||||
|
{State: BTCallEnded},
|
||||||
|
}
|
||||||
|
ev = kinds[rng.Intn(len(kinds))]
|
||||||
|
case 9:
|
||||||
|
ev = MediaEvent{Title: "T very long title here ok", Artist: "Ar", Album: "Al", Playing: rng.Intn(2) == 0}
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
s.Handle(ev, &out)
|
||||||
|
if len(s.Stack) == 0 || s.Stack[0].ID != PgNow {
|
||||||
|
t.Fatalf("seed %d step %d: stack bottom is not Now Playing: %+v", seed, i, s.Stack)
|
||||||
|
}
|
||||||
|
if s.Volume < 0 || s.Volume > 100 || s.Volume%VolStep != 0 {
|
||||||
|
t.Fatalf("seed %d step %d: bad volume %d", seed, i, s.Volume)
|
||||||
|
}
|
||||||
|
if s.confirm != nil && len(s.Stack) != 1 {
|
||||||
|
t.Fatalf("seed %d step %d: confirmation over a non-home stack", seed, i)
|
||||||
|
}
|
||||||
|
if s.BT.Pairing != s.onPage(PgBTPair) {
|
||||||
|
t.Fatalf("seed %d step %d: pairing=%v but pair page on stack=%v", seed, i, s.BT.Pairing, s.onPage(PgBTPair))
|
||||||
|
}
|
||||||
|
for r := 0; r < 2; r++ {
|
||||||
|
for _, c := range out.Frame.Line[r] {
|
||||||
|
if c < 0x20 {
|
||||||
|
t.Fatalf("seed %d step %d: control char in frame", seed, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Mode == ModeBoot { // a reboot was requested; restart like the app does
|
||||||
|
s = New(Config{}, DefaultSettings())
|
||||||
|
s.Start(&out)
|
||||||
|
s.Handle(HealthEvent{DevDSP, HealthOK}, &out)
|
||||||
|
s.Handle(ClockEvent{16, 7}, &out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// PageID identifies a page type.
|
||||||
|
type PageID int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PgNow PageID = iota
|
||||||
|
PgMenu
|
||||||
|
PgBTStatus
|
||||||
|
PgBTDevices
|
||||||
|
PgBTPair
|
||||||
|
PgReboot
|
||||||
|
PgClock
|
||||||
|
pageCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// MenuID identifies a generic menu list.
|
||||||
|
type MenuID int
|
||||||
|
|
||||||
|
const (
|
||||||
|
MenuTop MenuID = iota
|
||||||
|
MenuSource
|
||||||
|
MenuBT
|
||||||
|
MenuAdvanced
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m MenuID) String() string {
|
||||||
|
switch m {
|
||||||
|
case MenuTop:
|
||||||
|
return "TOP"
|
||||||
|
case MenuSource:
|
||||||
|
return "SOURCE"
|
||||||
|
case MenuBT:
|
||||||
|
return "BT"
|
||||||
|
}
|
||||||
|
return "ADVANCED"
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuTitle(m MenuID) string {
|
||||||
|
switch m {
|
||||||
|
case MenuTop:
|
||||||
|
return "Menu"
|
||||||
|
case MenuSource:
|
||||||
|
return "Select Source"
|
||||||
|
case MenuBT:
|
||||||
|
return "Bluetooth"
|
||||||
|
}
|
||||||
|
return "Advanced"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageInst is one entry in the page stack. The bottom is always Now Playing.
|
||||||
|
type PageInst struct {
|
||||||
|
ID PageID
|
||||||
|
Menu MenuID
|
||||||
|
Sel int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageName gives a stable name of the top page for logs and test scripts.
|
||||||
|
func (s *State) PageName() string {
|
||||||
|
p := s.top()
|
||||||
|
switch p.ID {
|
||||||
|
case PgNow:
|
||||||
|
return "NOW_PLAYING"
|
||||||
|
case PgMenu:
|
||||||
|
return "MENU_" + p.Menu.String()
|
||||||
|
case PgBTStatus:
|
||||||
|
return "BT_STATUS"
|
||||||
|
case PgBTDevices:
|
||||||
|
return "BT_DEVICES"
|
||||||
|
case PgBTPair:
|
||||||
|
return "BT_PAIR"
|
||||||
|
case PgReboot:
|
||||||
|
return "REBOOT"
|
||||||
|
}
|
||||||
|
return "CLOCK"
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageDef is one row of the page table (SDS §14.6).
|
||||||
|
type pageDef struct {
|
||||||
|
timeoutMs uint64
|
||||||
|
modal bool
|
||||||
|
noReset bool // keys do not reset the timeout
|
||||||
|
key func(*State, Key, *Out)
|
||||||
|
render func(*State, *Frame)
|
||||||
|
onTimeout func(*State, *Out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageDefs [pageCount]pageDef
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
home := func(s *State, o *Out) { s.goHome(o) }
|
||||||
|
back := func(s *State, o *Out) { s.pop(o) }
|
||||||
|
pageDefs[PgNow] = pageDef{key: nowKey, render: renderNow}
|
||||||
|
pageDefs[PgMenu] = pageDef{timeoutMs: MenuTimeoutMs, key: menuKey, render: renderMenu, onTimeout: home}
|
||||||
|
pageDefs[PgBTStatus] = pageDef{timeoutMs: MenuTimeoutMs, key: btStatusKey, render: renderBTStatus, onTimeout: home}
|
||||||
|
pageDefs[PgBTDevices] = pageDef{timeoutMs: MenuTimeoutMs, key: btDevicesKey, render: renderBTDevices, onTimeout: home}
|
||||||
|
pageDefs[PgBTPair] = pageDef{timeoutMs: PairTimeoutMs, noReset: true, key: btPairKey, render: renderBTPair,
|
||||||
|
onTimeout: func(s *State, o *Out) { s.setConfirm("Pairing ended", "", o) }}
|
||||||
|
pageDefs[PgReboot] = pageDef{timeoutMs: RebootTimeoutMs, modal: true, key: rebootKey, render: renderReboot, onTimeout: back}
|
||||||
|
pageDefs[PgClock] = pageDef{timeoutMs: ClockTimeoutMs, modal: true, key: clockKey, render: renderClock,
|
||||||
|
onTimeout: func(s *State, o *Out) { s.closeClock(o) }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Now Playing
|
||||||
|
|
||||||
|
func nowKey(s *State, k Key, out *Out) {
|
||||||
|
switch k {
|
||||||
|
case KeyMenu:
|
||||||
|
s.push(out, PageInst{ID: PgMenu, Menu: MenuTop})
|
||||||
|
case KeyOK:
|
||||||
|
if s.supportsMedia() {
|
||||||
|
out.add(BTPlayPause{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- menus
|
||||||
|
|
||||||
|
type menuItem struct {
|
||||||
|
label string
|
||||||
|
show func(*State) bool
|
||||||
|
mark func(*State) bool
|
||||||
|
run func(*State, *Out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// menuItems is the menu tree as data (SDS §7). Hidden items are dropped.
|
||||||
|
func menuItems(s *State, m MenuID) []menuItem {
|
||||||
|
var all []menuItem
|
||||||
|
switch m {
|
||||||
|
case MenuTop:
|
||||||
|
all = []menuItem{
|
||||||
|
{label: "Select Source", run: func(s *State, o *Out) { s.push(o, PageInst{ID: PgMenu, Menu: MenuSource}) }},
|
||||||
|
{label: "Bluetooth", run: func(s *State, o *Out) { s.push(o, PageInst{ID: PgMenu, Menu: MenuBT}) }},
|
||||||
|
{label: "Advanced", run: func(s *State, o *Out) { s.push(o, PageInst{ID: PgMenu, Menu: MenuAdvanced}) }},
|
||||||
|
}
|
||||||
|
case MenuSource:
|
||||||
|
all = []menuItem{
|
||||||
|
{label: "Aux", mark: func(s *State) bool { return s.Source == SourceAux },
|
||||||
|
run: func(s *State, o *Out) { s.selectSource(SourceAux, o) }},
|
||||||
|
{label: "Bluetooth", mark: func(s *State) bool { return s.Source == SourceBT },
|
||||||
|
run: func(s *State, o *Out) { s.selectSource(SourceBT, o) }},
|
||||||
|
}
|
||||||
|
case MenuBT:
|
||||||
|
all = []menuItem{
|
||||||
|
{label: "Status", run: func(s *State, o *Out) { s.push(o, PageInst{ID: PgBTStatus}) }},
|
||||||
|
{label: "Devices", run: func(s *State, o *Out) { s.push(o, PageInst{ID: PgBTDevices}) }},
|
||||||
|
{label: "Pair New", run: func(s *State, o *Out) {
|
||||||
|
s.BT.Pairing = true
|
||||||
|
o.add(BTPairing{true})
|
||||||
|
s.push(o, PageInst{ID: PgBTPair})
|
||||||
|
}},
|
||||||
|
{label: "Disconnect", show: func(s *State) bool { return s.BT.Connected },
|
||||||
|
run: func(s *State, o *Out) {
|
||||||
|
o.add(BTDisconnect{})
|
||||||
|
s.BT.Connected = false
|
||||||
|
s.BT.Device = ""
|
||||||
|
s.Media = MediaEvent{}
|
||||||
|
s.setConfirm("Disconnected", "", o)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
case MenuAdvanced:
|
||||||
|
all = []menuItem{
|
||||||
|
{label: "Reboot", run: func(s *State, o *Out) { s.push(o, PageInst{ID: PgReboot}) }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vis := make([]menuItem, 0, len(all))
|
||||||
|
for _, it := range all {
|
||||||
|
if it.show == nil || it.show(s) {
|
||||||
|
vis = append(vis, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vis
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampSel(sel, n int) int {
|
||||||
|
if sel >= n {
|
||||||
|
sel = n - 1
|
||||||
|
}
|
||||||
|
if sel < 0 {
|
||||||
|
sel = 0
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveSel moves a selection by one, wrapping at both ends.
|
||||||
|
func moveSel(sel, n int, k Key) int {
|
||||||
|
if n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sel = clampSel(sel, n)
|
||||||
|
if k == KeyTuneLeft {
|
||||||
|
return (sel - 1 + n) % n
|
||||||
|
}
|
||||||
|
return (sel + 1) % n
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuKey(s *State, k Key, out *Out) {
|
||||||
|
p := s.top()
|
||||||
|
items := menuItems(s, p.Menu)
|
||||||
|
switch k {
|
||||||
|
case KeyTuneLeft, KeyTuneRight:
|
||||||
|
p.Sel = moveSel(p.Sel, len(items), k)
|
||||||
|
case KeyOK:
|
||||||
|
if len(items) > 0 {
|
||||||
|
p.Sel = clampSel(p.Sel, len(items))
|
||||||
|
items[p.Sel].run(s, out)
|
||||||
|
}
|
||||||
|
case KeyMenu:
|
||||||
|
s.pop(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMenu(s *State, f *Frame) {
|
||||||
|
p := s.top()
|
||||||
|
items := menuItems(s, p.Menu)
|
||||||
|
n := len(items)
|
||||||
|
sel := clampSel(p.Sel, n)
|
||||||
|
f.Line[0] = leftRight(menuTitle(p.Menu), fmt.Sprintf("%d/%d", sel+1, n))
|
||||||
|
if n > 0 {
|
||||||
|
label := "› " + items[sel].label
|
||||||
|
if items[sel].mark != nil && items[sel].mark(s) {
|
||||||
|
label += " *"
|
||||||
|
}
|
||||||
|
put(&f.Line[1], label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Bluetooth pages
|
||||||
|
|
||||||
|
func btStatusKey(s *State, k Key, out *Out) {
|
||||||
|
if k == KeyOK || k == KeyMenu {
|
||||||
|
s.pop(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBTStatus(s *State, f *Frame) {
|
||||||
|
if s.BT.Connected {
|
||||||
|
put(&f.Line[0], "Connected")
|
||||||
|
put(&f.Line[1], clean(s.BT.Device))
|
||||||
|
} else {
|
||||||
|
put(&f.Line[0], "Not connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btDevicesKey(s *State, k Key, out *Out) {
|
||||||
|
p := s.top()
|
||||||
|
n := len(s.BT.Paired)
|
||||||
|
switch k {
|
||||||
|
case KeyTuneLeft, KeyTuneRight:
|
||||||
|
p.Sel = moveSel(p.Sel, n, k)
|
||||||
|
case KeyOK:
|
||||||
|
if n > 0 && !s.BT.Connecting {
|
||||||
|
p.Sel = clampSel(p.Sel, n)
|
||||||
|
s.BT.Connecting = true
|
||||||
|
out.add(BTConnect{p.Sel})
|
||||||
|
}
|
||||||
|
case KeyMenu:
|
||||||
|
s.pop(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBTDevices(s *State, f *Frame) {
|
||||||
|
n := len(s.BT.Paired)
|
||||||
|
if n == 0 {
|
||||||
|
put(&f.Line[0], "Devices")
|
||||||
|
put(&f.Line[1], "No paired devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sel := clampSel(s.top().Sel, n)
|
||||||
|
f.Line[0] = leftRight("Devices", fmt.Sprintf("%d/%d", sel+1, n))
|
||||||
|
if s.BT.Connecting {
|
||||||
|
put(&f.Line[1], "Connecting...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := "› " + clean(s.BT.Paired[sel])
|
||||||
|
if s.BT.Connected && s.BT.Paired[sel] == s.BT.Device {
|
||||||
|
label += " *"
|
||||||
|
}
|
||||||
|
put(&f.Line[1], label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func btPairKey(s *State, k Key, out *Out) {
|
||||||
|
if k == KeyMenu {
|
||||||
|
s.pop(out) // leaving the page ends discoverable mode (stackChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBTPair(s *State, f *Frame) {
|
||||||
|
put(&f.Line[0], "Pairing...")
|
||||||
|
put(&f.Line[1], clean(s.Cfg.BTName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Reboot prompt
|
||||||
|
|
||||||
|
func rebootKey(s *State, k Key, out *Out) {
|
||||||
|
switch k {
|
||||||
|
case KeyOK:
|
||||||
|
s.dismissOverlay()
|
||||||
|
s.volDirty = false
|
||||||
|
out.add(DSPMute{true})
|
||||||
|
out.add(SettingsSave{s.Volume, s.Source})
|
||||||
|
out.add(SystemReboot{})
|
||||||
|
s.Mode = ModeBoot
|
||||||
|
s.armBoot()
|
||||||
|
case KeyMenu:
|
||||||
|
s.pop(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderReboot(s *State, f *Frame) {
|
||||||
|
put(&f.Line[0], "Reboot system?")
|
||||||
|
put(&f.Line[1], "OK=Yes MENU=No")
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// render builds the complete frame for the current state (SDS §12).
|
||||||
|
func (s *State) render(f *Frame) {
|
||||||
|
*f = newFrame()
|
||||||
|
if s.clockKnown {
|
||||||
|
f.ClockOn = true
|
||||||
|
f.ClockText = fmt12(s.hour, s.minute)
|
||||||
|
}
|
||||||
|
switch s.Mode {
|
||||||
|
case ModeBoot:
|
||||||
|
// Splash shown while initializing (also during a reboot).
|
||||||
|
f.Line[0] = centered("Matthew's Ford")
|
||||||
|
f.Line[1] = centered("Fusion")
|
||||||
|
return
|
||||||
|
case ModeStandby:
|
||||||
|
if s.top().ID == PgClock {
|
||||||
|
pageDefs[PgClock].render(s, f)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.confirm != nil {
|
||||||
|
f.Label = s.label()
|
||||||
|
put(&f.Line[0], s.confirm.l1)
|
||||||
|
put(&f.Line[1], s.confirm.l2)
|
||||||
|
} else {
|
||||||
|
s.topDef().render(s, f)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case s.Call != CallNone:
|
||||||
|
clearBody(f)
|
||||||
|
s.renderCall(f)
|
||||||
|
case s.overlayOn:
|
||||||
|
clearBody(f)
|
||||||
|
s.renderVolume(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearBody(f *Frame) {
|
||||||
|
f.Label = ""
|
||||||
|
f.Line = [2][LineW]rune{newLine(), newLine()}
|
||||||
|
f.Blink = [2][LineW]bool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) label() string {
|
||||||
|
if s.Source == SourceBT {
|
||||||
|
return "SYNC"
|
||||||
|
}
|
||||||
|
return "AUX"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) renderCall(f *Frame) {
|
||||||
|
if s.Call == CallRinging {
|
||||||
|
put(&f.Line[0], "Incoming call")
|
||||||
|
} else {
|
||||||
|
put(&f.Line[0], "On call")
|
||||||
|
}
|
||||||
|
put(&f.Line[1], clean(s.Caller))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderVolume draws the volume overlay: one bar cell per 5% step.
|
||||||
|
func (s *State) renderVolume(f *Frame) {
|
||||||
|
val := fmt.Sprintf("%d%%", s.Volume)
|
||||||
|
if s.Volume == 0 {
|
||||||
|
val = "Mute"
|
||||||
|
}
|
||||||
|
f.Line[0] = leftRight("Volume", val)
|
||||||
|
n := s.Volume / VolStep
|
||||||
|
for i := 0; i < LineW; i++ {
|
||||||
|
if i < n {
|
||||||
|
f.Line[1][i] = GlyphFull
|
||||||
|
} else {
|
||||||
|
f.Line[1][i] = GlyphShade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Now Playing
|
||||||
|
|
||||||
|
func (s *State) faultText() string {
|
||||||
|
if s.Health[DevDSP] == HealthDown {
|
||||||
|
return "DSP not responding"
|
||||||
|
}
|
||||||
|
if s.Source == SourceBT && s.Health[DevBT] == HealthDown {
|
||||||
|
return "BT not responding"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinArtistAlbum(artist, album string) string {
|
||||||
|
switch {
|
||||||
|
case artist != "" && album != "":
|
||||||
|
return artist + " " + string(GlyphDot) + " " + album
|
||||||
|
case artist != "":
|
||||||
|
return artist
|
||||||
|
}
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
||||||
|
// npTexts returns the full (unpaged) text of the two Now Playing rows.
|
||||||
|
func (s *State) npTexts() (string, string) {
|
||||||
|
var l1, l2 string
|
||||||
|
switch s.Source {
|
||||||
|
case SourceBT:
|
||||||
|
switch {
|
||||||
|
case !s.BT.Connected:
|
||||||
|
l1, l2 = "Bluetooth", "Not connected"
|
||||||
|
case s.Media.Title == "":
|
||||||
|
l1, l2 = "Bluetooth", "Connected"
|
||||||
|
default:
|
||||||
|
l1 = clean(s.Media.Title)
|
||||||
|
if s.Media.Playing {
|
||||||
|
l2 = joinArtistAlbum(clean(s.Media.Artist), clean(s.Media.Album))
|
||||||
|
} else {
|
||||||
|
l2 = "Paused"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
l1 = "Aux Input"
|
||||||
|
}
|
||||||
|
if s.Volume == 0 {
|
||||||
|
l2 = "Muted"
|
||||||
|
}
|
||||||
|
if f := s.faultText(); f != "" {
|
||||||
|
l2 = f
|
||||||
|
}
|
||||||
|
return l1, l2
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderNow(s *State, f *Frame) {
|
||||||
|
f.Label = s.label()
|
||||||
|
l1, l2 := s.npTexts()
|
||||||
|
p1, p2 := paginate(l1), paginate(l2)
|
||||||
|
put(&f.Line[0], p1[min(s.npPage, len(p1)-1)])
|
||||||
|
put(&f.Line[1], p2[min(s.npPage, len(p2)-1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) npPageCount() int {
|
||||||
|
l1, l2 := s.npTexts()
|
||||||
|
return max(len(paginate(l1)), len(paginate(l2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncNP restarts paging at page 1 whenever the displayed text changes
|
||||||
|
// (track change, source change, ...).
|
||||||
|
func (s *State) syncNP() {
|
||||||
|
l1, l2 := s.npTexts()
|
||||||
|
key := l1 + "\x00" + l2
|
||||||
|
if key != s.npKey {
|
||||||
|
s.npKey = key
|
||||||
|
s.npPage = 0
|
||||||
|
s.npElapsed = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceNP runs the shared page clock: 5 s on page 1, 2.5 s on the rest.
|
||||||
|
func (s *State) advanceNP(dt uint32) {
|
||||||
|
n := s.npPageCount()
|
||||||
|
if n <= 1 {
|
||||||
|
s.npPage, s.npElapsed = 0, 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.npElapsed += dt
|
||||||
|
for {
|
||||||
|
d := uint32(NextPageMs)
|
||||||
|
if s.npPage == 0 {
|
||||||
|
d = FirstPageMs
|
||||||
|
}
|
||||||
|
if s.npElapsed < d {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.npElapsed -= d
|
||||||
|
s.npPage = (s.npPage + 1) % n
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunScript runs a plain-text UI test script against a fresh core and returns
|
||||||
|
// the first failure (SDS §14.11). The same scripts are meant to drive the C
|
||||||
|
// port. Directives:
|
||||||
|
//
|
||||||
|
// init volume=N source=aux|bt boot=1 bootcap=N splash=MS btname=X
|
||||||
|
// key NAME [count] tick MS clock H M
|
||||||
|
// media TITLE ARTIST ALBUM playing|paused
|
||||||
|
// bt connected NAME | disconnected | connectfailed | pairingended
|
||||||
|
// | devices A B ... | ringing NAME | callactive [NAME] | callended
|
||||||
|
// health DEVICE STATUS clear
|
||||||
|
// expect line1|line2 TEXT | expect line1|line2 contains|starts|ends TEXT
|
||||||
|
// expect bar N | page NAME | mode NAME | cmd ARGS... | nocmd ARGS...
|
||||||
|
// expect nocmds | count PREFIX N | order CMD CMD ...
|
||||||
|
// expect label X | clock X | volume N | source aux|bt
|
||||||
|
// expect overlay on|off | confirm on|off | blink ROW LO-HI | noblink
|
||||||
|
//
|
||||||
|
// A fresh script starts Active on Aux at 30% with the clock at 4:07 PM.
|
||||||
|
func RunScript(name, src string) error {
|
||||||
|
r := &runner{}
|
||||||
|
if err := r.init(map[string]string{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, raw := range strings.Split(src, "\n") {
|
||||||
|
toks, err := tokenize(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s:%d: %v", name, i+1, err)
|
||||||
|
}
|
||||||
|
if len(toks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.exec(toks); err != nil {
|
||||||
|
return fmt.Errorf("%s:%d: %s: %v", name, i+1, strings.TrimSpace(raw), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type runner struct {
|
||||||
|
s *State
|
||||||
|
out Out
|
||||||
|
cmds []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenize(line string) ([]string, error) {
|
||||||
|
var toks []string
|
||||||
|
var cur []rune
|
||||||
|
inQ, has := false, false
|
||||||
|
flush := func() {
|
||||||
|
if has || len(cur) > 0 {
|
||||||
|
toks = append(toks, string(cur))
|
||||||
|
}
|
||||||
|
cur, has = nil, false
|
||||||
|
}
|
||||||
|
loop:
|
||||||
|
for _, c := range line {
|
||||||
|
switch {
|
||||||
|
case inQ:
|
||||||
|
if c == '"' {
|
||||||
|
inQ = false
|
||||||
|
} else {
|
||||||
|
cur = append(cur, c)
|
||||||
|
}
|
||||||
|
case c == '"':
|
||||||
|
inQ, has = true, true
|
||||||
|
case c == '#':
|
||||||
|
break loop
|
||||||
|
case c == ' ' || c == '\t' || c == '\r':
|
||||||
|
flush()
|
||||||
|
default:
|
||||||
|
cur = append(cur, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inQ {
|
||||||
|
return nil, fmt.Errorf("unterminated quote")
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return toks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) record() {
|
||||||
|
for _, c := range r.out.Cmds {
|
||||||
|
r.cmds = append(r.cmds, c.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) feed(ev Event) {
|
||||||
|
r.out.Reset()
|
||||||
|
r.s.Handle(ev, &r.out)
|
||||||
|
r.record()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) init(kv map[string]string) error {
|
||||||
|
set := DefaultSettings()
|
||||||
|
var cfg Config
|
||||||
|
atoi := func(k string, dst *int) error {
|
||||||
|
if v, ok := kv[k]; ok {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bad %s=%q", k, v)
|
||||||
|
}
|
||||||
|
*dst = n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := atoi("volume", &set.Volume); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := atoi("bootcap", &cfg.BootVolumeCap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var splash int
|
||||||
|
if err := atoi("splash", &splash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.SplashMs = uint32(splash)
|
||||||
|
if kv["source"] == "bt" {
|
||||||
|
set.Source = SourceBT
|
||||||
|
}
|
||||||
|
cfg.BTName = kv["btname"]
|
||||||
|
r.s = New(cfg, set)
|
||||||
|
r.cmds = nil
|
||||||
|
r.out = Out{}
|
||||||
|
r.s.Start(&r.out)
|
||||||
|
r.record()
|
||||||
|
if kv["boot"] != "1" {
|
||||||
|
r.feed(HealthEvent{DevDSP, HealthOK})
|
||||||
|
r.feed(ClockEvent{16, 7})
|
||||||
|
r.cmds = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) exec(t []string) error {
|
||||||
|
switch t[0] {
|
||||||
|
case "init":
|
||||||
|
kv := map[string]string{}
|
||||||
|
for _, a := range t[1:] {
|
||||||
|
k, v, ok := strings.Cut(a, "=")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("want key=value, got %q", a)
|
||||||
|
}
|
||||||
|
kv[k] = v
|
||||||
|
}
|
||||||
|
return r.init(kv)
|
||||||
|
case "key":
|
||||||
|
if len(t) < 2 {
|
||||||
|
return fmt.Errorf("key NAME [count]")
|
||||||
|
}
|
||||||
|
k, ok := ParseKey(t[1])
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown key %q", t[1])
|
||||||
|
}
|
||||||
|
n := 1
|
||||||
|
if len(t) > 2 {
|
||||||
|
var err error
|
||||||
|
if n, err = strconv.Atoi(t[2]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
r.feed(KeyEvent{k})
|
||||||
|
}
|
||||||
|
case "tick":
|
||||||
|
if len(t) != 2 {
|
||||||
|
return fmt.Errorf("tick MS")
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(t[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for n > 0 {
|
||||||
|
d := min(n, TickMs)
|
||||||
|
r.feed(TickEvent{uint32(d)})
|
||||||
|
n -= d
|
||||||
|
}
|
||||||
|
case "clock":
|
||||||
|
if len(t) != 3 {
|
||||||
|
return fmt.Errorf("clock H M")
|
||||||
|
}
|
||||||
|
h, e1 := strconv.Atoi(t[1])
|
||||||
|
m, e2 := strconv.Atoi(t[2])
|
||||||
|
if e1 != nil || e2 != nil {
|
||||||
|
return fmt.Errorf("bad clock")
|
||||||
|
}
|
||||||
|
r.feed(ClockEvent{h, m})
|
||||||
|
case "media":
|
||||||
|
if len(t) != 5 {
|
||||||
|
return fmt.Errorf("media TITLE ARTIST ALBUM playing|paused")
|
||||||
|
}
|
||||||
|
r.feed(MediaEvent{Title: t[1], Artist: t[2], Album: t[3], Playing: t[4] == "playing"})
|
||||||
|
case "bt":
|
||||||
|
return r.btDirective(t[1:])
|
||||||
|
case "health":
|
||||||
|
if len(t) != 3 {
|
||||||
|
return fmt.Errorf("health DEVICE STATUS")
|
||||||
|
}
|
||||||
|
dev := map[string]Device{"can": DevCAN, "fcim": DevFCIM, "fdim": DevFDIM, "dsp": DevDSP, "bt": DevBT}
|
||||||
|
st := map[string]Health{"ok": HealthOK, "degraded": HealthDegraded, "down": HealthDown}
|
||||||
|
d, ok1 := dev[t[1]]
|
||||||
|
h, ok2 := st[t[2]]
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return fmt.Errorf("bad health")
|
||||||
|
}
|
||||||
|
r.feed(HealthEvent{d, h})
|
||||||
|
case "clear":
|
||||||
|
r.cmds = nil
|
||||||
|
case "expect":
|
||||||
|
return r.expect(t[1:])
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown directive %q", t[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) btDirective(t []string) error {
|
||||||
|
if len(t) == 0 {
|
||||||
|
return fmt.Errorf("bt KIND ...")
|
||||||
|
}
|
||||||
|
arg := func(i int) string {
|
||||||
|
if i < len(t) {
|
||||||
|
return t[i]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t[0] {
|
||||||
|
case "connected":
|
||||||
|
r.feed(BTEvent{State: BTConnected, Device: arg(1)})
|
||||||
|
case "disconnected":
|
||||||
|
r.feed(BTEvent{State: BTDisconnected})
|
||||||
|
case "connectfailed":
|
||||||
|
r.feed(BTEvent{State: BTConnectFailed})
|
||||||
|
case "pairingended":
|
||||||
|
r.feed(BTEvent{State: BTPairingEnded})
|
||||||
|
case "devices":
|
||||||
|
r.feed(BTEvent{State: BTDeviceList, Devices: append([]string(nil), t[1:]...)})
|
||||||
|
case "ringing":
|
||||||
|
r.feed(BTEvent{State: BTCallRinging, Caller: arg(1)})
|
||||||
|
case "callactive":
|
||||||
|
r.feed(BTEvent{State: BTCallActive, Caller: arg(1)})
|
||||||
|
case "callended":
|
||||||
|
r.feed(BTEvent{State: BTCallEnded})
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown bt kind %q", t[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdMatches(cmd, want string) bool {
|
||||||
|
return cmd == want || strings.HasPrefix(cmd, want+" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) has(want string) int {
|
||||||
|
for i, c := range r.cmds {
|
||||||
|
if cmdMatches(c, want) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) expect(t []string) error {
|
||||||
|
if len(t) == 0 {
|
||||||
|
return fmt.Errorf("expect WHAT ...")
|
||||||
|
}
|
||||||
|
f := r.out.Frame
|
||||||
|
s := r.s
|
||||||
|
need := func(n int) error {
|
||||||
|
if len(t) != n {
|
||||||
|
return fmt.Errorf("expect %s: want %d args", t[0], n-1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
eqS := func(what, got, want string) error {
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("%s: got %q, want %q", what, got, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch t[0] {
|
||||||
|
case "line1", "line2":
|
||||||
|
row := 0
|
||||||
|
if t[0] == "line2" {
|
||||||
|
row = 1
|
||||||
|
}
|
||||||
|
got := strings.TrimRight(f.Row(row), " ")
|
||||||
|
switch len(t) {
|
||||||
|
case 2:
|
||||||
|
return eqS(t[0], got, strings.TrimRight(t[1], " "))
|
||||||
|
case 3:
|
||||||
|
want := t[2]
|
||||||
|
ok := false
|
||||||
|
switch t[1] {
|
||||||
|
case "contains":
|
||||||
|
ok = strings.Contains(got, want)
|
||||||
|
case "starts":
|
||||||
|
ok = strings.HasPrefix(got, want)
|
||||||
|
case "ends":
|
||||||
|
ok = strings.HasSuffix(got, want)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown matcher %q", t[1])
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s: %q does not %s %q", t[0], got, t[1], want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("expect %s: bad args", t[0])
|
||||||
|
case "bar":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(t[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
want := strings.Repeat(string(GlyphFull), n) + strings.Repeat(string(GlyphShade), LineW-n)
|
||||||
|
return eqS("bar", f.Row(1), want)
|
||||||
|
case "page":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return eqS("page", s.PageName(), t[1])
|
||||||
|
case "mode":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return eqS("mode", s.Mode.String(), t[1])
|
||||||
|
case "label":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return eqS("label", f.Label, t[1])
|
||||||
|
case "clock":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got := ""
|
||||||
|
if f.ClockOn {
|
||||||
|
got = f.ClockText
|
||||||
|
}
|
||||||
|
return eqS("clock", got, t[1])
|
||||||
|
case "volume":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return eqS("volume", strconv.Itoa(s.Volume), t[1])
|
||||||
|
case "source":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return eqS("source", strings.ToLower(s.Source.String()), t[1])
|
||||||
|
case "overlay", "confirm":
|
||||||
|
if err := need(2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got := s.overlayOn
|
||||||
|
if t[0] == "confirm" {
|
||||||
|
got = s.confirm != nil
|
||||||
|
}
|
||||||
|
return eqS(t[0], onoff(got), t[1])
|
||||||
|
case "cmd", "nocmd":
|
||||||
|
if len(t) < 2 {
|
||||||
|
return fmt.Errorf("expect %s CMD", t[0])
|
||||||
|
}
|
||||||
|
want := strings.Join(t[1:], " ")
|
||||||
|
found := r.has(want) >= 0
|
||||||
|
if t[0] == "cmd" && !found {
|
||||||
|
return fmt.Errorf("command %q not issued; log: %v", want, r.cmds)
|
||||||
|
}
|
||||||
|
if t[0] == "nocmd" && found {
|
||||||
|
return fmt.Errorf("command %q unexpectedly issued; log: %v", want, r.cmds)
|
||||||
|
}
|
||||||
|
case "nocmds":
|
||||||
|
if len(r.cmds) != 0 {
|
||||||
|
return fmt.Errorf("expected no commands, log: %v", r.cmds)
|
||||||
|
}
|
||||||
|
case "count":
|
||||||
|
if err := need(3); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(t[2])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got := 0
|
||||||
|
for _, c := range r.cmds {
|
||||||
|
if cmdMatches(c, t[1]) {
|
||||||
|
got++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got != n {
|
||||||
|
return fmt.Errorf("count %q: got %d, want %d; log: %v", t[1], got, n, r.cmds)
|
||||||
|
}
|
||||||
|
case "order":
|
||||||
|
last := -1
|
||||||
|
for _, want := range t[1:] {
|
||||||
|
found := -1
|
||||||
|
for i := last + 1; i < len(r.cmds); i++ {
|
||||||
|
if cmdMatches(r.cmds[i], want) {
|
||||||
|
found = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found < 0 {
|
||||||
|
return fmt.Errorf("%q not found after position %d; log: %v", want, last, r.cmds)
|
||||||
|
}
|
||||||
|
last = found
|
||||||
|
}
|
||||||
|
case "noblink":
|
||||||
|
for row := 0; row < 2; row++ {
|
||||||
|
for c := 0; c < LineW; c++ {
|
||||||
|
if f.Blink[row][c] {
|
||||||
|
return fmt.Errorf("unexpected blink at row %d col %d", row+1, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "blink":
|
||||||
|
if err := need(3); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row, err := strconv.Atoi(t[1])
|
||||||
|
if err != nil || row < 1 || row > 2 {
|
||||||
|
return fmt.Errorf("bad row %q", t[1])
|
||||||
|
}
|
||||||
|
loS, hiS, _ := strings.Cut(t[2], "-")
|
||||||
|
lo, e1 := strconv.Atoi(loS)
|
||||||
|
hi, e2 := strconv.Atoi(hiS)
|
||||||
|
if e1 != nil || e2 != nil {
|
||||||
|
return fmt.Errorf("bad range %q", t[2])
|
||||||
|
}
|
||||||
|
for c := 0; c < LineW; c++ {
|
||||||
|
want := c >= lo && c <= hi
|
||||||
|
if f.Blink[row-1][c] != want {
|
||||||
|
return fmt.Errorf("blink row %d col %d: got %v, want %v", row, c, f.Blink[row-1][c], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown expectation %q", t[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestScripts runs every tests/*.test script (the shared acceptance suite).
|
||||||
|
func TestScripts(t *testing.T) {
|
||||||
|
files, err := filepath.Glob("../tests/*.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
t.Fatal("no test scripts found in ../tests")
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
f := f
|
||||||
|
t.Run(filepath.Base(f), func(t *testing.T) {
|
||||||
|
src, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RunScript(filepath.Base(f), string(src)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Glyphs the core may emit beyond printable ASCII. The FDIM driver maps them
|
||||||
|
// to a fallback when the real character set lacks them (SDS §12).
|
||||||
|
const (
|
||||||
|
GlyphMore = '›'
|
||||||
|
GlyphDot = '·'
|
||||||
|
GlyphFull = '█'
|
||||||
|
GlyphShade = '░'
|
||||||
|
)
|
||||||
|
|
||||||
|
var foldMap = map[rune]string{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
pairs := []struct{ from, to string }{
|
||||||
|
{"ÀÁÂÃÄÅĀĂĄ", "A"}, {"àáâãäåāăą", "a"}, {"ÇĆĈČ", "C"}, {"çćĉč", "c"},
|
||||||
|
{"ĎĐ", "D"}, {"ďđ", "d"}, {"ÈÉÊËĒĖĘĚ", "E"}, {"èéêëēėęě", "e"},
|
||||||
|
{"ĜĞĢ", "G"}, {"ĝğģ", "g"}, {"ÌÍÎÏĪĮİ", "I"}, {"ìíîïīįı", "i"},
|
||||||
|
{"Ł", "L"}, {"ł", "l"}, {"ÑŃŇ", "N"}, {"ñńň", "n"},
|
||||||
|
{"ÒÓÔÕÖØŌŐ", "O"}, {"òóôõöøōő", "o"}, {"ŔŘ", "R"}, {"ŕř", "r"},
|
||||||
|
{"ŚŠŞ", "S"}, {"śšş", "s"}, {"ŢŤ", "T"}, {"ţť", "t"},
|
||||||
|
{"ÙÚÛÜŪŮŰŲ", "U"}, {"ùúûüūůűų", "u"}, {"ÝŸ", "Y"}, {"ýÿ", "y"},
|
||||||
|
{"ŹŻŽ", "Z"}, {"źżž", "z"}, {"ß", "ss"}, {"Æ", "AE"}, {"æ", "ae"},
|
||||||
|
{"Œ", "OE"}, {"œ", "oe"},
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
for _, r := range p.from {
|
||||||
|
foldMap[r] = p.to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitize maps text to the supported set: accented letters to plain ASCII,
|
||||||
|
// everything unknown to '?'.
|
||||||
|
func sanitize(in string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range in {
|
||||||
|
switch {
|
||||||
|
case r >= 0x20 && r < 0x7f:
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == GlyphMore || r == GlyphDot || r == GlyphFull || r == GlyphShade:
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
if rep, ok := foldMap[r]; ok {
|
||||||
|
b.WriteString(rep)
|
||||||
|
} else {
|
||||||
|
b.WriteRune('?')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean sanitizes and collapses whitespace.
|
||||||
|
func clean(in string) string {
|
||||||
|
return strings.Join(strings.Fields(sanitize(in)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate splits text into pages of at most LineW characters, breaking at
|
||||||
|
// word boundaries (a single over-long word is hard-split). Every page except
|
||||||
|
// the last ends with a "more" marker which counts toward the 20 characters.
|
||||||
|
// A break at a space shows "word ›"; a break at exactly column 19 or inside a
|
||||||
|
// word shows "word›".
|
||||||
|
func paginate(text string) []string {
|
||||||
|
r := []rune(strings.Join(strings.Fields(text), " "))
|
||||||
|
if len(r) <= LineW {
|
||||||
|
return []string{string(r)}
|
||||||
|
}
|
||||||
|
var pages []string
|
||||||
|
for len(r) > LineW {
|
||||||
|
cut := -1
|
||||||
|
for i := LineW - 1; i >= 1; i-- {
|
||||||
|
if r[i] == ' ' {
|
||||||
|
cut = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case cut < 0: // hard split
|
||||||
|
pages = append(pages, string(r[:LineW-1])+string(GlyphMore))
|
||||||
|
r = r[LineW-1:]
|
||||||
|
case cut == LineW-1:
|
||||||
|
pages = append(pages, string(r[:cut])+string(GlyphMore))
|
||||||
|
r = trimLeft(r[cut:])
|
||||||
|
default:
|
||||||
|
pages = append(pages, string(r[:cut])+" "+string(GlyphMore))
|
||||||
|
r = trimLeft(r[cut:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(pages, string(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimLeft(r []rune) []rune {
|
||||||
|
for len(r) > 0 && r[0] == ' ' {
|
||||||
|
r = r[1:]
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt12 formats a 24 h time as shown in the info area ("4:07 PM").
|
||||||
|
func fmt12(h, m int) string {
|
||||||
|
ap := "AM"
|
||||||
|
if h >= 12 {
|
||||||
|
ap = "PM"
|
||||||
|
}
|
||||||
|
h12 := h % 12
|
||||||
|
if h12 == 0 {
|
||||||
|
h12 = 12
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d %s", h12, m, ap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// put writes s left-aligned into a row, truncating at 20 columns.
|
||||||
|
func put(dst *[LineW]rune, s string) {
|
||||||
|
rs := []rune(s)
|
||||||
|
for i := 0; i < len(rs) && i < LineW; i++ {
|
||||||
|
dst[i] = rs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// centered builds a row with s centered (truncated at 20 columns).
|
||||||
|
func centered(s string) [LineW]rune {
|
||||||
|
line := newLine()
|
||||||
|
rs := []rune(s)
|
||||||
|
if len(rs) > LineW {
|
||||||
|
rs = rs[:LineW]
|
||||||
|
}
|
||||||
|
copy(line[(LineW-len(rs))/2:], rs)
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftRight builds a row with a left title and a right-aligned value that
|
||||||
|
// ends at column 20. The title is truncated first.
|
||||||
|
func leftRight(l, r string) [LineW]rune {
|
||||||
|
line := newLine()
|
||||||
|
lr, rr := []rune(l), []rune(r)
|
||||||
|
if len(rr) > LineW {
|
||||||
|
rr = rr[:LineW]
|
||||||
|
}
|
||||||
|
maxL := LineW - len(rr)
|
||||||
|
if len(rr) > 0 {
|
||||||
|
maxL--
|
||||||
|
}
|
||||||
|
if maxL < 0 {
|
||||||
|
maxL = 0
|
||||||
|
}
|
||||||
|
if len(lr) > maxL {
|
||||||
|
lr = lr[:maxL]
|
||||||
|
}
|
||||||
|
copy(line[:], lr)
|
||||||
|
copy(line[LineW-len(rr):], rr)
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// Package core is the pure UI core of the Fusion audio controller.
|
||||||
|
//
|
||||||
|
// It does no I/O and never reads a clock: everything comes in as an Event and
|
||||||
|
// everything goes out as Commands plus a Frame (see SDS §14).
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineW is the width of the FDIM text rows.
|
||||||
|
const LineW = 20
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- keys
|
||||||
|
|
||||||
|
// Key is one FCIM button pulse.
|
||||||
|
type Key int
|
||||||
|
|
||||||
|
const (
|
||||||
|
KeyNone Key = iota
|
||||||
|
KeyPower
|
||||||
|
KeyVolLeft
|
||||||
|
KeyVolRight
|
||||||
|
KeyTuneLeft
|
||||||
|
KeyTuneRight
|
||||||
|
KeyOK
|
||||||
|
KeyMenu
|
||||||
|
KeyClock
|
||||||
|
KeyAux
|
||||||
|
KeySirius
|
||||||
|
KeyPhone
|
||||||
|
KeySound
|
||||||
|
KeySeekLeft
|
||||||
|
KeySeekRight
|
||||||
|
KeySeekUp
|
||||||
|
KeySeekDown
|
||||||
|
KeyLoad
|
||||||
|
KeyEject
|
||||||
|
KeyDirect
|
||||||
|
KeyScan
|
||||||
|
KeyCatFold
|
||||||
|
KeyText
|
||||||
|
KeyAMFM
|
||||||
|
KeyCD
|
||||||
|
Key0
|
||||||
|
Key1
|
||||||
|
Key2
|
||||||
|
Key3
|
||||||
|
Key4
|
||||||
|
Key5
|
||||||
|
Key6
|
||||||
|
Key7
|
||||||
|
Key8
|
||||||
|
Key9
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyNames = [...]string{
|
||||||
|
KeyNone: "NONE", KeyPower: "POWER", KeyVolLeft: "VOLUME_LEFT", KeyVolRight: "VOLUME_RIGHT",
|
||||||
|
KeyTuneLeft: "TUNE_LEFT", KeyTuneRight: "TUNE_RIGHT", KeyOK: "OK", KeyMenu: "MENU",
|
||||||
|
KeyClock: "CLOCK", KeyAux: "AUX", KeySirius: "SIRIUS", KeyPhone: "PHONE", KeySound: "SOUND",
|
||||||
|
KeySeekLeft: "SEEK_LEFT", KeySeekRight: "SEEK_RIGHT", KeySeekUp: "SEEK_UP", KeySeekDown: "SEEK_DOWN",
|
||||||
|
KeyLoad: "LOAD", KeyEject: "EJECT", KeyDirect: "DIRECT", KeyScan: "SCAN", KeyCatFold: "CAT_FOLD",
|
||||||
|
KeyText: "TEXT", KeyAMFM: "AM_FM", KeyCD: "CD",
|
||||||
|
Key0: "0", Key1: "1", Key2: "2", Key3: "3", Key4: "4",
|
||||||
|
Key5: "5", Key6: "6", Key7: "7", Key8: "8", Key9: "9",
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyByName = map[string]Key{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for i, n := range keyNames {
|
||||||
|
if n != "" && Key(i) != KeyNone {
|
||||||
|
keyByName[n] = Key(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyByName["VOL_LEFT"] = KeyVolLeft
|
||||||
|
keyByName["VOL_RIGHT"] = KeyVolRight
|
||||||
|
keyByName["PLAY"] = KeyOK
|
||||||
|
keyByName["PAUSE"] = KeyOK
|
||||||
|
keyByName["PLAY_PAUSE"] = KeyOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k Key) String() string {
|
||||||
|
if k >= 0 && int(k) < len(keyNames) && keyNames[k] != "" {
|
||||||
|
return keyNames[k]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("KEY(%d)", int(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDigit reports whether k is one of the number keys.
|
||||||
|
func (k Key) IsDigit() bool { return k >= Key0 && k <= Key9 }
|
||||||
|
|
||||||
|
// Digit returns the numeric value of a digit key.
|
||||||
|
func (k Key) Digit() int { return int(k - Key0) }
|
||||||
|
|
||||||
|
// ParseKey maps a button name (as sent by an FCIM) to a Key.
|
||||||
|
func ParseKey(name string) (Key, bool) {
|
||||||
|
k, ok := keyByName[strings.ToUpper(strings.TrimSpace(name))]
|
||||||
|
return k, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- sources
|
||||||
|
|
||||||
|
// Source is an audio input.
|
||||||
|
type Source int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceAux Source = iota
|
||||||
|
SourceBT
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Source) String() string {
|
||||||
|
if s == SourceBT {
|
||||||
|
return "BT"
|
||||||
|
}
|
||||||
|
return "AUX"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name is the user-visible source name.
|
||||||
|
func (s Source) Name() string {
|
||||||
|
if s == SourceBT {
|
||||||
|
return "Bluetooth"
|
||||||
|
}
|
||||||
|
return "Aux"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- health
|
||||||
|
|
||||||
|
// Device identifies a monitored peripheral.
|
||||||
|
type Device int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DevCAN Device = iota
|
||||||
|
DevFCIM
|
||||||
|
DevFDIM
|
||||||
|
DevDSP
|
||||||
|
DevBT
|
||||||
|
deviceCount
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d Device) String() string {
|
||||||
|
switch d {
|
||||||
|
case DevCAN:
|
||||||
|
return "CAN"
|
||||||
|
case DevFCIM:
|
||||||
|
return "FCIM"
|
||||||
|
case DevFDIM:
|
||||||
|
return "FDIM"
|
||||||
|
case DevDSP:
|
||||||
|
return "DSP"
|
||||||
|
case DevBT:
|
||||||
|
return "BT"
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health is a driver's reported status.
|
||||||
|
type Health int
|
||||||
|
|
||||||
|
const (
|
||||||
|
HealthUnknown Health = iota
|
||||||
|
HealthOK
|
||||||
|
HealthDegraded
|
||||||
|
HealthDown
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- events
|
||||||
|
|
||||||
|
// Event is anything posted to the core queue.
|
||||||
|
type Event interface{ isEvent() }
|
||||||
|
|
||||||
|
type KeyEvent struct{ Key Key }
|
||||||
|
|
||||||
|
// TickEvent advances the core's notion of time.
|
||||||
|
type TickEvent struct{ DtMs uint32 }
|
||||||
|
|
||||||
|
// ClockEvent reports the current wall clock time (24 h).
|
||||||
|
type ClockEvent struct{ Hour, Minute int }
|
||||||
|
|
||||||
|
// MediaEvent carries Bluetooth track metadata.
|
||||||
|
type MediaEvent struct {
|
||||||
|
Title, Artist, Album string
|
||||||
|
Playing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTKind is the kind of a Bluetooth state change.
|
||||||
|
type BTKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
BTDisconnected BTKind = iota
|
||||||
|
BTConnected
|
||||||
|
BTConnectFailed
|
||||||
|
BTPairingEnded
|
||||||
|
BTDeviceList
|
||||||
|
BTCallRinging
|
||||||
|
BTCallActive
|
||||||
|
BTCallEnded
|
||||||
|
)
|
||||||
|
|
||||||
|
// BTEvent reports a Bluetooth module state change.
|
||||||
|
type BTEvent struct {
|
||||||
|
State BTKind
|
||||||
|
Device string // BTConnected
|
||||||
|
Devices []string // BTDeviceList
|
||||||
|
Caller string // call states
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthEvent struct {
|
||||||
|
Device Device
|
||||||
|
Status Health
|
||||||
|
}
|
||||||
|
|
||||||
|
func (KeyEvent) isEvent() {}
|
||||||
|
func (TickEvent) isEvent() {}
|
||||||
|
func (ClockEvent) isEvent() {}
|
||||||
|
func (MediaEvent) isEvent() {}
|
||||||
|
func (BTEvent) isEvent() {}
|
||||||
|
func (HealthEvent) isEvent() {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
// Cmd is an instruction for a driver. String gives the canonical text form
|
||||||
|
// used by logs and by the test scripts.
|
||||||
|
type Cmd interface{ String() string }
|
||||||
|
|
||||||
|
func onoff(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
type DSPSetVolume struct{ Pct int }
|
||||||
|
type DSPMute struct{ On bool }
|
||||||
|
type DSPSetSource struct{ Src Source }
|
||||||
|
type DSPDACEnable struct{ On bool }
|
||||||
|
type DSPRampTo struct{ Pct, Ms int }
|
||||||
|
type BTPlayPause struct{}
|
||||||
|
type BTPlay struct{}
|
||||||
|
type BTPause struct{}
|
||||||
|
type BTNext struct{}
|
||||||
|
type BTPrev struct{}
|
||||||
|
type BTConnect struct{ Index int }
|
||||||
|
type BTDisconnect struct{}
|
||||||
|
type BTPairing struct{ On bool }
|
||||||
|
type BTAnswer struct{}
|
||||||
|
type BTHangup struct{}
|
||||||
|
type ClockSet struct{ Hour, Minute int }
|
||||||
|
type SettingsSave struct {
|
||||||
|
Volume int
|
||||||
|
Source Source
|
||||||
|
}
|
||||||
|
type SystemReboot struct{}
|
||||||
|
|
||||||
|
func (c DSPSetVolume) String() string { return fmt.Sprintf("DSP_SET_VOLUME %d", c.Pct) }
|
||||||
|
func (c DSPMute) String() string { return "DSP_MUTE " + onoff(c.On) }
|
||||||
|
func (c DSPSetSource) String() string { return "DSP_SET_SOURCE " + c.Src.String() }
|
||||||
|
func (c DSPDACEnable) String() string { return "DSP_DAC_ENABLE " + onoff(c.On) }
|
||||||
|
func (c DSPRampTo) String() string { return fmt.Sprintf("DSP_RAMP_TO %d %d", c.Pct, c.Ms) }
|
||||||
|
func (BTPlayPause) String() string { return "BT_PLAY_PAUSE" }
|
||||||
|
func (BTPlay) String() string { return "BT_PLAY" }
|
||||||
|
func (BTPause) String() string { return "BT_PAUSE" }
|
||||||
|
func (BTNext) String() string { return "BT_NEXT" }
|
||||||
|
func (BTPrev) String() string { return "BT_PREV" }
|
||||||
|
func (c BTConnect) String() string { return fmt.Sprintf("BT_CONNECT %d", c.Index) }
|
||||||
|
func (BTDisconnect) String() string { return "BT_DISCONNECT" }
|
||||||
|
func (c BTPairing) String() string { return "BT_PAIRING " + onoff(c.On) }
|
||||||
|
func (BTAnswer) String() string { return "BT_ANSWER" }
|
||||||
|
func (BTHangup) String() string { return "BT_HANGUP" }
|
||||||
|
func (c ClockSet) String() string { return fmt.Sprintf("CLOCK_SET %d %d", c.Hour, c.Minute) }
|
||||||
|
func (c SettingsSave) String() string { return fmt.Sprintf("SETTINGS_SAVE %d %s", c.Volume, c.Source) }
|
||||||
|
func (SystemReboot) String() string { return "SYSTEM_REBOOT" }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- frame
|
||||||
|
|
||||||
|
// Frame is the complete state of the FDIM: info area plus two text rows.
|
||||||
|
type Frame struct {
|
||||||
|
Label string // source label box text ("SYNC", "AUX", or "")
|
||||||
|
ClockOn bool
|
||||||
|
ClockText string // e.g. "4:07 PM"
|
||||||
|
Line [2][LineW]rune
|
||||||
|
Blink [2][LineW]bool // cells belonging to a blinking field
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLine() [LineW]rune {
|
||||||
|
var l [LineW]rune
|
||||||
|
for i := range l {
|
||||||
|
l[i] = ' '
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFrame() Frame {
|
||||||
|
return Frame{Line: [2][LineW]rune{newLine(), newLine()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row returns text row i (0 or 1) as a 20 character string.
|
||||||
|
func (f Frame) Row(i int) string { return string(f.Line[i][:]) }
|
||||||
|
|
||||||
|
// Out collects everything the core produces while handling an event.
|
||||||
|
type Out struct {
|
||||||
|
Cmds []Cmd
|
||||||
|
Frame Frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the command list (the frame is overwritten by every Handle).
|
||||||
|
func (o *Out) Reset() { o.Cmds = o.Cmds[:0] }
|
||||||
|
|
||||||
|
func (o *Out) add(c Cmd) { o.Cmds = append(o.Cmds, c) }
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package dsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hard-coded for the SigmaStudio build burned to EEPROM on 2026-10-25.
|
||||||
|
// Parameter addresses come from car-radio/dsp/Design 1_IC 1/ParamAddress.dat
|
||||||
|
// and move on every recompile; re-check them after re-burning.
|
||||||
|
const (
|
||||||
|
ADAU1467Device = "/dev/i2c-1"
|
||||||
|
ADAU1467Addr = 0x3B // 7-bit; ADDR0/ADDR1 both read high on the eval board
|
||||||
|
|
||||||
|
volumeTarget = 0x0315 // SWGainOptADAU145XAlg1.target (the block slews to it)
|
||||||
|
|
||||||
|
safeloadData = 0x6000 // __SafeLoad_Module__.data_SafeLoad (5 words)
|
||||||
|
safeloadAddr = 0x6005 // __SafeLoad_Module__.address_SafeLoad
|
||||||
|
safeloadCount = 0x6006 // __SafeLoad_Module__.num_SafeLoad_lower; writing it triggers
|
||||||
|
|
||||||
|
coreStatus = 0xF405 // control register, 16-bit
|
||||||
|
|
||||||
|
unity = 1 << 24 // 1.0 (0 dB) in 8.24 fixed point; the calibrated maximum
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bus is one I2C device: write w, then read len(r) bytes after a repeated start.
|
||||||
|
type Bus interface {
|
||||||
|
Tx(w, r []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADAU1467 drives the real DSP over its I2C slave control port: 16-bit
|
||||||
|
// big-endian subaddress, 32-bit big-endian 8.24 words, and software safeload
|
||||||
|
// for every write so a running program never clicks.
|
||||||
|
//
|
||||||
|
// Writes are latest-wins: SetGain and Mute only record the wanted target and
|
||||||
|
// wake one writer goroutine, so the app loop never waits on I2C and a fast
|
||||||
|
// volume spin writes only the newest value. A failed write is returned by the
|
||||||
|
// next SetGain/Mute call (so the app marks the DSP down) and retried every
|
||||||
|
// second until it lands.
|
||||||
|
//
|
||||||
|
// The design has no separate mute block: mute writes 0 to the master volume
|
||||||
|
// and unmute restores the last requested gain. Until the first SetGain the
|
||||||
|
// EEPROM default stays in place.
|
||||||
|
type ADAU1467 struct {
|
||||||
|
bus Bus
|
||||||
|
busMu sync.Mutex // one I2C sequence at a time (writer vs Ping)
|
||||||
|
trace func(string) // may be nil; one timing line per write
|
||||||
|
wake chan struct{}
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
gain uint32 // last requested gain, restored on unmute
|
||||||
|
haveGain bool
|
||||||
|
muted bool
|
||||||
|
want uint32 // target the writer should put on the DSP
|
||||||
|
haveWant bool
|
||||||
|
written uint32 // last value confirmed on the DSP
|
||||||
|
haveSent bool
|
||||||
|
requested time.Time // first unwritten request
|
||||||
|
coalesced int // requests folded into the next write
|
||||||
|
err error // last write error, reported by the next call
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewADAU1467 starts the writer; Close stops it. trace may be nil.
|
||||||
|
func NewADAU1467(bus Bus, trace func(string)) *ADAU1467 {
|
||||||
|
d := &ADAU1467{bus: bus, trace: trace, wake: make(chan struct{}, 1), stop: make(chan struct{})}
|
||||||
|
go d.run()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ADAU1467) Close() error {
|
||||||
|
d.once.Do(func() { close(d.stop) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping reads CORE_STATUS. While audio runs the core sleeps between frames,
|
||||||
|
// so 3 is the normal value; 0 means the program isn't running.
|
||||||
|
func (d *ADAU1467) Ping() error {
|
||||||
|
d.busMu.Lock()
|
||||||
|
defer d.busMu.Unlock()
|
||||||
|
r := make([]byte, 2)
|
||||||
|
if err := d.bus.Tx(be16(coreStatus), r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(r) == 0 {
|
||||||
|
return errors.New("dsp: core not running")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGain sets the master volume. The DSP's slew block ramps at its own
|
||||||
|
// fixed rate, so rampMs is ignored. Gains above 0 dB are clamped to 0 dB.
|
||||||
|
func (d *ADAU1467) SetGain(tenthsDB int, rampMs int) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.gain, d.haveGain = TenthsToFixed(tenthsDB), true
|
||||||
|
return d.request()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ADAU1467) Mute(on bool) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.muted = on
|
||||||
|
return d.request()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectInput is not mapped to the DSP's input mux yet; AUX is the only source.
|
||||||
|
func (d *ADAU1467) SelectInput(name string) error { return nil }
|
||||||
|
|
||||||
|
// EnableDAC is a no-op: the eval board's outputs are always on.
|
||||||
|
func (d *ADAU1467) EnableDAC(on bool) error { return nil }
|
||||||
|
|
||||||
|
// pending reports whether want hasn't been confirmed yet; d.mu must be held.
|
||||||
|
func (d *ADAU1467) pending() bool {
|
||||||
|
return d.haveWant && !(d.haveSent && d.written == d.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// request recomputes the target from gain and mute and wakes the writer if
|
||||||
|
// it changed; d.mu must be held. It returns (and clears) an earlier error.
|
||||||
|
func (d *ADAU1467) request() error {
|
||||||
|
v, ok := uint32(0), true
|
||||||
|
if !d.muted {
|
||||||
|
v, ok = d.gain, d.haveGain
|
||||||
|
}
|
||||||
|
if ok && !(d.haveWant && d.want == v) {
|
||||||
|
if d.pending() {
|
||||||
|
d.coalesced++
|
||||||
|
} else {
|
||||||
|
d.requested = time.Now()
|
||||||
|
}
|
||||||
|
d.want, d.haveWant = v, true
|
||||||
|
select {
|
||||||
|
case d.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := d.err
|
||||||
|
d.err = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ADAU1467) run() {
|
||||||
|
retry := time.NewTimer(time.Second)
|
||||||
|
retry.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-d.stop:
|
||||||
|
retry.Stop()
|
||||||
|
return
|
||||||
|
case <-d.wake:
|
||||||
|
case <-retry.C:
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
v, ok := d.want, d.pending()
|
||||||
|
requested, coalesced := d.requested, d.coalesced
|
||||||
|
d.coalesced = 0
|
||||||
|
d.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
d.busMu.Lock()
|
||||||
|
err := d.write(v)
|
||||||
|
d.busMu.Unlock()
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
d.err, d.haveSent = err, false
|
||||||
|
retry.Reset(time.Second)
|
||||||
|
} else {
|
||||||
|
d.written, d.haveSent, d.err = v, true, nil
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
if err == nil && d.trace != nil {
|
||||||
|
d.trace(fmt.Sprintf("dsp: wrote %s in %.1f ms, %.1f ms after request, %d coalesced",
|
||||||
|
fixedString(v), ms(time.Since(start)), ms(time.Since(requested)), coalesced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write safeloads the master volume target and reads it back.
|
||||||
|
func (d *ADAU1467) write(v uint32) error {
|
||||||
|
if err := d.safeload(volumeTarget, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := make([]byte, 4)
|
||||||
|
if err := d.bus.Tx(be16(volumeTarget), r); err != nil {
|
||||||
|
return fmt.Errorf("dsp: volume readback: %w", err)
|
||||||
|
}
|
||||||
|
if got := binary.BigEndian.Uint32(r); got != v {
|
||||||
|
return fmt.Errorf("dsp: volume readback: wrote 0x%08X, read 0x%08X", v, got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedString(v uint32) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "mute"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f dB", 20*math.Log10(float64(v)/unity))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
|
||||||
|
|
||||||
|
// safeload writes one word to a DM0 parameter address. The DSP copies it in
|
||||||
|
// at the next frame boundary.
|
||||||
|
func (d *ADAU1467) safeload(addr uint16, v uint32) error {
|
||||||
|
steps := [][]byte{
|
||||||
|
binary.BigEndian.AppendUint32(be16(safeloadData), v),
|
||||||
|
binary.BigEndian.AppendUint32(be16(safeloadAddr), uint32(addr)),
|
||||||
|
binary.BigEndian.AppendUint32(be16(safeloadCount), 1),
|
||||||
|
}
|
||||||
|
for _, s := range steps {
|
||||||
|
if err := d.bus.Tx(s, nil); err != nil {
|
||||||
|
return fmt.Errorf("dsp: safeload 0x%04X: %w", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenthsToFixed converts a gain in tenths of a dB to 8.24, clamped to 0 dB.
|
||||||
|
// MuteGain (or anything that rounds to nothing) is 0.
|
||||||
|
func TenthsToFixed(tenths int) uint32 {
|
||||||
|
if tenths <= MuteGain {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if tenths > 0 {
|
||||||
|
tenths = 0
|
||||||
|
}
|
||||||
|
g := math.Round(math.Pow(10, float64(tenths)/200) * unity)
|
||||||
|
return uint32(min(g, unity))
|
||||||
|
}
|
||||||
|
|
||||||
|
func be16(v uint16) []byte { return binary.BigEndian.AppendUint16(nil, v) }
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package dsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBus records writes and serves reads from a word map, applying
|
||||||
|
// safeloads the way the DSP does when the count register is written.
|
||||||
|
type fakeBus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
writes [][]byte
|
||||||
|
mem map[uint16]uint32
|
||||||
|
status uint16
|
||||||
|
slAddr uint16
|
||||||
|
slData uint32
|
||||||
|
delay time.Duration // per transaction, like a slow bus
|
||||||
|
failing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeBus() *fakeBus { return &fakeBus{mem: map[uint16]uint32{}, status: 3} }
|
||||||
|
|
||||||
|
func (f *fakeBus) Tx(w, r []byte) error {
|
||||||
|
time.Sleep(f.delay)
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.failing {
|
||||||
|
return errors.New("nack")
|
||||||
|
}
|
||||||
|
addr := binary.BigEndian.Uint16(w)
|
||||||
|
if len(r) == 2 && addr == coreStatus {
|
||||||
|
binary.BigEndian.PutUint16(r, f.status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(r) > 0 {
|
||||||
|
binary.BigEndian.PutUint32(r, f.mem[addr])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f.writes = append(f.writes, append([]byte(nil), w...))
|
||||||
|
v := binary.BigEndian.Uint32(w[2:])
|
||||||
|
switch addr {
|
||||||
|
case safeloadData:
|
||||||
|
f.slData = v
|
||||||
|
case safeloadAddr:
|
||||||
|
f.slAddr = uint16(v)
|
||||||
|
case safeloadCount:
|
||||||
|
f.mem[f.slAddr] = f.slData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBus) target() uint32 {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.mem[volumeTarget]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBus) nWrites() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.writes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitTarget polls until the DSP's volume target is want.
|
||||||
|
func waitTarget(t *testing.T, bus *fakeBus, want uint32) {
|
||||||
|
t.Helper()
|
||||||
|
for end := time.Now().Add(2 * time.Second); time.Now().Before(end); time.Sleep(2 * time.Millisecond) {
|
||||||
|
if bus.target() == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("target = 0x%08X, want 0x%08X", bus.target(), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle gives the writer time to act on anything pending.
|
||||||
|
func settle() { time.Sleep(30 * time.Millisecond) }
|
||||||
|
|
||||||
|
// TestSafeloadBytes pins the exact sequence that worked by hand on the bench:
|
||||||
|
//
|
||||||
|
// i2ctransfer -y 1 w6@0x3b 0x60 0x00 0x00 0x19 0x99 0x9a
|
||||||
|
// i2ctransfer -y 1 w6@0x3b 0x60 0x05 0x00 0x00 0x03 0x15
|
||||||
|
// i2ctransfer -y 1 w6@0x3b 0x60 0x06 0x00 0x00 0x00 0x01
|
||||||
|
func TestSafeloadBytes(t *testing.T) {
|
||||||
|
bus := newFakeBus()
|
||||||
|
d := NewADAU1467(bus, nil)
|
||||||
|
defer d.Close()
|
||||||
|
if err := d.SetGain(-200, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitTarget(t, bus, 0x0019999A)
|
||||||
|
want := [][]byte{
|
||||||
|
{0x60, 0x00, 0x00, 0x19, 0x99, 0x9a},
|
||||||
|
{0x60, 0x05, 0x00, 0x00, 0x03, 0x15},
|
||||||
|
{0x60, 0x06, 0x00, 0x00, 0x00, 0x01},
|
||||||
|
}
|
||||||
|
bus.mu.Lock()
|
||||||
|
defer bus.mu.Unlock()
|
||||||
|
if len(bus.writes) != len(want) {
|
||||||
|
t.Fatalf("got %d writes, want %d", len(bus.writes), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if !bytes.Equal(bus.writes[i], want[i]) {
|
||||||
|
t.Errorf("write %d = % X, want % X", i, bus.writes[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenthsToFixed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
tenths int
|
||||||
|
want uint32
|
||||||
|
}{
|
||||||
|
{-200, 0x0019999A},
|
||||||
|
{-280, 0x000A3109}, // the EEPROM default in DMem0.dat
|
||||||
|
{0, unity},
|
||||||
|
{60, unity}, // never above 0 dB
|
||||||
|
{MuteGain, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := TenthsToFixed(c.tenths); got != c.want {
|
||||||
|
t.Errorf("TenthsToFixed(%d) = 0x%08X, want 0x%08X", c.tenths, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADAU1467Mute(t *testing.T) {
|
||||||
|
bus := newFakeBus()
|
||||||
|
d := NewADAU1467(bus, nil)
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
// Unmute before any gain keeps the EEPROM default untouched.
|
||||||
|
d.Mute(false)
|
||||||
|
settle()
|
||||||
|
if n := bus.nWrites(); n != 0 {
|
||||||
|
t.Fatalf("unmute with no gain wrote %d frames", n)
|
||||||
|
}
|
||||||
|
d.SetGain(-200, 0)
|
||||||
|
waitTarget(t, bus, TenthsToFixed(-200))
|
||||||
|
d.Mute(true)
|
||||||
|
waitTarget(t, bus, 0)
|
||||||
|
|
||||||
|
// A gain change while muted must not leak audio.
|
||||||
|
n := bus.nWrites()
|
||||||
|
d.SetGain(-100, 0)
|
||||||
|
settle()
|
||||||
|
if bus.target() != 0 || bus.nWrites() != n {
|
||||||
|
t.Fatalf("SetGain while muted wrote 0x%08X", bus.target())
|
||||||
|
}
|
||||||
|
d.Mute(false)
|
||||||
|
waitTarget(t, bus, TenthsToFixed(-100))
|
||||||
|
|
||||||
|
// Repeating the same value is not re-sent.
|
||||||
|
n = bus.nWrites()
|
||||||
|
d.Mute(false)
|
||||||
|
d.SetGain(-100, 0)
|
||||||
|
settle()
|
||||||
|
if bus.nWrites() != n {
|
||||||
|
t.Errorf("duplicate writes sent: %d -> %d", n, bus.nWrites())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestADAU1467LatestWins spins the volume fast against a slow bus: calls must
|
||||||
|
// not block, only a few writes happen, and the last value lands.
|
||||||
|
func TestADAU1467LatestWins(t *testing.T) {
|
||||||
|
bus := newFakeBus()
|
||||||
|
bus.delay = 3 * time.Millisecond // ~12 ms per write
|
||||||
|
var mu sync.Mutex
|
||||||
|
var traces []string
|
||||||
|
d := NewADAU1467(bus, func(s string) { mu.Lock(); traces = append(traces, s); mu.Unlock() })
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for g := -400; g <= 0; g += 20 {
|
||||||
|
if err := d.SetGain(g, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if took := time.Since(start); took > 5*time.Millisecond {
|
||||||
|
t.Errorf("21 SetGain calls took %v; they must not wait on I2C", took)
|
||||||
|
}
|
||||||
|
waitTarget(t, bus, unity)
|
||||||
|
if writes := bus.nWrites() / 3; writes >= 21 {
|
||||||
|
t.Errorf("%d safeloads for 21 requests; expected coalescing", writes)
|
||||||
|
}
|
||||||
|
// The trace follows the readback, slightly after the target lands.
|
||||||
|
lastTrace := func() string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(traces) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return traces[len(traces)-1]
|
||||||
|
}
|
||||||
|
for end := time.Now().Add(time.Second); !strings.Contains(lastTrace(), "wrote 0.0 dB"); time.Sleep(2 * time.Millisecond) {
|
||||||
|
if time.Now().After(end) {
|
||||||
|
t.Fatalf("last trace = %q", lastTrace())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestADAU1467Errors: a failed write is reported by the next call and
|
||||||
|
// retried until it lands, after which no stale error remains.
|
||||||
|
func TestADAU1467Errors(t *testing.T) {
|
||||||
|
bus := newFakeBus()
|
||||||
|
bus.failing = true
|
||||||
|
d := NewADAU1467(bus, nil)
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
if err := d.SetGain(-200, 0); err != nil {
|
||||||
|
t.Fatalf("first call returned %v; errors arrive on the next call", err)
|
||||||
|
}
|
||||||
|
settle()
|
||||||
|
if err := d.SetGain(-200, 0); err == nil {
|
||||||
|
t.Fatal("write failure not reported")
|
||||||
|
}
|
||||||
|
bus.mu.Lock()
|
||||||
|
bus.failing = false
|
||||||
|
bus.mu.Unlock()
|
||||||
|
waitTarget(t, bus, TenthsToFixed(-200)) // the 1 s retry lands it
|
||||||
|
if err := d.SetGain(-200, 0); err != nil {
|
||||||
|
t.Errorf("stale error after recovery: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestADAU1467Controller drives the real device through the same Controller
|
||||||
|
// the app uses, from 0 % to 100 %, and checks the clamp holds on every write.
|
||||||
|
func TestADAU1467Controller(t *testing.T) {
|
||||||
|
bus := newFakeBus()
|
||||||
|
d := NewADAU1467(bus, nil)
|
||||||
|
defer d.Close()
|
||||||
|
c := NewController(d)
|
||||||
|
for p := 0; p <= 100; p += 5 {
|
||||||
|
if err := c.SetVolume(p); err != nil {
|
||||||
|
t.Fatalf("SetVolume(%d): %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitTarget(t, bus, unity)
|
||||||
|
c.SetVolume(0)
|
||||||
|
waitTarget(t, bus, 0)
|
||||||
|
bus.mu.Lock()
|
||||||
|
defer bus.mu.Unlock()
|
||||||
|
for _, w := range bus.writes {
|
||||||
|
if binary.BigEndian.Uint16(w) == safeloadData && binary.BigEndian.Uint32(w[2:]) > unity {
|
||||||
|
t.Errorf("wrote % X above 0 dB", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADAU1467Ping(t *testing.T) {
|
||||||
|
bus := newFakeBus()
|
||||||
|
d := NewADAU1467(bus, nil)
|
||||||
|
defer d.Close()
|
||||||
|
if err := d.Ping(); err != nil {
|
||||||
|
t.Errorf("running core: %v", err)
|
||||||
|
}
|
||||||
|
bus.mu.Lock()
|
||||||
|
bus.status = 0
|
||||||
|
bus.mu.Unlock()
|
||||||
|
if err := d.Ping(); err == nil {
|
||||||
|
t.Error("stopped core reported healthy")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Package dsp is the audio DSP / DAC driver: loudness curve, a controller that
|
||||||
|
// turns UI commands into device calls, and a simulated device.
|
||||||
|
package dsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MuteGain is the sentinel gain meaning "hard mute".
|
||||||
|
const MuteGain = -32768
|
||||||
|
|
||||||
|
// gainTenths is the 21-entry loudness lookup (SDS §9): perceived loudness
|
||||||
|
// halves every -10 dB, so dB = 10*log2(pct/100). Values are tenths of a dB;
|
||||||
|
// index = percent / 5. Entry 0 is a hard mute. No floating point at runtime.
|
||||||
|
var gainTenths = [21]int{
|
||||||
|
MuteGain, -432, -332, -274, -232, -200, -174, -151, -132, -115, -100,
|
||||||
|
-86, -74, -62, -51, -42, -32, -23, -15, -7, 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GainTenthsDB converts a UI percentage (0..100, 5% steps) to a gain in tenths
|
||||||
|
// of a dB. mute is true at 0%.
|
||||||
|
func GainTenthsDB(pct int) (tenths int, mute bool) {
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
g := gainTenths[pct/5]
|
||||||
|
return g, g == MuteGain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is the low-level DSP interface (SPI/I2C in hardware). The DSP slews
|
||||||
|
// gain changes itself, so rampMs is a request, not something the UI steps.
|
||||||
|
type Device interface {
|
||||||
|
Ping() error
|
||||||
|
SetGain(tenthsDB int, rampMs int) error
|
||||||
|
Mute(on bool) error
|
||||||
|
SelectInput(name string) error
|
||||||
|
EnableDAC(on bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller maps UI-level commands (percent, mute, source) to Device calls.
|
||||||
|
type Controller struct {
|
||||||
|
dev Device
|
||||||
|
pct int
|
||||||
|
muted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewController(d Device) *Controller { return &Controller{dev: d} }
|
||||||
|
|
||||||
|
func (c *Controller) apply(ms int) error {
|
||||||
|
g, zero := GainTenthsDB(c.pct)
|
||||||
|
if !zero {
|
||||||
|
if err := c.dev.SetGain(g, ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.dev.Mute(c.muted || zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVolume sets the level; 0% is a hard mute.
|
||||||
|
func (c *Controller) SetVolume(pct int) error { c.pct = pct; return c.apply(0) }
|
||||||
|
|
||||||
|
// Mute mutes or unmutes without changing the level (0% stays muted).
|
||||||
|
func (c *Controller) Mute(on bool) error { c.muted = on; return c.apply(0) }
|
||||||
|
|
||||||
|
// RampTo slews to a level and unmutes (used for boot, wake and sleep).
|
||||||
|
func (c *Controller) RampTo(pct, ms int) error {
|
||||||
|
c.muted = false
|
||||||
|
c.pct = pct
|
||||||
|
return c.apply(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) SetSource(name string) error { return c.dev.SelectInput(name) }
|
||||||
|
func (c *Controller) EnableDAC(on bool) error { return c.dev.EnableDAC(on) }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- simulation
|
||||||
|
|
||||||
|
// ErrDown is returned by a failing Sim.
|
||||||
|
var ErrDown = errors.New("dsp: not responding")
|
||||||
|
|
||||||
|
// SimState is the observable state of the simulated DSP.
|
||||||
|
type SimState struct {
|
||||||
|
GainTenths int
|
||||||
|
RampMs int
|
||||||
|
Muted bool
|
||||||
|
Input string
|
||||||
|
DAC bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sim is a simulated DSP for the emulator and for tests.
|
||||||
|
type Sim struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
st SimState
|
||||||
|
failing bool
|
||||||
|
Calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSim() *Sim { return &Sim{} }
|
||||||
|
|
||||||
|
// State returns a copy of the current state.
|
||||||
|
func (s *Sim) State() SimState {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.st
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFailing makes every call fail (a dead DSP) or recover.
|
||||||
|
func (s *Sim) SetFailing(b bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.failing = b
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) enter(call string) error {
|
||||||
|
s.Calls = append(s.Calls, call)
|
||||||
|
if s.failing {
|
||||||
|
return ErrDown
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Ping() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.failing {
|
||||||
|
return ErrDown
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) SetGain(t, ms int) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.enter("gain"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.st.GainTenths, s.st.RampMs = t, ms
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) Mute(on bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.enter("mute"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.st.Muted = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) SelectInput(n string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.enter("input"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.st.Input = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sim) EnableDAC(on bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.enter("dac"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.st.DAC = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package dsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoudnessTableMatchesSpec(t *testing.T) {
|
||||||
|
spec := map[int]int{100: 0, 90: -15, 75: -42, 50: -100, 25: -200, 10: -332, 5: -432}
|
||||||
|
for pct, want := range spec {
|
||||||
|
got, mute := GainTenthsDB(pct)
|
||||||
|
if mute || got != want {
|
||||||
|
t.Errorf("GainTenthsDB(%d) = (%d,%v), want (%d,false)", pct, got, mute, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, mute := GainTenthsDB(0); !mute {
|
||||||
|
t.Error("0% must be a hard mute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table must equal 10*log2(p) rounded to tenths of a dB.
|
||||||
|
func TestLoudnessTableIsTheFormula(t *testing.T) {
|
||||||
|
for k := 1; k <= 20; k++ {
|
||||||
|
p := float64(k) / 20
|
||||||
|
want := 10 * math.Log2(p) * 10
|
||||||
|
got, _ := GainTenthsDB(k * 5)
|
||||||
|
if math.Abs(float64(got)-want) > 0.51 {
|
||||||
|
t.Errorf("pct %d: table %d, formula %.2f", k*5, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableIsMonotonicAndClamped(t *testing.T) {
|
||||||
|
prev, _ := GainTenthsDB(5)
|
||||||
|
for pct := 10; pct <= 100; pct += 5 {
|
||||||
|
g, _ := GainTenthsDB(pct)
|
||||||
|
if g <= prev {
|
||||||
|
t.Errorf("not increasing at %d%%: %d <= %d", pct, g, prev)
|
||||||
|
}
|
||||||
|
prev = g
|
||||||
|
}
|
||||||
|
if g, _ := GainTenthsDB(100); g != 0 {
|
||||||
|
t.Error("100% must be 0 dB, never above the calibrated maximum")
|
||||||
|
}
|
||||||
|
if g, _ := GainTenthsDB(500); g != 0 {
|
||||||
|
t.Error("out-of-range input must clamp to 0 dB")
|
||||||
|
}
|
||||||
|
if _, m := GainTenthsDB(-10); !m {
|
||||||
|
t.Error("negative input must clamp to mute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestController(t *testing.T) {
|
||||||
|
sim := NewSim()
|
||||||
|
c := NewController(sim)
|
||||||
|
|
||||||
|
if err := c.SetVolume(50); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st := sim.State(); st.GainTenths != -100 || st.Muted {
|
||||||
|
t.Errorf("after 50%%: %+v", st)
|
||||||
|
}
|
||||||
|
c.Mute(true)
|
||||||
|
if !sim.State().Muted {
|
||||||
|
t.Error("explicit mute lost")
|
||||||
|
}
|
||||||
|
c.Mute(false)
|
||||||
|
if sim.State().Muted {
|
||||||
|
t.Error("unmute lost")
|
||||||
|
}
|
||||||
|
// 0% is a hard mute even when not user-muted, and stays muted on unmute
|
||||||
|
c.SetVolume(0)
|
||||||
|
if !sim.State().Muted {
|
||||||
|
t.Error("0% must mute")
|
||||||
|
}
|
||||||
|
c.Mute(false)
|
||||||
|
if !sim.State().Muted {
|
||||||
|
t.Error("unmute at 0% must stay muted")
|
||||||
|
}
|
||||||
|
// ramp unmutes and requests the slew time
|
||||||
|
c.Mute(true)
|
||||||
|
c.RampTo(30, 1000)
|
||||||
|
if st := sim.State(); st.Muted || st.RampMs != 1000 || st.GainTenths != -174 {
|
||||||
|
t.Errorf("after ramp: %+v", st)
|
||||||
|
}
|
||||||
|
c.SetSource("BT")
|
||||||
|
c.EnableDAC(true)
|
||||||
|
if st := sim.State(); st.Input != "BT" || !st.DAC {
|
||||||
|
t.Errorf("source/DAC: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimFailure(t *testing.T) {
|
||||||
|
sim := NewSim()
|
||||||
|
c := NewController(sim)
|
||||||
|
sim.SetFailing(true)
|
||||||
|
if err := c.SetVolume(50); err == nil {
|
||||||
|
t.Error("expected error from dead DSP")
|
||||||
|
}
|
||||||
|
if err := sim.Ping(); err == nil {
|
||||||
|
t.Error("ping must fail")
|
||||||
|
}
|
||||||
|
sim.SetFailing(false)
|
||||||
|
if err := sim.Ping(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package dsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linux i2c-dev ioctls and flags (include/uapi/linux/i2c-dev.h, i2c.h).
|
||||||
|
const (
|
||||||
|
i2cRDWR = 0x0707
|
||||||
|
i2cMRd = 0x0001
|
||||||
|
)
|
||||||
|
|
||||||
|
// i2cMsg mirrors struct i2c_msg on 64-bit Linux.
|
||||||
|
type i2cMsg struct {
|
||||||
|
addr uint16
|
||||||
|
flags uint16
|
||||||
|
len uint16
|
||||||
|
_ uint16
|
||||||
|
buf uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
// i2cRdwrData mirrors struct i2c_rdwr_ioctl_data on 64-bit Linux.
|
||||||
|
type i2cRdwrData struct {
|
||||||
|
msgs uintptr
|
||||||
|
nmsgs uint32
|
||||||
|
_ uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevBus talks to one I2C device through /dev/i2c-N using I2C_RDWR, so a
|
||||||
|
// register read is a single write+repeated-start+read transaction.
|
||||||
|
type DevBus struct {
|
||||||
|
f *os.File
|
||||||
|
addr uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDevBus opens an i2c-dev node (e.g. "/dev/i2c-1") for the 7-bit address addr.
|
||||||
|
func OpenDevBus(path string, addr uint16) (*DevBus, error) {
|
||||||
|
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &DevBus{f: f, addr: addr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tx writes w, then (if r is non-empty) reads len(r) bytes after a repeated start.
|
||||||
|
func (b *DevBus) Tx(w, r []byte) error {
|
||||||
|
var msgs []i2cMsg
|
||||||
|
if len(w) > 0 {
|
||||||
|
msgs = append(msgs, i2cMsg{addr: b.addr, len: uint16(len(w)), buf: uintptr(unsafe.Pointer(&w[0]))})
|
||||||
|
}
|
||||||
|
if len(r) > 0 {
|
||||||
|
msgs = append(msgs, i2cMsg{addr: b.addr, flags: i2cMRd, len: uint16(len(r)), buf: uintptr(unsafe.Pointer(&r[0]))})
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data := i2cRdwrData{msgs: uintptr(unsafe.Pointer(&msgs[0])), nmsgs: uint32(len(msgs))}
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, b.f.Fd(), i2cRDWR, uintptr(unsafe.Pointer(&data)))
|
||||||
|
runtime.KeepAlive(w)
|
||||||
|
runtime.KeepAlive(r)
|
||||||
|
runtime.KeepAlive(msgs)
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("i2c 0x%02X: %w", b.addr, errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *DevBus) Close() error { return b.f.Close() }
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package fcim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"fusion/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FCIM CAN protocol, from car-radio/code/ccode (bench-verified): the FCIM
|
||||||
|
// reports a press on 0x2E0 with Data[0] = button code and Data[1] = 0x88. It
|
||||||
|
// only reports while the radio (node 0x0C) answers its 0x55D network-
|
||||||
|
// management frames with 0x50C.
|
||||||
|
const (
|
||||||
|
buttonID = 0x2E0
|
||||||
|
pressFlag = 0x88
|
||||||
|
fcimNM = 0x55D
|
||||||
|
)
|
||||||
|
|
||||||
|
var radioNM = can.Frame{ID: 0x50C, Len: 3, Data: [8]uint8{0x0C, 0x02, 0x00}}
|
||||||
|
|
||||||
|
// buttonNames maps FCIM codes to core.ParseKey names. Codes come from
|
||||||
|
// ccode/fcim.go and may still need re-capturing (cmd/fcimread in ccode).
|
||||||
|
var buttonNames = map[uint8]string{
|
||||||
|
0x01: "POWER",
|
||||||
|
0x02: "OK",
|
||||||
|
0x03: "MENU",
|
||||||
|
0x08: "CAT_FOLD",
|
||||||
|
0x0F: "CLOCK",
|
||||||
|
0x21: "SCAN",
|
||||||
|
0x24: "SEEK_RIGHT",
|
||||||
|
0x25: "SEEK_LEFT",
|
||||||
|
0x29: "TUNE_RIGHT",
|
||||||
|
0x2A: "TUNE_LEFT",
|
||||||
|
0x2B: "VOLUME_RIGHT", // volume up
|
||||||
|
0x2C: "VOLUME_LEFT", // volume down
|
||||||
|
0x30: "0", 0x31: "1", 0x32: "2", 0x33: "3", 0x34: "4",
|
||||||
|
0x35: "5", 0x36: "6", 0x37: "7", 0x38: "8", 0x39: "9",
|
||||||
|
0x43: "TEXT",
|
||||||
|
0x76: "DIRECT",
|
||||||
|
0x77: "SOUND",
|
||||||
|
0x78: "AUX",
|
||||||
|
0x79: "SIRIUS",
|
||||||
|
0x80: "AM_FM",
|
||||||
|
0x81: "CD",
|
||||||
|
0x82: "LOAD",
|
||||||
|
0x83: "EJECT",
|
||||||
|
0x84: "PHONE",
|
||||||
|
0x86: "SEEK_UP",
|
||||||
|
0x87: "SEEK_DOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
// CANSource is the real FCIM on MS-CAN. Subscribe Handle to the bus.
|
||||||
|
type CANSource struct {
|
||||||
|
tx can.Sender
|
||||||
|
trace func(string) // may be nil; one line per press as it arrives
|
||||||
|
ch chan string
|
||||||
|
mu sync.Mutex
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCANSource announces the radio once so the FCIM starts its NM exchange.
|
||||||
|
// trace may be nil.
|
||||||
|
func NewCANSource(tx can.Sender, trace func(string)) *CANSource {
|
||||||
|
s := &CANSource{tx: tx, trace: trace, ch: make(chan string, 64)}
|
||||||
|
tx.Send(radioNM)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle processes one received frame. Unknown codes are passed on as
|
||||||
|
// "FCIM_xx" so fcim.Run's unknown hook can log them for re-mapping.
|
||||||
|
func (s *CANSource) Handle(f can.Frame) {
|
||||||
|
switch {
|
||||||
|
case f.ID == fcimNM:
|
||||||
|
s.tx.Send(radioNM)
|
||||||
|
case f.ID == buttonID && f.Len >= 2 && f.Data[1] == pressFlag:
|
||||||
|
name, ok := buttonNames[f.Data[0]]
|
||||||
|
if !ok {
|
||||||
|
name = fmt.Sprintf("FCIM_%02X", f.Data[0])
|
||||||
|
}
|
||||||
|
if s.trace != nil {
|
||||||
|
s.trace("fcim: rx " + name)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.ch <- name:
|
||||||
|
default: // queue full: drop rather than stall the bus reader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CANSource) Pulses() <-chan string { return s.ch }
|
||||||
|
|
||||||
|
func (s *CANSource) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.done {
|
||||||
|
s.done = true
|
||||||
|
close(s.ch)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package fcim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fusion/can"
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sentFrames []can.Frame
|
||||||
|
|
||||||
|
func (s *sentFrames) Send(f can.Frame) error { *s = append(*s, f); return nil }
|
||||||
|
|
||||||
|
func press(code uint8) can.Frame {
|
||||||
|
return can.Frame{ID: 0x2E0, Len: 8, Data: [8]uint8{code, 0x88}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCANSourceButtons(t *testing.T) {
|
||||||
|
tx := &sentFrames{}
|
||||||
|
s := NewCANSource(tx, nil)
|
||||||
|
s.Handle(press(0x2B))
|
||||||
|
s.Handle(press(0x2C))
|
||||||
|
s.Handle(can.Frame{ID: 0x2E0, Len: 8, Data: [8]uint8{0x2B, 0x00}}) // not a press
|
||||||
|
s.Handle(press(0xEE))
|
||||||
|
for _, want := range []string{"VOLUME_RIGHT", "VOLUME_LEFT", "FCIM_EE"} {
|
||||||
|
if got := <-s.Pulses(); got != want {
|
||||||
|
t.Errorf("pulse = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case p := <-s.Pulses():
|
||||||
|
t.Errorf("unexpected pulse %q", p)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every mapped name must be one the core understands.
|
||||||
|
func TestButtonNamesParse(t *testing.T) {
|
||||||
|
for code, name := range buttonNames {
|
||||||
|
if _, ok := core.ParseKey(name); !ok {
|
||||||
|
t.Errorf("code %02X -> %q is not a core key", code, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCANSourceAnswersNM(t *testing.T) {
|
||||||
|
tx := &sentFrames{}
|
||||||
|
s := NewCANSource(tx, nil)
|
||||||
|
s.Handle(can.Frame{ID: 0x55D, Len: 8})
|
||||||
|
if len(*tx) != 2 || (*tx)[0] != radioNM || (*tx)[1] != radioNM {
|
||||||
|
t.Errorf("sent %v, want startup + reply 0x50C", *tx)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
s.Handle(press(0x2B)) // after close: must not panic
|
||||||
|
if _, ok := <-s.Pulses(); ok {
|
||||||
|
t.Error("channel still open after Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package fcim is the front control input module (FCIM): the buttons and
|
||||||
|
// knobs. Every control is a single pulse; there is no hold or release.
|
||||||
|
package fcim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source is the generic FCIM API: a stream of button pulses, each identified
|
||||||
|
// by its name (see core.ParseKey: "POWER", "VOLUME_LEFT", "TUNE_RIGHT",
|
||||||
|
// "OK", "MENU", "CLOCK", "0".."9", ...). Implementations: Chan (tests),
|
||||||
|
// NetSource (receives pulses from the FCIM emulator terminal), or a real CAN
|
||||||
|
// driver.
|
||||||
|
type Source interface {
|
||||||
|
Pulses() <-chan string
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run reads pulses from src and posts them to the core queue until ctx is
|
||||||
|
// done or the source closes (which is reported as FCIM health Down). Unknown
|
||||||
|
// button names are passed to unknown (may be nil) and otherwise ignored.
|
||||||
|
func Run(ctx context.Context, src Source, post func(core.Event), unknown func(string)) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case name, ok := <-src.Pulses():
|
||||||
|
if !ok {
|
||||||
|
post(core.HealthEvent{Device: core.DevFCIM, Status: core.HealthDown})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k, valid := core.ParseKey(name)
|
||||||
|
if !valid {
|
||||||
|
if unknown != nil {
|
||||||
|
unknown(name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
post(core.KeyEvent{Key: k})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chan is an in-memory Source for tests.
|
||||||
|
type Chan struct {
|
||||||
|
ch chan string
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChan() *Chan { return &Chan{ch: make(chan string, 64)} }
|
||||||
|
|
||||||
|
// Press injects one pulse.
|
||||||
|
func (c *Chan) Press(name string) { c.ch <- name }
|
||||||
|
|
||||||
|
func (c *Chan) Pulses() <-chan string { return c.ch }
|
||||||
|
|
||||||
|
func (c *Chan) Close() error {
|
||||||
|
c.once.Do(func() { close(c.ch) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package fcim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunPostsKeysAndReportsUnknown(t *testing.T) {
|
||||||
|
src := NewChan()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
events := make(chan core.Event, 8)
|
||||||
|
unknown := make(chan string, 8)
|
||||||
|
go Run(ctx, src, func(e core.Event) { events <- e }, func(n string) { unknown <- n })
|
||||||
|
|
||||||
|
src.Press("VOLUME_RIGHT")
|
||||||
|
src.Press("BOGUS")
|
||||||
|
src.Press("ok")
|
||||||
|
src.Press("7")
|
||||||
|
|
||||||
|
want := []core.Key{core.KeyVolRight, core.KeyOK, core.Key7}
|
||||||
|
for _, w := range want {
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
if ke, ok := e.(core.KeyEvent); !ok || ke.Key != w {
|
||||||
|
t.Fatalf("got %#v, want key %v", e, w)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timeout waiting for key event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case n := <-unknown:
|
||||||
|
if n != "BOGUS" {
|
||||||
|
t.Errorf("unknown = %q", n)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("unknown button not reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReportsClosedSource(t *testing.T) {
|
||||||
|
src := NewChan()
|
||||||
|
events := make(chan core.Event, 2)
|
||||||
|
go Run(context.Background(), src, func(e core.Event) { events <- e }, nil)
|
||||||
|
src.Close()
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
h, ok := e.(core.HealthEvent)
|
||||||
|
if !ok || h.Device != core.DevFCIM || h.Status != core.HealthDown {
|
||||||
|
t.Fatalf("got %#v", e)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("no health event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetSourceAndSender(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no loopback networking:", err)
|
||||||
|
}
|
||||||
|
ns := NewNetSource(ln)
|
||||||
|
defer ns.Close()
|
||||||
|
s := NewSender(ns.Addr().String())
|
||||||
|
defer s.Close()
|
||||||
|
for _, b := range []string{"POWER", "TUNE_LEFT"} {
|
||||||
|
if err := s.Press(b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"POWER", "TUNE_LEFT"} {
|
||||||
|
select {
|
||||||
|
case got := <-ns.Pulses():
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapKeys(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
n int
|
||||||
|
}{
|
||||||
|
{"\r", "OK", 1}, {" ", "OK", 1}, {"m", "MENU", 1}, {"M", "MENU", 1},
|
||||||
|
{"p", "POWER", 1}, {"c", "CLOCK", 1}, {"a", "AUX", 1}, {"s", "SIRIUS", 1},
|
||||||
|
{"h", "PHONE", 1}, {"o", "SOUND", 1}, {",", "SEEK_LEFT", 1}, {".", "SEEK_RIGHT", 1},
|
||||||
|
{"[", "VOLUME_LEFT", 1}, {"]", "VOLUME_RIGHT", 1}, {"-", "VOLUME_LEFT", 1}, {"=", "VOLUME_RIGHT", 1},
|
||||||
|
{"5", "5", 1}, {"0", "0", 1}, {"q", "QUIT", 1}, {"\x03", "QUIT", 1},
|
||||||
|
{"\x1b[D", "TUNE_LEFT", 3}, {"\x1b[C", "TUNE_RIGHT", 3},
|
||||||
|
{"\x1b[A", "VOLUME_RIGHT", 3}, {"\x1b[B", "VOLUME_LEFT", 3},
|
||||||
|
{"\x1b", "", 1}, {"\x1b[Z", "", 3}, {"z", "", 1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, n := MapKeys([]byte(c.in))
|
||||||
|
if got != c.want || n != c.n {
|
||||||
|
t.Errorf("MapKeys(%q) = (%q,%d), want (%q,%d)", c.in, got, n, c.want, c.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, n := MapKeys(nil); n != 0 {
|
||||||
|
t.Error("empty buffer must consume nothing")
|
||||||
|
}
|
||||||
|
// every mapped name (except QUIT) must be a known core key
|
||||||
|
for _, c := range cases {
|
||||||
|
if c.want == "" || c.want == "QUIT" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := core.ParseKey(c.want); !ok {
|
||||||
|
t.Errorf("emulator sends %q which the core cannot parse", c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package fcim
|
||||||
|
|
||||||
|
// Legend lists the emulator's keyboard layout for display.
|
||||||
|
var Legend = []string{
|
||||||
|
"[ / ] or - / = VOLUME left/right left/right arrows TUNE left/right",
|
||||||
|
"Enter / Space OK (play/pause) m MENU p POWER c CLOCK",
|
||||||
|
", / . SEEK left/right a AUX s SIRIUS h PHONE o SOUND",
|
||||||
|
"0-9 digits up/down arrows VOLUME right/left",
|
||||||
|
"l LOAD e EJECT d DIRECT n SCAN f CAT/FOLD t TEXT r AM/FM v CD u/j SEEK up/down",
|
||||||
|
"q or Ctrl-C quit",
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapKeys translates the first keystroke in buf (raw terminal bytes) to a
|
||||||
|
// button name. It returns the name ("" if the key is unmapped) and how many
|
||||||
|
// bytes were consumed (0 only if buf is empty). Quit is reported as "QUIT".
|
||||||
|
func MapKeys(buf []byte) (string, int) {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
c := buf[0]
|
||||||
|
if c == 0x1b {
|
||||||
|
if len(buf) >= 3 && (buf[1] == '[' || buf[1] == 'O') {
|
||||||
|
switch buf[2] {
|
||||||
|
case 'A':
|
||||||
|
return "VOLUME_RIGHT", 3
|
||||||
|
case 'B':
|
||||||
|
return "VOLUME_LEFT", 3
|
||||||
|
case 'C':
|
||||||
|
return "TUNE_RIGHT", 3
|
||||||
|
case 'D':
|
||||||
|
return "TUNE_LEFT", 3
|
||||||
|
}
|
||||||
|
return "", 3
|
||||||
|
}
|
||||||
|
return "", 1
|
||||||
|
}
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
c += 'a' - 'A'
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case 0x03, 'q':
|
||||||
|
return "QUIT", 1
|
||||||
|
case '\r', '\n', ' ':
|
||||||
|
return "OK", 1
|
||||||
|
case 'm':
|
||||||
|
return "MENU", 1
|
||||||
|
case 'p':
|
||||||
|
return "POWER", 1
|
||||||
|
case 'c':
|
||||||
|
return "CLOCK", 1
|
||||||
|
case 'a':
|
||||||
|
return "AUX", 1
|
||||||
|
case 's':
|
||||||
|
return "SIRIUS", 1
|
||||||
|
case 'h':
|
||||||
|
return "PHONE", 1
|
||||||
|
case 'o':
|
||||||
|
return "SOUND", 1
|
||||||
|
case ',':
|
||||||
|
return "SEEK_LEFT", 1
|
||||||
|
case '.':
|
||||||
|
return "SEEK_RIGHT", 1
|
||||||
|
case 'u':
|
||||||
|
return "SEEK_UP", 1
|
||||||
|
case 'j':
|
||||||
|
return "SEEK_DOWN", 1
|
||||||
|
case '[', '-':
|
||||||
|
return "VOLUME_LEFT", 1
|
||||||
|
case ']', '=', '+':
|
||||||
|
return "VOLUME_RIGHT", 1
|
||||||
|
case 'l':
|
||||||
|
return "LOAD", 1
|
||||||
|
case 'e':
|
||||||
|
return "EJECT", 1
|
||||||
|
case 'd':
|
||||||
|
return "DIRECT", 1
|
||||||
|
case 'n':
|
||||||
|
return "SCAN", 1
|
||||||
|
case 'f':
|
||||||
|
return "CAT_FOLD", 1
|
||||||
|
case 't':
|
||||||
|
return "TEXT", 1
|
||||||
|
case 'r':
|
||||||
|
return "AM_FM", 1
|
||||||
|
case 'v':
|
||||||
|
return "CD", 1
|
||||||
|
}
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
return string(rune(c)), 1
|
||||||
|
}
|
||||||
|
return "", 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package fcim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NetSource receives button pulses from FCIM emulators over TCP. The wire
|
||||||
|
// protocol is one button name per line ("VOLUME_RIGHT\n"). Any number of
|
||||||
|
// emulators may be connected; their pulses are merged.
|
||||||
|
type NetSource struct {
|
||||||
|
ln net.Listener
|
||||||
|
ch chan string
|
||||||
|
closed chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen starts accepting emulator connections on addr.
|
||||||
|
func Listen(addr string) (*NetSource, error) {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewNetSource(ln), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNetSource serves pulses from an existing listener.
|
||||||
|
func NewNetSource(ln net.Listener) *NetSource {
|
||||||
|
n := &NetSource{ln: ln, ch: make(chan string, 256), closed: make(chan struct{})}
|
||||||
|
go n.accept()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr is the listening address.
|
||||||
|
func (n *NetSource) Addr() net.Addr { return n.ln.Addr() }
|
||||||
|
|
||||||
|
func (n *NetSource) accept() {
|
||||||
|
for {
|
||||||
|
c, err := n.ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go n.serve(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetSource) serve(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
sc := bufio.NewScanner(c)
|
||||||
|
for sc.Scan() {
|
||||||
|
name := strings.TrimSpace(sc.Text())
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case n.ch <- name:
|
||||||
|
case <-n.closed:
|
||||||
|
return
|
||||||
|
default: // queue full: drop the pulse rather than stall the link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetSource) Pulses() <-chan string { return n.ch }
|
||||||
|
|
||||||
|
func (n *NetSource) Close() error {
|
||||||
|
n.once.Do(func() {
|
||||||
|
close(n.closed)
|
||||||
|
n.ln.Close()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender is the emulator side: it connects to the core's FCIM port on demand
|
||||||
|
// and writes one line per pulse.
|
||||||
|
type Sender struct {
|
||||||
|
addr string
|
||||||
|
mu sync.Mutex
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSender(addr string) *Sender { return &Sender{addr: addr} }
|
||||||
|
|
||||||
|
// Press sends one pulse, (re)connecting if needed.
|
||||||
|
func (s *Sender) Press(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.conn == nil {
|
||||||
|
c, err := net.DialTimeout("tcp", s.addr, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.conn = c
|
||||||
|
}
|
||||||
|
s.conn.SetWriteDeadline(time.Now().Add(time.Second))
|
||||||
|
if _, err := s.conn.Write([]byte(name + "\n")); err != nil {
|
||||||
|
s.conn.Close()
|
||||||
|
s.conn = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected reports whether a connection is currently held.
|
||||||
|
func (s *Sender) Connected() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.conn != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.conn != nil {
|
||||||
|
s.conn.Close()
|
||||||
|
s.conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefreshInterval is how long CANDisplay waits after its last send before
|
||||||
|
// resending the text and clock: the FDIM drops text it hasn't seen for about
|
||||||
|
// a second.
|
||||||
|
const RefreshInterval = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// StatusInterval is how often CANDisplay sends the frames that must keep
|
||||||
|
// flowing regardless of text changes: the backlight and the clock.
|
||||||
|
const StatusInterval = time.Second
|
||||||
|
|
||||||
|
// frameGap spaces the frames of one text message, as the bench code does.
|
||||||
|
const frameGap = 5 * time.Millisecond
|
||||||
|
|
||||||
|
// CANDisplay is the real FDIM on MS-CAN. Calls only record the newest
|
||||||
|
// rows/clock and wake one sender goroutine, so the app loop never waits on
|
||||||
|
// CAN; during a fast volume spin the intermediate frames are skipped and the
|
||||||
|
// display jumps straight to the latest one.
|
||||||
|
type CANDisplay struct {
|
||||||
|
tx can.Sender
|
||||||
|
refresh time.Duration
|
||||||
|
status time.Duration
|
||||||
|
trace func(string) // may be nil; one timing line per change-driven text send
|
||||||
|
wake chan struct{}
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
r1, r2 string
|
||||||
|
haveText bool
|
||||||
|
textDirty bool
|
||||||
|
clock *can.Frame
|
||||||
|
clockDirty bool
|
||||||
|
changedAt time.Time // first unsent change
|
||||||
|
coalesced int // changes folded into the next send
|
||||||
|
err error // last send error, reported by the next call
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCANDisplay starts the sender; Close stops it. trace may be nil.
|
||||||
|
func NewCANDisplay(tx can.Sender, trace func(string)) *CANDisplay {
|
||||||
|
return newCANDisplay(tx, RefreshInterval, StatusInterval, trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCANDisplay(tx can.Sender, refresh, status time.Duration, trace func(string)) *CANDisplay {
|
||||||
|
d := &CANDisplay{tx: tx, refresh: refresh, status: status, trace: trace,
|
||||||
|
wake: make(chan struct{}, 1), stop: make(chan struct{})}
|
||||||
|
go d.run()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *CANDisplay) run() {
|
||||||
|
t := time.NewTimer(d.refresh)
|
||||||
|
defer t.Stop()
|
||||||
|
st := time.NewTicker(d.status)
|
||||||
|
defer st.Stop()
|
||||||
|
d.tx.Send(BacklightFrame)
|
||||||
|
for {
|
||||||
|
refresh, status := false, false
|
||||||
|
select {
|
||||||
|
case <-d.stop:
|
||||||
|
return
|
||||||
|
case <-d.wake:
|
||||||
|
case <-t.C:
|
||||||
|
refresh = true
|
||||||
|
case <-st.C:
|
||||||
|
status = true
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
r1, r2 := d.r1, d.r2
|
||||||
|
sendText := d.haveText && (d.textDirty || refresh)
|
||||||
|
clock := d.clock
|
||||||
|
sendClock := clock != nil && (d.clockDirty || refresh || status)
|
||||||
|
changed := d.textDirty
|
||||||
|
changedAt, coalesced := d.changedAt, d.coalesced
|
||||||
|
d.textDirty, d.clockDirty, d.coalesced = false, false, 0
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var err error
|
||||||
|
if sendText {
|
||||||
|
err = d.sendText(r1, r2)
|
||||||
|
}
|
||||||
|
if sendClock && err == nil {
|
||||||
|
err = d.tx.Send(*clock)
|
||||||
|
}
|
||||||
|
if status && err == nil {
|
||||||
|
err = d.tx.Send(BacklightFrame)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.err = err
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
if changed && d.trace != nil {
|
||||||
|
d.trace(fmt.Sprintf("fdim: text sent in %.1f ms, %.1f ms after change, %d coalesced",
|
||||||
|
ms(time.Since(start)), ms(time.Since(changedAt)), coalesced))
|
||||||
|
}
|
||||||
|
if !status {
|
||||||
|
t.Reset(d.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *CANDisplay) sendText(r1, r2 string) error {
|
||||||
|
for i, f := range textFrames(r1, r2) {
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(frameGap)
|
||||||
|
}
|
||||||
|
if err := d.tx.Send(f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// note records a change and wakes the sender; d.mu must be held. It returns
|
||||||
|
// (and clears) the error from an earlier send.
|
||||||
|
func (d *CANDisplay) note() error {
|
||||||
|
if d.coalesced == 0 && !d.textDirty && !d.clockDirty {
|
||||||
|
d.changedAt = time.Now()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case d.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
err := d.err
|
||||||
|
d.err = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *CANDisplay) DisplayText(r1, r2 string) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.textDirty {
|
||||||
|
d.coalesced++
|
||||||
|
}
|
||||||
|
d.r1, d.r2, d.haveText = r1, r2, true
|
||||||
|
err := d.note()
|
||||||
|
d.textDirty = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSourceLabel is a no-op until the source box (AM/FM/SYNC/AUX) is decoded.
|
||||||
|
func (d *CANDisplay) SetSourceLabel(label string) error { return nil }
|
||||||
|
|
||||||
|
// SetClock queues the clock and keeps refreshing it. "" stops sending;
|
||||||
|
// the FDIM's own handling of a missing clock is untested.
|
||||||
|
func (d *CANDisplay) SetClock(text string) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if text == "" {
|
||||||
|
d.clock = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := clockFrame(text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.clock = &f
|
||||||
|
err = d.note()
|
||||||
|
d.clockDirty = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *CANDisplay) KeepAlive() error { return d.tx.Send(KeepAliveFrame) }
|
||||||
|
|
||||||
|
func (d *CANDisplay) Close() error {
|
||||||
|
d.once.Do(func() { close(d.stop) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sentFrames records frames; each Send takes delay, like a busy bus.
|
||||||
|
type sentFrames struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
f []can.Frame
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sentFrames) Send(f can.Frame) error {
|
||||||
|
time.Sleep(s.delay)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.f = append(s.f, f)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sentFrames) snapshot() []can.Frame {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]can.Frame(nil), s.f...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor polls cond for up to a second.
|
||||||
|
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
for end := time.Now().Add(time.Second); time.Now().Before(end); time.Sleep(5 * time.Millisecond) {
|
||||||
|
if cond() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("timed out waiting for %s", what)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastText decodes the rows of the last complete 0x337 message in fs.
|
||||||
|
func lastText(fs []can.Frame) string {
|
||||||
|
var payload []byte
|
||||||
|
var last string
|
||||||
|
for _, f := range fs {
|
||||||
|
if f.ID != textID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Data[0]&0xF0 == 0x10 {
|
||||||
|
payload = append([]byte(nil), f.Data[2:]...)
|
||||||
|
} else {
|
||||||
|
payload = append(payload, f.Data[1:]...)
|
||||||
|
}
|
||||||
|
if len(payload) >= mainLen {
|
||||||
|
last = strings.ReplaceAll(string(payload[:mainLen]), "@", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTextFrames pins the frames for "ABCDEFGHIJKLMNOPQRST"/"01234567890123456789",
|
||||||
|
// the "both rows" case of ccode's fdimtest that displayed correctly on the bench.
|
||||||
|
func TestTextFrames(t *testing.T) {
|
||||||
|
got := textFrames("ABCDEFGHIJKLMNOPQRST", "01234567890123456789")
|
||||||
|
want := []can.Frame{
|
||||||
|
{ID: 0x336, Len: 8, Data: [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03}},
|
||||||
|
{ID: 0x324, Len: 8, Data: [8]uint8{0x03, 0x01, 0x02}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x10, 0x2B, 'A', 'B', 'C', '@', 'D', 'E'}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x21, 'F', 'G', 'H', 'I', 'J', '@', 'K'}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x22, 'L', 'M', 'N', 'O', 'P', 'Q', 'R'}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x23, 'S', 'T', '0', '1', '2', '@', '3'}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x24, '4', '5', '6', '7', '8', '9', '0'}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x25, '1', '2', '3', '4', '5', '6', '7'}},
|
||||||
|
{ID: 0x337, Len: 8, Data: [8]uint8{0x26, '8', '9'}},
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d frames, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("frame %d = %v, want %v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFitRow(t *testing.T) {
|
||||||
|
if got := fitRow("a@b\x01é"); got != "a?b??"+" " {
|
||||||
|
t.Errorf("fitRow = %q", got)
|
||||||
|
}
|
||||||
|
if got := fitRow("123456789012345678901234"); got != "12345678901234567890" {
|
||||||
|
t.Errorf("fitRow long = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClockFrame(t *testing.T) {
|
||||||
|
f, err := clockFrame("4:07 PM")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := can.Frame{ID: 0x3F2, Len: 8, Data: [8]uint8{0x04, 0x07, 0, 0, 0, 0xF0, 0, 0}}
|
||||||
|
if f != want {
|
||||||
|
t.Errorf("clockFrame = %v, want %v", f, want)
|
||||||
|
}
|
||||||
|
if f, _ := clockFrame("12:45 AM"); f.Data[0] != 0x12 || f.Data[1] != 0x45 {
|
||||||
|
t.Errorf("12:45 = % X", f.Data[:2])
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "13:00 PM", "4:61 AM", "noon"} {
|
||||||
|
if _, err := clockFrame(bad); err == nil {
|
||||||
|
t.Errorf("clockFrame(%q) accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCANDisplaySends(t *testing.T) {
|
||||||
|
tx := &sentFrames{}
|
||||||
|
d := NewCANDisplay(tx, nil)
|
||||||
|
defer d.Close()
|
||||||
|
if err := d.DisplayText("HELLO", "WORLD"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := d.SetClock("9:30 AM"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitFor(t, "text and clock", func() bool {
|
||||||
|
fs := tx.snapshot()
|
||||||
|
return len(fs) >= 10 && fs[len(fs)-1].ID == clockID
|
||||||
|
})
|
||||||
|
if got, want := lastText(tx.snapshot()), fitRow("HELLO")+fitRow("WORLD"); got != want {
|
||||||
|
t.Errorf("text = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCANDisplayLatestWins spins the "volume" fast against a slow bus: calls
|
||||||
|
// must not block, intermediate rows are skipped, and the last one is shown.
|
||||||
|
func TestCANDisplayLatestWins(t *testing.T) {
|
||||||
|
tx := &sentFrames{delay: 2 * time.Millisecond} // ~60 ms per text message
|
||||||
|
var mu sync.Mutex
|
||||||
|
var traces []string
|
||||||
|
d := NewCANDisplay(tx, func(s string) { mu.Lock(); traces = append(traces, s); mu.Unlock() })
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for v := 0; v <= 100; v += 5 {
|
||||||
|
d.DisplayText("VOLUME", strings.Repeat("#", v/5))
|
||||||
|
}
|
||||||
|
if took := time.Since(start); took > 10*time.Millisecond {
|
||||||
|
t.Errorf("21 DisplayText calls took %v; they must not wait on the bus", took)
|
||||||
|
}
|
||||||
|
want := fitRow("VOLUME") + fitRow(strings.Repeat("#", 20))
|
||||||
|
waitFor(t, "final rows", func() bool { return lastText(tx.snapshot()) == want })
|
||||||
|
if n := len(tx.snapshot()) / 9; n >= 21 {
|
||||||
|
t.Errorf("sent %d text messages for 21 changes; expected coalescing", n)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(traces) == 0 || !strings.Contains(traces[len(traces)-1], "coalesced") {
|
||||||
|
t.Errorf("traces = %q", traces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCANDisplayRefresh(t *testing.T) {
|
||||||
|
tx := &sentFrames{}
|
||||||
|
d := newCANDisplay(tx, 20*time.Millisecond, time.Hour, nil)
|
||||||
|
defer d.Close()
|
||||||
|
d.DisplayText("HELLO", "")
|
||||||
|
waitFor(t, "two refreshes", func() bool { return len(tx.snapshot()) >= 3*9 })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCANDisplayKeepAlive(t *testing.T) {
|
||||||
|
tx := &sentFrames{}
|
||||||
|
d := NewCANDisplay(tx, nil)
|
||||||
|
defer d.Close()
|
||||||
|
if err := d.KeepAlive(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitFor(t, "keep-alive", func() bool { return count(tx.snapshot(), KeepAliveFrame) == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCANDisplayBacklight: the backlight frame goes out at start and keeps
|
||||||
|
// going every status interval, even with no text at all.
|
||||||
|
func TestCANDisplayBacklight(t *testing.T) {
|
||||||
|
tx := &sentFrames{}
|
||||||
|
d := newCANDisplay(tx, time.Hour, 20*time.Millisecond, nil)
|
||||||
|
defer d.Close()
|
||||||
|
waitFor(t, "three backlight frames", func() bool { return count(tx.snapshot(), BacklightFrame) >= 3 })
|
||||||
|
if fs := tx.snapshot(); fs[0] != BacklightFrame {
|
||||||
|
t.Errorf("first frame = %v, want the backlight", fs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(fs []can.Frame, f can.Frame) int {
|
||||||
|
n := 0
|
||||||
|
for _, g := range fs {
|
||||||
|
if g == f {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fusion/can"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FDIM CAN protocol, ported from car-radio/code/ccode (bench-verified):
|
||||||
|
// two text rows go out as one 43-byte ISO-TP message on 0x337, preceded by
|
||||||
|
// the "pair B" header frames on 0x336 and 0x324 (FoDiMoCo layout). The clock
|
||||||
|
// is a BCD frame on 0x3F2. The radio keeps the FDIM awake by announcing
|
||||||
|
// itself as node 0x0C on 0x50C.
|
||||||
|
|
||||||
|
const (
|
||||||
|
textID = 0x337
|
||||||
|
clockID = 0x3F2
|
||||||
|
radioNM = 0x50C
|
||||||
|
mainLen = 43
|
||||||
|
padByte = 0x00
|
||||||
|
sepByte = '@'
|
||||||
|
rowWidth = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pairB336 = can.Frame{ID: 0x336, Len: 8, Data: [8]uint8{0x03, 0x01, 0x05, 0x03, 0x03}}
|
||||||
|
pairB324 = can.Frame{ID: 0x324, Len: 8, Data: [8]uint8{0x03, 0x01, 0x02}}
|
||||||
|
|
||||||
|
// BacklightFrame is the 0x3E8 baseline (source AUX, byte 2 0x10, byte 3
|
||||||
|
// bit 0x04) that the bench tools found turns the backlight on when sent
|
||||||
|
// about once a second. Which byte does it is not isolated yet
|
||||||
|
// (ccode/cmd/backlight).
|
||||||
|
BacklightFrame = can.Frame{ID: 0x3E8, Len: 8, Data: [8]uint8{0x07, 0x00, 0x10, 0x04}}
|
||||||
|
|
||||||
|
// KeepAliveFrame announces the radio (node 0x0C) in the network-management ring.
|
||||||
|
KeepAliveFrame = can.Frame{ID: radioNM, Len: 3, Data: [8]uint8{0x0C, 0x02, 0x00}}
|
||||||
|
)
|
||||||
|
|
||||||
|
// '@' sits at fixed indices 3, 11 and 25; the other 40 bytes are the rows.
|
||||||
|
var sepIdx = map[int]bool{3: true, 11: true, 25: true}
|
||||||
|
|
||||||
|
// buildTwoLine returns the 43-byte 0x337 payload for two 20-column rows.
|
||||||
|
func buildTwoLine(row1, row2 string) []byte {
|
||||||
|
p := make([]byte, mainLen)
|
||||||
|
text := fitRow(row1) + fitRow(row2)
|
||||||
|
k := 0
|
||||||
|
for i := range p {
|
||||||
|
if sepIdx[i] {
|
||||||
|
p[i] = sepByte
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p[i] = text[k]
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// fitRow sanitises to printable ASCII ('@' is a separator, so it's replaced)
|
||||||
|
// and pads or truncates to 20 columns.
|
||||||
|
func fitRow(s string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if r < 0x20 || r > 0x7E || r == sepByte {
|
||||||
|
r = '?'
|
||||||
|
}
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
s = sb.String()
|
||||||
|
if len(s) > rowWidth {
|
||||||
|
return s[:rowWidth]
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", rowWidth-len(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeISOTP splits payload into ISO 15765-2 frames (classic CAN, normal
|
||||||
|
// addressing, no flow control: the FDIM never sends one).
|
||||||
|
func encodeISOTP(id uint32, payload []byte) []can.Frame {
|
||||||
|
n := len(payload)
|
||||||
|
if n <= 7 {
|
||||||
|
f := can.Frame{ID: id, Len: 8}
|
||||||
|
f.Data[0] = uint8(n)
|
||||||
|
copy(f.Data[1:], payload)
|
||||||
|
return []can.Frame{f}
|
||||||
|
}
|
||||||
|
first := can.Frame{ID: id, Len: 8}
|
||||||
|
first.Data[0] = 0x10 | uint8(n>>8)
|
||||||
|
first.Data[1] = uint8(n)
|
||||||
|
copy(first.Data[2:], payload[:6])
|
||||||
|
out := []can.Frame{first}
|
||||||
|
sn := uint8(1)
|
||||||
|
for off := 6; off < n; off += 7 {
|
||||||
|
c := can.Frame{ID: id, Len: 8}
|
||||||
|
c.Data[0] = 0x20 | sn&0x0F
|
||||||
|
copy(c.Data[1:], payload[off:])
|
||||||
|
out = append(out, c)
|
||||||
|
sn++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// textFrames is the full sequence that shows two rows.
|
||||||
|
func textFrames(row1, row2 string) []can.Frame {
|
||||||
|
return append([]can.Frame{pairB336, pairB324}, encodeISOTP(textID, buildTwoLine(row1, row2))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clockFrame parses the core's clock text ("4:07 PM") into the 0x3F2 BCD frame.
|
||||||
|
func clockFrame(text string) (can.Frame, error) {
|
||||||
|
var h, m int
|
||||||
|
var ap string
|
||||||
|
if _, err := fmt.Sscanf(text, "%d:%d %s", &h, &m, &ap); err != nil || h < 1 || h > 12 || m < 0 || m > 59 {
|
||||||
|
return can.Frame{}, fmt.Errorf("fdim: bad clock text %q", text)
|
||||||
|
}
|
||||||
|
return can.Frame{ID: clockID, Len: 8, Data: [8]uint8{toBCD(h), toBCD(m), 0, 0, 0, 0xF0, 0, 0}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBCD(n int) uint8 { return uint8(n/10<<4 | n%10) }
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Package fdim is the front display (FDIM): a generic Display API, a driver
|
||||||
|
// that turns core frames into minimal display updates, and an emulator link.
|
||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Display is the generic FDIM API. Implementations: Memory (tests), NetDisplay
|
||||||
|
// (talks to the FDIM emulator terminal), or a real CAN driver.
|
||||||
|
//
|
||||||
|
// Rows are exactly 20 characters wide; the driver pads and maps characters.
|
||||||
|
type Display interface {
|
||||||
|
// DisplayText replaces both text rows.
|
||||||
|
DisplayText(row1, row2 string) error
|
||||||
|
// SetSourceLabel sets the source label box (e.g. "SYNC", "AUX"; "" clears).
|
||||||
|
SetSourceLabel(label string) error
|
||||||
|
// SetClock sets the info-area clock text; "" hides the clock.
|
||||||
|
SetClock(text string) error
|
||||||
|
// KeepAlive sends whatever the FDIM needs to stay awake.
|
||||||
|
KeepAlive() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrDown is returned by fake displays that are told to fail.
|
||||||
|
var ErrDown = errors.New("fdim: display down")
|
||||||
|
|
||||||
|
// Memory is an in-memory Display for tests.
|
||||||
|
type Memory struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Row1, Row2 string
|
||||||
|
Label string
|
||||||
|
Clock string
|
||||||
|
Calls []string
|
||||||
|
KeepAlives int
|
||||||
|
Failing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) rec(format string, a ...any) error {
|
||||||
|
m.Calls = append(m.Calls, fmt.Sprintf(format, a...))
|
||||||
|
if m.Failing {
|
||||||
|
return ErrDown
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) DisplayText(r1, r2 string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if err := m.rec("text %q %q", r1, r2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Row1, m.Row2 = r1, r2
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) SetSourceLabel(l string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if err := m.rec("label %q", l); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Label = l
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) SetClock(t string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if err := m.rec("clock %q", t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Clock = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) KeepAlive() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if err := m.rec("keepalive"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.KeepAlives++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) Close() error { return nil }
|
||||||
|
|
||||||
|
// Snapshot returns the current contents safely.
|
||||||
|
func (m *Memory) Snapshot() (row1, row2, label, clock string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.Row1, m.Row2, m.Label, m.Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFailing makes every call fail (or succeed again).
|
||||||
|
func (m *Memory) SetFailing(b bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.Failing = b
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallCount returns the number of calls recorded so far.
|
||||||
|
func (m *Memory) CallCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.Calls)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Charset maps the core's characters to what the display supports (SDS §12).
|
||||||
|
// The real FDIM character set is still an open question; ASCII is the safe
|
||||||
|
// fallback and Extended is for displays (and the emulator) that have the glyphs.
|
||||||
|
type Charset struct{ Extended bool }
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ASCII uses only printable ASCII: '>' for the more-marker, '.'-free
|
||||||
|
// fallbacks for the separator and the volume bar ('#' filled, '-' empty).
|
||||||
|
ASCII = Charset{}
|
||||||
|
// Extended passes the core's special glyphs through.
|
||||||
|
Extended = Charset{Extended: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Map converts one character.
|
||||||
|
func (c Charset) Map(r rune) rune {
|
||||||
|
if r >= 0x20 && r < 0x7f {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if c.Extended {
|
||||||
|
if r == core.GlyphMore || r == core.GlyphDot || r == core.GlyphFull || r == core.GlyphShade {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '?'
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
case core.GlyphMore:
|
||||||
|
return '>'
|
||||||
|
case core.GlyphDot:
|
||||||
|
return '-'
|
||||||
|
case core.GlyphFull:
|
||||||
|
return '#'
|
||||||
|
case core.GlyphShade:
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row renders one 20-column row.
|
||||||
|
func (c Charset) Row(line [core.LineW]rune) string {
|
||||||
|
out := make([]rune, core.LineW)
|
||||||
|
for i, r := range line {
|
||||||
|
out[i] = c.Map(r)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeepAliveMs is how often the driver sends a keep-alive when idle.
|
||||||
|
const KeepAliveMs = 1000
|
||||||
|
|
||||||
|
// Driver turns complete frames into the minimum set of Display calls. It
|
||||||
|
// remembers what the display shows, resends everything after an error, and
|
||||||
|
// sends a keep-alive when nothing else has been sent for a while.
|
||||||
|
type Driver struct {
|
||||||
|
disp Display
|
||||||
|
cs Charset
|
||||||
|
|
||||||
|
row1, row2 string
|
||||||
|
label string
|
||||||
|
clock string
|
||||||
|
have struct{ text, label, clock bool }
|
||||||
|
|
||||||
|
nowMs, lastSendMs uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDriver wraps a Display.
|
||||||
|
func NewDriver(d Display, cs Charset) *Driver { return &Driver{disp: d, cs: cs} }
|
||||||
|
|
||||||
|
// Apply shows a frame; it returns the first error encountered. After an error
|
||||||
|
// the failed part is resent on the next Apply.
|
||||||
|
func (d *Driver) Apply(f core.Frame) error {
|
||||||
|
var first error
|
||||||
|
note := func(err error) {
|
||||||
|
if err != nil && first == nil {
|
||||||
|
first = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r1, r2 := d.cs.Row(f.Line[0]), d.cs.Row(f.Line[1])
|
||||||
|
if !d.have.text || r1 != d.row1 || r2 != d.row2 {
|
||||||
|
if err := d.disp.DisplayText(r1, r2); err != nil {
|
||||||
|
d.have.text = false
|
||||||
|
note(err)
|
||||||
|
} else {
|
||||||
|
d.row1, d.row2, d.have.text = r1, r2, true
|
||||||
|
d.lastSendMs = d.nowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !d.have.label || f.Label != d.label {
|
||||||
|
if err := d.disp.SetSourceLabel(f.Label); err != nil {
|
||||||
|
d.have.label = false
|
||||||
|
note(err)
|
||||||
|
} else {
|
||||||
|
d.label, d.have.label = f.Label, true
|
||||||
|
d.lastSendMs = d.nowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clock := ""
|
||||||
|
if f.ClockOn {
|
||||||
|
clock = f.ClockText
|
||||||
|
}
|
||||||
|
if !d.have.clock || clock != d.clock {
|
||||||
|
if err := d.disp.SetClock(clock); err != nil {
|
||||||
|
d.have.clock = false
|
||||||
|
note(err)
|
||||||
|
} else {
|
||||||
|
d.clock, d.have.clock = clock, true
|
||||||
|
d.lastSendMs = d.nowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick advances the driver's clock and sends a keep-alive when due.
|
||||||
|
func (d *Driver) Tick(dtMs uint32) error {
|
||||||
|
d.nowMs += uint64(dtMs)
|
||||||
|
if d.nowMs-d.lastSendMs < KeepAliveMs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.lastSendMs = d.nowMs
|
||||||
|
return d.disp.KeepAlive()
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pad20(s string) string {
|
||||||
|
return s + strings.Repeat(" ", core.LineW-len([]rune(s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameOf(l1, l2, label, clock string) core.Frame {
|
||||||
|
var f core.Frame
|
||||||
|
for r, s := range []string{l1, l2} {
|
||||||
|
rs := []rune(s)
|
||||||
|
for i := 0; i < core.LineW; i++ {
|
||||||
|
if i < len(rs) {
|
||||||
|
f.Line[r][i] = rs[i]
|
||||||
|
} else {
|
||||||
|
f.Line[r][i] = ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Label = label
|
||||||
|
if clock != "" {
|
||||||
|
f.ClockOn, f.ClockText = true, clock
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharset(t *testing.T) {
|
||||||
|
var line [core.LineW]rune
|
||||||
|
for i := range line {
|
||||||
|
line[i] = ' '
|
||||||
|
}
|
||||||
|
copy(line[:], []rune("a › · █ ░ é"))
|
||||||
|
if got, want := ASCII.Row(line), pad20("a > - # - ?"); got != want {
|
||||||
|
t.Errorf("ASCII row = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := Extended.Row(line), pad20("a › · █ ░ ?"); got != want {
|
||||||
|
t.Errorf("Extended row = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriverDiffs(t *testing.T) {
|
||||||
|
m := &Memory{}
|
||||||
|
d := NewDriver(m, ASCII)
|
||||||
|
f := frameOf("Aux Input", "", "AUX", "4:07 PM")
|
||||||
|
if err := d.Apply(f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.CallCount() != 3 {
|
||||||
|
t.Fatalf("first apply: %d calls %v", m.CallCount(), m.Calls)
|
||||||
|
}
|
||||||
|
r1, r2, label, clock := m.Snapshot()
|
||||||
|
if r1 != pad20("Aux Input") || r2 != pad20("") || label != "AUX" || clock != "4:07 PM" {
|
||||||
|
t.Errorf("display = %q %q %q %q", r1, r2, label, clock)
|
||||||
|
}
|
||||||
|
// identical frame: nothing sent
|
||||||
|
d.Apply(f)
|
||||||
|
if m.CallCount() != 3 {
|
||||||
|
t.Errorf("identical frame sent calls: %v", m.Calls)
|
||||||
|
}
|
||||||
|
// only the clock changed: only the clock is sent
|
||||||
|
d.Apply(frameOf("Aux Input", "", "AUX", "4:08 PM"))
|
||||||
|
if m.CallCount() != 4 || !strings.HasPrefix(m.Calls[3], "clock") {
|
||||||
|
t.Errorf("calls: %v", m.Calls)
|
||||||
|
}
|
||||||
|
// hiding the clock sends an empty clock
|
||||||
|
g := frameOf("Aux Input", "", "AUX", "")
|
||||||
|
d.Apply(g)
|
||||||
|
if _, _, _, c := m.Snapshot(); c != "" {
|
||||||
|
t.Errorf("clock not hidden: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriverRecoversAfterError(t *testing.T) {
|
||||||
|
m := &Memory{}
|
||||||
|
d := NewDriver(m, ASCII)
|
||||||
|
f := frameOf("Hello", "World", "SYNC", "1:00 AM")
|
||||||
|
m.SetFailing(true)
|
||||||
|
if err := d.Apply(f); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
m.SetFailing(false)
|
||||||
|
if err := d.Apply(f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r1, r2, label, clock := m.Snapshot()
|
||||||
|
if !strings.HasPrefix(r1, "Hello") || !strings.HasPrefix(r2, "World") || label != "SYNC" || clock != "1:00 AM" {
|
||||||
|
t.Errorf("display not fully resent: %q %q %q %q", r1, r2, label, clock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriverKeepAlive(t *testing.T) {
|
||||||
|
m := &Memory{}
|
||||||
|
d := NewDriver(m, ASCII)
|
||||||
|
d.Apply(frameOf("x", "", "", ""))
|
||||||
|
d.Tick(900)
|
||||||
|
if m.KeepAlives != 0 {
|
||||||
|
t.Fatal("keep-alive too early")
|
||||||
|
}
|
||||||
|
d.Tick(100)
|
||||||
|
if m.KeepAlives != 1 {
|
||||||
|
t.Fatalf("keep-alives = %d", m.KeepAlives)
|
||||||
|
}
|
||||||
|
d.Tick(500)
|
||||||
|
if m.KeepAlives != 1 {
|
||||||
|
t.Fatal("keep-alive repeated too soon")
|
||||||
|
}
|
||||||
|
d.Tick(500)
|
||||||
|
if m.KeepAlives != 2 {
|
||||||
|
t.Fatalf("keep-alives = %d", m.KeepAlives)
|
||||||
|
}
|
||||||
|
// real traffic counts as activity
|
||||||
|
d.Tick(900)
|
||||||
|
d.Apply(frameOf("y", "", "", ""))
|
||||||
|
d.Tick(900)
|
||||||
|
if m.KeepAlives != 2 {
|
||||||
|
t.Fatalf("keep-alive sent despite recent traffic: %d", m.KeepAlives)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetDisplayReplaysState(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no loopback networking:", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
got := make(chan EmuState, 64)
|
||||||
|
go Serve(ctx, ln, func(s EmuState) { got <- s })
|
||||||
|
|
||||||
|
// State set before the link exists must appear once it connects.
|
||||||
|
nd := DialDisplay(ln.Addr().String())
|
||||||
|
defer nd.Close()
|
||||||
|
nd.DisplayText(pad20("Hello"), pad20("World"))
|
||||||
|
nd.SetSourceLabel("AUX")
|
||||||
|
nd.SetClock("4:07 PM")
|
||||||
|
|
||||||
|
deadline := time.After(5 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s := <-got:
|
||||||
|
if s.R1 == pad20("Hello") && s.R2 == pad20("World") && s.Label == "AUX" && s.Clock == "4:07 PM" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("emulator never showed the replayed state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayBlanksWithoutWakeSignal(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no loopback networking:", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
got := make(chan EmuState, 256)
|
||||||
|
go ServeTimeout(ctx, ln, 300*time.Millisecond, func(s EmuState) { got <- s })
|
||||||
|
|
||||||
|
c, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
c.Write([]byte(`{"op":"text","r1":"Hello","r2":"World"}` + "\n"))
|
||||||
|
|
||||||
|
wait := func(what string, ok func(EmuState) bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.After(3 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s := <-got:
|
||||||
|
if ok(s) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timeout waiting for %s", what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait("text shown", func(s EmuState) bool { return s.R1 == "Hello" && !s.Asleep })
|
||||||
|
// no further messages: the display must drop to nothing
|
||||||
|
wait("display off", func(s EmuState) bool {
|
||||||
|
return s.Asleep && strings.TrimSpace(s.R1+s.R2+s.Label+s.Clock) == ""
|
||||||
|
})
|
||||||
|
// a keep-alive wakes the link but the lost contents stay lost
|
||||||
|
c.Write([]byte(`{"op":"ka"}` + "\n"))
|
||||||
|
wait("awake but blank", func(s EmuState) bool { return !s.Asleep && strings.TrimSpace(s.R1) == "" })
|
||||||
|
// new content shows up again
|
||||||
|
c.Write([]byte(`{"op":"text","r1":"Back","r2":""}` + "\n"))
|
||||||
|
wait("content back", func(s EmuState) bool { return s.R1 == "Back" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderBox(t *testing.T) {
|
||||||
|
out := RenderBox(EmuState{R1: "Midnight City", R2: "M83", Label: "SYNC", Clock: "4:07 PM"}, false)
|
||||||
|
for _, want := range []string{"[SYNC]", "4:07 PM", "Midnight City", "M83"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("render lacks %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
if len(lines) != 5 {
|
||||||
|
t.Fatalf("want 5 lines, got %d", len(lines))
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if n := len([]rune(l)); n != 24 {
|
||||||
|
t.Errorf("line %q is %d wide, want 24", l, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package fdim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Msg is the FDIM wire protocol: one JSON object per line.
|
||||||
|
//
|
||||||
|
// {"op":"text","r1":"...","r2":"..."} {"op":"label","v":"SYNC"}
|
||||||
|
// {"op":"clock","v":"4:07 PM"} {"op":"ka"}
|
||||||
|
type Msg struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
R1 string `json:"r1,omitempty"`
|
||||||
|
R2 string `json:"r2,omitempty"`
|
||||||
|
V string `json:"v,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetDisplay is a Display that talks to an FDIM emulator over TCP. It
|
||||||
|
// reconnects with backoff (0.5 s doubling to 10 s) and replays its full state
|
||||||
|
// after every reconnect, so the display is always correct once it is back.
|
||||||
|
// While the link is down calls succeed silently: the UI runs blind, audio is
|
||||||
|
// unaffected (SDS §14.10).
|
||||||
|
type NetDisplay struct {
|
||||||
|
addr string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
conn net.Conn
|
||||||
|
r1, r2, label, clk string
|
||||||
|
haveText bool
|
||||||
|
|
||||||
|
closed chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialDisplay starts connecting to the emulator at addr.
|
||||||
|
func DialDisplay(addr string) *NetDisplay {
|
||||||
|
n := &NetDisplay{addr: addr, closed: make(chan struct{})}
|
||||||
|
go n.loop()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) loop() {
|
||||||
|
backoff := 500 * time.Millisecond
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.closed:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
c, err := net.DialTimeout("tcp", n.addr, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-n.closed:
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff *= 2; backoff > 10*time.Second {
|
||||||
|
backoff = 10 * time.Second
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backoff = 500 * time.Millisecond
|
||||||
|
n.mu.Lock()
|
||||||
|
n.conn = c
|
||||||
|
n.replayLocked()
|
||||||
|
n.mu.Unlock()
|
||||||
|
// The emulator never sends; reading detects the link dropping.
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
if _, err := c.Read(buf); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.mu.Lock()
|
||||||
|
if n.conn == c {
|
||||||
|
n.conn = nil
|
||||||
|
}
|
||||||
|
n.mu.Unlock()
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected reports whether the emulator link is up.
|
||||||
|
func (n *NetDisplay) Connected() bool {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
return n.conn != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) sendLocked(m Msg) {
|
||||||
|
if n.conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
b = append(b, '\n')
|
||||||
|
n.conn.SetWriteDeadline(time.Now().Add(time.Second))
|
||||||
|
if _, err := n.conn.Write(b); err != nil {
|
||||||
|
n.conn.Close()
|
||||||
|
n.conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) replayLocked() {
|
||||||
|
if n.haveText {
|
||||||
|
n.sendLocked(Msg{Op: "text", R1: n.r1, R2: n.r2})
|
||||||
|
}
|
||||||
|
n.sendLocked(Msg{Op: "label", V: n.label})
|
||||||
|
n.sendLocked(Msg{Op: "clock", V: n.clk})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) DisplayText(r1, r2 string) error {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
n.r1, n.r2, n.haveText = r1, r2, true
|
||||||
|
n.sendLocked(Msg{Op: "text", R1: r1, R2: r2})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) SetSourceLabel(l string) error {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
n.label = l
|
||||||
|
n.sendLocked(Msg{Op: "label", V: l})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) SetClock(t string) error {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
n.clk = t
|
||||||
|
n.sendLocked(Msg{Op: "clock", V: t})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) KeepAlive() error {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
n.sendLocked(Msg{Op: "ka"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetDisplay) Close() error {
|
||||||
|
n.once.Do(func() {
|
||||||
|
close(n.closed)
|
||||||
|
n.mu.Lock()
|
||||||
|
if n.conn != nil {
|
||||||
|
n.conn.Close()
|
||||||
|
n.conn = nil
|
||||||
|
}
|
||||||
|
n.mu.Unlock()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- emulator side
|
||||||
|
|
||||||
|
// EmuState is what the emulated FDIM currently shows.
|
||||||
|
type EmuState struct {
|
||||||
|
R1, R2, Label, Clock string
|
||||||
|
Connected bool
|
||||||
|
LastMsg time.Time // last message of any kind, including keep-alives
|
||||||
|
// Asleep is set when no wake signal (keep-alive or any other message)
|
||||||
|
// arrived within the timeout: the display has dropped to nothing and
|
||||||
|
// lost its contents, like the real FDIM.
|
||||||
|
Asleep bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultWakeTimeout is how long the emulated FDIM stays lit without a wake
|
||||||
|
// signal. The core's driver sends a keep-alive after 1 s of silence.
|
||||||
|
const DefaultWakeTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// Serve runs the emulated FDIM with no wake-signal timeout.
|
||||||
|
func Serve(ctx context.Context, ln net.Listener, onChange func(EmuState)) error {
|
||||||
|
return ServeTimeout(ctx, ln, 0, onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeTimeout runs the emulated FDIM: it accepts a connection from the core
|
||||||
|
// and calls onChange (from several goroutines; it must be thread-safe)
|
||||||
|
// whenever the state changes. If timeout > 0 and no message arrives for that
|
||||||
|
// long, the display blanks and forgets its contents until new ones are sent.
|
||||||
|
// It returns when ctx is cancelled or the listener fails.
|
||||||
|
func ServeTimeout(ctx context.Context, ln net.Listener, timeout time.Duration, onChange func(EmuState)) error {
|
||||||
|
var mu sync.Mutex
|
||||||
|
blank := strings.Repeat(" ", 20)
|
||||||
|
st := EmuState{R1: blank, R2: blank}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
ln.Close()
|
||||||
|
}()
|
||||||
|
if timeout > 0 {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(50 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
// A dead link sends no wake signals either, so this must not
|
||||||
|
// depend on st.Connected (a killed core closes the socket).
|
||||||
|
expired := !st.LastMsg.IsZero() && !st.Asleep && time.Since(st.LastMsg) > timeout
|
||||||
|
if expired {
|
||||||
|
st.Asleep = true
|
||||||
|
st.R1, st.R2, st.Label, st.Clock = blank, blank, "", ""
|
||||||
|
}
|
||||||
|
snap := st
|
||||||
|
mu.Unlock()
|
||||||
|
if expired {
|
||||||
|
onChange(snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
mu.Lock()
|
||||||
|
st.Connected = true
|
||||||
|
st.Asleep = false
|
||||||
|
st.LastMsg = time.Now()
|
||||||
|
snap := st
|
||||||
|
mu.Unlock()
|
||||||
|
onChange(snap)
|
||||||
|
sc := bufio.NewScanner(c)
|
||||||
|
sc.Buffer(make([]byte, 0, 4096), 64*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
var m Msg
|
||||||
|
if json.Unmarshal(sc.Bytes(), &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
switch m.Op {
|
||||||
|
case "text":
|
||||||
|
st.R1, st.R2 = m.R1, m.R2
|
||||||
|
case "label":
|
||||||
|
st.Label = m.V
|
||||||
|
case "clock":
|
||||||
|
st.Clock = m.V
|
||||||
|
}
|
||||||
|
st.LastMsg = time.Now()
|
||||||
|
st.Asleep = false
|
||||||
|
snap := st
|
||||||
|
mu.Unlock()
|
||||||
|
onChange(snap)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
st.Connected = false
|
||||||
|
snap = st
|
||||||
|
mu.Unlock()
|
||||||
|
onChange(snap)
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderBox draws the emulated VFD: an info row (label box left, clock right)
|
||||||
|
// above the two text rows, inside a frame. With color it uses ANSI green on a
|
||||||
|
// dark background like a real VFD.
|
||||||
|
func RenderBox(st EmuState, color bool) string {
|
||||||
|
const w = 20
|
||||||
|
pad := func(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) > w {
|
||||||
|
r = r[:w]
|
||||||
|
}
|
||||||
|
for len(r) < w {
|
||||||
|
r = append(r, ' ')
|
||||||
|
}
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
info := ""
|
||||||
|
if st.Label != "" {
|
||||||
|
info = "[" + st.Label + "]"
|
||||||
|
}
|
||||||
|
gap := w - len([]rune(info)) - len([]rune(st.Clock))
|
||||||
|
if gap < 1 {
|
||||||
|
gap = 1
|
||||||
|
}
|
||||||
|
info = pad(info + strings.Repeat(" ", gap) + st.Clock)
|
||||||
|
|
||||||
|
on, off, dim := "", "", ""
|
||||||
|
if color {
|
||||||
|
on = "\x1b[48;2;12;26;23m\x1b[38;2;95;242;200m"
|
||||||
|
off = "\x1b[0m"
|
||||||
|
dim = "\x1b[2m"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("+" + strings.Repeat("-", w+2) + "+\n")
|
||||||
|
b.WriteString("| " + on + dim + info + off + " |\n")
|
||||||
|
b.WriteString("| " + on + pad(st.R1) + off + " |\n")
|
||||||
|
b.WriteString("| " + on + pad(st.R2) + off + " |\n")
|
||||||
|
b.WriteString("+" + strings.Repeat("-", w+2) + "+\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module fusion
|
||||||
|
|
||||||
|
go 1.24
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Package platform holds the small OS-level services: the wall clock and the
|
||||||
|
// settings store.
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fusion/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clock is the system/RTC time service.
|
||||||
|
type Clock interface {
|
||||||
|
Now() (hour, minute int)
|
||||||
|
// SetTime sets the time of day (seconds become 00).
|
||||||
|
SetTime(hour, minute int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysClock reads the host clock. The emulator cannot set the host's time, so
|
||||||
|
// SetTime keeps an offset (a real deployment would call settimeofday / write
|
||||||
|
// the RTC instead). NowFn may be set to inject time in tests.
|
||||||
|
type SysClock struct {
|
||||||
|
NowFn func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
offset time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SysClock) nowLocked() time.Time {
|
||||||
|
f := c.NowFn
|
||||||
|
if f == nil {
|
||||||
|
f = time.Now
|
||||||
|
}
|
||||||
|
return f().Add(c.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SysClock) Now() (int, int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
t := c.nowLocked()
|
||||||
|
return t.Hour(), t.Minute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SysClock) SetTime(h, m int) error {
|
||||||
|
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||||
|
return errors.New("platform: invalid time")
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
t := c.nowLocked()
|
||||||
|
target := time.Date(t.Year(), t.Month(), t.Day(), h, m, 0, 0, t.Location())
|
||||||
|
c.offset += target.Sub(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists the two saved settings: volume and source (SDS §13).
|
||||||
|
type Store interface {
|
||||||
|
// Load returns the saved settings. If they are missing or unreadable it
|
||||||
|
// returns the defaults together with an error describing why.
|
||||||
|
Load() (core.Settings, error)
|
||||||
|
Save(core.Settings) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemStore is an in-memory Store.
|
||||||
|
type MemStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
set core.Settings
|
||||||
|
have bool
|
||||||
|
Saves int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Load() (core.Settings, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if !m.have {
|
||||||
|
return core.DefaultSettings(), errors.New("platform: no saved settings")
|
||||||
|
}
|
||||||
|
return m.set, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Save(s core.Settings) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.set, m.have = s, true
|
||||||
|
m.Saves++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStore keeps the settings in one small JSON file.
|
||||||
|
type FileStore struct{ Path string }
|
||||||
|
|
||||||
|
type fileFormat struct {
|
||||||
|
Volume int `json:"volume"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FileStore) Load() (core.Settings, error) {
|
||||||
|
b, err := os.ReadFile(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
return core.DefaultSettings(), err
|
||||||
|
}
|
||||||
|
var ff fileFormat
|
||||||
|
if err := json.Unmarshal(b, &ff); err != nil {
|
||||||
|
return core.DefaultSettings(), err
|
||||||
|
}
|
||||||
|
s := core.Settings{Volume: ff.Volume, Source: core.SourceAux}
|
||||||
|
if ff.Source == "BT" {
|
||||||
|
s.Source = core.SourceBT
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes atomically (temp file + rename) so a power cut cannot leave a
|
||||||
|
// half-written file.
|
||||||
|
func (f FileStore) Save(s core.Settings) error {
|
||||||
|
b, err := json.Marshal(fileFormat{Volume: s.Volume, Source: s.Source.String()})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(f.Path); dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp := f.Path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, f.Path)
|
||||||
|
}
|
||||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user