initial commit

This commit is contained in:
matthewkotras committed 2026-10-27 21:13:36 -05:00
1 parent 3f403dbca1
commit 1436174509
116 files changed
+15931

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(go version *)"
]
}
}
Binary file not shown.
+12
View File
@@ -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.
+232
View File
@@ -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>.
+128
View File
@@ -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 &amp; 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>
+3
View File
@@ -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
+131
View File
@@ -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)
+26
View File
@@ -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
+38
View File
@@ -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
+187
View File
@@ -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()
}
+7
View File
@@ -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
+4
View File
@@ -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=
+133
View File
@@ -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"
}
}
+45
View File
@@ -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 {
}
}
+106
View File
@@ -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}")
+138
View File
@@ -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."
+28
View File
@@ -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)
}
+27
View File
@@ -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)
}
}
}
+178
View File
@@ -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)
}
+53
View File
@@ -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)
}
}
+310
View File
@@ -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)
}
+170
View File
@@ -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()
}
+59
View File
@@ -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)
}
}
+121
View File
@@ -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()
}
+133
View File
@@ -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"
}
}
+61
View File
@@ -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")
}
+220
View File
@@ -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)
}
+145
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+7
View File
@@ -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
+4
View File
@@ -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=
+104
View File
@@ -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}},
}
}
+80
View File
@@ -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")
}
}
+71
View File
@@ -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}}
)
+55
View File
@@ -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])
}
+3
View File
@@ -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"
+71
View File
@@ -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.
+31
View File
@@ -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
+22
View File
@@ -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
+12
View File
@@ -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;
+18
View File
@@ -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
+5
View File
@@ -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.
+214
View File
@@ -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
+91
View File
@@ -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%
+4
View File
@@ -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
+32
View File
@@ -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
+36
View File
@@ -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")
+80
View File
@@ -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.
+300
View File
@@ -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
}
}
+279
View File
@@ -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")
}
}
+250
View File
@@ -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
}
+101
View File
@@ -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() }
+44
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+86
View File
@@ -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))
}
}
}
}
+80
View File
@@ -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)
}
}
+123
View File
@@ -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)
}
}
}
+162
View File
@@ -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"
}
+177
View File
@@ -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] = ' '
}
}
}
+560
View File
@@ -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 = ""
}
}
+196
View File
@@ -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)
}
}
}
}
+315
View File
@@ -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")
}
+176
View File
@@ -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
}
}
+449
View File
@@ -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
}
+30
View File
@@ -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)
}
})
}
}
+158
View File
@@ -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
}
+310
View File
@@ -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) }
+240
View File
@@ -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) }
+267
View File
@@ -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")
}
}
+172
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+74
View File
@@ -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() }
+106
View File
@@ -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
}
+58
View File
@@ -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")
}
}
+63
View File
@@ -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
}
+121
View File
@@ -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)
}
}
}
+92
View File
@@ -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
}
+121
View File
@@ -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
}
}
+181
View File
@@ -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 }
+202
View File
@@ -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
}
+117
View File
@@ -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) }
+110
View File
@@ -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)
}
+126
View File
@@ -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()
}
+219
View File
@@ -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)
}
}
}
+308
View File
@@ -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()
}
+3
View File
@@ -0,0 +1,3 @@
module fusion
go 1.24
+134
View File
@@ -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