From 14361745097a824736b03f7fa07832b9a025cbf7 Mon Sep 17 00:00:00 2001 From: Matthew Kotras Date: Tue, 27 Oct 2026 21:13:36 -0500 Subject: [PATCH] initial commit --- .claude/settings.local.json | 7 + 3d-print/grommet.3mf | Bin 0 -> 33743 bytes 3d-print/grommet.scad | 12 + 3d-print/grommet.stl | Bin 0 -> 11484 bytes car-radio/LICENSE | 232 +++ car-radio/PROJECT_MAP.html | 128 ++ car-radio/README.md | 3 + car-radio/archive/FDIM/display-matthew.txt | 26 + car-radio/archive/FDIM/display-text.py | 131 ++ car-radio/archive/FDIM/maybe-text.txt | 26 + car-radio/archive/FDIM/send.sh | 38 + car-radio/archive/code/display.go | 187 +++ car-radio/archive/code/go.mod | 7 + car-radio/archive/code/go.sum | 4 + car-radio/archive/code/inputs.go | 133 ++ car-radio/archive/code/main.go | 45 + car-radio/archive/decode.py | 106 ++ car-radio/archive/start_can.sh | 138 ++ car-radio/code/clock.go | 28 + car-radio/code/clock_test.go | 27 + car-radio/code/cmd/backlight/main.go | 178 +++ car-radio/code/cmd/fcimread/main.go | 53 + car-radio/code/cmd/fdimprobe/main.go | 310 ++++ car-radio/code/cmd/fdimtest/main.go | 170 ++ car-radio/code/cmd/fdimtext/main.go | 59 + car-radio/code/cmd/radio/display.go | 121 ++ car-radio/code/cmd/radio/inputs.go | 133 ++ car-radio/code/cmd/radio/main.go | 61 + car-radio/code/cmd/sweep/main.go | 220 +++ car-radio/code/fcim.go | 145 ++ car-radio/code/fcim_test.go | 25 + car-radio/code/go.mod | 7 + car-radio/code/go.sum | 4 + car-radio/code/isotp.go | 104 ++ car-radio/code/isotp_test.go | 80 + car-radio/code/layout.go | 71 + car-radio/code/layout_test.go | 55 + car-radio/deploy/99-canable.rules | 3 + car-radio/deploy/README.md | 71 + car-radio/deploy/bootstrap.sh | 31 + car-radio/deploy/fusion.service | 22 + car-radio/deploy/read-can.txt | 12 + car-radio/deploy/slcan.service | 18 + car-radio/deploy/write-can.txt | 5 + car-radio/dsp/Design 1.dspproj | Bin 0 -> 271457 bytes car-radio/dsp/Design 1_IC 1/DMem0.dat | 214 +++ car-radio/dsp/Design 1_IC 1/DMem1.dat | 91 ++ car-radio/dsp/Design 1_IC 1/Design 1.dspproj | Bin 0 -> 269034 bytes car-radio/dsp/Design 1_IC 1/PMem.dat | 0 car-radio/dsp/Design 1_IC 1/ParamAddress.dat | 500 ++++++ car-radio/dsp/Design 1_IC 1/Program.bin | 1410 +++++++++++++++++ car-radio/dsp/Design 1_IC 1/Program.dat | 1410 +++++++++++++++++ .../dsp/Design 1_IC 1/bass_remapping.txt | 32 + .../dsp/Design 1_IC 1/compiler_output.log | 94 ++ car-radio/dsp/Design 1_IC 1/report.dat | 4 + .../IndexerVolumeGuid | Bin 0 -> 76 bytes .../System Volume Information/WPSettings.dat | Bin 0 -> 12 bytes car-radio/dsp/bass_remapping.txt | 32 + car-radio/dsp/generate_bass_remapping.py | 36 + car-radio/fusion/README.md | 80 + car-radio/fusion/app/app.go | 300 ++++ car-radio/fusion/app/app_test.go | 279 ++++ car-radio/fusion/bt/bt.go | 250 +++ car-radio/fusion/can/can.go | 101 ++ car-radio/fusion/can/can_linux.go | 44 + car-radio/fusion/can/can_test.go | 56 + car-radio/fusion/cmd/fcim/main.go | 86 + car-radio/fusion/cmd/fdim/main.go | 80 + car-radio/fusion/cmd/fusiond/console.go | 123 ++ car-radio/fusion/cmd/fusiond/console_test.go | 85 + car-radio/fusion/cmd/fusiond/main.go | 162 ++ car-radio/fusion/core/clockedit.go | 177 +++ car-radio/fusion/core/core.go | 560 +++++++ car-radio/fusion/core/core_test.go | 196 +++ car-radio/fusion/core/pages.go | 315 ++++ car-radio/fusion/core/render.go | 176 ++ car-radio/fusion/core/script.go | 449 ++++++ car-radio/fusion/core/script_test.go | 30 + car-radio/fusion/core/text.go | 158 ++ car-radio/fusion/core/types.go | 310 ++++ car-radio/fusion/dsp/adau1467.go | 240 +++ car-radio/fusion/dsp/adau1467_test.go | 267 ++++ car-radio/fusion/dsp/dsp.go | 172 ++ car-radio/fusion/dsp/dsp_test.go | 107 ++ car-radio/fusion/dsp/i2c_linux.go | 74 + car-radio/fusion/fcim/can.go | 106 ++ car-radio/fusion/fcim/can_test.go | 58 + car-radio/fusion/fcim/fcim.go | 63 + car-radio/fusion/fcim/fcim_test.go | 121 ++ car-radio/fusion/fcim/keymap.go | 92 ++ car-radio/fusion/fcim/net.go | 121 ++ car-radio/fusion/fdim/can.go | 181 +++ car-radio/fusion/fdim/can_test.go | 202 +++ car-radio/fusion/fdim/canproto.go | 117 ++ car-radio/fusion/fdim/display.go | 110 ++ car-radio/fusion/fdim/driver.go | 126 ++ car-radio/fusion/fdim/fdim_test.go | 219 +++ car-radio/fusion/fdim/net.go | 308 ++++ car-radio/fusion/go.mod | 3 + car-radio/fusion/platform/platform.go | 134 ++ car-radio/fusion/platform/platform_test.go | 70 + car-radio/fusion/run-all.sh | 71 + car-radio/fusion/termui/termui.go | 40 + car-radio/fusion/tests/bluetooth.test | 198 +++ car-radio/fusion/tests/boot_standby.test | 259 +++ car-radio/fusion/tests/clock_editor.test | 219 +++ car-radio/fusion/tests/menus_source.test | 203 +++ car-radio/fusion/tests/now_playing.test | 137 ++ .../fusion/tests/phone_health_advanced.test | 174 ++ car-radio/fusion/tests/volume.test | 140 ++ .../Ford FDIM text protocol.md | 139 ++ car-radio/notes-and-knowledge/fdim.txt | 67 + .../notes-and-knowledge/interface-design.md | 202 +++ car-radio/notes-and-knowledge/keep-wake.txt | 23 + .../notes-and-knowledge/software-design.md | 60 + car-radio/notes-and-knowledge/wiring.txt | 32 + 116 files changed, 15931 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 3d-print/grommet.3mf create mode 100644 3d-print/grommet.scad create mode 100644 3d-print/grommet.stl create mode 100644 car-radio/LICENSE create mode 100644 car-radio/PROJECT_MAP.html create mode 100644 car-radio/README.md create mode 100644 car-radio/archive/FDIM/display-matthew.txt create mode 100644 car-radio/archive/FDIM/display-text.py create mode 100644 car-radio/archive/FDIM/maybe-text.txt create mode 100755 car-radio/archive/FDIM/send.sh create mode 100644 car-radio/archive/code/display.go create mode 100644 car-radio/archive/code/go.mod create mode 100644 car-radio/archive/code/go.sum create mode 100644 car-radio/archive/code/inputs.go create mode 100644 car-radio/archive/code/main.go create mode 100644 car-radio/archive/decode.py create mode 100755 car-radio/archive/start_can.sh create mode 100644 car-radio/code/clock.go create mode 100644 car-radio/code/clock_test.go create mode 100644 car-radio/code/cmd/backlight/main.go create mode 100644 car-radio/code/cmd/fcimread/main.go create mode 100644 car-radio/code/cmd/fdimprobe/main.go create mode 100644 car-radio/code/cmd/fdimtest/main.go create mode 100644 car-radio/code/cmd/fdimtext/main.go create mode 100644 car-radio/code/cmd/radio/display.go create mode 100644 car-radio/code/cmd/radio/inputs.go create mode 100644 car-radio/code/cmd/radio/main.go create mode 100644 car-radio/code/cmd/sweep/main.go create mode 100644 car-radio/code/fcim.go create mode 100644 car-radio/code/fcim_test.go create mode 100644 car-radio/code/go.mod create mode 100644 car-radio/code/go.sum create mode 100644 car-radio/code/isotp.go create mode 100644 car-radio/code/isotp_test.go create mode 100644 car-radio/code/layout.go create mode 100644 car-radio/code/layout_test.go create mode 100644 car-radio/deploy/99-canable.rules create mode 100644 car-radio/deploy/README.md create mode 100755 car-radio/deploy/bootstrap.sh create mode 100644 car-radio/deploy/fusion.service create mode 100644 car-radio/deploy/read-can.txt create mode 100644 car-radio/deploy/slcan.service create mode 100644 car-radio/deploy/write-can.txt create mode 100644 car-radio/dsp/Design 1.dspproj create mode 100644 car-radio/dsp/Design 1_IC 1/DMem0.dat create mode 100644 car-radio/dsp/Design 1_IC 1/DMem1.dat create mode 100644 car-radio/dsp/Design 1_IC 1/Design 1.dspproj create mode 100644 car-radio/dsp/Design 1_IC 1/PMem.dat create mode 100644 car-radio/dsp/Design 1_IC 1/ParamAddress.dat create mode 100644 car-radio/dsp/Design 1_IC 1/Program.bin create mode 100644 car-radio/dsp/Design 1_IC 1/Program.dat create mode 100644 car-radio/dsp/Design 1_IC 1/bass_remapping.txt create mode 100644 car-radio/dsp/Design 1_IC 1/compiler_output.log create mode 100644 car-radio/dsp/Design 1_IC 1/report.dat create mode 100644 car-radio/dsp/System Volume Information/IndexerVolumeGuid create mode 100644 car-radio/dsp/System Volume Information/WPSettings.dat create mode 100644 car-radio/dsp/bass_remapping.txt create mode 100644 car-radio/dsp/generate_bass_remapping.py create mode 100644 car-radio/fusion/README.md create mode 100644 car-radio/fusion/app/app.go create mode 100644 car-radio/fusion/app/app_test.go create mode 100644 car-radio/fusion/bt/bt.go create mode 100644 car-radio/fusion/can/can.go create mode 100644 car-radio/fusion/can/can_linux.go create mode 100644 car-radio/fusion/can/can_test.go create mode 100644 car-radio/fusion/cmd/fcim/main.go create mode 100644 car-radio/fusion/cmd/fdim/main.go create mode 100644 car-radio/fusion/cmd/fusiond/console.go create mode 100644 car-radio/fusion/cmd/fusiond/console_test.go create mode 100644 car-radio/fusion/cmd/fusiond/main.go create mode 100644 car-radio/fusion/core/clockedit.go create mode 100644 car-radio/fusion/core/core.go create mode 100644 car-radio/fusion/core/core_test.go create mode 100644 car-radio/fusion/core/pages.go create mode 100644 car-radio/fusion/core/render.go create mode 100644 car-radio/fusion/core/script.go create mode 100644 car-radio/fusion/core/script_test.go create mode 100644 car-radio/fusion/core/text.go create mode 100644 car-radio/fusion/core/types.go create mode 100644 car-radio/fusion/dsp/adau1467.go create mode 100644 car-radio/fusion/dsp/adau1467_test.go create mode 100644 car-radio/fusion/dsp/dsp.go create mode 100644 car-radio/fusion/dsp/dsp_test.go create mode 100644 car-radio/fusion/dsp/i2c_linux.go create mode 100644 car-radio/fusion/fcim/can.go create mode 100644 car-radio/fusion/fcim/can_test.go create mode 100644 car-radio/fusion/fcim/fcim.go create mode 100644 car-radio/fusion/fcim/fcim_test.go create mode 100644 car-radio/fusion/fcim/keymap.go create mode 100644 car-radio/fusion/fcim/net.go create mode 100644 car-radio/fusion/fdim/can.go create mode 100644 car-radio/fusion/fdim/can_test.go create mode 100644 car-radio/fusion/fdim/canproto.go create mode 100644 car-radio/fusion/fdim/display.go create mode 100644 car-radio/fusion/fdim/driver.go create mode 100644 car-radio/fusion/fdim/fdim_test.go create mode 100644 car-radio/fusion/fdim/net.go create mode 100644 car-radio/fusion/go.mod create mode 100644 car-radio/fusion/platform/platform.go create mode 100644 car-radio/fusion/platform/platform_test.go create mode 100755 car-radio/fusion/run-all.sh create mode 100644 car-radio/fusion/termui/termui.go create mode 100644 car-radio/fusion/tests/bluetooth.test create mode 100644 car-radio/fusion/tests/boot_standby.test create mode 100644 car-radio/fusion/tests/clock_editor.test create mode 100644 car-radio/fusion/tests/menus_source.test create mode 100644 car-radio/fusion/tests/now_playing.test create mode 100644 car-radio/fusion/tests/phone_health_advanced.test create mode 100644 car-radio/fusion/tests/volume.test create mode 100644 car-radio/notes-and-knowledge/Ford FDIM text protocol.md create mode 100644 car-radio/notes-and-knowledge/fdim.txt create mode 100644 car-radio/notes-and-knowledge/interface-design.md create mode 100644 car-radio/notes-and-knowledge/keep-wake.txt create mode 100644 car-radio/notes-and-knowledge/software-design.md create mode 100644 car-radio/notes-and-knowledge/wiring.txt diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8e6343b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(go version *)" + ] + } +} diff --git a/3d-print/grommet.3mf b/3d-print/grommet.3mf new file mode 100644 index 0000000000000000000000000000000000000000..2a9fe03b58db37caf419e834a1f9acf993db0467 GIT binary patch literal 33743 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 BTmk?9 literal 0 HcmV?d00001 diff --git a/3d-print/grommet.scad b/3d-print/grommet.scad new file mode 100644 index 0000000..70ee9b0 --- /dev/null +++ b/3d-print/grommet.scad @@ -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); +} +}; \ No newline at end of file diff --git a/3d-print/grommet.stl b/3d-print/grommet.stl new file mode 100644 index 0000000000000000000000000000000000000000..3554426a7787d0aae94184fe410540a872750407 GIT binary patch literal 11484 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�#=1N43&vT6=EP=Ka a4pE_ZF}4Ne5s80MmU8$Y;t(ySVe7wB1=Aw{ literal 0 HcmV?d00001 diff --git a/car-radio/LICENSE b/car-radio/LICENSE new file mode 100644 index 0000000..50fa852 --- /dev/null +++ b/car-radio/LICENSE @@ -0,0 +1,232 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS + +0. Definitions. + +“This License” refers to version 3 of the GNU General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based on the Program. + +To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. + +A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. + + 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 . + +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 . + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/car-radio/PROJECT_MAP.html b/car-radio/PROJECT_MAP.html new file mode 100644 index 0000000..75711a3 --- /dev/null +++ b/car-radio/PROJECT_MAP.html @@ -0,0 +1,128 @@ + + + + + +Car Radio Map + + +
+

Car Radio — project map

+

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.

+ +

ACTIVE current, maintained code EXPERIMENT one-off reverse-engineering tools TEST automated tests CLUTTER stale, duplicate or accidental

+ +
TL;DR. The real software is car-radio/fusion/, plus deploy/ to put it on the Pi. Everything under code/ is the earlier CAN-cracking work. The protocol knowledge lives in notes-and-knowledge/, FDIM/ and the ccode/cmd/* probes. Several files are leftovers (see the cleanup section).
+ +

Outer folder: projects/car/

+ + + + + +
car-radio/The git repo. Almost everything lives here.
start_can.shFinds a CANable USB adapter (candleLight or slcan firmware) and brings it up as a SocketCAN canN at 125 kbit/s (Ford MS-CAN). Re-runs itself with sudo/run0. It sits outside the repo, so it isn't version-controlled. ACTIVE
grommet.scad / .stl / .3mfOpenSCAD 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. HARDWARE
.claude/Claude Code local permission settings.
+ +

Repo root: car-radio/

+ + + + + +
README.md, LICENSEA one-line README ("Repo for everything involving… my custom car radio") and the license.
decode.pyThe original Python (python-can) FCIM button decoder, including send_wake_fcim. It's the first working decoder from the initial commit. The Go code later ported it. LEGACY
read-can.txtA cheat sheet: slcand plus candump commands for sniffing the bus. NOTES
write-can.txtPasted shell history (line numbers included) of cansend/cangen experiments. One line is a bad paste where two commands ran together. CLUTTER
+ +

fusion/: the main application ACTIVE

+

This is a Go implementation of the "Fusion Audio UI & Software Specification". It has a pure UI core, and every piece of hardware sits behind an interface. Terminal emulators for the display and the buttons let the whole system run on a laptop. README.md documents how to run it.

+ + + + + + + + + + + + + + + + +
cmd/fusiond/The daemon that wires the core to drivers (real CAN/I²C or simulated). console.go is a simulated-hardware REPL with commands like connect Pixel, ring Alice and dsp fail.
cmd/fdim/A terminal emulator of the 2×20 front display. It listens on :7002.
cmd/fcim/A terminal emulator of the button panel. Each keystroke is sent to fusiond as a button pulse on :7001.
core/The UI state machine: pages and menus (pages.go), rendering, text, the clock editor and shared types. script.go runs the .test scenario files.
app/Dependency injection (app.Deps) and the main loop that ties core and drivers together.
fdim/The display driver. can.go/canproto.go drive the real FDIM over CAN, net.go drives the emulator, and there's an in-memory version for tests.
fcim/The button source: the real FCIM over CAN, the emulator over the network, and keymap.go.
can/The low-level SocketCAN wrapper (can_linux.go).
dsp/The DSP interface, a simulator, and the real ADAU1467 driver over I²C (i2c_linux.go).
bt/The Bluetooth module interface. It's simulated only so far.
platform/Clock and persistent settings store.
termui/Shared terminal-drawing helpers for the emulators.
tests/*.testSeven scenario scripts written as key X / expect … steps: boot/standby, volume, menus/source, now playing, clock editor, Bluetooth, phone/health. Each one cites a spec section. TEST
*_test.goGo unit tests in core, app, fdim, fcim, can, dsp, platform and fusiond/console. TEST
run-all.shOpens the three programs in separate terminals or in tmux panes. --build compiles them first.
+ +

deploy/: putting it in the car ACTIVE

+ + + + + +
bootstrap.shOne-time, idempotent setup of the Pi, run from the laptop (deploy/bootstrap.sh main@fusion.local).
99-canable.rulesA udev rule that identifies the CANable adapter.
slcan.serviceA systemd unit that brings up the slcan CAN interface at boot.
fusion.serviceA systemd unit that auto-starts fusiond (latest commit: "Make fusiond auto-boot").
+ +

code/: earlier Go prototypes EXPERIMENT

+

This folder holds the work done before fusion/ existed, and it's the messiest part of the repo. It has three layers, each a newer copy of the previous one. ccode/ and new/ are untracked in git.

+ + + + + + + + + + + +
main.go, display.go, inputs.go, go.modThe first Go radio prototype (May). It's been superseded: an updated copy lives at ccode/cmd/radio/. STALE
new/An intermediate snapshot: fdimtext, ISO-TP and layout code with tests, plus Ford FDIM text protocol.md, the most complete write-up of how FDIM text is encoded. layout.go and isotp.go are identical to the copies in ccode/. DUPLICATE (keep the .md)
ccode/The newest prototype library: isotp.go (multi-frame CAN transport), layout.go (fitting text into 2×20), clock.go, fcim.go, each with a _test.go. TEST
ccode/cmd/radioAn updated version of the old radio prototype.
ccode/cmd/fcimreadReceive-only sniffer that decodes FCIM button presses.
ccode/cmd/fdimtextPrints the cansend frames for a two-line message without touching the bus. Same purpose as new/main.go.
ccode/cmd/fdimtestCycles test patterns across the whole 2×20 text area.
ccode/cmd/fdimprobeSteps through experiments on the source box, the volume bar and unmapped icons.
ccode/cmd/sweepA fast blind search for the frames that drive the AM/FM/SYNC/AUX source box.
ccode/cmd/backlightIsolates which frame turns on the FCIM backlight. The answer (the 0x3E8 baseline) went into fusion.
+ +

FDIM/: raw display experiments EXPERIMENT

+ + + + + +
send.shReplays a cansend script onto a given CAN interface.
display-text.pyAn early Python generator for display text frames.
display-matthew.txtA captured or hand-written frame sequence that shows a name on the display.
maybe-text.txtA near-copy of the above that's slightly different. It was a guess, as the name says. CLUTTER
+ +

notes-and-knowledge/: research REFERENCE

+ + + + + +
fdim.txtA summary of the FDIM protocol (heartbeat, text) based on p1ne's fdim-controller.
keep-wake.txtNotes on the Ford network-management "ring" that keeps modules awake.
wiring.txtLinks to ACM pinouts and wiring diagrams.
software-design.md, interface-design.mdEarly design docs for the menu and UI that came before the formal spec.
+ +

dsp/: ADAU1467 SigmaStudio project

+ + + + + + +
Design 1.dspprojThe SigmaStudio project (the audio signal chain).
Design 1_IC 1/SigmaStudio's compiled export: Program.bin/.dat, the memory images, ParamAddress.dat (register addresses the Go driver needs) and the compiler log. It also contains a second copy of the .dspproj and of bass_remapping.txt.
generate_bass_remapping.pyGenerates a sigmoid lookup curve for remapping bass levels. The output goes to bass_remapping.txt.
Settings/Design 1, Design 3Empty folders. CLUTTER
System Volume Information/, FOUND.000/Windows filesystem debris from when this folder was on a USB stick or SD card. FILE0000.CHK is a chkdsk recovery fragment. These are committed to git by mistake. CLUTTER
+ +

Cleanup suggestions

+
    +
  • Remove from git: dsp/System Volume Information/, dsp/FOUND.000/, and the empty dsp/Settings/. Add them to a .gitignore (the repo doesn't have one).
  • +
  • Collapse code/: keep ccode/ and move new/Ford FDIM text protocol.md into notes-and-knowledge/. Then delete new/ and the top-level code/*.go, which are older copies of ccode/. Commit ccode/; right now it's untracked.
  • +
  • Move start_can.sh into the repo (for example deploy/ or tools/) so it's versioned.
  • +
  • Merge read-can.txt and write-can.txt into one cleaned-up CAN cheat sheet in notes-and-knowledge/, and delete FDIM/maybe-text.txt.
  • +
  • git status shows every file under dsp/ as modified. That's probably just file-mode or line-ending noise from the FAT copy (try git diff --stat to check). Only one .dspproj or bass_remapping.txt copy needs tracking.
  • +
  • Expand the root README to point at fusion/README.md.
  • +
+
+ diff --git a/car-radio/README.md b/car-radio/README.md new file mode 100644 index 0000000..a7fa048 --- /dev/null +++ b/car-radio/README.md @@ -0,0 +1,3 @@ +# Car Radio + +Repo for everything involving the development of my custom car radio \ No newline at end of file diff --git a/car-radio/archive/FDIM/display-matthew.txt b/car-radio/archive/FDIM/display-matthew.txt new file mode 100644 index 0000000..6076657 --- /dev/null +++ b/car-radio/archive/FDIM/display-matthew.txt @@ -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 \ No newline at end of file diff --git a/car-radio/archive/FDIM/display-text.py b/car-radio/archive/FDIM/display-text.py new file mode 100644 index 0000000..20749a6 --- /dev/null +++ b/car-radio/archive/FDIM/display-text.py @@ -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) \ No newline at end of file diff --git a/car-radio/archive/FDIM/maybe-text.txt b/car-radio/archive/FDIM/maybe-text.txt new file mode 100644 index 0000000..f8a5b39 --- /dev/null +++ b/car-radio/archive/FDIM/maybe-text.txt @@ -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 \ No newline at end of file diff --git a/car-radio/archive/FDIM/send.sh b/car-radio/archive/FDIM/send.sh new file mode 100755 index 0000000..b637e23 --- /dev/null +++ b/car-radio/archive/FDIM/send.sh @@ -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 \ No newline at end of file diff --git a/car-radio/archive/code/display.go b/car-radio/archive/code/display.go new file mode 100644 index 0000000..bdabb91 --- /dev/null +++ b/car-radio/archive/code/display.go @@ -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() +} diff --git a/car-radio/archive/code/go.mod b/car-radio/archive/code/go.mod new file mode 100644 index 0000000..3bed992 --- /dev/null +++ b/car-radio/archive/code/go.mod @@ -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 diff --git a/car-radio/archive/code/go.sum b/car-radio/archive/code/go.sum new file mode 100644 index 0000000..5f66fad --- /dev/null +++ b/car-radio/archive/code/go.sum @@ -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= diff --git a/car-radio/archive/code/inputs.go b/car-radio/archive/code/inputs.go new file mode 100644 index 0000000..1a128e8 --- /dev/null +++ b/car-radio/archive/code/inputs.go @@ -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" + } +} diff --git a/car-radio/archive/code/main.go b/car-radio/archive/code/main.go new file mode 100644 index 0000000..2aae223 --- /dev/null +++ b/car-radio/archive/code/main.go @@ -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 { + + } +} diff --git a/car-radio/archive/decode.py b/car-radio/archive/decode.py new file mode 100644 index 0000000..6596f81 --- /dev/null +++ b/car-radio/archive/decode.py @@ -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}") diff --git a/car-radio/archive/start_can.sh b/car-radio/archive/start_can.sh new file mode 100755 index 0000000..99d16a9 --- /dev/null +++ b/car-radio/archive/start_can.sh @@ -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 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." diff --git a/car-radio/code/clock.go b/car-radio/code/clock.go new file mode 100644 index 0000000..981b24c --- /dev/null +++ b/car-radio/code/clock.go @@ -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) +} diff --git a/car-radio/code/clock_test.go b/car-radio/code/clock_test.go new file mode 100644 index 0000000..acb064f --- /dev/null +++ b/car-radio/code/clock_test.go @@ -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) + } + } +} diff --git a/car-radio/code/cmd/backlight/main.go b/car-radio/code/cmd/backlight/main.go new file mode 100644 index 0000000..584c82c --- /dev/null +++ b/car-radio/code/cmd/backlight/main.go @@ -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) +} diff --git a/car-radio/code/cmd/fcimread/main.go b/car-radio/code/cmd/fcimread/main.go new file mode 100644 index 0000000..6960cff --- /dev/null +++ b/car-radio/code/cmd/fcimread/main.go @@ -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) + } +} diff --git a/car-radio/code/cmd/fdimprobe/main.go b/car-radio/code/cmd/fdimprobe/main.go new file mode 100644 index 0000000..8bdffaa --- /dev/null +++ b/car-radio/code/cmd/fdimprobe/main.go @@ -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< %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) +} diff --git a/car-radio/code/cmd/fdimtest/main.go b/car-radio/code/cmd/fdimtest/main.go new file mode 100644 index 0000000..a7bf7be --- /dev/null +++ b/car-radio/code/cmd/fdimtest/main.go @@ -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() +} diff --git a/car-radio/code/cmd/fdimtext/main.go b/car-radio/code/cmd/fdimtext/main.go new file mode 100644 index 0000000..902e8d7 --- /dev/null +++ b/car-radio/code/cmd/fdimtext/main.go @@ -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) + } +} diff --git a/car-radio/code/cmd/radio/display.go b/car-radio/code/cmd/radio/display.go new file mode 100644 index 0000000..0e0d93c --- /dev/null +++ b/car-radio/code/cmd/radio/display.go @@ -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() +} diff --git a/car-radio/code/cmd/radio/inputs.go b/car-radio/code/cmd/radio/inputs.go new file mode 100644 index 0000000..08f9543 --- /dev/null +++ b/car-radio/code/cmd/radio/inputs.go @@ -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" + } +} diff --git a/car-radio/code/cmd/radio/main.go b/car-radio/code/cmd/radio/main.go new file mode 100644 index 0000000..1dea82d --- /dev/null +++ b/car-radio/code/cmd/radio/main.go @@ -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") +} diff --git a/car-radio/code/cmd/sweep/main.go b/car-radio/code/cmd/sweep/main.go new file mode 100644 index 0000000..049851d --- /dev/null +++ b/car-radio/code/cmd/sweep/main.go @@ -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) +} diff --git a/car-radio/code/fcim.go b/car-radio/code/fcim.go new file mode 100644 index 0000000..ed95c5b --- /dev/null +++ b/car-radio/code/fcim.go @@ -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 +} diff --git a/car-radio/code/fcim_test.go b/car-radio/code/fcim_test.go new file mode 100644 index 0000000..250d097 --- /dev/null +++ b/car-radio/code/fcim_test.go @@ -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) + } +} diff --git a/car-radio/code/go.mod b/car-radio/code/go.mod new file mode 100644 index 0000000..fc2cdf6 --- /dev/null +++ b/car-radio/code/go.mod @@ -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 diff --git a/car-radio/code/go.sum b/car-radio/code/go.sum new file mode 100644 index 0000000..5f66fad --- /dev/null +++ b/car-radio/code/go.sum @@ -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= diff --git a/car-radio/code/isotp.go b/car-radio/code/isotp.go new file mode 100644 index 0000000..9d9d24a --- /dev/null +++ b/car-radio/code/isotp.go @@ -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}}, + } +} diff --git a/car-radio/code/isotp_test.go b/car-radio/code/isotp_test.go new file mode 100644 index 0000000..9e84525 --- /dev/null +++ b/car-radio/code/isotp_test.go @@ -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") + } +} diff --git a/car-radio/code/layout.go b/car-radio/code/layout.go new file mode 100644 index 0000000..e890fcf --- /dev/null +++ b/car-radio/code/layout.go @@ -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}} +) diff --git a/car-radio/code/layout_test.go b/car-radio/code/layout_test.go new file mode 100644 index 0000000..681e3f1 --- /dev/null +++ b/car-radio/code/layout_test.go @@ -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]) +} diff --git a/car-radio/deploy/99-canable.rules b/car-radio/deploy/99-canable.rules new file mode 100644 index 0000000..c5a470f --- /dev/null +++ b/car-radio/deploy/99-canable.rules @@ -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" diff --git a/car-radio/deploy/README.md b/car-radio/deploy/README.md new file mode 100644 index 0000000..abf0e70 --- /dev/null +++ b/car-radio/deploy/README.md @@ -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. diff --git a/car-radio/deploy/bootstrap.sh b/car-radio/deploy/bootstrap.sh new file mode 100755 index 0000000..a82b159 --- /dev/null +++ b/car-radio/deploy/bootstrap.sh @@ -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" < 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 diff --git a/car-radio/deploy/write-can.txt b/car-radio/deploy/write-can.txt new file mode 100644 index 0000000..ea0c1bb --- /dev/null +++ b/car-radio/deploy/write-can.txt @@ -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 diff --git a/car-radio/dsp/Design 1.dspproj b/car-radio/dsp/Design 1.dspproj new file mode 100644 index 0000000000000000000000000000000000000000..fef831a1ed3f9dc073da8066299a93865ed044b8 GIT binary patch literal 271457 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 literal 0 HcmV?d00001 diff --git a/car-radio/dsp/Design 1_IC 1/DMem0.dat b/car-radio/dsp/Design 1_IC 1/DMem0.dat new file mode 100644 index 0000000..08a74db --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/DMem0.dat @@ -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 diff --git a/car-radio/dsp/Design 1_IC 1/DMem1.dat b/car-radio/dsp/Design 1_IC 1/DMem1.dat new file mode 100644 index 0000000..47fa004 --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/DMem1.dat @@ -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 diff --git a/car-radio/dsp/Design 1_IC 1/Design 1.dspproj b/car-radio/dsp/Design 1_IC 1/Design 1.dspproj new file mode 100644 index 0000000000000000000000000000000000000000..76520b26267853740fc7068d1b39ea8548c3c11e GIT binary patch literal 269034 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 literal 0 HcmV?d00001 diff --git a/car-radio/dsp/Design 1_IC 1/PMem.dat b/car-radio/dsp/Design 1_IC 1/PMem.dat new file mode 100644 index 0000000..e69de29 diff --git a/car-radio/dsp/Design 1_IC 1/ParamAddress.dat b/car-radio/dsp/Design 1_IC 1/ParamAddress.dat new file mode 100644 index 0000000..a8e8628 --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/ParamAddress.dat @@ -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 diff --git a/car-radio/dsp/Design 1_IC 1/Program.bin b/car-radio/dsp/Design 1_IC 1/Program.bin new file mode 100644 index 0000000..7086824 --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/Program.bin @@ -0,0 +1,1410 @@ +00000000000000000000000000000010 +00001100000000001101110011011100 +00001101000000001111111111010010 +00001101000000001111111111010000 +00001101000000001111010001010000 +00001101000000001111010000000000 +11000000000000000010001110000000 +10000000000000000000000000000000 +11000000000000000010001100000000 +10000000000000000000000000000000 +11000000000000000010001111000000 +10000000000000000000000000010000 +11000000000000000010001000000000 +10000000000000000000000000000000 +00001000100000001101110011100000 +00001100000000001101110011011100 +00001010001000010000000000010010 +00001000000000000000000000010000 +00000001000000000000000000010100 +00000110010000000100000000010000 +00001010001000010000000000010110 +00001000000000000000000000010100 +00000001000000000000000000011000 +00000110010000000100000000010000 +00001010001000010000000000011010 +00001000000000000000000000011000 +00000001000000000000000000011100 +00000110010000000101000000010000 +00001010001000010000000000011110 +00001000000000000000000000011100 +00000001000000000000000000100000 +00000110010000000101000000010000 +11000000000000000010000000000000 +10000000000000000000000000110011 +00001101000000001111111111000001 +11000000000000000010000000000000 +10000000000000000000000000000001 +00001101000000001111010001100010 +11000000000000000010000000000000 +10000000000000000000000000000000 +00001101000000001111010001100010 +11000000000000000010000000000000 +10000000000000000000000000000010 +00001101000000001111111111010001 +00000000000000000000000000000011 +00001101000000001111111111010010 +00000000000000000000000000000101 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000010000000010000000000101110 +00000000000000000000000000000000 +00000000000000000000000000000000 +00001000100111101101110011100000 +00001000100111000000000000011000 +00001000100110100000000000010000 +00001000100110000000000000011100 +00001000100101100000000000010100 +00000000000000000000000000000000 +11000000000000000000000000000000 +10000010010011110000000000000000 +00001000100010101101110011100000 +00001010001000110000000000000110 +00001010001010110000000000000111 +00000000100010000010000000000000 +00001010101001110000000000000101 +00000000000000000000000000100010 +11000000000000000010110100110000 +10000000000000000101000000000000 +00110000000000001011010001101010 +00000000100010000010000000000000 +11000000000000000010100001110000 +10000000000000000110000000000000 +00000010000010010000000001010110 +00000000100011000001000110010001 +00000000000000000000000000100100 +00000000100011000010101000000010 +00110000000000001011110011101001 +00000000100001000000110110011100 +00000000001000000000101100101101 +00000000000000001100001011110001 +00000001000000000000000001010100 +00000110000000000101000000010100 +00110110010000000101000000010101 +00100110010000000100000000010101 +00001001001010110000000000000110 +00001001001010110000000000000111 +00000000000000000000000000000000 +00001100000000000000001011010111 +00000110010000000001000000001110 +00001100000000000000001100110000 +00000110010000000001000000011110 +00001100000000000000001100110001 +00000110010000000001010010001110 +00001100000000001111110001100000 +00000110010000000001000000101110 +00001100000000001111110001100001 +00000110010000000001000001001110 +00001000100100000000000000100111 +00001100011100000000000000100000 +00001110001111010000000000100011 +00000000111101001010111000001111 +00001010001001011101110011011100 +00001010111000010000000000100001 +00000000000000001101110001110000 +00000000000000001010010011011000 +00000000110101100010000100000001 +00000000000000001110000000101001 +00000111000001011000100000001000 +00000000100001000001100110110011 +00000000000001000100010110010000 +00000000010001000010110000110000 +00000000000001000100100100101000 +00000000100101000000100010000111 +00001011111000010000000000100001 +00000101010011010001000010011110 +00001000100100000000000000110001 +00001100011100000000000000101010 +00001110001111010000000000101101 +00000000111101001010111000001111 +00001010001001011101110011011100 +00001010111000010000000000101011 +00000000000000001101110001110000 +00000000000000001010010011011000 +00000000110101100010000100000001 +00000000000000001110000000101001 +00000111000001011000100000001000 +00000000100001000001100110110011 +00000000000001000100010110010000 +00000000010001000010110000110000 +00000000000001000100100100101000 +00000000100101000000100010000111 +00001011111000010000000000101011 +00000101010011010001000010101110 +00001000100100000000000000111011 +00001100011100000000000000110100 +00001110001111010000000000110111 +00000000111101001010111000001111 +00001010001001011101110011011100 +00001010111000010000000000110101 +00000000000000001101110001110000 +00000000000000001010010011011000 +00000000110101100010000100000001 +00000000000000001110000000101001 +00000111000001011000100000001000 +00000000100001000001100110110011 +00000000000001000100010110010000 +00000000010001000010110000110000 +00000000000001000100100100101000 +00000000100101000000100010000111 +00001011111000010000000000110101 +00000101010011010001000010111110 +00001100000000000000001100110010 +00000110010000000001000001011110 +00000101000011000001000000001110 +00000101000011001001000000011110 +00001011001000011111110101100000 +00001011001001011111110101100100 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000001100000000001110 +00000000000000001100100000011110 +00000000000000001101000000101110 +11000000000000000011110000000000 +10000000000000000000000001000101 +00000000000000000000000000000000 +11000000000000000000101000000000 +10001000000000001111110101101000 +00000110000000000001000000101110 +00000110010000000001000000111110 +00001101000000000000000001001010 +00000000000001000000000000000000 +00001010111000010000000001000001 +00001100010000000000000000111110 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000000001000001 +00001011101000010000000001001000 +00000000110101111110110100001000 +00001011101101010000000001001001 +00001100010000000000001100110011 +00000110000000000001000000111110 +00000110000000001001000001001110 +00000000000100000000000000000001 +00000000000000000000000000000000 +00000101010011000001000001101110 +00000101011011000001000010001110 +00000101000011111001000001011110 +11000000000000000000000000000000 +10000010011011100000000001010100 +00000101010011111001000000100110 +11000000000000000000000000000000 +10000010011011010000000000000000 +00000101010011111000000000100110 +11000000000000000000000000000000 +10000010011011010000000000000011 +00000101010011111000000000100110 +11000000000000000000000000000000 +10000010011011100000000001010111 +00000101010011111001000000100110 +11000000000000000000000000000000 +10000010011011110000000001001100 +11000000000000000000000000000000 +10000010101011110000000001010000 +11000000000000000000000000000000 +10000010011111100000000000000000 +00000100000110111100000000001010 +00000000000000000000000000000000 +00000000000000000010011110000000 +11000000000000000000000000000000 +10000010101011110000000001010100 +11000000000000000000000000000000 +10000010010011100000000000000000 +00000100000110100100000000001010 +00000000000000000000000000000000 +00000000000000000010010010000000 +11000000000000000000000000000000 +10000010001111110000000001011000 +11000000000000000000000000000000 +10000010010111110000000001011100 +11000000000000000000000000000000 +10000010101011110000000110000100 +11000000000000000000000000000000 +10000010100111110000000110001000 +00000011000000010000000101010010 +11000000000000000000000000000000 +10000010000011100000000000000000 +11000000000000000000000000000000 +10000010100011100000000000000000 +00000100000110000100000000011010 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000110001100 +00000110000010000101000000001000 +00000110000110000100000000110001 +11110001011011001000000000000001 +10001000000000000000000100100010 +11110000011011001000000000100001 +10000001100000100000010000100011 +00000110000001001101000000100111 +11100000001101001010001000000101 +10000001100000000000000000100000 +11100000100000000000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000111100001110100001 +11000000000000000000000000000000 +10000010000011010000000000000000 +11000000000000000000000000000000 +10000010100011010000000000000000 +00000100000110000100000000011010 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000000001000 +00000110000010000100000000001000 +00000110000110000101000000110001 +11110110011000101000000000000001 +10001000000000100100010000000000 +11110110011110001000000000100001 +10000001100000000100011100000010 +11111010011110101010001000000101 +10000001100000100100101000000000 +11110110010000010000000000100101 +10001100000000000100011100000010 +11111010011110001010011001000001 +10000011100000100100101000000000 +11110110010000110000000000100001 +10001110000001000100011100000010 +00000110000001001101000000100111 +11111010010000000010001000000101 +10000001100000000100000000000000 +11100000000000010000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000111100001110100001 +11000000000000000000000000000000 +10000010000011010000000000000000 +11000000000000000000000000000000 +10000010100011010000000000000000 +00000100000110000100000000011010 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000000011100 +00000110000010000100000000001000 +00000110000110000101000000110001 +11110110011000101000000000000001 +10001000000000100100010000000000 +11110110011110001000000000100001 +10000001100000000100011100000010 +11111010011110101010001000000101 +10000001100000100100101000000000 +11110110010000010000000000100101 +10001100000000000100011100000010 +11111010011110001010011001000001 +10000011100000100100101000000000 +11110110010000110000000000100001 +10001110000001000100011100000010 +00000110000001001101000000100111 +11111010010000000010001000000101 +10000001100000000100000000000000 +11100000000000010000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000111100001110100001 +11000000000000000000000000000000 +10000010000011100000000000000000 +11000000000000000000000000000000 +10000010100011100000000000000000 +00000100000110000100000000011010 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000110010110 +00000110000010000101000000001000 +00000110000110000100000000110001 +11110001011011001000000000000001 +10001000000000000000000100100010 +11110000011011001000000000100001 +10000001100000100000010000100011 +00000110000001001101000000100111 +11100000001101001010001000000101 +10000001100000000000000000100000 +11100000100000000000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000111100001110100001 +11000000000000000000000000000000 +10000010011011110000000001001100 +00000011000000010000000111000101 +11000000000000000000000000000000 +10000010000011100000000000000000 +11000000000000000000000000000000 +10000010100011100000000000000000 +00000100000110000100000000011001 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000110100000 +00000110000010000101000000001000 +00000110000110000100000000110001 +11110001011011001000000000000001 +10001000000000000000000100100010 +11110000011011001000000000100001 +10000001100000100000010000100011 +00000110000001001101000000100100 +11100000001101001010001000000101 +10000001100000000000000000100000 +11100000100000000000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000110000001101100001 +11000000000000000000000000000000 +10000010000011010000000000000000 +11000000000000000000000000000000 +10000010100011010000000000000000 +00000100000110000100000000011001 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000000110000 +00000110000010000100000000001000 +00000110000110000101000000110001 +11110110011000101000000000000001 +10001000000000100100010000000000 +11110110011110001000000000100001 +10000001100000000100011100000010 +11111010011110101010001000000101 +10000001100000100100101000000000 +11110110010000010000000000100101 +10001100000000000100011100000010 +11111010011110001010011001000001 +10000011100000100100101000000000 +11110110010000110000000000100001 +10001110000001000100011100000010 +00000110000001001101000000100100 +11111010010000000010001000000101 +10000001100000000100000000000000 +11100000000000010000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000110000001101100001 +11000000000000000000000000000000 +10000010000011010000000000000000 +11000000000000000000000000000000 +10000010100011010000000000000000 +00000100000110000100000000011001 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000001000100 +00000110000010000100000000001000 +00000110000110000101000000110001 +11110110011000101000000000000001 +10001000000000100100010000000000 +11110110011110001000000000100001 +10000001100000000100011100000010 +11111010011110101010001000000101 +10000001100000100100101000000000 +11110110010000010000000000100101 +10001100000000000100011100000010 +11111010011110001010011001000001 +10000011100000100100101000000000 +11110110010000110000000000100001 +10001110000001000100011100000010 +00000110000001001101000000100100 +11111010010000000010001000000101 +10000001100000000100000000000000 +11100000000000010000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000110000001101100001 +11000000000000000000000000000000 +10000010000011100000000000000000 +11000000000000000000000000000000 +10000010100011100000000000000000 +00000100000110000100000000011001 +00000100000111000100000000010110 +00000000000000000010000010000000 +00000000000000000010100010000000 +11000000000000000000000000000000 +10000010000111110000000110101010 +00000110000010000101000000001000 +00000110000110000100000000110001 +11110001011011001000000000000001 +10001000000000000000000100100010 +11110000011011001000000000100001 +10000001100000100000010000100011 +00000110000001001101000000100100 +11100000001101001010001000000101 +10000001100000000000000000100000 +11100000100000000000000000100101 +10001100000000000000000000000000 +00000000100001000000000100011001 +11100001100000110000000000000000 +10000000000000110000001101100001 +11000000000000000000000000000000 +10000010100111110000000110110100 +11000000000000000000000000000000 +10000010010011110000000110111000 +11000000000000000000000000000000 +10000010001111110000000001011000 +11000000000000000000000000000000 +10000010011011100000000001110010 +11000000000000000000000000000000 +10000010011111100000000010001010 +11000000000000000000000000000000 +10000010001011110000000100111000 +11000000000000000000000000000000 +10000010000111110000000101011000 +11000000000000000010100111110011 +10001000000000000000000000000000 +11000000000000000010110111110000 +10001010000000000000000000000000 +11000000000000000010010111000000 +10000001000000000000000000000000 +00001010001100011101110000000010 +00000011000000010000001000110111 +11000000000000000000000000000000 +10000010010111110000000101100000 +11000000000000000000000000000000 +10000010101011110000000001100000 +00000011000001000000001000110101 +11000000000000000000000000000000 +10000010000010100000000000000000 +00000101000011000100000000010011 +00000101010011000001000000010111 +00000110000001000101000000100111 +11100010011101001000000000000000 +10000000000000111000010110000011 +11100010011101001000000000011001 +10001000000000111000010110000011 +11100010011101001000000000010101 +10001000000000111011101110011010 +11100110100000000000000000110101 +10001000000000111000010000000000 +11100101100000000000000000000000 +10000000000000111000010000000000 +11110101100110101000000000000000 +10000000000000111000001010100000 +00000000000000001110000001101000 +00000000000001000100000100000000 +00000101001111000100000000000010 +00000000000111001101000001101000 +00000000100001000000100010000001 +00000000100001000001111010001001 +00000101011111110100000000100010 +00000000000000001110000001101110 +00000000000001000100000101100000 +00000101001111000100000000000010 +00000000000111001101000001101000 +00000000100001000000100010000001 +00000000100001000001011010001001 +00000101010111110100000000100010 +00000000000000001110000001101101 +00000000000001000100000101010000 +00000101001111000100000000000010 +00000000000111001101000001101000 +00000000100001000000100010000001 +00000000100001000001010110001001 +00000101010111101100000000100010 +00000000000000001110000001111101 +00000000000001000100000111010000 +00000101001111000100000000000010 +00000000000111001101000001101000 +00000000100001000000100010000001 +00000000100001000001110110001001 +00000101011111101100000000100010 +00000000101010000001000001100101 +00000000001100111001010011101101 +00000000100010000001000001101110 +00000000001000000000000111001110 +00000000100010000010000000000110 +00000000000000000000000000000000 +00000000000000000000000000100101 +00000101001011011100000000000001 +00110000111001100110011000000100 +00001011011110011111110100100000 +00000000000000000000000000000000 +00001010101000011111110100101010 +00000000100001000000100010000111 +00000000100001000001100010001111 +00000000000001000110000110001111 +00000110001000001100000000000001 +00000101001011110000000000010001 +00000101001011010000000000010101 +00100000110001010101111011101010 +00000000100010000001100011101000 +00000101000011001000000000100101 +00000000001000000000101011001000 +00000000001000000000101001100001 +00110000100001011001101110110000 +00000101011011011100000000010001 +00000101011011110100000000010001 +00000000111011100010000000001110 +00000000001000000000000100010000 +00000000000000001101100001100000 +00001010001000011101110010101110 +00000000100101000000000011100000 +00000000000000001110100001101000 +00000000000000000010000010000000 +00000111000001000100000000000000 +00000000100001000001100110000000 +00000000000001000110000110011000 +00000000100001000000000000001000 +00000101010011000100000000011001 +00000000000000000010010100000011 +00000000000000000010101000110110 +00000000000000000000000000000000 +00000000000000000000000000000000 +11000000000000000000000000000000 +10000010001111110000000001011000 +11000000000000000000000000000000 +10000010010011110000000110110100 +11000000000000000000000000000000 +10000010010111110000000001011100 +11000000000000000000000000000000 +10000010100011110000000101110100 +00000110000011000100000001000100 +00000110000111000100000001000101 +00000110010011000100000001001000 +00000000000001101000000000000000 +00000000000000000000000000000000 +00000101010011000001010100111110 +11000000000000000000000000000000 +10000010001011110000000101111000 +11000000000000000000000000000000 +10000010000111110000000101101100 +11000000000000000000000000000000 +10000010001111110000000001011100 +00000011000000010000001001011101 +11000000000000000000000000000000 +10000010010111110000000101100000 +00000011000001000000001001011011 +00000101000011000100000000010011 +00000000000000001110000001101000 +00000000000001000100000100000000 +00000101001111000100000000000010 +00000110000100101100000000110101 +00000000000111001101000001101000 +00000000100001000000100010000001 +00000000100001000001111010001001 +00000101011111110100000000100010 +00000101011011110100000000010001 +00000000110101100010100000001110 +00000101011011000100000000010001 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000110000000000001000001101110 +00000110010000000001000001111110 +00001101000000000000000111001101 +00000000000001000000000000000000 +00001010111000010000000111000100 +00001100010000000000000111000001 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000000111000100 +00001011101000010000000111001011 +00000000110101111110110100001000 +00001011101101010000000111001100 +11000000000000000000000000000000 +10000010000011110000000111001111 +00001000011000000000001100110100 +11000000000000000000000000000000 +10000010000111100000000000000000 +00000100000110001000100000000000 +00000000000000000010000010000000 +00000101000011000001100000000001 +00000100000110001000000000010000 +00000101010011000001000011001110 +00000101000011000001100000000001 +00000101010011000001000011011110 +00001100001101000000001100010100 +00000000000001100000000000000000 +00000110000100000001000011001110 +00000110000100001001000011011110 +00000000000000001011110001101000 +00000000000111001000000001001000 +00001011001000010000001100010100 +00000101010011001001000100001110 +00000101011011001001000100111110 +00000101000011000001000100001110 +00000101001011000001000100111110 +00000000100001000000000000001000 +00000101010011000001000101001110 +00000110000000000001000100111110 +11000000000000000000000000011000 +10000000010000000001100111101110 +00001101000000000000000111100101 +00000000000001000000000000000000 +00001010111000010000000111011100 +00001100010000000000000111011001 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000000111011100 +00001011101000010000000111100011 +00000000110101111110110100001000 +00001011101101010000000111100100 +11000000000000000000000000000000 +10000010000011000000000000000000 +11000000000000000000000000000000 +10000010000110110000000000000000 +11000000000000000000000000000000 +10000010011011110000000111100111 +11000000000000000000000000000000 +10000010001011100000000000001110 +11000000000000000000000000000000 +10000010001111100000000000010001 +11111000011101001000000000000000 +10000000000000001000000111000011 +11111001011011001000000000001001 +10001000000000001100000111000010 +11111100010000000000000000101001 +10001010000000100000100000000000 +11100000001110101000000000000111 +10000000100000000000000100100100 +11100000000000000010100001100111 +10000001100000000000000000000000 +11100000000000000100101001000000 +10000000000000000000000000000000 +11101010101101010000000000000000 +10000000000000000000000000100000 +00000101010011010001000101111110 +00000101011011010001000110101110 +11000000000000000000000000000000 +10000010000011000000000000000110 +11000000000000000000000000000000 +10000010000110110000000000000110 +11000000000000000000000000000000 +10000010011011110000000111101100 +11000000000000000000000000000000 +10000010001011100000000000001110 +11000000000000000000000000000000 +10000010001111100000000000010001 +11111000011101001000000000000000 +10000000000000001000000111000011 +11111001011011001000000000001001 +10001000000000001100000111000010 +11111100010000000000000000101001 +10001010000000100000100000000000 +11100000001110101000000000000111 +10000000100000000000000100100100 +11100000000000000010100001100111 +10000001100000000000000000000000 +11100000000000000100101001000000 +10000000000000000000000000000000 +11101010101101010000000000000000 +10000000000000000000000000100000 +00000101010011010001000110111110 +00000101011011010001000111001110 +00001100010000000000001100110101 +00000110000000000001000100001110 +00000110000000001001000100111110 +00000000000100000000000000000001 +00000000000000000000000000000000 +00000101010011000001000111111110 +00000101011011000001001000101110 +00001100010000000000001100110110 +00000110000000000001000101001110 +00000000000001000000000010000000 +00000000000000000000000000000000 +00000101010011000001001000111110 +11000000000000000000000000000000 +10000010000011000000000000001100 +11000000000000000000000000000000 +10000010000110110000000000001100 +11000000000000000000000000000000 +10000010011011110000000111110001 +11000000000000000000000000000000 +10000010001011100000000000010101 +11000000000000000000000000000000 +10000010001111100000000000011000 +11111000011101001000000000000000 +10000000000000001000000111000011 +11111001011011001000000000001001 +10001000000000001100000111000010 +11111100010000000000000000101001 +10001010000000100000100000000000 +11100000001110101000000000000111 +10000000100000000000000100100100 +11100000000000000010100001100111 +10000001100000000000000000000000 +11100000000000000100101001000000 +10000000000000000000000000000000 +11101010101101010000000000000000 +10000000000000000000000000100000 +00000101010011010001001001001110 +00000101011011010001001001011110 +00001100010000000000001100110111 +00000110000000000001000110111110 +00000110000000001001000111001110 +00000000000100000000000000000001 +00000000000000000000000000000000 +00000101010011000001001010001110 +00000101011011000001001010011110 +11000000000000000000000000000000 +10000010000011000000000000010010 +11000000000000000000000000000000 +10000010000110110000000000010010 +11000000000000000000000000000000 +10000010011011110000000111110110 +11000000000000000000000000000000 +10000010001011100000000000011101 +11000000000000000000000000000000 +10000010001111100000000000100000 +11111000011101001000000000000000 +10000000000000001000000111000011 +11111001011011001000000000001001 +10001000000000001100000111000010 +11111100010000000000000000101001 +10001010000000100000100000000000 +11100000001110101000000000000111 +10000000100000000000000100100100 +11100000000000000010100001100111 +10000001100000000000000000000000 +11100000000000000100101001000000 +10000000000000000000000000000000 +11101010101101010000000000000000 +10000000000000000000000000100000 +00000101010011010001001001101110 +00000101011011010001001001111110 +00000110000000000001001000111110 +00000110010000000001001011001110 +00001101000000000000001000000111 +00000000000001000000000000000000 +00001010111000010000000111111110 +00001100010000000000000111111011 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000000111111110 +00001011101000010000001000000101 +00000000110101111110110100001000 +00001011101101010000001000000110 +00000101000011000001001001001110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001000001001 +00001010111000010000001000001010 +00001100010000000000001100111000 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001000001010 +00001010001001010000001000001001 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001101000011110 +00000000000000000000000000100011 +00001100010000000000001100111001 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001101000101110 +11000000000000000000000000010000 +10000000010011000001101000001110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001100111111110 +00000101010011000001001011011110 +00000101000011000001001001011110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001000001100 +00001010111000010000001000001101 +00001100010000000000001100111010 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001000001101 +00001010001001010000001000001100 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001101001001110 +00000000000000000000000000100011 +00001100010000000000001100111011 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001101001011110 +00000101010011000001001100111110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001101000111110 +00000101010011000001001011101110 +00000101000011000001001001101110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001000001111 +00001010111000010000001000010000 +00001100010000000000001100111100 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001000010000 +00001010001001010000001000001111 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001101010001110 +00000000000000000000000000100011 +00001100010000000000001100111101 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001101010011110 +11000000000000000000000000010000 +10000000010011000001101001111110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001101001101110 +00000101010011000001001011111110 +00000101000011000001001001111110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001000010010 +00001010111000010000001000010011 +00001100010000000000001100111110 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001000010011 +00001010001001010000001000010010 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001101010111110 +00000000000000000000000000100011 +00001100010000000000001100111111 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001101011001110 +00000101010011000001001101001110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001101010101110 +00000101010011000001001100001110 +00000101000011000001001010001110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001000010101 +00001010111000010000001000010110 +00001100010000000000001101000000 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001000010110 +00001010001001010000001000010101 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001101011111110 +00000000000000000000000000100011 +00001100010000000000001101000001 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001101100001110 +11000000000000000000000000010000 +10000000010011000001101011101110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001101011011110 +00000101010011000001001100011110 +00000101000011000001001010011110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001000011000 +00001010111000010000001000011001 +00001100010000000000001101000010 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001000011001 +00001010001001010000001000011000 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001101100111110 +00000000000000000000000000100011 +00001100010000000000001101000011 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001101101001110 +11000000000000000000000000010000 +10000000010011000001101100101110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001101100011110 +00000101010011000001001100101110 +11000000000000000000000000000000 +10000010000011000000000000011000 +11000000000000000000000000000000 +10000010000111100000000000101010 +11000000000000000000000000000000 +10000010011011110000001000011011 +11111101011010101000000000000000 +10000000000000000000000011000011 +11111000011101001000000000000011 +10000001010000000100000111000101 +11111100011010101000000000100001 +10001000000000000000100011000011 +11100000001101001010001000000111 +10000000010000100000100111000101 +00000101010111000101000000000000 +00000101010011000001001110011110 +11000000000000000000100000001111 +10001101100000000000001000100000 +11000000000000000000100010001111 +10001101100001000000001100011000 +11000000000000000000100010001111 +10001101100101000000001100011010 +00000110000000000001001011011110 +00000110000000001001001001001110 +00001100010100011111110110100000 +00000000000001100000000000000000 +00001101010100000000001100011010 +00000101010011000001001101011110 +11000000000000000000100000001111 +10001101100000000000001000100001 +11000000000000000000100010001111 +10001101100001000000001100011100 +11000000000000000000100010001111 +10001101100101000000001100011110 +00000110000000000001001011101110 +00000110000000001001001001011110 +00001100010100011111110110100000 +00000000000001100000000000000000 +00001101010100000000001100011110 +00000101010011000001001111011110 +11000000000000000000100000001111 +10001101100000000000001000100010 +11000000000000000000100010001111 +10001101100001000000001100100000 +11000000000000000000100010001111 +10001101100101000000001100100010 +00000110000000000001001011111110 +00000110000000001001001001101110 +00001100010100011111110110100000 +00000000000001100000000000000000 +00001101010100000000001100100010 +00000101010011000001001101101110 +11000000000000000000100000001111 +10001101100000000000001000100011 +11000000000000000000100010001111 +10001101100001000000001100100100 +11000000000000000000100010001111 +10001101100101000000001100100110 +00000110000000000001001100001110 +00000110000000001001001001111110 +00001100010100011111110110100000 +00000000000001100000000000000000 +00001101010100000000001100100110 +00000101010011000001001111111110 +11000000000000000000100000001111 +10001101100000000000001000100100 +11000000000000000000100010001111 +10001101100001000000001100101000 +11000000000000000000100010001111 +10001101100101000000001100101010 +00000110000000000001001100011110 +00000110000000001001001010001110 +00001100010100011111110110100000 +00000000000001100000000000000000 +00001101010100000000001100101010 +00000101010011000001001110101110 +11000000000000000000100000001111 +10001101100000000000001000100101 +11000000000000000000100010001111 +10001101100001000000001100101100 +11000000000000000000100010001111 +10001101100101000000001100101110 +00000110000000000001001100101110 +00000110000000001001001010011110 +00001100010100011111110110100000 +00000000000001100000000000000000 +00001101010100000000001100101110 +00000101010011000001001110111110 +00000110000000000001001100111110 +11000000000000000000000000011000 +10000000010000000001101101011110 +00001101000000000000001000110010 +00000000000001000000000000000000 +00001010111000010000001000101001 +00001100010000000000001000100110 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001000101001 +00001011101000010000001000110000 +00000000110101111110110100001000 +00001011101101010000001000110001 +00000110000000000001001101001110 +11000000000000000000000000011000 +10000000010000000001101101101110 +00001101000000000000001001000000 +00000000000001000000000000000000 +00001010111000010000001000110111 +00001100010000000000001000110100 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001000110111 +00001011101000010000001000111110 +00000000110101111110110100001000 +00001011101101010000001000111111 +00000110000000000001001101011110 +00000110010000000001001111001110 +00001101000000000000001001001110 +00000000000001000000000000000000 +00001010111000010000001001000101 +00001100010000000000001001000010 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001001000101 +00001011101000010000001001001100 +00000000110101111110110100001000 +00001011101101010000001001001101 +00000110000000000001001101101110 +00000110010000000001001111101110 +00001101000000000000001001011100 +00000000000001000000000000000000 +00001010111000010000001001010011 +00001100010000000000001001010000 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001001010011 +00001011101000010000001001011010 +00000000110101111110110100001000 +00001011101101010000001001011011 +00000101000011111001001110011110 +11000000000000000000000000010000 +10000000010011111001101110011110 +11000000000000000000000000000000 +10000010000111100000000010110111 +11000000000000000000000000000000 +10000010001011000000000000011110 +11000000000000000000000000000000 +10000010000011110000001001011110 +11100000110000000000000000000000 +10000000000000000100010000000000 +11110000011101001000000000000000 +10000000000000100100011100000011 +11111100011010001000000000000001 +10001100000000101000100100000010 +11100000000000000000000000000111 +10000000000000000000000000000000 +11100000000000000010000001000000 +10000000000000000000000000000000 +11101000100000000000000000000000 +10000000000000101000010000000000 +11000000000000000000000000010000 +10000000010011000001101111001110 +11110000011101001000000000000000 +10000000000000100100011100000011 +11111100011010001000000000000001 +10001100000000101000100100000010 +11100000000000000000000000000111 +10000000000000000000000000000000 +11100000000000000010000001000000 +10000000000000000000000000000000 +11101000100000000000000000000000 +10000000000000101000010000000000 +11000000000000000000000000010000 +10000000010011000001101111111110 +11110000011101001000000000000000 +10000000000000100100011100000011 +11111100011010001000000000000001 +10001100000000101000100100000010 +11100000000000000000000000000111 +10000000000000000000000000000000 +11100000000000000010000001000000 +10000000000000000000000000000000 +11101000100000000000000000000000 +10000000000000101000010000000000 +11000000000000000000000000010000 +10000000010011000001110000101110 +11110000011101001000000000000000 +10000000000000100100011100000011 +11111100011010001000000000000001 +10001100000000101000100100000010 +11100000000000000000000000000111 +10000000000000000000000000000000 +11100000000000000010000001000000 +10000000000000000000000000000000 +11101000100000000000000000000000 +10000000000000101000010000000000 +00000101010011000001010000001110 +11000000000000000000000000000000 +10000010000011000000000000110110 +11000000000000000000000000000000 +10000010000111100000000000110111 +11000000000000000000000000000000 +10000010011011110000001001110010 +11111101011010101000000000000000 +10000000000000000000000011000011 +11111000011101001000000000000011 +10000001010000000100000111000101 +11111100011010101000000000100001 +10001000000000000000100011000011 +11100000001101001010001000000111 +10000000010000100000100111000101 +00000101010111000101000000000000 +00000101010011000001010000011110 +00000110000000000001001110101110 +00000110000000001001001110111110 +00001101000100001111110011001000 +00000110000000000001001111001110 +00000110000000001001001111011110 +00001101000100001111110011000000 +00000110000000000001001111101110 +00000110000000001001001111111110 +00001101000100001111110011000100 +00001100100000100000001101000100 +00000000000000000000000000000000 +00110101000011000001010000011110 +00100101000011000001010000001110 +00000101010011000001010000101110 +00000110000000000001010000101110 +11000000000000000000000000011000 +10000000010000000001110000111110 +00001101000000000000001010000011 +00000000000001000000000000000000 +00001010111000010000001001111010 +00001100010000000000001001110111 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001001111010 +00001011101000010000001010000001 +00000000110101111110110100001000 +00001011101101010000001010000010 +00000101000011000001010000101110 +00000000000000001110000000101000 +00000000000001000100000100000000 +00001010111000010000001101001000 +00001100010000000000001101000101 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001101001000 +00001100100000100000001101001011 +00001010001010010000001101001010 +00001010101010010000001101000111 +00100000100001000001001000101010 +00000000100010000001000000101000 +00001010001011010000001101001011 +00001010001100010000001101000110 +00000000001000000000001001001000 +00000000001000000000001001100100 +00001011001010010000001101001010 +00110000100001011001001100110000 +00001011001011010000001101001011 +00001010001110010000001101001010 +00000101010011110001010000111110 +00000101000011000001010000111110 +00001011001000010000001010000101 +00000101010011000001010001001110 +00000101000011000001010001001110 +00000110000000000001010001011110 +00000000000001000100010100000000 +00000000000000000000000000000000 +00000101010011001001010001101110 +00000101000011000001010001101110 +00001011001000010000001010000110 +00000101010011000001010001111110 +00000101000011000001010001111110 +00000110000000000001010010001110 +00000000000001000100010100000000 +00000000000000000000000000000000 +00000101010011001001010010011110 +00000101000011000001010010011110 +00001011001000010000001010000111 +00000101010011000001010010101110 +11000000000000000000000000000000 +10000010000011110000001010001000 +00001010101000010000001101001100 +00000101000011000001010010101110 +00000000100010000001000000001000 +00000000001000000000001100001000 +00000000111011100010000000000000 +00000000001000000000000100010000 +00000000000000001101100001000000 +00000000000000000000000000000000 +00000101000011001000100000000000 +00000101010011001001010010111110 +00000101000011000001010010111110 +00001011001000010000001010101000 +00000101010011000001010011001110 +11000000000000000000100000011111 +10001101100000000000000001011101 +00000101000011000001010011001110 +00001011001000011111110110000100 +11000000000000000000100000011111 +10001101100101000000000001011100 +00000110000100000001010000101110 +00001100000000001111110110100000 +00000000000001000000000000001000 +00001101000000010000000001011100 +00000101010011000001010011011110 +00000101000011000001010011001110 +00001011001000010000001010101001 +11000000000000000000000000010000 +10000000010011000001110001001110 +00001100100000100000001101001101 +00000000000000000000000000000000 +00110101000011000001010000101110 +00100101000011000001010011011110 +00000101010011000001010011101110 +00000101000011000001010011101110 +00000000100001001010000100000000 +11000000000000000000100000001111 +10001101011000000000001010101010 +00001010111000010000001010101011 +00001100010000000000001101001110 +00000000000111001000001001000000 +00000000100001000000100010000001 +00000000100001000001100010001001 +00001011101000011111110101100100 +00001011111000010000001010101011 +00001010001001010000001010101010 +00000000100010000001000010000001 +11000000000000000000000000010000 +10000000001011000001110001101110 +00000000000000000000000000100011 +00001100010000000000001101001111 +00000000000000001110000000101000 +00001010001000011101110000110000 +00111010001000011111110101101000 +00000000100010000001000000001000 +00000000000111010000000010000000 +00000000100001000001000100101010 +00000000100001000000000100011000 +00000000001000000000001100000001 +11000000000000000000000000010000 +10000000010011000001110001111110 +00000101010011000001010100001110 +00000000000001000100000100000000 +11000000000000000000000000011000 +10000000011000001001110001011110 +00000101010011000001010011111110 +00000110000000000001010011101110 +11000000000000000000000000011000 +10000000010000000001110010001110 +00001101000000000000001010111001 +00000000000001000000000000000000 +00001010111000010000001010110000 +00001100010000000000001010101101 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001010110000 +00001011101000010000001010110111 +00000000110101111110110100001000 +00001011101101010000001010111000 +00001100100000100000001101010000 +00000000000000000000000000000000 +00110101000011000001010011111110 +00100101000011000001010011101110 +00000101010011000001010100011110 +00000110000000000001010100001110 +11000000000000000000000000011000 +10000000010000000001110010011110 +00001101000000000000001011000111 +00000000000001000000000000000000 +00001010111000010000001010111110 +00001100010000000000001010111011 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001010111110 +00001011101000010000001011000101 +00000000110101111110110100001000 +00001011101101010000001011000110 +00000110000000000001010100011110 +00000110010000000001010100101110 +00001101000000000000001011010101 +00000000000001000000000000000000 +00001010111000010000001011001100 +00001100010000000000001011001001 +00000000000111001000000001000000 +00000000100001000000100010000001 +00000000100011000001100010001001 +00001011111000010000001011001100 +00001011101000010000001011010011 +00000000110101111110110100001000 +00001011101101010000001011010100 +00000110000000000001010100101110 +00000110000000001001010100101110 +00001101000100001111110011001100 +00001000100000000000000000010000 +00001000100000100000000000010100 +00000000000000000010000000000001 +00000000000000000010000100000010 +00001001100000000000000000010000 +00001001100000100000000000010100 +00001000100000000000000000011000 +00001000100000100000000000011100 +00000000000000000010000000000001 +00000000000000000010000100000010 +00001001100000000000000000011000 +00001001100000100000000000011100 +00000000000000000000000000000010 +00001100000100001111010001100000 +00000010110000100000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 diff --git a/car-radio/dsp/Design 1_IC 1/Program.dat b/car-radio/dsp/Design 1_IC 1/Program.dat new file mode 100644 index 0000000..1be927a --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/Program.dat @@ -0,0 +1,1410 @@ +00000002 +0C00DCDC +0D00FFD2 +0D00FFD0 +0D00F450 +0D00F400 +C0002380 +80000000 +C0002300 +80000000 +C00023C0 +80000010 +C0002200 +80000000 +0880DCE0 +0C00DCDC +0A210012 +08000010 +01000014 +06404010 +0A210016 +08000014 +01000018 +06404010 +0A21001A +08000018 +0100001C +06405010 +0A21001E +0800001C +01000020 +06405010 +C0002000 +80000033 +0D00FFC1 +C0002000 +80000001 +0D00F462 +C0002000 +80000000 +0D00F462 +C0002000 +80000002 +0D00FFD1 +00000003 +0D00FFD2 +00000005 +00000000 +00000000 +0201002E +00000000 +00000000 +089EDCE0 +089C0018 +089A0010 +0898001C +08960014 +00000000 +C0000000 +824F0000 +088ADCE0 +0A230006 +0A2B0007 +00882000 +0AA70005 +00000022 +C0002D30 +80005000 +3000B46A +00882000 +C0002870 +80006000 +02090056 +008C1191 +00000024 +008C2A02 +3000BCE9 +00840D9C +00200B2D +0000C2F1 +01000054 +06005014 +36405015 +26404015 +092B0006 +092B0007 +00000000 +0C0002D7 +0640100E +0C000330 +0640101E +0C000331 +0640148E +0C00FC60 +0640102E +0C00FC61 +0640104E +08900027 +0C700020 +0E3D0023 +00F4AE0F +0A25DCDC +0AE10021 +0000DC70 +0000A4D8 +00D62101 +0000E029 +07058808 +008419B3 +00044590 +00442C30 +00044928 +00940887 +0BE10021 +054D109E +08900031 +0C70002A +0E3D002D +00F4AE0F +0A25DCDC +0AE1002B +0000DC70 +0000A4D8 +00D62101 +0000E029 +07058808 +008419B3 +00044590 +00442C30 +00044928 +00940887 +0BE1002B +054D10AE +0890003B +0C700034 +0E3D0037 +00F4AE0F +0A25DCDC +0AE10035 +0000DC70 +0000A4D8 +00D62101 +0000E029 +07058808 +008419B3 +00044590 +00442C30 +00044928 +00940887 +0BE10035 +054D10BE +0C000332 +0640105E +050C100E +050C901E +0B21FD60 +0B25FD64 +00000000 +00000000 +00000000 +0000C00E +0000C81E +0000D02E +C0003C00 +80000045 +00000000 +C0000A00 +8800FD68 +0600102E +0640103E +0D00004A +00040000 +0AE10041 +0C40003E +001C8040 +00840881 +008C1889 +0BE10041 +0BA10048 +00D7ED08 +0BB50049 +0C400333 +0600103E +0600904E +00100001 +00000000 +054C106E +056C108E +050F905E +C0000000 +826E0054 +054F9026 +C0000000 +826D0000 +054F8026 +C0000000 +826D0003 +054F8026 +C0000000 +826E0057 +054F9026 +C0000000 +826F004C +C0000000 +82AF0050 +C0000000 +827E0000 +041BC00A +00000000 +00002780 +C0000000 +82AF0054 +C0000000 +824E0000 +041A400A +00000000 +00002480 +C0000000 +823F0058 +C0000000 +825F005C +C0000000 +82AF0184 +C0000000 +829F0188 +03010152 +C0000000 +820E0000 +C0000000 +828E0000 +0418401A +041C4016 +00002080 +00002880 +C0000000 +821F018C +06085008 +06184031 +F16C8001 +88000122 +F06C8021 +81820423 +0604D027 +E034A205 +81800020 +E0800025 +8C000000 +00840119 +E1830000 +8003C3A1 +C0000000 +820D0000 +C0000000 +828D0000 +0418401A +041C4016 +00002080 +00002880 +C0000000 +821F0008 +06084008 +06185031 +F6628001 +88024400 +F6788021 +81804702 +FA7AA205 +81824A00 +F6410025 +8C004702 +FA78A641 +83824A00 +F6430021 +8E044702 +0604D027 +FA402205 +81804000 +E0010025 +8C000000 +00840119 +E1830000 +8003C3A1 +C0000000 +820D0000 +C0000000 +828D0000 +0418401A +041C4016 +00002080 +00002880 +C0000000 +821F001C +06084008 +06185031 +F6628001 +88024400 +F6788021 +81804702 +FA7AA205 +81824A00 +F6410025 +8C004702 +FA78A641 +83824A00 +F6430021 +8E044702 +0604D027 +FA402205 +81804000 +E0010025 +8C000000 +00840119 +E1830000 +8003C3A1 +C0000000 +820E0000 +C0000000 +828E0000 +0418401A +041C4016 +00002080 +00002880 +C0000000 +821F0196 +06085008 +06184031 +F16C8001 +88000122 +F06C8021 +81820423 +0604D027 +E034A205 +81800020 +E0800025 +8C000000 +00840119 +E1830000 +8003C3A1 +C0000000 +826F004C +030101C5 +C0000000 +820E0000 +C0000000 +828E0000 +04184019 +041C4016 +00002080 +00002880 +C0000000 +821F01A0 +06085008 +06184031 +F16C8001 +88000122 +F06C8021 +81820423 +0604D024 +E034A205 +81800020 +E0800025 +8C000000 +00840119 +E1830000 +80030361 +C0000000 +820D0000 +C0000000 +828D0000 +04184019 +041C4016 +00002080 +00002880 +C0000000 +821F0030 +06084008 +06185031 +F6628001 +88024400 +F6788021 +81804702 +FA7AA205 +81824A00 +F6410025 +8C004702 +FA78A641 +83824A00 +F6430021 +8E044702 +0604D024 +FA402205 +81804000 +E0010025 +8C000000 +00840119 +E1830000 +80030361 +C0000000 +820D0000 +C0000000 +828D0000 +04184019 +041C4016 +00002080 +00002880 +C0000000 +821F0044 +06084008 +06185031 +F6628001 +88024400 +F6788021 +81804702 +FA7AA205 +81824A00 +F6410025 +8C004702 +FA78A641 +83824A00 +F6430021 +8E044702 +0604D024 +FA402205 +81804000 +E0010025 +8C000000 +00840119 +E1830000 +80030361 +C0000000 +820E0000 +C0000000 +828E0000 +04184019 +041C4016 +00002080 +00002880 +C0000000 +821F01AA +06085008 +06184031 +F16C8001 +88000122 +F06C8021 +81820423 +0604D024 +E034A205 +81800020 +E0800025 +8C000000 +00840119 +E1830000 +80030361 +C0000000 +829F01B4 +C0000000 +824F01B8 +C0000000 +823F0058 +C0000000 +826E0072 +C0000000 +827E008A +C0000000 +822F0138 +C0000000 +821F0158 +C00029F3 +88000000 +C0002DF0 +8A000000 +C00025C0 +81000000 +0A31DC02 +03010237 +C0000000 +825F0160 +C0000000 +82AF0060 +03040235 +C0000000 +820A0000 +050C4013 +054C1017 +06045027 +E2748000 +80038583 +E2748019 +88038583 +E2748015 +8803BB9A +E6800035 +88038400 +E5800000 +80038400 +F59A8000 +800382A0 +0000E068 +00044100 +053C4002 +001CD068 +00840881 +00841E89 +057F4022 +0000E06E +00044160 +053C4002 +001CD068 +00840881 +00841689 +055F4022 +0000E06D +00044150 +053C4002 +001CD068 +00840881 +00841589 +055EC022 +0000E07D +000441D0 +053C4002 +001CD068 +00840881 +00841D89 +057EC022 +00A81065 +003394ED +0088106E +002001CE +00882006 +00000000 +00000025 +052DC001 +30E66604 +0B79FD20 +00000000 +0AA1FD2A +00840887 +0084188F +0004618F +0620C001 +052F0011 +052D0015 +20C55EEA +008818E8 +050C8025 +00200AC8 +00200A61 +30859BB0 +056DC011 +056F4011 +00EE200E +00200110 +0000D860 +0A21DCAE +009400E0 +0000E868 +00002080 +07044000 +00841980 +00046198 +00840008 +054C4019 +00002503 +00002A36 +00000000 +00000000 +C0000000 +823F0058 +C0000000 +824F01B4 +C0000000 +825F005C +C0000000 +828F0174 +060C4044 +061C4045 +064C4048 +00068000 +00000000 +054C153E +C0000000 +822F0178 +C0000000 +821F016C +C0000000 +823F005C +0301025D +C0000000 +825F0160 +0304025B +050C4013 +0000E068 +00044100 +053C4002 +0612C035 +001CD068 +00840881 +00841E89 +057F4022 +056F4011 +00D6280E +056C4011 +00000000 +00000000 +0600106E +0640107E +0D0001CD +00040000 +0AE101C4 +0C4001C1 +001C8040 +00840881 +008C1889 +0BE101C4 +0BA101CB +00D7ED08 +0BB501CC +C0000000 +820F01CF +08600334 +C0000000 +821E0000 +04188800 +00002080 +050C1801 +04188010 +054C10CE +050C1801 +054C10DE +0C340314 +00060000 +061010CE +061090DE +0000BC68 +001C8048 +0B210314 +054C910E +056C913E +050C110E +052C113E +00840008 +054C114E +0600113E +C0000018 +804019EE +0D0001E5 +00040000 +0AE101DC +0C4001D9 +001C8040 +00840881 +008C1889 +0BE101DC +0BA101E3 +00D7ED08 +0BB501E4 +C0000000 +820C0000 +C0000000 +821B0000 +C0000000 +826F01E7 +C0000000 +822E000E +C0000000 +823E0011 +F8748000 +800081C3 +F96C8009 +8800C1C2 +FC400029 +8A020800 +E03A8007 +80800124 +E0002867 +81800000 +E0004A40 +80000000 +EAB50000 +80000020 +054D117E +056D11AE +C0000000 +820C0006 +C0000000 +821B0006 +C0000000 +826F01EC +C0000000 +822E000E +C0000000 +823E0011 +F8748000 +800081C3 +F96C8009 +8800C1C2 +FC400029 +8A020800 +E03A8007 +80800124 +E0002867 +81800000 +E0004A40 +80000000 +EAB50000 +80000020 +054D11BE +056D11CE +0C400335 +0600110E +0600913E +00100001 +00000000 +054C11FE +056C122E +0C400336 +0600114E +00040080 +00000000 +054C123E +C0000000 +820C000C +C0000000 +821B000C +C0000000 +826F01F1 +C0000000 +822E0015 +C0000000 +823E0018 +F8748000 +800081C3 +F96C8009 +8800C1C2 +FC400029 +8A020800 +E03A8007 +80800124 +E0002867 +81800000 +E0004A40 +80000000 +EAB50000 +80000020 +054D124E +056D125E +0C400337 +060011BE +060091CE +00100001 +00000000 +054C128E +056C129E +C0000000 +820C0012 +C0000000 +821B0012 +C0000000 +826F01F6 +C0000000 +822E001D +C0000000 +823E0020 +F8748000 +800081C3 +F96C8009 +8800C1C2 +FC400029 +8A020800 +E03A8007 +80800124 +E0002867 +81800000 +E0004A40 +80000000 +EAB50000 +80000020 +054D126E +056D127E +0600123E +064012CE +0D000207 +00040000 +0AE101FE +0C4001FB +001C8040 +00840881 +008C1889 +0BE101FE +0BA10205 +00D7ED08 +0BB50206 +050C124E +0084A100 +C000080F +8D600209 +0AE1020A +0C400338 +001C8240 +00840881 +00841889 +0BA1FD64 +0BE1020A +0A250209 +00881081 +C0000010 +802C1A1E +00000023 +0C400339 +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1A2E +C0000010 +804C1A0E +00044100 +C0000018 +806099FE +054C12DE +050C125E +0084A100 +C000080F +8D60020C +0AE1020D +0C40033A +001C8240 +00840881 +00841889 +0BA1FD64 +0BE1020D +0A25020C +00881081 +C0000010 +802C1A4E +00000023 +0C40033B +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1A5E +054C133E +00044100 +C0000018 +80609A3E +054C12EE +050C126E +0084A100 +C000080F +8D60020F +0AE10210 +0C40033C +001C8240 +00840881 +00841889 +0BA1FD64 +0BE10210 +0A25020F +00881081 +C0000010 +802C1A8E +00000023 +0C40033D +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1A9E +C0000010 +804C1A7E +00044100 +C0000018 +80609A6E +054C12FE +050C127E +0084A100 +C000080F +8D600212 +0AE10213 +0C40033E +001C8240 +00840881 +00841889 +0BA1FD64 +0BE10213 +0A250212 +00881081 +C0000010 +802C1ABE +00000023 +0C40033F +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1ACE +054C134E +00044100 +C0000018 +80609AAE +054C130E +050C128E +0084A100 +C000080F +8D600215 +0AE10216 +0C400340 +001C8240 +00840881 +00841889 +0BA1FD64 +0BE10216 +0A250215 +00881081 +C0000010 +802C1AFE +00000023 +0C400341 +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1B0E +C0000010 +804C1AEE +00044100 +C0000018 +80609ADE +054C131E +050C129E +0084A100 +C000080F +8D600218 +0AE10219 +0C400342 +001C8240 +00840881 +00841889 +0BA1FD64 +0BE10219 +0A250218 +00881081 +C0000010 +802C1B3E +00000023 +0C400343 +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1B4E +C0000010 +804C1B2E +00044100 +C0000018 +80609B1E +054C132E +C0000000 +820C0018 +C0000000 +821E002A +C0000000 +826F021B +FD6A8000 +800000C3 +F8748003 +814041C5 +FC6A8021 +880008C3 +E034A207 +804209C5 +055C5000 +054C139E +C000080F +8D800220 +C000088F +8D840318 +C000088F +8D94031A +060012DE +0600924E +0C51FDA0 +00060000 +0D50031A +054C135E +C000080F +8D800221 +C000088F +8D84031C +C000088F +8D94031E +060012EE +0600925E +0C51FDA0 +00060000 +0D50031E +054C13DE +C000080F +8D800222 +C000088F +8D840320 +C000088F +8D940322 +060012FE +0600926E +0C51FDA0 +00060000 +0D500322 +054C136E +C000080F +8D800223 +C000088F +8D840324 +C000088F +8D940326 +0600130E +0600927E +0C51FDA0 +00060000 +0D500326 +054C13FE +C000080F +8D800224 +C000088F +8D840328 +C000088F +8D94032A +0600131E +0600928E +0C51FDA0 +00060000 +0D50032A +054C13AE +C000080F +8D800225 +C000088F +8D84032C +C000088F +8D94032E +0600132E +0600929E +0C51FDA0 +00060000 +0D50032E +054C13BE +0600133E +C0000018 +80401B5E +0D000232 +00040000 +0AE10229 +0C400226 +001C8040 +00840881 +008C1889 +0BE10229 +0BA10230 +00D7ED08 +0BB50231 +0600134E +C0000018 +80401B6E +0D000240 +00040000 +0AE10237 +0C400234 +001C8040 +00840881 +008C1889 +0BE10237 +0BA1023E +00D7ED08 +0BB5023F +0600135E +064013CE +0D00024E +00040000 +0AE10245 +0C400242 +001C8040 +00840881 +008C1889 +0BE10245 +0BA1024C +00D7ED08 +0BB5024D +0600136E +064013EE +0D00025C +00040000 +0AE10253 +0C400250 +001C8040 +00840881 +008C1889 +0BE10253 +0BA1025A +00D7ED08 +0BB5025B +050F939E +C0000010 +804F9B9E +C0000000 +821E00B7 +C0000000 +822C001E +C0000000 +820F025E +E0C00000 +80004400 +F0748000 +80024703 +FC688001 +8C028902 +E0000007 +80000000 +E0002040 +80000000 +E8800000 +80028400 +C0000010 +804C1BCE +F0748000 +80024703 +FC688001 +8C028902 +E0000007 +80000000 +E0002040 +80000000 +E8800000 +80028400 +C0000010 +804C1BFE +F0748000 +80024703 +FC688001 +8C028902 +E0000007 +80000000 +E0002040 +80000000 +E8800000 +80028400 +C0000010 +804C1C2E +F0748000 +80024703 +FC688001 +8C028902 +E0000007 +80000000 +E0002040 +80000000 +E8800000 +80028400 +054C140E +C0000000 +820C0036 +C0000000 +821E0037 +C0000000 +826F0272 +FD6A8000 +800000C3 +F8748003 +814041C5 +FC6A8021 +880008C3 +E034A207 +804209C5 +055C5000 +054C141E +060013AE +060093BE +0D10FCC8 +060013CE +060093DE +0D10FCC0 +060013EE +060093FE +0D10FCC4 +0C820344 +00000000 +350C141E +250C140E +054C142E +0600142E +C0000018 +80401C3E +0D000283 +00040000 +0AE1027A +0C400277 +001C8040 +00840881 +008C1889 +0BE1027A +0BA10281 +00D7ED08 +0BB50282 +050C142E +0000E028 +00044100 +0AE10348 +0C400345 +001C8040 +00840881 +008C1889 +0BE10348 +0C82034B +0A29034A +0AA90347 +2084122A +00881028 +0A2D034B +0A310346 +00200248 +00200264 +0B29034A +30859330 +0B2D034B +0A39034A +054F143E +050C143E +0B210285 +054C144E +050C144E +0600145E +00044500 +00000000 +054C946E +050C146E +0B210286 +054C147E +050C147E +0600148E +00044500 +00000000 +054C949E +050C149E +0B210287 +054C14AE +C0000000 +820F0288 +0AA1034C +050C14AE +00881008 +00200308 +00EE2000 +00200110 +0000D840 +00000000 +050C8800 +054C94BE +050C14BE +0B2102A8 +054C14CE +C000081F +8D80005D +050C14CE +0B21FD84 +C000081F +8D94005C +0610142E +0C00FDA0 +00040008 +0D01005C +054C14DE +050C14CE +0B2102A9 +C0000010 +804C1C4E +0C82034D +00000000 +350C142E +250C14DE +054C14EE +050C14EE +0084A100 +C000080F +8D6002AA +0AE102AB +0C40034E +001C8240 +00840881 +00841889 +0BA1FD64 +0BE102AB +0A2502AA +00881081 +C0000010 +802C1C6E +00000023 +0C40034F +0000E028 +0A21DC30 +3A21FD68 +00881008 +001D0080 +0084112A +00840118 +00200301 +C0000010 +804C1C7E +054C150E +00044100 +C0000018 +80609C5E +054C14FE +060014EE +C0000018 +80401C8E +0D0002B9 +00040000 +0AE102B0 +0C4002AD +001C8040 +00840881 +008C1889 +0BE102B0 +0BA102B7 +00D7ED08 +0BB502B8 +0C820350 +00000000 +350C14FE +250C14EE +054C151E +0600150E +C0000018 +80401C9E +0D0002C7 +00040000 +0AE102BE +0C4002BB +001C8040 +00840881 +008C1889 +0BE102BE +0BA102C5 +00D7ED08 +0BB502C6 +0600151E +0640152E +0D0002D5 +00040000 +0AE102CC +0C4002C9 +001C8040 +00840881 +008C1889 +0BE102CC +0BA102D3 +00D7ED08 +0BB502D4 +0600152E +0600952E +0D10FCCC +08800010 +08820014 +00002001 +00002102 +09800010 +09820014 +08800018 +0882001C +00002001 +00002102 +09800018 +0982001C +00000002 +0C10F460 +02C20000 +00000000 +00000000 +00000000 +00000000 diff --git a/car-radio/dsp/Design 1_IC 1/bass_remapping.txt b/car-radio/dsp/Design 1_IC 1/bass_remapping.txt new file mode 100644 index 0000000..d301bf9 --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/bass_remapping.txt @@ -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 diff --git a/car-radio/dsp/Design 1_IC 1/compiler_output.log b/car-radio/dsp/Design 1_IC 1/compiler_output.log new file mode 100644 index 0000000..bdf140e --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/compiler_output.log @@ -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% + diff --git a/car-radio/dsp/Design 1_IC 1/report.dat b/car-radio/dsp/Design 1_IC 1/report.dat new file mode 100644 index 0000000..3f9cf1d --- /dev/null +++ b/car-radio/dsp/Design 1_IC 1/report.dat @@ -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 diff --git a/car-radio/dsp/System Volume Information/IndexerVolumeGuid b/car-radio/dsp/System Volume Information/IndexerVolumeGuid new file mode 100644 index 0000000000000000000000000000000000000000..1a986b8d2355a71e59ba3f4ad5fd1cc769401a85 GIT binary patch literal 76 zcmWlPSqi`)3a#qf$8D!>j6S^Hd?1zR0l8@9ktd H-G`qGQws>b literal 0 HcmV?d00001 diff --git a/car-radio/dsp/System Volume Information/WPSettings.dat b/car-radio/dsp/System Volume Information/WPSettings.dat new file mode 100644 index 0000000000000000000000000000000000000000..1267c71f90c49561791876bd3aeff0767b37f1e0 GIT binary patch literal 12 Tcmd;KU|RzLJXuj4fU5n=>S literal 0 HcmV?d00001 diff --git a/car-radio/dsp/bass_remapping.txt b/car-radio/dsp/bass_remapping.txt new file mode 100644 index 0000000..d301bf9 --- /dev/null +++ b/car-radio/dsp/bass_remapping.txt @@ -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 diff --git a/car-radio/dsp/generate_bass_remapping.py b/car-radio/dsp/generate_bass_remapping.py new file mode 100644 index 0000000..2e2aa60 --- /dev/null +++ b/car-radio/dsp/generate_bass_remapping.py @@ -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") diff --git a/car-radio/fusion/README.md b/car-radio/fusion/README.md new file mode 100644 index 0000000..0c9c810 --- /dev/null +++ b/car-radio/fusion/README.md @@ -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. diff --git a/car-radio/fusion/app/app.go b/car-radio/fusion/app/app.go new file mode 100644 index 0000000..967ee68 --- /dev/null +++ b/car-radio/fusion/app/app.go @@ -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 + } +} diff --git a/car-radio/fusion/app/app_test.go b/car-radio/fusion/app/app_test.go new file mode 100644 index 0000000..3ec4765 --- /dev/null +++ b/car-radio/fusion/app/app_test.go @@ -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") + } +} diff --git a/car-radio/fusion/bt/bt.go b/car-radio/fusion/bt/bt.go new file mode 100644 index 0000000..d71c4e1 --- /dev/null +++ b/car-radio/fusion/bt/bt.go @@ -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 +} diff --git a/car-radio/fusion/can/can.go b/car-radio/fusion/can/can.go new file mode 100644 index 0000000..d339ddd --- /dev/null +++ b/car-radio/fusion/can/can.go @@ -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() } diff --git a/car-radio/fusion/can/can_linux.go b/car-radio/fusion/can/can_linux.go new file mode 100644 index 0000000..4b20d1b --- /dev/null +++ b/car-radio/fusion/can/can_linux.go @@ -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 +} diff --git a/car-radio/fusion/can/can_test.go b/car-radio/fusion/can/can_test.go new file mode 100644 index 0000000..45e6461 --- /dev/null +++ b/car-radio/fusion/can/can_test.go @@ -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) + } +} diff --git a/car-radio/fusion/cmd/fcim/main.go b/car-radio/fusion/cmd/fcim/main.go new file mode 100644 index 0000000..4e3a539 --- /dev/null +++ b/car-radio/fusion/cmd/fcim/main.go @@ -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)) + } + } + } +} diff --git a/car-radio/fusion/cmd/fdim/main.go b/car-radio/fusion/cmd/fdim/main.go new file mode 100644 index 0000000..359aa4a --- /dev/null +++ b/car-radio/fusion/cmd/fdim/main.go @@ -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) + } +} diff --git a/car-radio/fusion/cmd/fusiond/console.go b/car-radio/fusion/cmd/fusiond/console.go new file mode 100644 index 0000000..7500be7 --- /dev/null +++ b/car-radio/fusion/cmd/fusiond/console.go @@ -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 +} diff --git a/car-radio/fusion/cmd/fusiond/console_test.go b/car-radio/fusion/cmd/fusiond/console_test.go new file mode 100644 index 0000000..c1ea463 --- /dev/null +++ b/car-radio/fusion/cmd/fusiond/console_test.go @@ -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) + } + } +} diff --git a/car-radio/fusion/cmd/fusiond/main.go b/car-radio/fusion/cmd/fusiond/main.go new file mode 100644 index 0000000..682e15a --- /dev/null +++ b/car-radio/fusion/cmd/fusiond/main.go @@ -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" +} diff --git a/car-radio/fusion/core/clockedit.go b/car-radio/fusion/core/clockedit.go new file mode 100644 index 0000000..3bdb1ed --- /dev/null +++ b/car-radio/fusion/core/clockedit.go @@ -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] = ' ' + } + } +} diff --git a/car-radio/fusion/core/core.go b/car-radio/fusion/core/core.go new file mode 100644 index 0000000..2edc381 --- /dev/null +++ b/car-radio/fusion/core/core.go @@ -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 = "" + } +} diff --git a/car-radio/fusion/core/core_test.go b/car-radio/fusion/core/core_test.go new file mode 100644 index 0000000..158df1a --- /dev/null +++ b/car-radio/fusion/core/core_test.go @@ -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) + } + } + } +} diff --git a/car-radio/fusion/core/pages.go b/car-radio/fusion/core/pages.go new file mode 100644 index 0000000..2fcad7a --- /dev/null +++ b/car-radio/fusion/core/pages.go @@ -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") +} diff --git a/car-radio/fusion/core/render.go b/car-radio/fusion/core/render.go new file mode 100644 index 0000000..14f4311 --- /dev/null +++ b/car-radio/fusion/core/render.go @@ -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 + } +} diff --git a/car-radio/fusion/core/script.go b/car-radio/fusion/core/script.go new file mode 100644 index 0000000..edadb63 --- /dev/null +++ b/car-radio/fusion/core/script.go @@ -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 +} diff --git a/car-radio/fusion/core/script_test.go b/car-radio/fusion/core/script_test.go new file mode 100644 index 0000000..666acf9 --- /dev/null +++ b/car-radio/fusion/core/script_test.go @@ -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) + } + }) + } +} diff --git a/car-radio/fusion/core/text.go b/car-radio/fusion/core/text.go new file mode 100644 index 0000000..eed24f3 --- /dev/null +++ b/car-radio/fusion/core/text.go @@ -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 +} diff --git a/car-radio/fusion/core/types.go b/car-radio/fusion/core/types.go new file mode 100644 index 0000000..803f05f --- /dev/null +++ b/car-radio/fusion/core/types.go @@ -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) } diff --git a/car-radio/fusion/dsp/adau1467.go b/car-radio/fusion/dsp/adau1467.go new file mode 100644 index 0000000..1fc54b7 --- /dev/null +++ b/car-radio/fusion/dsp/adau1467.go @@ -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) } diff --git a/car-radio/fusion/dsp/adau1467_test.go b/car-radio/fusion/dsp/adau1467_test.go new file mode 100644 index 0000000..437283a --- /dev/null +++ b/car-radio/fusion/dsp/adau1467_test.go @@ -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") + } +} diff --git a/car-radio/fusion/dsp/dsp.go b/car-radio/fusion/dsp/dsp.go new file mode 100644 index 0000000..dcbd15b --- /dev/null +++ b/car-radio/fusion/dsp/dsp.go @@ -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 +} diff --git a/car-radio/fusion/dsp/dsp_test.go b/car-radio/fusion/dsp/dsp_test.go new file mode 100644 index 0000000..8b2654a --- /dev/null +++ b/car-radio/fusion/dsp/dsp_test.go @@ -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) + } +} diff --git a/car-radio/fusion/dsp/i2c_linux.go b/car-radio/fusion/dsp/i2c_linux.go new file mode 100644 index 0000000..58d2f85 --- /dev/null +++ b/car-radio/fusion/dsp/i2c_linux.go @@ -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() } diff --git a/car-radio/fusion/fcim/can.go b/car-radio/fusion/fcim/can.go new file mode 100644 index 0000000..b116dd0 --- /dev/null +++ b/car-radio/fusion/fcim/can.go @@ -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 +} diff --git a/car-radio/fusion/fcim/can_test.go b/car-radio/fusion/fcim/can_test.go new file mode 100644 index 0000000..cea2eea --- /dev/null +++ b/car-radio/fusion/fcim/can_test.go @@ -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") + } +} diff --git a/car-radio/fusion/fcim/fcim.go b/car-radio/fusion/fcim/fcim.go new file mode 100644 index 0000000..32862c7 --- /dev/null +++ b/car-radio/fusion/fcim/fcim.go @@ -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 +} diff --git a/car-radio/fusion/fcim/fcim_test.go b/car-radio/fusion/fcim/fcim_test.go new file mode 100644 index 0000000..b310076 --- /dev/null +++ b/car-radio/fusion/fcim/fcim_test.go @@ -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) + } + } +} diff --git a/car-radio/fusion/fcim/keymap.go b/car-radio/fusion/fcim/keymap.go new file mode 100644 index 0000000..d6edc6e --- /dev/null +++ b/car-radio/fusion/fcim/keymap.go @@ -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 +} diff --git a/car-radio/fusion/fcim/net.go b/car-radio/fusion/fcim/net.go new file mode 100644 index 0000000..88027a2 --- /dev/null +++ b/car-radio/fusion/fcim/net.go @@ -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 + } +} diff --git a/car-radio/fusion/fdim/can.go b/car-radio/fusion/fdim/can.go new file mode 100644 index 0000000..8bb2b06 --- /dev/null +++ b/car-radio/fusion/fdim/can.go @@ -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 } diff --git a/car-radio/fusion/fdim/can_test.go b/car-radio/fusion/fdim/can_test.go new file mode 100644 index 0000000..821452b --- /dev/null +++ b/car-radio/fusion/fdim/can_test.go @@ -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 +} diff --git a/car-radio/fusion/fdim/canproto.go b/car-radio/fusion/fdim/canproto.go new file mode 100644 index 0000000..ea1bc9b --- /dev/null +++ b/car-radio/fusion/fdim/canproto.go @@ -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) } diff --git a/car-radio/fusion/fdim/display.go b/car-radio/fusion/fdim/display.go new file mode 100644 index 0000000..b38a4f4 --- /dev/null +++ b/car-radio/fusion/fdim/display.go @@ -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) +} diff --git a/car-radio/fusion/fdim/driver.go b/car-radio/fusion/fdim/driver.go new file mode 100644 index 0000000..d0c240e --- /dev/null +++ b/car-radio/fusion/fdim/driver.go @@ -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() +} diff --git a/car-radio/fusion/fdim/fdim_test.go b/car-radio/fusion/fdim/fdim_test.go new file mode 100644 index 0000000..981eb6c --- /dev/null +++ b/car-radio/fusion/fdim/fdim_test.go @@ -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) + } + } +} diff --git a/car-radio/fusion/fdim/net.go b/car-radio/fusion/fdim/net.go new file mode 100644 index 0000000..352b3c0 --- /dev/null +++ b/car-radio/fusion/fdim/net.go @@ -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() +} diff --git a/car-radio/fusion/go.mod b/car-radio/fusion/go.mod new file mode 100644 index 0000000..8b37d34 --- /dev/null +++ b/car-radio/fusion/go.mod @@ -0,0 +1,3 @@ +module fusion + +go 1.24 diff --git a/car-radio/fusion/platform/platform.go b/car-radio/fusion/platform/platform.go new file mode 100644 index 0000000..f01a84a --- /dev/null +++ b/car-radio/fusion/platform/platform.go @@ -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) +} diff --git a/car-radio/fusion/platform/platform_test.go b/car-radio/fusion/platform/platform_test.go new file mode 100644 index 0000000..888061f --- /dev/null +++ b/car-radio/fusion/platform/platform_test.go @@ -0,0 +1,70 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" + "time" + + "fusion/core" +) + +func TestSysClockSetTime(t *testing.T) { + base := time.Date(2026, 10, 23, 16, 7, 42, 0, time.UTC) + cur := base + c := &SysClock{NowFn: func() time.Time { return cur }} + if h, m := c.Now(); h != 16 || m != 7 { + t.Fatalf("Now = %d:%d", h, m) + } + if err := c.SetTime(11, 35); err != nil { + t.Fatal(err) + } + if h, m := c.Now(); h != 11 || m != 35 { + t.Fatalf("after set: %d:%d", h, m) + } + // the host clock keeps running; the offset is preserved + cur = base.Add(2 * time.Minute) + if h, m := c.Now(); h != 11 || m != 37 { + t.Fatalf("after 2 min: %d:%d", h, m) + } + if c.SetTime(24, 0) == nil || c.SetTime(0, 60) == nil { + t.Error("invalid times accepted") + } +} + +func TestFileStoreRoundTrip(t *testing.T) { + dir := t.TempDir() + fs := FileStore{Path: filepath.Join(dir, "sub", "settings.json")} + if s, err := fs.Load(); err == nil || s != core.DefaultSettings() { + t.Errorf("missing file: %+v, %v", s, err) + } + want := core.Settings{Volume: 45, Source: core.SourceBT} + if err := fs.Save(want); err != nil { + t.Fatal(err) + } + got, err := fs.Load() + if err != nil || got != want { + t.Errorf("round trip: %+v, %v", got, err) + } + if _, err := os.Stat(fs.Path + ".tmp"); err == nil { + t.Error("temp file left behind") + } + // corrupt file falls back to defaults + if err := os.WriteFile(fs.Path, []byte("{nope"), 0o644); err != nil { + t.Fatal(err) + } + if s, err := fs.Load(); err == nil || s != core.DefaultSettings() { + t.Errorf("corrupt file: %+v, %v", s, err) + } +} + +func TestMemStore(t *testing.T) { + m := &MemStore{} + if s, err := m.Load(); err == nil || s != core.DefaultSettings() { + t.Error("empty store must return defaults and an error") + } + m.Save(core.Settings{Volume: 10, Source: core.SourceAux}) + if s, err := m.Load(); err != nil || s.Volume != 10 || m.Saves != 1 { + t.Errorf("%+v %v %d", s, err, m.Saves) + } +} diff --git a/car-radio/fusion/run-all.sh b/car-radio/fusion/run-all.sh new file mode 100755 index 0000000..0346f2c --- /dev/null +++ b/car-radio/fusion/run-all.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Opens three fresh terminal windows: FDIM display, core software, FCIM buttons. +# Usage: ./run-all.sh [--tmux] [--build] +# --tmux don't open windows; use one tmux session with three panes +# --build compile to ./bin first and run the binaries instead of `go run` +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +USE_TMUX=0 +BUILD=0 +for a in "$@"; do + case "$a" in + --tmux) USE_TMUX=1 ;; + --build) BUILD=1 ;; + -h|--help) sed -n '2,5p' "$0"; exit 0 ;; + *) echo "unknown option: $a" >&2; exit 2 ;; + esac +done + +if [ "$BUILD" = 1 ]; then + (cd "$DIR" && mkdir -p bin && go build -o bin/ ./cmd/fdim ./cmd/fusiond ./cmd/fcim) + RUN_FDIM="./bin/fdim"; RUN_CORE="./bin/fusiond"; RUN_FCIM="./bin/fcim" +else + RUN_FDIM="go run ./cmd/fdim"; RUN_CORE="go run ./cmd/fusiond"; RUN_FCIM="go run ./cmd/fcim" +fi + +# Each window runs its program, then waits so errors stay readable. +wrap() { # $1 = command + printf 'cd %q && %s; echo; echo "[exited with status $?]"; read -r -p "press Enter to close " _' "$DIR" "$1" +} + +if [ "$USE_TMUX" = 1 ] || { [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v tmux >/dev/null; }; then + command -v tmux >/dev/null || { echo "tmux not installed" >&2; exit 1; } + S=fusion + tmux kill-session -t "$S" 2>/dev/null || true + tmux new-session -d -s "$S" -n fusion "bash -c '$(wrap "$RUN_FDIM")'" + tmux split-window -t "$S" -h "bash -c '$(wrap "$RUN_CORE")'" + tmux split-window -t "$S" -v "bash -c '$(wrap "$RUN_FCIM")'" + tmux select-pane -t "$S.2" # keyboard focus on the buttons + exec tmux attach -t "$S" +fi + +# Pick a terminal emulator. +TERM_BIN="" +for t in gnome-terminal konsole xfce4-terminal kitty alacritty wezterm xterm x-terminal-emulator; do + if command -v "$t" >/dev/null 2>&1; then TERM_BIN="$t"; break; fi +done +[ -n "$TERM_BIN" ] || { echo "no terminal emulator found; try: $0 --tmux" >&2; exit 1; } + +open_term() { # $1 = title, $2 = command line + local title="$1" cmd + cmd="$(wrap "$2")" + case "$TERM_BIN" in + gnome-terminal) gnome-terminal --title="$title" -- bash -c "$cmd" ;; + konsole) konsole --new-tab -p "tabtitle=$title" -e bash -c "$cmd" & ;; + xfce4-terminal) xfce4-terminal --title="$title" -x bash -c "$cmd" ;; + kitty) kitty --title "$title" bash -c "$cmd" & ;; + alacritty) alacritty --title "$title" -e bash -c "$cmd" & ;; + wezterm) wezterm start -- bash -c "$cmd" & ;; + xterm) xterm -T "$title" -e bash -c "$cmd" & ;; + *) "$TERM_BIN" -e bash -c "$cmd" & ;; + esac +} + +echo "using $TERM_BIN" +open_term "FDIM display" "$RUN_FDIM" +sleep 1 # let the display start listening first +open_term "Core (fusiond)" "$RUN_CORE" +sleep 1 +open_term "FCIM buttons" "$RUN_FCIM" +echo "opened 3 terminals; focus the FCIM window to press buttons" diff --git a/car-radio/fusion/termui/termui.go b/car-radio/fusion/termui/termui.go new file mode 100644 index 0000000..d55335b --- /dev/null +++ b/car-radio/fusion/termui/termui.go @@ -0,0 +1,40 @@ +// Package termui has the tiny terminal helpers the emulator programs share. +package termui + +import ( + "os" + "os/exec" + "strings" +) + +// ANSI sequences. +const ( + Clear = "\x1b[2J\x1b[H" + HideCursor = "\x1b[?25l" + ShowCursor = "\x1b[?25h" + EraseLine = "\r\x1b[K" +) + +// RawMode puts the terminal on stdin into raw, no-echo mode using stty and +// returns a function that restores it. If stdin is not a terminal it returns +// an error and a no-op restore. +func RawMode() (restore func(), err error) { + noop := func() {} + get := exec.Command("stty", "-g") + get.Stdin = os.Stdin + saved, err := get.Output() + if err != nil { + return noop, err + } + set := exec.Command("stty", "raw", "-echo") + set.Stdin = os.Stdin + if err := set.Run(); err != nil { + return noop, err + } + state := strings.TrimSpace(string(saved)) + return func() { + r := exec.Command("stty", state) + r.Stdin = os.Stdin + r.Run() + }, nil +} diff --git a/car-radio/fusion/tests/bluetooth.test b/car-radio/fusion/tests/bluetooth.test new file mode 100644 index 0000000..9b56cf9 --- /dev/null +++ b/car-radio/fusion/tests/bluetooth.test @@ -0,0 +1,198 @@ +# ---- Bluetooth menu: Status, Devices, Disconnect (SDS §8.4) +init source=bt +bt devices Pixel iPhone +key MENU +key TUNE_RIGHT +key OK +expect page MENU_BT +expect line1 starts "Bluetooth" +expect line1 ends "1/3" +expect line2 "› Status" +key OK +expect page BT_STATUS +expect line1 "Not connected" +expect line2 "" +key OK +expect page MENU_BT +key TUNE_RIGHT +key OK +expect page BT_DEVICES +expect line1 starts "Devices" +expect line1 ends "1/2" +expect line2 "› Pixel" +key TUNE_RIGHT +expect line2 "› iPhone" +key TUNE_RIGHT +expect line2 "› Pixel" +key TUNE_RIGHT +key OK +expect cmd BT_CONNECT 1 +expect line2 "Connecting..." +clear +key OK +expect nocmds +bt connected iPhone +expect confirm on +expect line1 "Connected: iPhone" +expect page NOW_PLAYING +expect label SYNC +key OK + +# Disconnect only appears while connected +key MENU +key TUNE_RIGHT +key OK +expect line1 ends "1/4" +key OK +expect page BT_STATUS +expect line1 "Connected" +expect line2 "iPhone" +key MENU +expect page MENU_BT +key TUNE_LEFT +expect line2 "› Disconnect" +key OK +expect cmd BT_DISCONNECT +expect line1 "Disconnected" +key OK +expect line1 "Bluetooth" +expect line2 "Not connected" + +# the connected device is starred in the list +init source=bt +bt devices Pixel iPhone +bt connected iPhone +expect confirm off +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT +key OK +expect page BT_DEVICES +key TUNE_RIGHT +expect line2 "› iPhone *" + +# connection failure +init source=bt +bt devices Pixel +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT +key OK +key OK +expect cmd BT_CONNECT 0 +bt connectfailed +expect line1 "Connect failed" +expect page NOW_PLAYING + +# no paired devices +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT +key OK +expect line2 "No paired devices" +key OK +expect nocmds + +# an unsolicited connection does not interrupt the user +init source=bt +key MENU +bt connected Pixel +expect page MENU_TOP +expect confirm off + +# ---- Pair New: 120 s discoverable window (SDS §6, §8.4) +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +expect line2 "› Pair New" +key OK +expect cmd BT_PAIRING on +expect page BT_PAIR +expect line1 "Pairing..." +expect line2 "Fusion Audio" +clear +tick 100000 +key TUNE_RIGHT +key OK +key VOLUME_RIGHT +tick 19900 +expect page BT_PAIR +tick 100 +expect page NOW_PLAYING +expect cmd BT_PAIRING off +expect line1 "Pairing ended" + +# MENU ends pairing and goes back +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +clear +key MENU +expect cmd BT_PAIRING off +expect page MENU_BT + +# a new connection ends pairing with a confirmation +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +clear +bt connected Nokia +expect line1 "Connected: Nokia" +expect page NOW_PLAYING +expect nocmds + +# the module ending discoverable mode early +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +clear +bt pairingended +expect line1 "Pairing ended" +expect nocmds + +# leaving through a global key ends pairing +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +clear +key AUX +expect cmd BT_PAIRING off +expect page NOW_PLAYING + +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +clear +key CLOCK +expect cmd BT_PAIRING off +expect page CLOCK + +# configurable advertised name +init source=bt btname=MyCar +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +expect line2 "MyCar" diff --git a/car-radio/fusion/tests/boot_standby.test b/car-radio/fusion/tests/boot_standby.test new file mode 100644 index 0000000..faa21fc --- /dev/null +++ b/car-radio/fusion/tests/boot_standby.test @@ -0,0 +1,259 @@ +# ---- Boot (SDS §3) +init boot=1 +expect mode boot +expect line1 contains "Matthew's Ford" +expect line2 contains "Fusion" +expect clock "" +expect cmd DSP_MUTE on +key POWER +key MENU +key VOLUME_RIGHT +expect mode boot +expect line1 contains "Matthew's Ford" +expect line2 contains "Fusion" +tick 100 +health dsp ok +expect mode active +expect order "DSP_SET_SOURCE AUX" "DSP_DAC_ENABLE on" "DSP_RAMP_TO 30 1000" +expect line1 "Aux Input" +expect line2 "" + +# the splash stays up for its minimum time even if the DSP answers at once +init boot=1 splash=2000 +health dsp ok +expect mode boot +expect line1 contains "Matthew's Ford" +expect line2 contains "Fusion" +tick 1900 +expect mode boot +expect nocmd DSP_RAMP_TO +tick 100 +expect mode active +expect cmd DSP_RAMP_TO 30 1000 +expect line1 "Aux Input" + +# a slow DSP keeps the splash up until it answers +init boot=1 splash=2000 +tick 3000 +expect mode boot +health dsp ok +expect mode active + +# boot timeout shows a fault +init boot=1 +tick 4900 +expect mode boot +tick 100 +expect mode active +expect line2 "DSP not responding" +health dsp ok +expect cmd DSP_SET_SOURCE AUX +expect cmd DSP_SET_VOLUME 30 +expect line2 "" + +# boot restores the saved source +init boot=1 source=bt +health dsp ok +expect cmd DSP_SET_SOURCE BT +expect label SYNC +expect line1 "Bluetooth" +expect line2 "Not connected" + +# boot volume cap (open question 3, configurable) +init boot=1 volume=80 bootcap=50 +health dsp ok +expect volume 50 +expect cmd DSP_RAMP_TO 50 1000 + +# saved volume is snapped to the 5% grid +init boot=1 volume=33 +health dsp ok +expect volume 30 + +# ---- Sleep (POWER in Active) +init +key POWER +expect mode standby +expect order "DSP_RAMP_TO 0 200" "DSP_DAC_ENABLE off" "SETTINGS_SAVE 30 AUX" +expect nocmd BT_PAUSE +expect line1 "" +expect line2 "" +expect label "" +expect clock "4:07 PM" +clear +key VOLUME_RIGHT +key VOLUME_LEFT +key MENU +key OK +key AUX +key SIRIUS +key TUNE_RIGHT +key 5 +key SEEK_RIGHT +key PHONE +expect nocmds +expect mode standby +expect line1 "" +tick 60000 +expect line1 "" + +# ---- Wake +key POWER +expect mode active +expect order "DSP_DAC_ENABLE on" "DSP_RAMP_TO 30 500" +expect line1 "Aux Input" +expect page NOW_PLAYING + +# sleep clears the page stack, confirmation and open editors +init +key MENU +key OK +key POWER +key POWER +expect page NOW_PLAYING +init +key AUX +expect confirm on +key POWER +key POWER +expect confirm off +init +key CLOCK +key TUNE_RIGHT +key POWER +key POWER +expect page NOW_PLAYING +expect clock "4:07 PM" +expect nocmd CLOCK_SET + +# sleep dismisses the overlay and saves the settled volume exactly once +init +key VOLUME_RIGHT +key POWER +expect overlay off +expect count SETTINGS_SAVE 1 +expect cmd SETTINGS_SAVE 35 AUX +key POWER +expect cmd DSP_RAMP_TO 35 500 + +# media sources pause on sleep and resume only what was playing +init source=bt +bt connected Phone +media "Song" "Artist" "Album" playing +clear +key POWER +expect order "BT_PAUSE" "DSP_RAMP_TO 0 200" +clear +key POWER +expect order "DSP_DAC_ENABLE on" "BT_PLAY" "DSP_RAMP_TO 30 500" + +init source=bt +bt connected Phone +media "Song" "Artist" "Album" paused +clear +key POWER +expect nocmd BT_PAUSE +key POWER +expect nocmd BT_PLAY + +init source=bt +clear +key POWER +expect nocmd BT_PAUSE +key POWER +expect nocmd BT_PLAY + +# POWER discards a pairing session +init source=bt +key MENU +key TUNE_RIGHT +key OK +key TUNE_RIGHT 2 +key OK +expect page BT_PAIR +clear +key POWER +expect cmd BT_PAIRING off + +# ---- Standby clock editor +init +key POWER +key CLOCK +expect mode standby +expect page CLOCK +expect line1 "Set Clock" +expect line2 "04:07 PM" +expect clock "4:07 PM" +clear +key VOLUME_RIGHT +key VOLUME_LEFT +expect nocmds +key TUNE_RIGHT +expect line2 "05:07 PM" +key OK +key OK +key OK +expect cmd CLOCK_SET 17 7 +expect mode standby +expect page NOW_PLAYING +expect clock "5:07 PM" +expect line1 "" + +# MENU cancels and returns to the standby clock +init +key POWER +key CLOCK +key TUNE_RIGHT +key MENU +expect mode standby +expect page NOW_PLAYING +expect nocmd CLOCK_SET +expect clock "4:07 PM" +expect line1 "" + +# POWER discards the edit and wakes +init +key POWER +key CLOCK +key TUNE_RIGHT +key POWER +expect mode active +expect page NOW_PLAYING +expect nocmd CLOCK_SET +expect clock "4:07 PM" +expect line1 "Aux Input" + +# the editor times out in standby too +init +key POWER +key CLOCK +tick 59900 +expect page CLOCK +tick 100 +expect page NOW_PLAYING +expect mode standby + +# keys routed to the editor reset the timer, dropped keys do not +init +key POWER +key CLOCK +tick 59000 +key TUNE_RIGHT +tick 59000 +expect page CLOCK +init +key POWER +key CLOCK +tick 59000 +key VOLUME_RIGHT +tick 1000 +expect page NOW_PLAYING + +# CLOCK inside the standby editor resets to the hour stage +init +key POWER +key CLOCK +key OK +expect blink 2 3-4 +key CLOCK +expect blink 2 0-1 diff --git a/car-radio/fusion/tests/clock_editor.test b/car-radio/fusion/tests/clock_editor.test new file mode 100644 index 0000000..be1d13a --- /dev/null +++ b/car-radio/fusion/tests/clock_editor.test @@ -0,0 +1,219 @@ +# ---- Clock editor (SDS §10) +init +key CLOCK +expect page CLOCK +expect label "" +expect line1 "Set Clock" +expect line2 "04:07 PM" +expect blink 2 0-1 +expect clock "4:07 PM" + +# the active field blinks 500 ms on / 500 ms off, and is solid after an edit +tick 500 +expect line2 " :07 PM" +expect blink 2 0-1 +tick 500 +expect line2 "04:07 PM" +tick 500 +key TUNE_RIGHT +expect line2 "05:07 PM" + +# ---- opening from menus and Now Playing; MENU cancels +init +key MENU +key CLOCK +expect page CLOCK +key MENU +expect page NOW_PLAYING +expect nocmd CLOCK_SET + +init +key CLOCK +key TUNE_RIGHT +key MENU +expect page NOW_PLAYING +expect nocmd CLOCK_SET +expect clock "4:07 PM" + +# ---- hour digits +init +key CLOCK +key 1 +expect line2 "10:07 PM" +expect blink 2 0-1 +key 5 +expect line2 "10:07 PM" +key 1 +expect line2 "11:07 PM" +expect blink 2 3-4 +key 3 +expect line2 "11:37 PM" +key 5 +expect line2 "11:35 PM" +expect blink 2 6-7 +key TUNE_RIGHT +expect line2 "11:35 AM" +key 4 +expect line2 "11:35 AM" +key OK +expect cmd CLOCK_SET 11 35 +expect page NOW_PLAYING +expect clock "11:35 AM" + +# first digit 2-9 becomes 0N and jumps to minutes +init +key CLOCK +key 7 +expect line2 "07:07 PM" +expect blink 2 3-4 + +# first digit 0 keeps the prefilled second digit; 00 is saved as 12 +init +key CLOCK +key 0 +expect line2 "04:07 PM" +expect blink 2 0-1 +key 0 +expect line2 "00:07 PM" +expect blink 2 3-4 +key OK +key OK +expect cmd CLOCK_SET 12 7 + +init +key CLOCK +key 0 +key 9 +expect line2 "09:07 PM" +expect blink 2 3-4 + +# first digit 1 with a prefilled hour whose second digit is 0-2 keeps it +init +clock 13 0 +key CLOCK +expect line2 "01:00 PM" +key 1 +expect line2 "11:00 PM" +key 2 +expect line2 "12:00 PM" + +# ---- minute digits +init +key CLOCK +key OK +expect blink 2 3-4 +key 7 +key 6 +expect line2 "04:07 PM" +key 2 +expect line2 "04:27 PM" +expect blink 2 3-4 +key 9 +expect line2 "04:29 PM" +expect blink 2 6-7 + +# minute: a first digit of 0 then a second digit +init +key CLOCK +key OK +key 0 +expect line2 "04:07 PM" +key 5 +expect line2 "04:05 PM" +expect blink 2 6-7 + +# ---- TUNE +init +clock 12 0 +key CLOCK +expect line2 "12:00 PM" +key TUNE_RIGHT +expect line2 "01:00 PM" +key TUNE_LEFT +expect line2 "12:00 PM" +key TUNE_LEFT +expect line2 "11:00 PM" + +init +clock 9 0 +key CLOCK +key OK +key TUNE_LEFT +expect line2 "09:59 AM" +key TUNE_RIGHT +expect line2 "09:00 AM" +key TUNE_RIGHT +expect line2 "09:01 AM" + +# TUNE resets the digit position +init +key CLOCK +key 1 +key TUNE_RIGHT +expect line2 "11:07 PM" +key 2 +expect line2 "02:07 PM" +expect blink 2 3-4 + +# midnight prefill and save +init +clock 0 5 +key CLOCK +expect line2 "12:05 AM" +key OK +key OK +key OK +expect cmd CLOCK_SET 0 5 +expect clock "12:05 AM" + +# noon save +init +clock 12 30 +key CLOCK +key OK +key OK +key OK +expect cmd CLOCK_SET 12 30 + +# ---- CLOCK inside the editor resets to the hour stage and reloads the time +init +key CLOCK +key TUNE_RIGHT +key OK +key TUNE_RIGHT +expect line2 "05:08 PM" +key CLOCK +expect line2 "04:07 PM" +expect blink 2 0-1 + +# ---- timeout: 60 s, reset by routed keys +init +key CLOCK +tick 59900 +expect page CLOCK +key TUNE_RIGHT +tick 59900 +expect page CLOCK +tick 100 +expect page NOW_PLAYING +expect nocmd CLOCK_SET +expect clock "4:07 PM" + +# ---- modal: other keys are ignored +init +key CLOCK +clear +key AUX +key SIRIUS +key PHONE +key SEEK_RIGHT +key SOUND +key LOAD +expect nocmds +expect page CLOCK +expect source aux +key OK +key OK +key 4 +expect line2 "04:07 PM" +expect page CLOCK diff --git a/car-radio/fusion/tests/menus_source.test b/car-radio/fusion/tests/menus_source.test new file mode 100644 index 0000000..797d53f --- /dev/null +++ b/car-radio/fusion/tests/menus_source.test @@ -0,0 +1,203 @@ +# ---- Top menu (SDS §7, §8.2) +init +key MENU +expect page MENU_TOP +expect label "" +expect line1 starts "Menu" +expect line1 ends "1/3" +expect line2 "› Select Source" +key TUNE_RIGHT +expect line2 "› Bluetooth" +expect line1 ends "2/3" +key TUNE_RIGHT +expect line2 "› Advanced" +key TUNE_RIGHT +expect line2 "› Select Source" +key TUNE_LEFT +expect line2 "› Advanced" + +# Select Source list marks the current choice +init +key MENU +key OK +expect page MENU_SOURCE +expect line1 starts "Select Source" +expect line1 ends "1/2" +expect line2 "› Aux *" +key TUNE_RIGHT +expect line2 "› Bluetooth" +expect line1 ends "2/2" + +init source=bt +key MENU +key OK +key TUNE_RIGHT +expect line2 "› Bluetooth *" + +# MENU goes back one level; entering selects the first item, going back +# restores the item you came from +init +key MENU +key TUNE_RIGHT +key OK +expect page MENU_BT +expect line2 "› Status" +key MENU +expect page MENU_TOP +expect line2 "› Bluetooth" +key MENU +expect page NOW_PLAYING + +# ---- menu timeouts (10 s) and the timer rule +init +key MENU +tick 9900 +expect page MENU_TOP +tick 100 +expect page NOW_PLAYING +expect nocmds + +init +key MENU +key OK +tick 10000 +expect page NOW_PLAYING + +init +key MENU +tick 9000 +key TUNE_RIGHT +tick 9000 +expect page MENU_TOP +key LOAD +tick 9000 +expect page MENU_TOP +tick 1000 +expect page NOW_PLAYING + +# ---- unused keys are ignored everywhere +init +key LOAD +key EJECT +key DIRECT +key SCAN +key CAT_FOLD +key TEXT +key AM_FM +key CD +key SEEK_UP +key SEEK_DOWN +key SOUND +key 5 +key TUNE_LEFT +key TUNE_RIGHT +key PHONE +key SEEK_LEFT +key SEEK_RIGHT +key OK +expect nocmds +expect page NOW_PLAYING +expect overlay off +expect confirm off +expect line1 "Aux Input" + +init +key MENU +key SOUND +key 3 +key SEEK_UP +key LOAD +key PHONE +expect page MENU_TOP +expect line2 "› Select Source" +expect nocmds + +# ---- Select Source applies the action, then confirms (SDS §8.3, §8.6) +init +key MENU +key OK +key TUNE_RIGHT +key OK +expect order "DSP_MUTE on" "DSP_SET_SOURCE BT" "DSP_MUTE off" "SETTINGS_SAVE 30 BT" +expect source bt +expect confirm on +expect page NOW_PLAYING +expect label SYNC +expect line1 "Source: Bluetooth" +expect line2 "" +tick 4900 +expect confirm on +tick 100 +expect confirm off +expect page NOW_PLAYING +expect line1 "Bluetooth" +expect line2 "Not connected" + +# choosing the source already playing still confirms, but changes nothing +init +key MENU +key OK +key OK +expect confirm on +expect line1 "Source: Aux" +expect label AUX +expect nocmd DSP_SET_SOURCE +expect nocmd SETTINGS_SAVE + +# OK skips the confirmation; MENU and other keys do not +key MENU +key TUNE_RIGHT +key 3 +key LOAD +expect confirm on +expect page NOW_PLAYING +key OK +expect confirm off +expect page NOW_PLAYING +expect nocmds + +# ---- hidden shortcuts duplicate the menu (SDS §5) +init +key SIRIUS +expect source bt +expect cmd DSP_SET_SOURCE BT +expect line1 "Source: Bluetooth" +expect label SYNC +key AUX +expect source aux +expect cmd DSP_SET_SOURCE AUX +expect line1 "Source: Aux" +expect label AUX + +init +key MENU +key SIRIUS +expect page NOW_PLAYING +expect source bt + +init source=bt +key SIRIUS +expect line1 "Source: Bluetooth" +expect nocmd DSP_SET_SOURCE + +# global keys replace a confirmation +init +key AUX +key CLOCK +expect confirm off +expect page CLOCK + +init +key AUX +key SIRIUS +expect confirm on +expect line1 "Source: Bluetooth" + +# SEEK also acts during a confirmation and replaces it +init +bt connected Phone +key SIRIUS +expect confirm on +key SEEK_RIGHT +expect cmd BT_NEXT +expect confirm off diff --git a/car-radio/fusion/tests/now_playing.test b/car-radio/fusion/tests/now_playing.test new file mode 100644 index 0000000..0f2bca4 --- /dev/null +++ b/car-radio/fusion/tests/now_playing.test @@ -0,0 +1,137 @@ +# ---- Now Playing, Aux +init +expect label AUX +expect line1 "Aux Input" +expect line2 "" +expect clock "4:07 PM" +key OK +key SEEK_LEFT +key SEEK_RIGHT +expect nocmds + +# ---- Now Playing, Bluetooth with no device +init source=bt +expect label SYNC +expect line1 "Bluetooth" +expect line2 "Not connected" +key OK +key SEEK_RIGHT +key SEEK_LEFT +expect nocmds + +# ---- connected, playing, paged text (SDS §8.1, §12) +bt devices Pixel iPhone +bt connected Pixel +expect line1 "Bluetooth" +expect line2 "Connected" +media "Midnight City" "M83" "Hurry Up, We're Dreaming" playing +expect line1 "Midnight City" +expect line2 "M83 · Hurry Up, ›" +tick 4900 +expect line2 "M83 · Hurry Up, ›" +tick 100 +expect line1 "Midnight City" +expect line2 "We're Dreaming" +tick 2400 +expect line2 "We're Dreaming" +tick 100 +expect line2 "M83 · Hurry Up, ›" +tick 4900 +expect line2 "M83 · Hurry Up, ›" +tick 100 +expect line2 "We're Dreaming" + +# a track change restarts both lines at page 1 +media "Another Track" "M83" "Hurry Up, We're Dreaming" playing +expect line2 "M83 · Hurry Up, ›" + +# both lines page on the same clock +media "A Very Long Song Title Indeed" "Artist Name Is Quite Long" "Album" playing +expect line1 "A Very Long Song ›" +expect line2 "Artist Name Is ›" +tick 5000 +expect line1 "Title Indeed" +expect line2 "Quite Long · Album" +tick 2500 +expect line1 "A Very Long Song ›" +expect line2 "Artist Name Is ›" + +# a line that fits never changes +media "Hi" "Artist Name Is Quite Long" "Album" playing +tick 5000 +expect line1 "Hi" +expect line2 "Quite Long · Album" +tick 2500 +expect line1 "Hi" +expect line2 "Artist Name Is ›" + +# separators are dropped when artist or album is missing +media "Song" "" "Album Only" playing +expect line2 "Album Only" +media "Song" "Artist Only" "" playing +expect line2 "Artist Only" +media "Song" "" "" playing +expect line2 "" + +# unsupported characters are mapped before reaching the display +media "Café Ñandú" "日本" "x" playing +expect line1 "Cafe Nandu" +expect line2 "?? · x" + +# paused +media "Midnight City" "M83" "Album" paused +expect line1 "Midnight City" +expect line2 "Paused" +key OK +expect cmd BT_PLAY_PAUSE +media "Midnight City" "M83" "Album" playing +expect line2 "M83 · Album" +clear +key OK +expect cmd BT_PLAY_PAUSE + +# volume 0 and faults replace line 2 +init source=bt +bt connected Pixel +media "Song" "Artist" "Album" playing +health bt down +expect line2 "BT not responding" +health bt ok +expect line2 "Artist · Album" + +# disconnect clears the track +bt disconnected +expect line1 "Bluetooth" +expect line2 "Not connected" + +# ---- SEEK is global in Active mode (SDS §5) +init source=bt +bt connected Pixel +clear +key SEEK_RIGHT +expect cmd BT_NEXT +key SEEK_LEFT +expect cmd BT_PREV +clear +key MENU +key SEEK_RIGHT +key SEEK_LEFT +expect page MENU_TOP +expect cmd BT_NEXT +expect cmd BT_PREV + +# ... but ignored in the modal clock editor +init source=bt +bt connected Pixel +key CLOCK +clear +key SEEK_RIGHT +key SEEK_LEFT +expect nocmds + +# an Aux source has no media control even with a phone connected +init +bt connected Pixel +key OK +key SEEK_RIGHT +expect nocmds diff --git a/car-radio/fusion/tests/phone_health_advanced.test b/car-radio/fusion/tests/phone_health_advanced.test new file mode 100644 index 0000000..53f41dd --- /dev/null +++ b/car-radio/fusion/tests/phone_health_advanced.test @@ -0,0 +1,174 @@ +# ---- Phone calls (SDS §8.8) +init +bt ringing Alice +expect line1 "Incoming call" +expect line2 "Alice" +expect label "" +key PHONE +expect cmd BT_ANSWER +expect line1 "On call" +expect line2 "Alice" +key PHONE +expect cmd BT_HANGUP +expect line1 "Aux Input" + +# PHONE with no call does nothing +init +key PHONE +expect nocmds + +# the call overlay sits above the volume overlay and stays up +init +key VOLUME_RIGHT +bt ringing Bob +expect line1 "Incoming call" +bt callended +expect line1 starts "Volume" + +init +bt ringing Bob +key MENU +expect page MENU_TOP +expect line1 "Incoming call" +tick 20000 +expect line1 "Incoming call" + +# remote hang-up removes it +init +bt ringing Zed +bt callended +expect line1 "Aux Input" + +# PHONE answers from menus, confirmations and modal pages +init +key MENU +bt ringing Carol +key PHONE +expect cmd BT_ANSWER +expect page MENU_TOP + +init +key CLOCK +bt ringing Carol +key PHONE +expect cmd BT_ANSWER +expect page CLOCK +key PHONE +expect cmd BT_HANGUP +expect page CLOCK + +init +key AUX +bt ringing Carol +key PHONE +expect cmd BT_ANSWER + +init +key MENU +key TUNE_LEFT +key OK +key OK +expect page REBOOT +bt ringing Carol +key PHONE +expect cmd BT_ANSWER +expect page REBOOT + +# PHONE is ignored in standby +init +key POWER +bt ringing Dan +clear +key PHONE +expect nocmds + +# ---- Health and degraded operation (SDS §14.10) +init +health dsp down +expect line2 "DSP not responding" +health fcim down +health fdim down +health can down +expect line1 "Aux Input" +health dsp ok +expect order "DSP_SET_SOURCE AUX" "DSP_SET_VOLUME 30" +expect line2 "" + +# a fault wins over Muted +init volume=0 +health dsp down +expect line2 "DSP not responding" + +# DSP returning resends the latest settings +init +key VOLUME_RIGHT 2 +health dsp down +clear +health dsp ok +expect cmd DSP_SET_VOLUME 40 +expect cmd DSP_SET_SOURCE AUX + +# ---- Advanced / Reboot (SDS §8.5) +init +key MENU +key TUNE_LEFT +expect line2 "› Advanced" +key OK +expect page MENU_ADVANCED +expect line1 starts "Advanced" +expect line1 ends "1/1" +expect line2 "› Reboot" +key OK +expect page REBOOT +expect label "" +expect line1 "Reboot system?" +expect line2 "OK=Yes MENU=No" +key MENU +expect page MENU_ADVANCED +key OK + +# 10 s timeout, reset by any key, cancels back to Advanced +tick 9900 +expect page REBOOT +key LOAD +tick 9900 +expect page REBOOT +tick 100 +expect page MENU_ADVANCED + +# modal: shortcuts and globals do nothing; volume still works +key OK +clear +key AUX +key SIRIUS +key CLOCK +key SEEK_RIGHT +key TUNE_RIGHT +expect nocmds +expect page REBOOT +key VOLUME_RIGHT +expect overlay on +expect page REBOOT + +# confirming mutes, saves, reboots +init +key MENU +key TUNE_LEFT +key OK +key OK +key OK +expect order "DSP_MUTE on" "SETTINGS_SAVE 30 AUX" "SYSTEM_REBOOT" +expect mode boot +expect line1 contains "Matthew's Ford" +expect line2 contains "Fusion" + +# a pending volume change is saved before the reboot +init +key VOLUME_RIGHT +key MENU +key TUNE_LEFT +key OK +key OK +key OK +expect cmd SETTINGS_SAVE 35 AUX +expect cmd SYSTEM_REBOOT diff --git a/car-radio/fusion/tests/volume.test b/car-radio/fusion/tests/volume.test new file mode 100644 index 0000000..987d0dd --- /dev/null +++ b/car-radio/fusion/tests/volume.test @@ -0,0 +1,140 @@ +# ---- Volume steps and overlay (SDS §8.7, §9) +init +key VOLUME_RIGHT +expect volume 35 +expect cmd DSP_SET_VOLUME 35 +expect overlay on +expect line1 starts "Volume" +expect line1 ends "35%" +expect bar 7 +expect label "" +key VOLUME_LEFT 2 +expect volume 25 +expect bar 5 + +# overlay lasts 5 s and then the settled volume is saved +init +key VOLUME_RIGHT +tick 4900 +expect overlay on +tick 100 +expect overlay off +expect cmd SETTINGS_SAVE 35 AUX +expect line1 "Aux Input" + +# each pulse refreshes the overlay timer +init +key VOLUME_RIGHT +tick 3000 +key VOLUME_RIGHT +tick 3000 +expect overlay on +tick 2000 +expect overlay off + +# a spin of the knob causes one write +init +key VOLUME_RIGHT 3 +tick 5000 +expect count SETTINGS_SAVE 1 +expect cmd SETTINGS_SAVE 45 AUX + +# clamping at both ends +init volume=100 +key VOLUME_RIGHT +expect volume 100 +expect nocmd DSP_SET_VOLUME +expect overlay on +expect bar 20 +expect line1 ends "100%" +tick 5000 +expect nocmd SETTINGS_SAVE + +init volume=5 +key VOLUME_LEFT +expect volume 0 +expect cmd DSP_SET_VOLUME 0 +expect line1 ends "Mute" +expect bar 0 +clear +key VOLUME_LEFT +expect volume 0 +expect nocmd DSP_SET_VOLUME +tick 5000 +expect line1 "Aux Input" +expect line2 "Muted" + +# muted replaces line 2 on every source +init volume=0 source=bt +expect line2 "Muted" + +# ---- overlay dismissal: CLOCK, MENU and OK hide it and then act +init +key VOLUME_RIGHT +key MENU +expect overlay off +expect page MENU_TOP +expect cmd SETTINGS_SAVE 35 AUX + +init +key VOLUME_RIGHT +key OK +expect overlay off +expect page NOW_PLAYING + +init +key VOLUME_RIGHT +key CLOCK +expect overlay off +expect page CLOCK + +# other keys leave it up +init +key VOLUME_RIGHT +key TUNE_RIGHT +expect overlay on + +# ---- the overlay does not change the page beneath it +init +key MENU +key VOLUME_RIGHT +expect page MENU_TOP +expect line1 starts "Volume" +tick 5000 +expect line1 starts "Menu" +expect page MENU_TOP + +# VOLUME resets the page timeout so the page has its full time left +init +key MENU +tick 9000 +key VOLUME_RIGHT +tick 9000 +expect page MENU_TOP +tick 1000 +expect page NOW_PLAYING + +# volume works inside a modal page and resets its timeout +init +key CLOCK +tick 50000 +key VOLUME_RIGHT +expect volume 35 +expect page CLOCK +tick 50000 +expect page CLOCK +tick 10000 +expect page NOW_PLAYING + +# the confirmation timer is never reset +init +key MENU +key OK +key OK +expect confirm on +tick 3000 +key VOLUME_RIGHT +tick 1900 +expect confirm on +tick 100 +expect confirm off diff --git a/car-radio/notes-and-knowledge/Ford FDIM text protocol.md b/car-radio/notes-and-knowledge/Ford FDIM text protocol.md new file mode 100644 index 0000000..e5597a0 --- /dev/null +++ b/car-radio/notes-and-knowledge/Ford FDIM text protocol.md @@ -0,0 +1,139 @@ +# Fix the FDIM payload, then automate discovery + +The text writes go wrong because of how the payload is built, not because of the transport. The only public byte-level source is FoDiMoCo (repo `p1ne/fdim-controller`). Its stock "Mercury / Mariner" capture on 0x337 is a **43-byte ISO-TP message with '@' (0x40) at fixed indices 3, 11 and 25**. The other **40 bytes are two 20-character lines**, which gives field widths 3 + 7 + 13 + 17 ([FordMessages.h](https://github.com/p1ne/fdim-controller/blob/master/can-clock/FordMessages.h), [can-clock.ino](https://github.com/p1ne/fdim-controller/blob/master/can-clock/can-clock.ino)). The user's working `writeTest` payload is the same layout with one separator missing (index 3) and three filler bytes ('1', '1', NUL) that FoDiMoCo never sends. The general `writeString` path does not match the layout at all. It puts '@' at index 4, drops a byte at some lengths, panics on short strings, and does not compile as uploaded. The fix is to always send the full fixed 43-byte frame with the three '@' in place and space padding. Only 0x337 should change; 0x336/0x324 "pair B" goes out exactly as captured. All sends should go through one writer goroutine. On the tooling side, the user should move off the unmaintained brutella/can, keep a pure, golden-tested ISO-TP encoder as the reference for the future C/MCU port, and use bench sweeps to answer the remaining unknowns: what 0x336/0x324 mean, whether the FDIM sends flow control, and what the glyph table looks like. Sniffing the factory ACM is optional. The bench experiments below can settle almost everything that is still open. + +## The 43-byte frame is two 20-column lines split by three '@' + +FoDiMoCo was developed on 2008-2012 Escape and Mariner, not on the Fusion. It replays a fixed 500 ms text cycle that was sniffed from a stock ACM and has been unchanged since January 2016 ([commit a27b27d](https://github.com/p1ne/fdim-controller/commit/a27b27d04a0c738fe9db64a128d97dc981580988), [README](https://github.com/p1ne/fdim-controller/blob/master/README.md)). The cycle has two transfers, each sent after its own pair of control frames: + +| t (ms) | ID | Data | Role | +|---|---|---|---| +| 0 | 0x336 | `03 01 0A 01 FE 00 00 00` | pair A control | +| 50 | 0x324 | `01 01 02 00 00 00 00 00` | pair A control | +| 100 | 0x337 | `06 20 c0 40 c1 40 c2 00` | ISO-TP single frame: 3-char top field | +| 200 | 0x336 | `03 01 05 03 03 00 00 00` | pair B control (same as the user's) | +| 225 | 0x324 | `03 01 02 00 00 00 00 00` | pair B control (same as the user's) | +| 250-400 | 0x337 | `10 2B ...` then `21`..`26`, 25 ms apart | ISO-TP 43-byte main text | + +FoDiMoCo's `displayText()` writes line 1 into payload indices 0-2, 4-10 and 12-21. It writes line 2 into 22-24 and 26-42. It forces '@' at 3, 11 and 25 and skips over them, and it pads short strings with spaces ([can-clock.ino](https://github.com/p1ne/fdim-controller/blob/master/can-clock/can-clock.ino)). So **'@' is a non-printing field delimiter at fixed positions**. It is not a line break and it does not centre text. Words get split across it ("Merc@ury" shows as "Mercury"), and the break between line 1 and line 2 falls *inside* the third field, at index 22. FoDiMoCo centres text in software with `padCenter` ([FormattedString.h](https://github.com/p1ne/fdim-controller/blob/master/can-clock/FormattedString.h)). Because '@' is the delimiter, **0x40 can never be displayed**, and user text has to have '@' replaced before it is sent. + +The meaning of 0x336 and 0x324 is unknown. FoDiMoCo's README has headings for them and nothing under them. The author's slides only say "0x337 - characters, 0x336/0x324 - control" ([presentation](https://github.com/p1ne/fdim-controller/tree/master/presentation)). The opendbc Ford 2011 body DBC does not list any of the three IDs ([opendbc](https://github.com/commaai/opendbc/blob/master/opendbc/dbc/ford_cgea1_2_bodycan_2011.dbc)). The working hypothesis is that each pair selects the region or template that the next 0x337 transfer fills: pair A goes with the 3-character field and pair B with the two main lines. Two observations support it. Byte 0 of 0x324 (01 or 03) matches byte 3 of 0x336 in each pair. And byte 0 of 0x336 (`03`) reads as an ISO-TP single-frame PCI for three data bytes. Neither is confirmed. + +### Reconciling the user's payload with FoDiMoCo + +The earlier code analysis proposed field widths {10, 13, 15} framed by '1' markers. That model fits `writeTest` byte for byte, but it rests on one sample. FoDiMoCo's layout explains the same 43 bytes with fewer assumptions. Here is the user's reassembled payload under each model: + +``` +idx: 0 1-2 3 4-10 11 12-24 25 26-40 41 42 +user: '1' " " ' ' " Hell" @ "o " @ " FORD! " '1' 00 +FoDi: ---- field 0 --- @ -- field 1 -- @ ---- field 2 (13) --- @ ------- field 3 (17) ------- +``` + +The two models agree on the two '@' the user sends: indices 11 and 25 match FoDiMoCo exactly, so fields 2 (13 chars) and 3 (17 chars) are already correct. The difference is at **index 3, where the user sends a space instead of '@'**. An FDIM that parses on '@' therefore sees one 11-character first field in place of 3 + 7. The '1' at index 0 is not a framing marker. In FoDiMoCo's layout it is **the first visible character of the 3-character top-left field**. The '1' at 41 and the NUL at 42 are the **last two columns of line 2**. Mapping the user's bytes through FoDiMoCo's slots (verified with a Go test, see below) gives line 1 = `"1 Hello "` and line 2 = `" FORD! 1"` followed by a NUL. If the merged first field does not get truncated, the user should be seeing a stray "1" at both ends of the display, and those are the "odd padding characters" they describe. The length 0x2B = 43 is correct under both models. The "bad data" seen with `writeString` comes from it sending variable lengths (10, 14, 42…) with a single '@' at index 4, not from anything about '1' or NUL. + +| Claim | Status | +|---|---| +| 0x337 uses ISO-TP SF/FF/CF framing, 43-byte main transfer, pair B headers | Confirmed by FoDiMoCo source and by the user's working frames | +| '@' at 3/11/25, 40 visible characters = 2x20 | Confirmed on Escape/Mariner (FoDiMoCo). **Untested on the Fusion SYNC 2-line FDIM** | +| '1' markers / trailing NUL needed | Not supported: FoDiMoCo uses neither | +| Which screen region each field occupies on the Fusion | Unknown; the fill test below answers it | +| Meaning of 0x336/0x324 bytes | Unknown; the pairs are replayed verbatim | +| 0x80-0xA7 special glyphs (arrows, boxes, sub/superscript digits) | Lead only. Added in Feb 2016, removed in Mar 2016, never confirmed ([commit 0ba2901](https://github.com/p1ne/fdim-controller/commit/0ba29016786548ee12f96aa4fcfedd8648d83615), [commit b4f7184](https://github.com/p1ne/fdim-controller/commit/b4f718481eec565aaa1c56393a680979cbb2e099)) | + +### A corrected payload builder + +The builder below was added next to the existing package and tested. Its golden test reproduces **FoDiMoCo's seven "Mercury/Mariner" 0x337 frames byte for byte**. The existing `EncodeISOTP` is correct as written. `BuildTextPayload` and its `{10,13,15}` widths in `/home/claude/research_notes/Ford FDIM text protocol/fdim_pkg/isotp.go` are now obsolete. Keep them only as the `writeTest` regression vector, and route all real text through `BuildTwoLine`. + +```go +const MainLen = 43 +var sepIdx = map[int]bool{3: true, 11: true, 25: true} + +// MainSlots: payload index of each visible char in display order (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 +}() + +func BuildTwoLine(line1, line2 string) []byte { + p := make([]byte, MainLen) + for i := range sepIdx { p[i] = '@' } + text := fit(line1, 20) + fit(line2, 20) // sanitise, truncate, space-pad + for k, idx := range MainSlots { p[idx] = text[k] } + return p +} + +// Pair A top field: 20 c0 '@' c1 '@' c2 (6-byte ISO-TP single frame) +func BuildTopField(s string) []byte { + f := fit(s, 3) + return []byte{0x20, f[0], '@', f[1], '@', f[2]} +} + +// fit: map non-printable and '@' to '?', truncate to w, right-pad with spaces. +``` + +To send a frame, write `PairB336`, then `PairB324`, then `EncodeISOTP(0x337, BuildTwoLine(a, b))`, with 5-25 ms between frames, and repeat the whole sequence every 500 ms in the same way FoDiMoCo refreshes. Whether the FDIM times out text that is not refreshed is unverified. Refreshing costs almost nothing, so it is the safe default. The full implementation with tests (`layout.go`, `layout_test.go`, including the pair A/B header frames) now sits next to `isotp.go` in `/home/claude/research_notes/Ford FDIM text protocol/fdim_pkg/`, and `go test` passes. + +## Flow control is probably absent, but 0x33F is worth one listen + +0x337 uses ISO 15765-2 framing. FoDiMoCo never reads a flow-control frame. Its receive filters cover only 0x423, the TPMS IDs and 0x466. It sends the consecutive frames blind, 25 ms apart, and this works on the Escape and Mariner ([can-clock.ino](https://github.com/p1ne/fdim-controller/blob/master/can-clock/can-clock.ino)). On the related Fiesta Mk5 radio-to-cluster text (0x4C0), the receiver *does* reply `30 00 00` on **request ID + 8** (0x4C8) ([roncapat](https://github.com/roncapat/Ford-Fiesta-MK5-MS-CAN-bus/blob/master/README.md)). By analogy, any FDIM flow control would show up on **0x33F**. If it appears with BS = 0, a blind sender is fine. If it appears with a non-zero BS or a WAIT, longer transfers would need real ISO-TP handling. On Linux the kernel ISO-TP socket covers both cases. Its `CAN_ISOTP_CF_BROADCAST` mode sends consecutive frames without waiting for FC, and `CAN_ISOTP_TX_STMIN` sets the pacing in microseconds. Both are exposed in `isotpsend -C -f` ([kernel docs](https://docs.kernel.org/networking/iso15765-2.html), [isotpsend.c](https://github.com/linux-can/can-utils/blob/master/isotpsend.c)). + +## The keep-alive set and a BCD clock + +The user's keep-alive already works. This is the full FoDiMoCo set to fall back on if the Fusion FDIM drops out or will not show the clock. The README says the FDIM "goes inactive" without 0x50C and 0x3E8 ([FoDiMoCo README](https://p1ne.github.io/fdim-controller/)): + +| ID | Data | Rate | +|---|---|---| +| 0x50C | `01 02 00` (DLC 3); the code sends `0C 01 00` first, `11 02 00` in steady state | 10 Hz (README); the code uses 500 ms | +| 0x3E8 | `[source, 00, volume, 00/04, 00 00 00 00]`; source 0x0F = clock-only top line | 1 Hz | +| 0x3EF | `32 32 32 32 03 00 00 {00 first, 20 once active}` | 1 Hz | +| 0x3F1 | `F5 90 00 00 00 00 00 00` | once at start | +| 0x3F2 | clock, `[HH MM DD MM YY flags 00 00]`, BCD | 1 Hz is enough (README says 20 Hz) | +| 0x55C | `0C 02` (user's own FCIM wake; replies on 0x55D) | user's current rate | + +FoDiMoCo's start order is: 50C `0C 01 00`, 3E8, 3EF `…00`, 3F2 `FF FF FF FF FF 60 00 00`, 50C `01 02 00`, 3F2 `00 00 FF FF FF F0 00 00`, 3F1, 50C, then text from t ≥ 1 s ([FordMessages.h](https://github.com/p1ne/fdim-controller/blob/master/can-clock/FordMessages.h)). The 0x5xx frames follow Ford's ring network management. In each frame, byte 0 is the next node and byte 1 is the status (0x02 = awake and stable, 0x12 = ready to sleep) ([Electro-Tech-Online](https://www.electro-tech-online.com/threads/ford-canbus-modules-wake-up.158834/?amp=1)). That explains both 0x50C and the user's 0x55C `0C 02`, which points at the ACM's node 0x0C. The likely reason the clock does not work is **encoding**. FoDiMoCo's example 13:02 is `13 02 FF FF FF F0 00 00`: the hour and minute are BCD, the date bytes are 0xFF, and the flags byte is 0xF0. The user's `display.go` sends `now.Hour()` in binary with zero date bytes. Any hour of 10 or later is therefore wrong. Flags 0xA0/0xC0/0xE0 are documented as AM/PM/24 h, but the README lists 0xC0 twice, so treat those values as candidates to test. + +## Bugs to fix before any more experiments + +Several of these bugs on their own would produce "garbage or nothing" on the display, so fix them first. Otherwise you cannot tell whether a failed experiment says something about the protocol or about the code. They are listed most severe first. + +| # | File:line | Defect | Fix | +|---|---|---|---| +| 1 | display.go:30 | `slices.Insert` on a string does not compile. The intended '@' goes at text index 4 and panics for "CD" or "AUX" | Delete; use `BuildTwoLine` | +| 2 | display.go:33-47 | `ceil(n/7)` frame count drops the last byte when n is a multiple of 7 (the 41-space clear, `OK_PLAY_PAUSE`) | `EncodeISOTP` | +| 3 | display.go (whole path) | Variable-length payload, '@' in the wrong place, no fixed 43 bytes | Always send 43 bytes with '@' at 3/11/25 | +| 4 | main.go:206-213 | Multi-frame send with `time.Sleep` runs inside the brutella receive callback, which blocks all receive, including buttons | Callback only enqueues; the writer sends | +| 5 | main.go:216-225 | The clock goroutine, `writeTest` and the callbacks all call `Publish` concurrently, so 0x337 streams can interleave | One writer goroutine owns the bus | +| 6 | display.go:43 | CF sequence number `0x20+i` does not wrap; it becomes `0x30` (an FC PCI) after 15 CFs | `0x20 \| sn&0x0F` | +| 7 | display.go:13-16,155 | `clear=true` recursively sends the 0x336/0x324 headers twice, plus a malformed clear | A clear is `BuildTwoLine("", "")` | +| 8 | display.go:166-177 | Clock hour and minute sent in binary, date bytes zero | BCD, `FF FF FF`, flags `F0` | +| 9 | main.go:227-231 | `ConnectAndPublish` error ignored; `for true {}` spins a core; `defer Disconnect` never runs | `signal.NotifyContext` + errgroup | +| 10 | display.go:45,87; 31/49 | FF length high nibble never set; length patched into `frames[2]` by index; runes and bytes mixed | `EncodeISOTP` + ASCII sanitising | +| 11 | main.go:209 | No debounce; 0x88 treated as "pressed" | Decode byte 1 as state = b1>>6, target = (b1>>2)&0x0F per opendbc 0x2E0 ([opendbc](https://github.com/commaai/opendbc/blob/master/opendbc/dbc/ford_cgea1_2_bodycan_2011.dbc)) and log 0x08/0x48/0xC8 | + +Bugs 4 and 5 come from brutella/can's design. It has had **no release since v0.0.2 (May 2019)**. Its handlers run synchronously on the read goroutine, and whether `Publish` is thread-safe has been an open question since 2020 ([pkg.go.dev](https://pkg.go.dev/github.com/brutella/can?tab=versions), [bus.go](https://github.com/brutella/can/blob/master/bus.go), [issues](https://github.com/brutella/can/issues)). + +## One writer, pure encoders, and an emulator make a discovery rig + +The architecture that fits the rest of the project, including the C port that comes later, has three layers. At the core are **pure functions** with no I/O: `EncodeISOTP`, `BuildTwoLine`, `BuildTopField`, a reassembler, a BCD clock encoder and an 0x2E0 button decoder. All are covered by table-driven golden tests and `go test -fuzz`. These are the reference implementation the MCU code must match, byte for byte. Above them sits a small project-owned `Bus` interface (`Send(ctx, Frame)`, `Frames() <-chan Frame`). It has adapters for **go.einride.tech/can**, which is actively maintained (v0.17.0, February 2026) and provides context-aware SocketCAN transmit and receive, `candevice` link setup, DBC tooling and a **UDP-multicast bus emulator** that needs no root ([releases](https://github.com/einride/can-go/releases), [socketcan docs](https://pkg.go.dev/go.einride.tech/can/pkg/socketcan)). It also has a vcan adapter and an in-memory fake. The runtime layer is one **writer goroutine** that owns transmission. It accepts jobs (a frame batch plus a gap) on a channel, paces them with timers inside a `select` on `ctx.Done()`, and runs the 0x50C/0x3E8/0x3EF/0x3F2/0x55C tickers as separate goroutines that feed the same channel. Text jobs are atomic, so keep-alives can slot in only *between* complete transfers. 5 ms gaps from `time.Sleep` are accurate. Sub-millisecond Go sleeps overshoot to about 0.6-1.1 ms on Linux ([golang/go#44343](https://github.com/golang/go/issues/44343)), so stay at 1 ms or more, or let the kernel ISO-TP socket do the pacing. + +For discovery, a **fake FDIM** goroutine on the emulator or on vcan0 reassembles 0x337, renders the 2x20 view using `MainSlots`, can optionally reply with FC on 0x33F, and flags gaps that are too short. Run it with `-race`. The CLI should have `sniff`, `text "line1" "line2"`, `raw 336 03 01 05 03 03`, `sweep 337 idx=5 80..A7 every=2s` and `replay file.log`, plus a small REPL for poking bytes live. Every step a sweep takes should be logged with a timestamp next to a parallel `candump -l -tA`, so whatever the display does can be lined up with what was sent. Use can-utils (`candump`, `canplayer`, `cansniffer`, `isotpdump`) and SavvyCAN for analysis. Keep a DBC for the fixed-layout frames, since cantools can generate C from it for the MCU later. Keep a Go catalogue for the text protocol, because DBC has no string type ([can-utils](https://github.com/linux-can/can-utils), [cantools](https://cantools.readthedocs.io/en/latest/), [SavvyCAN](https://github.com/collin80/SavvyCAN)). Finally, **flash the CANable to candleLight**. It then shows up as a native `can0` through gs_usb, with no slcand, and the CANable docs recommend it for Linux and under high bus load ([canable.io](https://canable.io/getting-started.html)). After that, setup is a single `ip link set can0 up type can bitrate 125000 restart-ms 100`. + +## Next steps + +These are in order. Each step answers one question. + +1. Copy `layout.go`/`layout_test.go` into the project, run `go test ./...`, and swap `writeString`'s body for `BuildTwoLine` + `EncodeISOTP` (this fixes bugs 1-3 and 6-7). +2. **Field-letter fill test**: send pair B and then the payload `AAA@BBBBBBB@CCCCCCCCCCCCC@DDDDDDDDDDDDDDDDD` (43 bytes). Photograph where each letter lands. If the result is two clean 20-column lines, the FoDiMoCo layout holds on the Fusion. Then repeat the user's old payload and check whether the stray '1's are visible. +3. While step 2 runs, run `candump -l -tA can0` and look for any frame on **0x33F** (or any new ID) within about 50 ms of the `10 2B` first frame. That tells you whether the FDIM sends flow control. +4. **Pair A test**: send `336 03 01 0A 01 FE`, `324 01 01 02`, `337 06 20 31 40 32 40 33 00` and look for "123" in the top field. +5. **Clock**: send `3F2 13 02 FF FF FF F0 00 00` at 1 Hz, then the same frame with flags 0xE0, 0xA0 and 0xC0. +6. **Glyph sweep**: put byte 0x80…0xA7 into line-1 column 1 (payload index 0), one value every 2 s, and log each step. +7. Restructure into a single writer goroutine on brutella first (bugs 4, 5, 9), then migrate to einride behind the `Bus` interface. +8. Flash candleLight, then sweep single bytes of 0x336 (bytes 2 and 4 first) and 0x324 to map what the control frames mean. + +## Conclusion + +The text protocol is closer to solved than it looked. The "odd padding" was a missing delimiter plus three characters that the display most likely renders, not hidden framing rules. What genuinely remains unknown is narrow: the meaning of the control pair, the Fusion's field-to-screen mapping, the glyph table, and whether flow control exists. The fill and sweep experiments above can resolve all four, which is why tapping into the car to sniff the factory ACM can wait. If the Fusion's layout turns out to differ from the Escape's, a capture of the stock ACM is the single most valuable next data point. + +Keeping the encoders pure also pays off for the C stack. Each golden vector (FoDiMoCo's Mercury/Mariner frames, the user's `writeTest`, the pair-A top field) becomes a unit test the MCU code must pass. The protocol knowledge then lives in test data rather than in either implementation. diff --git a/car-radio/notes-and-knowledge/fdim.txt b/car-radio/notes-and-knowledge/fdim.txt new file mode 100644 index 0000000..40279de --- /dev/null +++ b/car-radio/notes-and-knowledge/fdim.txt @@ -0,0 +1,67 @@ +https://p1ne.github.io/fdim-controller +FDIM has been mostly hacked by this repo. + +Summary: +Heartbeat: +10hz +Indicates presence of ACM, FDIM goes inactive without this +0x 50c 01 02 00 + +Top line and heartbeat: +1hz +0x3e8 +source enum +volume +clock length + +Unknown init heartbeat: +1hz +0x3ef with a value for init and heartbeat + +Clock: +20hz +0x3f2 filled with time info +Has empty clock special case + +Init message: +0x 3f1 f5 90 + +Lacking info on these: +0x324 - text message +0x336 - text message +0x337 - text message +0x726 - TPMS request +0x72e - TPMS response +0x3b5 - TPMS broadcast + +This repo seems to hav a module that has 3 text rows, so it may be different. + + +Display is 20 cols 2 rows + + + +This sends ford mariner after the system is running. You can repeatedly send it and the text stays up for about a second. So you probably need to repeat the buffer over and over to keep things displayed. + +The sequence seems to be 336 324 337. +337 contains the actual text payload. don't know what the @'s (hex 0x40) are for. +The first 337 message, first payload byte, is the length of the message. The last frame needs to be padded with 0x00 to fill the frame. +This should be all we need to send messages to the display. + +void initTextMessages() +{ + TEXT_COUNT = 0; + + text[TEXT_COUNT++].set( 1000, 0, 500, 0x336, 8, 0x03, 0x01, 0x0A, 0x01, 0xFE, 0x00, 0x00, 0x00 ); + text[TEXT_COUNT++].set( 1000, 50, 500, 0x324, 8, 0x01, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 ); + text[TEXT_COUNT++].set( 1000, 100, 500, 0x337, 8, 0x06, 0x20, ' ', '@', ' ', '@', ' ', 0x00 ); + text[TEXT_COUNT++].set( 1000, 200, 500, 0x336, 8, 0x03, 0x01, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00 ); + text[TEXT_COUNT++].set( 1000, 225, 500, 0x324, 8, 0x03, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 ); + text[TEXT_COUNT++].set( 1000, 250, 500, 0x337, 8, 0x10, 0x2B, ' ', ' ', ' ', '@', ' ', ' ' ); + text[TEXT_COUNT++].set( 1000, 275, 500, 0x337, 8, 0x21, ' ', 'M', 'e', 'r', 'c', '@', 'u' ); + text[TEXT_COUNT++].set( 1000, 300, 500, 0x337, 8, 0x22, 'r', 'y', ' ', ' ', ' ', ' ', ' ' ); + text[TEXT_COUNT++].set( 1000, 325, 500, 0x337, 8, 0x23, ' ', ' ', ' ', ' ', ' ', '@', ' ' ); + text[TEXT_COUNT++].set( 1000, 350, 500, 0x337, 8, 0x24, ' ', ' ', 'M', 'a', 'r', 'i', 'n' ); + text[TEXT_COUNT++].set( 1000, 375, 500, 0x337, 8, 0x25, 'e', 'r', ' ', ' ', ' ', ' ', ' ' ); + text[TEXT_COUNT++].set( 1000, 400, 500, 0x337, 8, 0x26, ' ', ' ', 0x00, 0x00, 0x00, 0x00, 0x00 ); +} \ No newline at end of file diff --git a/car-radio/notes-and-knowledge/interface-design.md b/car-radio/notes-and-knowledge/interface-design.md new file mode 100644 index 0000000..92138e5 --- /dev/null +++ b/car-radio/notes-and-knowledge/interface-design.md @@ -0,0 +1,202 @@ +# Interface Design +## Purpose: +This document will outline all of the interface design of this project. + +The Front Display Interface Module is a small VFD display that has some information text at the top, and a clock. And two rows of text available. All display decisions must be made with that in mind. More details on specific charactersets and line lengths will be provided later. + +## Available Features: +List of all controls on the FCIM: +* LOAD +* EJECT +* 1 +* 2 +* 3 +* 4 +* 5 +* 6 +* 7 +* 8 +* 9 +* 0 +* DIRECT +* CLOCK +* VOLUME LEFT/RIGHT +* POWER (Push button attached to VOLUME knob) +* PHONE +* SCAN +* CAT/FOLD +* SEEK UP/DOWN/LEFT/RIGHT +* SOUND +* MENU +* TEXT +* TUNE LEFT/RIGHT +* OK/PLAY/PAUSE (Push button attached to TUNE knob) +* AM/FM +* CD +* AUX +* SIRIUS + +All of these are sent as single pulses through the system, there is no hold tracking. + +## Basic Overview +### VOLUME LEFT/RIGHT: +Adjusts master volume by one interval and displays a temporary volume overlay for 5 seconds. Additional volume adjustments reset the timer. + +The overlay does not change the current page, it acts like a pop-up. Global buttons continue to work normally while it is visible: +* CLOCK: Opens the clock menu +* SOUND: Opens Menu -> Sound +* MENU: Performs the normal MENU action for the underlying page +* OK: Performs the normal OK action for the underlying page + +The overlay is dismissed immediately when CLOCK, SOUND, MENU, OK, or POWER are pressed. It also disappears automatically after 5 seconds of no volume input. + +The volume slider range is 0-100% in 5% increments. + +Note: + +The formula for gain adjustment is `db=20log10(p)`. So `50% => p=0.5 => 20log10(0.5)=-6.02db`. + +At 0%, the formula is replaced for a hard output shut off. + +### CLOCK: +Allows for time editing. Displays current time in the format `HH:MM AM/PM`. Prefilled with the current system time. The active editor will blink over HH MM or AM/PM to display what phase you are on. The page will have a 60 second timeout overall which is reset every time an input is given. + +Pressing CLOCK from within this menu will reset the editor to the hour editor and refill the menu with the current system time. + +#### Hour edit + +* The TUNE knob will adjust the hour up/down 1 hour, wrapping from 12 to 1. +* Allows entering time via the number padm 0-9 is allowed +* If 1X is entered, the prefilled value for X is 3-9, it will change to 0 in the editor. i.e, A valid number shall always be displayed. +* If 1X is entered, only 0, 1, or 2 are allowed for X +* If 0X is entered, 0-9 is allowed for the second digit +* If 00 is entered, it will be converted to 12 when saving +* If 2-9 are entered for the first digit, it is treated as if you entered a 0 and then the number. e.g. 7 gets converted to 07, and continues to the minute stage + +#### Minute edit + +* The TUNE knob will adjust the minute up/down 1 hour, wrapping from 59 to 0. +* 0-5 is allowed for the first number +* 0-9 is allowed for the second numer + +For both, when a valid second number is entered, or if OK is pressed, it will advance to the next editing stage + +For both, using the TUNE knob will reset the editing number to the first digit + +#### AM/PM edit + +* The TUNE knob will flip between AM and PM +* The OK button will save the entered time into the system and exit the clock menu. + +Pressing MENU will close the menu with no changes. + +#### Validation: + +* The hour field accepts 00 through 12 +* 00:MM AM/PM is converted to 12:MM AM/PM when saved +* Two digit hours must be 0[0-9] or 1[0-2] +* Minute field may be [0-5][0-9] + +### LOAD, EJECT, DIRECT: +Unused. + +### NUMBERS: +Context specific, does nothing on unused pages. + +### PHONE: +Once a bluetooth module is connected, the PHONE button can be used to answer and hang up using the BT features. + +### SCAN: +Unused. + +### CAT/FOLD: +Unused. + +Note: Traditionally used for filesystem based media stores, it switches between a category or folder navigation scheme. Probably based on metadata and the OK and TUNE L/R buttons to select after that. + +### SOUND: +Opens Menu->Sound from Now Playing or any standard menu page. This retains the top-level menu as part of the history, so MENU will return to the top-level if you hit them in sequence. + +### MENU: +* From Now Playing, opens the top-level menu. +* Inside a submenu, moves back one level +* At top-level menu, returns to Now Playing + +### TEXT: +Unused. + +Traditionally used for scanning through metadata: Title, Artist, Album, Station info, file name, etc. + +### AM/FM CD AUX SIRIUS: +Unused. + +Note: Maybe these could be used as shorthand for input control. They would have to be a "secret feature" because I can't change the button icons so they wouldn't be obvious. + +### OK/PLAY/PAUSE: +In menus, OK confirms or advances. On Now Playing, media will play/pause if available. + +### TUNE LEFT/RIGHT +Nothing unless context specific. + +### SEEK UP/DOWN/LEFT/RIGHT: +If available, LEFT/RIGHT sends back/next track signals. + +UP/DOWN unused. + +### POWER: +Switches the system between Active and Standby mode from any page. + +## Menu Design + +The home page will be called Now Playing, or the Now Playing Page. It is the standard screen that shows the current song / media state, clock, or anything else available. + +10 seconds of inactivity on any menu page returns to Now Playing. + +Exceptions: +* Clock edit has a 60 second timeout and cancels without saving. +* Bluetooth pairing and other eternal interactions define their own timeout. +* Volume temporary overlay does not affect the current page timeout. +* Now Playing has no timeout. + +OK goes into a submenu or confirms the action selected. + +Whenever an action is confirmed the screen may stay open for 5 seconds to display a confirmation to the user. e.g. Select Source -> Aux, a confirmation message that says `Source: Aux` may display. MENU will not back out from a confirmation. OK will skip the confirmation timer and return to Now Playing. + +### Menu Tree +``` +Sound + Balance + Slider L/R + Fade + Slider F/R + Bass Control + Slider increment in dB. Range and increment to be determined +Select Source + Aux + S/PDIF + Bluetooth +Bluetooth + Unknown control scheme at the moment, include features like disconnect, connect, pair new device, etc +Advanced + Any necessary advanced features like system reboot (requires extra confirmation) +``` + +### Standby Mode +* Audio output is muted. +* Playback is paused if relevant. +* Display only shows the clock, which can be adjusted. +* POWER puts the system back into active mode. +* No buttons except those relevant to clock menu and power do anything. Numbers, TUNE, OK, MENU, CLOCK, all work within the clock menu. +* If power is pressed while in the clock menu, it will be discarded and the system will move to active mode. + +#### Sleep mechanics: +* If mode has media control, pause playback +* Volume is ramped down to 0 quickly +* DAC output is fully disabled +* Enter clock only display mode + +#### Wake mechanics: +* Enable DAC output +* If mode has media control, restore playback to previous state (do not start if it was paused before) +* Ramp volume back to saved state +* Display Now Playing page \ No newline at end of file diff --git a/car-radio/notes-and-knowledge/keep-wake.txt b/car-radio/notes-and-knowledge/keep-wake.txt new file mode 100644 index 0000000..1c2b83a --- /dev/null +++ b/car-radio/notes-and-knowledge/keep-wake.txt @@ -0,0 +1,23 @@ +https://www.electro-tech-online.com/threads/ford-canbus-modules-wake-up.158834/ + +It seems like there is a circle of messages that happen for things to have safe messaging. + +" +So any message at all will wake the module, but it may then go to sleep if there are no sleep/wake messages, only to be woken again a moment later. + +What happens is that all the modules that form a "ring" with each module transmitting 100 ms after the previous one. The first byte of each message is the ID of the next module in the ring, without the 0x5 at the start. The IDs go up in order, so if you have three modules, the sleep wake messages might be like this:- + +0x525 33 12 +0x533 37 12 +0x537 25 02 +" + +Currently, decode.py waits for 55D, meaning the FCIM is saying "55D go". So we send 50C 0C 12 00 + +" + The 2nd byte has a 1 in the first nibble when the module is ready to sleep, and a 0 when it needs the CANbus to be awake. So in this case the module transmitting 0x537 needs the CANbus awake. The 2nd nibble of the 2nd byte is 1 when the module is working, and 2 when the ring is stable. As the car wakes up, the different modules work out the order of the ring, and it's a bit chaotic for a couple of seconds, but if they all follow the rules, it all sorts itself out in a few seconds. +" + +So if we change 0C 12 to 0C 02, will we get permanent wake? + +Yes that works. \ No newline at end of file diff --git a/car-radio/notes-and-knowledge/software-design.md b/car-radio/notes-and-knowledge/software-design.md new file mode 100644 index 0000000..1b3c09d --- /dev/null +++ b/car-radio/notes-and-knowledge/software-design.md @@ -0,0 +1,60 @@ +The whole system will run on a raspberry pi running GNU GUIX with a daemon running a monolithic golang program for the majority of it's controls. + +Go program: + +One subsystem will be the CAN bus manager +Using net and unix library it can open and manage the canX interface to read and write. +One goroutine will read from the socket and publish to outbound channel queues for subscribers to consume. Exposed via subscribe() function. +One goroutine will take from a thread safe channel and transmit messages over the bus. +This gives all other systems access to the CAN bus to display things +Should have a readable status to handle decayed functionality +Should attempt to auto restart if there are issues + +FCIM Manager +Using the CAN bus manager, initializes and keeps the FCIM alive +Filters for button presses and sends out to a pub/sub + +FDIM Manager +Using the CAN bus manager, initializes and keeps the FDIM alive +Handles CAN message encoding given plaintext strings and times +Caches display state and handles refreshing for clock, text, etc. + +Display Renderer +Provides formatting tools to easily display what you want +Handles scrolling for long text +Handles blinking for editors + +UI Manager +Keeps state of the current page +Reaches out to clock manager to display the clock when appropriate + +Media Manager +Handles tracking the currently playing source +Provides interface for getting current metadata +Provides interface for media controls if available + +Bluetooth Manager +Using a serial interface (unknown) manager, handles interfacing like pairing, conncetion states, metadata collection, media control +Reports information like is paired, has control/metadata etc. + +DSP Manager +Using an SPI interface manager, handles all features provided therein +* Volume control +* Bass boosting +* Balance +* Fade +* Input selection +* GET variables such as Input mux index + +I2C manager +Handles operating system hookup to I2C communications +Ensures communications are kept available + +SPI Manager +Handles operating system hookup to SPI communications + +Clock Manager +Handles interation and errors with the OS to set and get the current time with formats and conversions. +Does not own any UI. + + diff --git a/car-radio/notes-and-knowledge/wiring.txt b/car-radio/notes-and-knowledge/wiring.txt new file mode 100644 index 0000000..2daf79a --- /dev/null +++ b/car-radio/notes-and-knowledge/wiring.txt @@ -0,0 +1,32 @@ +https://pinoutguide.com/Car-Stereo-Ford-Volvo/Ford_2019-2022_Fusion_ACM_pinout.shtml +https://www.powerstroke.org/threads/08-f-250-radio-wiring.265404/ +https://www.electro-tech-online.com/threads/ford-canbus-modules-wake-up.158834/ + +https://portal-diagnostov.com/en/2020/04/11/radio-ford-fusion-sel-2011-system-wiring-diagrams/?utm_source=chatgpt.com +https://portal-diagnostov.com/en/2020/04/11/radio-ford-fusion-se-2011-system-wiring-diagrams/ + +ATX Power supply: +On the 24 pin, looking towards the pins from the outside, on the side with the clip, short the 3rd and 4th pins. Actualy numbered 15 and 16 on diagrams. +On the 8 pin EPS, use the clip side for voltage, other side for ground. + +Terms and wiring: +ACM - Audio Control Module - The head unit, attached behind everything. +Has two main connectors, +C240C - When looking at the back, it's the left, slimmer one. +Last 2 conductors are CANH CANL +Other connector, first conductor is voltage, + +FCIM +Front Controls Interface Module +The block of buttons and knobs +Single connector, from bottom to top: Voltage, Ground, CANH, CANL + +FDIM +Front Display Interface Module +the display system. +Looking at the back, there is one connector, read left to right, top to bottom. +Pin Purpose +4 CANH +5 CANL +8 GND +12 VOLTAGE \ No newline at end of file