fix: remove duplicated hardware/firmware/hardware/ nesting - consolidate to single hardware structure

This commit is contained in:
Clément SAILLANT
2026-03-01 19:04:25 +01:00
parent cdd4f2e069
commit 5ad66a755c
634 changed files with 0 additions and 252429 deletions
@@ -1,437 +0,0 @@
# --- Visual Studio & Windows ---
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
*_wpftmp.csproj
#!**/[Bb]in/*.refresh
# Visual Studio cache/options directory
.vs/
# Visual Studio auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
*.pubxml
*.publishproj
PublishScripts/
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
*.[Cc]ache
# ZeroClaw local runtime (generated locally, never committed)
/.zeroclaw/
/config.toml
/workspace/
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
#*.snk
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
- [Bb]ackup.rdl
- [Bb]ackup ([0-9]).rdl
- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# --- PlatformIO/ESP32/VSCode/MacOS ---
# PlatformIO
.pio/
.pioenvs/
.piolibdeps/
.build/
.platformio/
coverage/
*.gcda
*.gcno
*.gcov
.idea/
*.code-workspace
__pycache__/
*.pyc
.env
# C++/Arduino
*.elf
*.bin
*.hex
*.map
*.dmp
*.d
*.o
*.a
*.gch
*.lst
*.sdf
*.suo
*.idb
*.pdb
*.ipch
*.aps
*.ncb
*.opendb
*.opensdf
*.VC.db
*.VC.VC.opendb
# VS Code
.vscode/
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# MacOS
.DS_Store
# Logs
*.log
logs/
# Test/coverage
coverage/
*.gcda
*.gcno
*.gcov
# Divers
.history/
*.swp
*~
# Project data
# If data/audio was previously committed, run:
# git rm --cached -r data/audio
data/audio/
docs/specs/tone_plan_wav_assets/assets
docs/wav_clean_8k/
docs/wav_ptt_vintage_8k/
@@ -1,6 +0,0 @@
[submodule "external/esp32-llm"]
path = external/esp32-llm
url = https://github.com/DaveBben/esp32-llm
[submodule "external/esp-picotts"]
path = external/esp-picotts
url = https://github.com/DiUS/esp-picotts
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
@@ -1,28 +0,0 @@
# Intégration Makefile : flash universel
# Variables de base
FLASH_SCRIPT = tools/dev/autoflash.py
FLASH_CONFIG = tools/dev/flash_config.json
# Liste des rôles connus (adapter selon ton flash_config.json)
FLASH_ROLES = esp32 esp8266 esp32s3 rp2040 stm32 arduino
# Cible générique : make flash ROLE=esp32
flash:
@if [ -z "$(ROLE)" ]; then \
echo "Usage : make flash ROLE=<$(FLASH_ROLES)>"; exit 1; \
fi; \
$(FLASH_SCRIPT) flash --config $(FLASH_CONFIG) --role $(ROLE)
# Cible pour lister les devices et rôles
flash-list:
$(FLASH_SCRIPT) list --config $(FLASH_CONFIG)
# Cible dry-run (voir ce qu'il ferait)
flash-dry:
@if [ -z "$(ROLE)" ]; then \
echo "Usage : make flash-dry ROLE=<$(FLASH_ROLES)>"; exit 1; \
fi; \
$(FLASH_SCRIPT) flash --config $(FLASH_CONFIG) --role $(ROLE) --dry-run
.PHONY: flash flash-list flash-dry
@@ -1,155 +0,0 @@
# RTC_BL_PHONE
Téléphone RTC expérimental sur ESP32 A252, orienté terrain: numérotation à impulsion, hotline audio, bridge ESP-NOW, validation série stricte.
## Esprit Zacus
Ce repo se pilote comme une session de terrain:
- `Opérateur` : décroche, compose, déclenche les scénarios.
- `Analyste` : surveille `STATUS` / `HOTLINE_STATUS` / `ESPNOW_STATUS`.
- `Archiviste` : garde les logs et rapports dans `artifacts/`.
Référence rôles: [docs/roles_agents.md](docs/roles_agents.md)
## Cible active
- Board focus: `ESP32_A252`
- Port série usuel: `/dev/cu.usbserial-0001`
- Contrat firmware courant: `A252_AUDIO_CHAIN_V4`
## Comportement hotline (actuel)
- Preset forcé au boot:
- `1 -> /hotline/menu_dtmf_short.wav` (SD)
- `2 -> /hotline/menu_dtmf.wav` (SD)
- `3 -> /hotline/menu_dtmf_long.wav` (SD)
- Numérotation impulsion active combiné décroché.
- Après sélection dun numéro valide:
- tentative de lecture MP3 via table explicite `scene + validation_state + digit`,
- fallback heuristique par stems si entrée explicite absente,
- fallback automatique vers mapping WAV `1/2/3` si aucun MP3 contextualisé nest trouvé,
- pause 3s,
- boucle jusquau raccroché.
- `WAITING_VALIDATION` déclenche la sonnerie + prompt SD contextualisé (fallback `enter_code_5__fr-fr-deniseneural.mp3` / `enter_code_5.wav`).
- `WAITING_VALIDATION {"scene_id":"SCENE_WIN_ETAPE","step_id":"RTC_ESP_ETAPE1","validation_state":"waiting"}` met à jour le contexte scène/état avant routage MP3.
- Lecture MP3 SD activée (decodeur Helix) pour les prompts scène hotline.
- Raccroché détecté rapidement (~300 ms).
- Pas de sonnerie automatique au boot (ring déclenché par événement runtime uniquement).
## Audio scène hotline (SD)
- `SCENE <scene_id>` conserve l’état scène et tente une lecture SD mappée (`/hotline/scene_*`).
- `SCENE {"id":"<scene_id>","step_id":"<step_id>","validation_state":"waiting|granted|refused|none"}` met à jour le contexte hotline complet.
- Commande dédiée: `HOTLINE_SCENE_PLAY <scene_id>` pour forcer la lecture scène.
- Mapping voix par défaut: suffixe `__fr-fr-deniseneural.mp3`.
- Mapping scène explicite validé:
- `SCENE_U_SON_PROTO -> fiches-hotline_2`
- `SCENE_LA_DETECTOR -> scene_la_detector_2`
- `SCENE_WIN_ETAPE`, `SCENE_WIN_ETAPE1`, `SCENE_WIN_ETAPE2`, `SCENE_CREDITS -> scene_win_2`
- `SCENE_WARNING -> scene_broken_2`
- `SCENE_QR_DETECTOR -> scene_camera_scan_2`
- `SCENE_LEFOU_DETECTOR`, `SCENE_POLICE_CHASE_ARCADE -> scene_search_2`
- `HOTLINE_STATUS` expose aussi:
- `route_lookup_key` (clé `scene|state|digit`),
- `route_resolution` (`explicit_table:*`, `heuristic_stems`, `dial_map`, etc.),
- `route_target` (fichier/tone effectivement routé).
## ESP-NOW (actuel)
- Identité device persistante: `HOTLINE_PHONE`
- Commandes dédiées:
- `ESPNOW_DEVICE_NAME_GET`
- `ESPNOW_DEVICE_NAME_SET <NAME>`
- Runtime auto-discovery peers:
- broadcast `ESPNOW_DEVICE_NAME_GET` toutes les 60s,
- auto-ajout des MAC qui répondent,
- télémétrie visible dans `STATUS.espnow.peer_discovery_*`.
## Démarrage rapide A252
1. Build:
```bash
pio run -e esp32dev
```
2. Flash:
```bash
pio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001
```
3. Validation minimale série (via terminal série 115200):
```text
PING
STATUS
ESPNOW_DEVICE_NAME_GET
DIAL_MEDIA_MAP_GET
HOTLINE_STATUS
FS_LIST
HOTLINE_SCENE_PLAY SCENE_READY
```
## Inventaire fichiers firmware (SD/LittleFS)
Commande générique paginée:
- `FS_LIST` (defaults: `source=SD`, `path=/`, `page=0`, récursif, dossiers+fichiers)
- `FS_LIST sd`
- `FS_LIST littlefs`
- `FS_LIST {"source":"sd","page":1}`
Exemple opérateur pour paginer:
```text
FS_LIST {"source":"sd","page":0,"page_size":50}
FS_LIST {"source":"sd","page":1,"page_size":50}
```
## Script contrôleur ESP-NOW (terrain)
Script: `scripts/espnow_hotline_control.py`
Exemples:
```bash
python3 scripts/espnow_hotline_control.py --port /dev/cu.usbserial-0001 --target broadcast+discovery --target-name HOTLINE_PHONE --action ring
python3 scripts/espnow_hotline_control.py --port /dev/cu.usbserial-0001 --action discover --target-name HOTLINE_PHONE --discover-rounds 3
python3 scripts/espnow_hotline_control.py --port /dev/cu.usbserial-0001 --target AA:BB:CC:DD:EE:FF --ensure-peer --action hotline1
```
## Monitoring hotline live
```bash
python3 scripts/hotline_live_monitor.py --port /dev/cu.usbserial-0001 --expect 1,2,3
```
## Gate de validation
- Contrats/tests Python:
```bash
python3 -m pytest -q scripts/test_hw_validation_contracts.py scripts/test_runtime_contracts.py
```
- Validation hardware A252:
```bash
python3 scripts/hw_validation.py \
--port-a252 /dev/cu.usbserial-0001 \
--no-require-hook-toggle \
--strict-serial-smoke \
--allow-capture-fail-when-disabled \
--audio-probe-path /welcome.wav \
--require-contract-version A252_AUDIO_CHAIN_V4
```
## Docs clés
- Contrat ESP-NOW: [docs/espnow_contract.md](docs/espnow_contract.md)
- API ESP-NOW: [docs/espnow_api_v1.md](docs/espnow_api_v1.md)
- Plan tonal/audio: [docs/audio_tone_plan.md](docs/audio_tone_plan.md)
- Gate qualité: [docs/branch_quality_gate.md](docs/branch_quality_gate.md)
- Orchestration dual-repo RTC/Zacus: [docs/CROSS_REPO_INTELLIGENCE.md](docs/CROSS_REPO_INTELLIGENCE.md)
@@ -1,251 +0,0 @@
default_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
default_temperature = 0.7
model_routes = []
embedding_routes = []
[observability]
backend = "none"
[autonomy]
level = "supervised"
workspace_only = true
allowed_commands = [
"git",
"npm",
"cargo",
"ls",
"cat",
"grep",
"find",
"echo",
"pwd",
"wc",
"head",
"tail",
]
forbidden_paths = [
"/etc",
"/root",
"/home",
"/usr",
"/bin",
"/sbin",
"/lib",
"/opt",
"/boot",
"/dev",
"/proc",
"/sys",
"/var",
"/tmp",
"~/.ssh",
"~/.gnupg",
"~/.aws",
"~/.config",
]
max_actions_per_hour = 20
max_cost_per_day_cents = 500
require_approval_for_medium_risk = true
block_high_risk_commands = true
auto_approve = [
"file_read",
"memory_recall",
]
always_ask = []
[runtime]
kind = "native"
[runtime.docker]
image = "alpine:3.20"
network = "none"
memory_limit_mb = 512
cpu_limit = 1.0
read_only_rootfs = true
mount_workspace = true
allowed_workspace_roots = []
[reliability]
provider_retries = 2
provider_backoff_ms = 500
fallback_providers = []
api_keys = []
channel_initial_backoff_secs = 2
channel_max_backoff_secs = 60
scheduler_poll_secs = 15
scheduler_retries = 2
[reliability.model_fallbacks]
[scheduler]
enabled = true
max_tasks = 64
max_concurrent = 4
[agent]
compact_context = false
max_tool_iterations = 10
max_history_messages = 50
parallel_tools = false
tool_dispatcher = "auto"
[query_classification]
enabled = false
rules = []
[heartbeat]
enabled = false
interval_minutes = 30
[cron]
enabled = true
max_run_history = 50
[channels_config]
cli = true
message_timeout_secs = 300
[memory]
backend = "sqlite"
auto_save = true
hygiene_enabled = true
archive_after_days = 7
purge_after_days = 30
conversation_retention_days = 30
embedding_provider = "none"
embedding_model = "text-embedding-3-small"
embedding_dimensions = 1536
vector_weight = 0.7
keyword_weight = 0.3
min_relevance_score = 0.4
embedding_cache_size = 10000
chunk_max_tokens = 512
response_cache_enabled = false
response_cache_ttl_minutes = 60
response_cache_max_entries = 5000
snapshot_enabled = false
snapshot_on_hygiene = false
auto_hydrate = true
[storage.provider.config]
provider = ""
schema = "public"
table = "memories"
[tunnel]
provider = "none"
[gateway]
port = 3000
host = "127.0.0.1"
require_pairing = true
allow_public_bind = false
paired_tokens = []
pair_rate_limit_per_minute = 10
webhook_rate_limit_per_minute = 60
trust_forwarded_headers = false
rate_limit_max_keys = 10000
idempotency_ttl_secs = 300
idempotency_max_keys = 10000
[composio]
enabled = false
entity_id = "default"
[secrets]
encrypt = true
[browser]
enabled = false
allowed_domains = []
backend = "agent_browser"
native_headless = true
native_webdriver_url = "http://127.0.0.1:9515"
[browser.computer_use]
endpoint = "http://127.0.0.1:8787/v1/actions"
timeout_ms = 15000
allow_remote_endpoint = false
window_allowlist = []
[http_request]
enabled = false
allowed_domains = []
max_response_size = 0
timeout_secs = 0
[multimodal]
max_images = 4
max_image_size_mb = 5
allow_remote_fetch = false
[web_search]
enabled = true
provider = "duckduckgo"
max_results = 5
timeout_secs = 15
[proxy]
enabled = false
no_proxy = []
scope = "zeroclaw"
services = []
[identity]
format = "openclaw"
[cost]
enabled = false
daily_limit_usd = 10.0
monthly_limit_usd = 100.0
warn_at_percent = 80
allow_override = false
[cost.prices."anthropic/claude-sonnet-4-20250514"]
input = 3.0
output = 15.0
[cost.prices."openai/gpt-4o-mini"]
input = 0.15
output = 0.6
[cost.prices."google/gemini-2.0-flash"]
input = 0.1
output = 0.4
[cost.prices."google/gemini-1.5-pro"]
input = 1.25
output = 5.0
[cost.prices."anthropic/claude-opus-4-20250514"]
input = 15.0
output = 75.0
[cost.prices."anthropic/claude-3-haiku"]
input = 0.25
output = 1.25
[cost.prices."openai/gpt-4o"]
input = 5.0
output = 15.0
[cost.prices."anthropic/claude-3.5-sonnet"]
input = 3.0
output = 15.0
[cost.prices."openai/o1-preview"]
input = 15.0
output = 60.0
[peripherals]
enabled = false
boards = []
[agents]
[hardware]
enabled = false
transport = "None"
baud_rate = 115200
workspace_datasheets = false
@@ -1,789 +0,0 @@
{
"entries": {
"SCENE_CAM/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "17889b739fb2175062f7e9406980446f46c5d89b1b836d15da6efda92d07482c",
"size": 32688,
"source": "scene:SCENE_CAM",
"updated_at": "2026-02-25T08:08:25.042383+00:00",
"voice": "Jacques"
},
"SCENE_CAM/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "4319de331d582f67357409fc8c399a624b37f1b584929b62ccdcddc11440a3e6",
"size": 42086,
"source": "scene:SCENE_CAM",
"updated_at": "2026-02-25T08:08:27.889620+00:00",
"voice": "Thomas"
},
"SCENE_CAM/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "684164ede9538b93d18e18f6d1fcb92f033af23a62f082b067c14fb56f9c5637",
"size": 30212,
"source": "scene:SCENE_CAM",
"updated_at": "2026-02-25T08:08:30.307440+00:00",
"voice": "Amélie"
},
"SCENE_FOU_DETECTOR/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "2b68a125aed416ab68451f9db16c1f4ff05999ce40d09f8bc98be5a40185208e",
"size": 43530,
"source": "scene:SCENE_FOU_DETECTOR",
"updated_at": "2026-02-25T08:08:32.834083+00:00",
"voice": "Rocko"
},
"SCENE_FOU_DETECTOR/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "f43287ed73f331b18a5875bc09d52dbd040a0d47050d199c415072a53e00a627",
"size": 44842,
"source": "scene:SCENE_FOU_DETECTOR",
"updated_at": "2026-02-25T08:08:35.938537+00:00",
"voice": "Thomas"
},
"SCENE_FOU_DETECTOR/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "bc5169abb648552d388e3d08ffa4780bf5eda786db8aa5b572d88216ec9afa38",
"size": 46938,
"source": "scene:SCENE_FOU_DETECTOR",
"updated_at": "2026-02-25T08:08:38.323000+00:00",
"voice": "Reed"
},
"SCENE_LA_DETECTOR/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "e3e0753797d3e1d954e7f189b79f282d295f51a1b95eaeed2defcad502c06687",
"size": 44622,
"source": "scene:SCENE_LA_DETECTOR",
"updated_at": "2026-02-25T08:08:40.454230+00:00",
"voice": "Eddy"
},
"SCENE_LA_DETECTOR/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "cc1e6b085a41d68697e247ddc70655b5132d8063c527df8ac842e8c807f57771",
"size": 57214,
"source": "scene:SCENE_LA_DETECTOR",
"updated_at": "2026-02-25T08:08:42.583865+00:00",
"voice": "Grandma"
},
"SCENE_LA_DETECTOR/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "3544f4f6b99c7ebd2471aa7736625a8498c411d2cb4e00bf45cda2656767cfd2",
"size": 46442,
"source": "scene:SCENE_LA_DETECTOR",
"updated_at": "2026-02-25T08:08:45.218103+00:00",
"voice": "Rocko"
},
"SCENE_LA_OK/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "04dd20619a579edf509bdc3088f2f754244085a220b14fec02adcd151208b74a",
"size": 54628,
"source": "scene:SCENE_LA_OK",
"updated_at": "2026-02-25T08:08:48.043315+00:00",
"voice": "Sandy"
},
"SCENE_LA_OK/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "b03446fd2e9a8c8627339df73e5c8618cd34eeb288a8556d8e53714fe175aab7",
"size": 64476,
"source": "scene:SCENE_LA_OK",
"updated_at": "2026-02-25T08:08:50.643608+00:00",
"voice": "Flo"
},
"SCENE_LA_OK/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "b557978f145f1c9e3f5c6cd47dc1ef8490b5d77ef28882e99f66dd832286c089",
"size": 38802,
"source": "scene:SCENE_LA_OK",
"updated_at": "2026-02-25T08:08:53.041805+00:00",
"voice": "Rocko"
},
"SCENE_LOCKED/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "46fed51e3e58c7ff8b62e875d946ca1c00719b29b69f2ecc22beec9c6b11d555",
"size": 28538,
"source": "scene:SCENE_LOCKED",
"updated_at": "2026-02-25T08:08:57.350077+00:00",
"voice": "Thomas"
},
"SCENE_LOCKED/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "7a286869cf11800f25943183431f9b414f31129316121970e87ad4b222915085",
"size": 33496,
"source": "scene:SCENE_LOCKED",
"updated_at": "2026-02-25T08:09:00.093956+00:00",
"voice": "Rocko"
},
"SCENE_LOCKED/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "0da69494253a9205885293fd7fa38c3de6ac429d1c384772cb8da8dda613c15d",
"size": 37416,
"source": "scene:SCENE_LOCKED",
"updated_at": "2026-02-25T08:09:02.448840+00:00",
"voice": "Sandy"
},
"SCENE_WINNER/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "fcef06530c3b0847c766d48488b56aaa837043773603c55919d56dffd631b84a",
"size": 30292,
"source": "scene:SCENE_WINNER",
"updated_at": "2026-02-25T08:09:04.265298+00:00",
"voice": "Jacques"
},
"SCENE_WINNER/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "e27e95e3ef55f4d8c0ec87e5f33ba14d44a038bcafa2d2f9946b4efaf1268ee6",
"size": 18922,
"source": "scene:SCENE_WINNER",
"updated_at": "2026-02-25T08:09:07.425501+00:00",
"voice": "Thomas"
},
"SCENE_WINNER/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "bf3b229cb926587cdf0924f8d58b90dbd6f81f64d01b32e159ef7a9d5eba071b",
"size": 47248,
"source": "scene:SCENE_WINNER",
"updated_at": "2026-02-25T08:09:11.276898+00:00",
"voice": "Sandy"
},
"SCENE_WIN_ETAPE/1.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "947b3303754b54dfc77a820bc1db7a624adb98944f4624cf9c19009b6df3832b",
"size": 28838,
"source": "scene:SCENE_WIN_ETAPE",
"updated_at": "2026-02-25T08:09:14.514869+00:00",
"voice": "Thomas"
},
"SCENE_WIN_ETAPE/2.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "e628e42a4749bea349c379badc30d7ccfa82db81e80524e344afa25e673d2363",
"size": 28494,
"source": "scene:SCENE_WIN_ETAPE",
"updated_at": "2026-02-25T08:09:17.147902+00:00",
"voice": "Amélie"
},
"SCENE_WIN_ETAPE/3.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "b77e3a200af55299a9de992f208e2ac4f384ccfe9331e3fd7db77f9a0f93abe4",
"size": 48252,
"source": "scene:SCENE_WIN_ETAPE",
"updated_at": "2026-02-25T08:09:19.288323+00:00",
"voice": "Reed"
},
"bip.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "e8daaa1b90200076120ae0b72c676ec8cfdecb0e952e3ad059aca4a01da6b439",
"size": 11472,
"source": "global",
"updated_at": "2026-02-25T08:09:23.037481+00:00",
"voice": "Thomas"
},
"la_busy.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "d58b6569e4d525cfbf23d041fc724b169f8dc0feff9e5387496b4bac2a24cd88",
"size": 34966,
"source": "global",
"updated_at": "2026-02-25T08:09:25.092267+00:00",
"voice": "Flo"
},
"la_ok.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "f3e4b4afc2273c40289262a6d92340a30a436546cccff5e6bd926eb462461b18",
"size": 48504,
"source": "global",
"updated_at": "2026-02-25T08:09:27.919758+00:00",
"voice": "Amélie"
},
"musique.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16.0,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46.0,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46.0
},
"profile": "aggressive",
"rate": 210,
"sample_rate": 22050,
"signature": "4a6180498061b5a5e1e77bae7e07f3970f54d68fd077c01554a90c8c9e446fc1",
"size": 37782,
"source": "global",
"updated_at": "2026-02-25T08:09:30.490264+00:00",
"voice": "Reed"
},
"note.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "30a3ea7ed6816aa2cf81edfcc79d5470c0707f8f9b54e1901255b822a9ff6462",
"size": 30918,
"source": "global",
"updated_at": "2026-02-25T08:09:32.518549+00:00",
"voice": "Jacques"
},
"radio.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "6873e8b330895c367ac4150f2deaae2af0c5b479665e5a260cf9c3a61b53101a",
"size": 36404,
"source": "global",
"updated_at": "2026-02-25T08:09:34.669480+00:00",
"voice": "Rocko"
},
"souffle.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "7367f48d31275e2d5b94e72230064f4ac96c853494f1cf52dcf125ff00d30a87",
"size": 36972,
"source": "global",
"updated_at": "2026-02-25T08:09:36.683498+00:00",
"voice": "Sandy"
},
"welcome.wav": {
"channels": 1,
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_filter": null,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10.0,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19.0,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52.0,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52.0
},
"profile": "gentle",
"rate": 170,
"sample_rate": 16000,
"signature": "a9ca7f7e7002bd6a6765fa1b564e263bff4bb898b0287af64718c423dec3494f",
"size": 23474,
"source": "global",
"updated_at": "2026-02-25T08:09:39.730703+00:00",
"voice": "Thomas"
}
},
"generated_at": "2026-02-25T08:09:39.730813+00:00",
"schema": 2
}
@@ -1,130 +0,0 @@
# Arborescence audio (USB-MSC / FFAT)
Arborescence de base :
- `/welcome.wav`
- `/la_ok.wav`
- `/la_busy.wav`
- `/bip.wav`
- `/souffle.wav`
- `/radio.wav`
- `/musique.wav`
- `/note.wav`
Structure par scène (selon `SCENE_ID`) :
- `SCENE_LOCKED/1.wav`
- `SCENE_LOCKED/2.wav`
- `SCENE_LOCKED/3.wav`
- `SCENE_LA_DETECTOR/1.wav`
- `SCENE_LA_DETECTOR/2.wav`
- `SCENE_LA_DETECTOR/3.wav`
- `SCENE_WIN_ETAPE/1.wav`
- `SCENE_WIN_ETAPE/2.wav`
- `SCENE_WIN_ETAPE/3.wav`
- `SCENE_LA_OK/1.wav`
- `SCENE_LA_OK/2.wav`
- `SCENE_LA_OK/3.wav`
- `SCENE_WINNER/1.wav`
- `SCENE_WINNER/2.wav`
- `SCENE_WINNER/3.wav`
- `SCENE_FOU_DETECTOR/1.wav`
- `SCENE_FOU_DETECTOR/2.wav`
- `SCENE_FOU_DETECTOR/3.wav`
- `SCENE_CAM/1.wav`
- `SCENE_CAM/2.wav`
- `SCENE_CAM/3.wav`
- Tonalités planifiées (A252 contractuelles):
- `assets/wav/ETSI_EU/<event>.wav`
- `assets/wav/FR_FR/<event>.wav`
- `assets/wav/UK_GB/<event>.wav`
- `assets/wav/NA_US/<event>.wav`
## Profils qualité (`profiles`)
- `gentle` : voix plus posée, tempo plus bas, normalisation douce.
- `aggressive` : voix plus rapide/fermée, normalisation plus poussée.
Vous pouvez forcer un profil global avec `--profile <nom>` ou par entrée audio via :
```json
{
"text": "Texte...",
"profile": "aggressive"
}
```
Ajouter d'autres dossiers de scènes au même niveau (`/<SCENE_ID>/...`) avec :
- `1.wav`
- `2.wav`
- `3.wav`
### Génération depuis macOS
Depuis la racine du projet :
- Générer tous les WAV depuis `scripts/audio_tts_prompts.json` :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --overwrite`
- Vérifier la conformité locale (fichiers présents + manifest) :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --verify`
- Prévisualiser la liste à générer avant écriture :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --dry-run`
- Lister les voix macOS disponibles (par défaut en FR) :
- `python3 scripts/generate_audio_from_tts.py --list-voices`
- `python3 scripts/generate_audio_from_tts.py --list-voices --allow-non-french` *(toutes voix)*
- Génération batch (ne réécrit pas les fichiers déjà à jour) :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --batch`
## Plan tonal A252 (contrat)
- Les mappings tonals sont référencés dans [docs/audio_tone_plan.md](../../docs/audio_tone_plan.md).
- Chaque fichier suit le contrat commun WAV: PCM16 mono 8 kHz (Little Endian).
- Exemples de test:
- `PLAY /assets/wav/ETSI_EU/dial.wav`
- `PLAY /assets/wav/FR_FR/ringback.wav`
- `PLAY /assets/wav/NA_US/busy.wav`
- Génération rapide sans post-traitement FFmpeg (sinon trim + normalisation RMS auto) :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --batch --skip-post`
- Génération en stéréo (2 canaux / voie 2) :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --channels 2 --batch`
- Filtrer ou choisir une voix :
- `python3 scripts/generate_audio_from_tts.py --list-voices --voice-filter Thomas`
- `python3 scripts/generate_audio_from_tts.py --voice-filter Thomas --voice-from-filter --batch`
- Ajuster le post-traitement :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --batch --normalize-mode loudnorm --normalize-target-rms-db -18 --trim-start-threshold-db -42`
- Vérification stricte de FFmpeg (pré-requis si post-processing activé) :
- `python3 scripts/generate_audio_from_tts.py --audio-root data/audio --prompts scripts/audio_tts_prompts.json --batch --validate-ffmpeg`
Options disponibles (extra) :
- `--channels {1,2}`
- `--french-only` (par défaut actif)
- `--allow-non-french`
- `--voice`, `--voice-filter`, `--voice-filter-regex`, `--voice-from-filter`
- `--post-enable` / `--post-disable`
- `--trim`, `--no-trim`
- `--normalize`, `--no-normalize`
- `--normalize-mode {dynaudnorm,loudnorm}`
- `--normalize-target-rms-db`, `--normalize-peak`, `--normalize-framelen-ms`, `--normalize-max-gain`, `--normalize-compress`
- `--normalize-filter`
- `--trim-start-threshold-db`, `--trim-start-duration`, `--trim-stop-threshold-db`, `--trim-stop-duration`
@@ -1,26 +0,0 @@
# Gate critique — Blocage WiFiServer.h
## Description
- Blocage du build PlatformIO sur ESP32: fatal error WiFiServer.h (WebServer)
- Origine: WebServer inclus par le framework Arduino ESP32, non compatible ou absent
- Aucun code source du projet ne dépend de WebServer, mais la bibliothèque est installée par défaut
## Actions tentées
- Retrait des dépendances tierces (audio-tools)
- Mise à jour du framework espressif32 et des bibliothèques
- Audit des dépendances installées
## Recommandation experte
- Utiliser exclusivement ESPAsyncWebServer pour tous les endpoints HTTP
- Ne pas inclure WebServer ni WiFiServer.h dans le code source
- Documenter ce gate comme critique dans la CI et la synthèse de phase
- Proposer une stratégie de contournement: tests unitaires sur les modules non dépendants du serveur HTTP
## Stratégie CI
- Valider les tests unitaires sur les modules audio, SLIC, téléphone, RTOS
- Reporter le blocage serveur HTTP dans docs/AGENT_TODO.md et docs/RC_AUTOFIX_CICD.md
- Suivre l’évolution du framework Arduino ESP32 pour correction future
---
**Version :** 2026-02-17
@@ -1,21 +0,0 @@
# Fiche technique AudioManager
## Interface
- Méthodes principales : init(), play(), stop(), setVolume(), getStatus()
- Gestion des flux audio (lecture, enregistrement)
## Flux de données
- Entrée : fichiers audio, flux PCM
- Sortie : DAC, I2S, logs
## Scénarios dutilisation
- Lecture de fichier audio
- Contrôle du volume
- Gestion des erreurs
## Exemple dintégration
```cpp
AudioManager audio;
audio.init();
audio.play("test.wav");
```
@@ -1,21 +0,0 @@
# Fiche technique BluetoothManager
## Interface
- Méthodes principales : init(), connect(), disconnect(), send(), receive(), getStatus()
- Gestion HFP, BLE, sécurité
## Flux de données
- Entrée : commandes Bluetooth, données
- Sortie : logs, états, notifications
## Scénarios dutilisation
- Connexion HFP
- Transmission BLE
- Sécurité des échanges
## Exemple dintégration
```cpp
BluetoothManager bt;
bt.init();
bt.connect("device");
```
@@ -1,14 +0,0 @@
# Rapport validation HW (A252)
- Date UTC: 2026-02-25T12:22:55.632294+00:00
- Verdict global: PASS
| Scénario | État | Détails |
|---|---|---|
| serial_smoke | PASS | `{"ping": {"ok": true, "result": "PONG"}, "status": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "IDLE", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "audio_frames_requested": 0, "audio_frames_read": 0, "audio_drop_frames": 0, "audio_underrun_count": 0, "audio_last_latency_ms": 0, "audio_max_latency_ms": 0, "audio": {"full_duplex": true, "dial_tone_active": false, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "call": {"ok": true, "line": "OK CALL"}, "capture_start": {"ok": false, "line": "ERR CAPTURE_START"}, "capture_stop": {"ok": true, "line": "OK CAPTURE_STOP"}, "reset_metrics": {"ok": true, "line": "OK RESET_METRICS"}, "required_checks": ["PING", "STATUS", "CALL", "CAPTURE_STOP", "RESET_METRICS"], "failed_checks": [], "warnings": ["capture_start_failed_capture_disabled"]}` |
| serial_network_stack | PASS | `{"wifi_status_before": {"connected": false, "status": 6, "ssid": "", "ip": "", "rssi": 0, "channel": 0, "mode": "APSTA"}, "wifi_scan": [{"ssid": "Freenove-Setup", "rssi": -24, "chan": 1, "enc": 0}, {"ssid": "Les cils", "rssi": -52, "chan": 11, "enc": 4}, {"ssid": "CILS House", "rssi": -68, "chan": 7, "enc": 7}, {"ssid": "Les cils", "rssi": -71, "chan": 6, "enc": 3}, {"ssid": "Les cils", "rssi": -75, "chan": 1, "enc": 4}, {"ssid": "La-clic", "rssi": -80, "chan": 1, "enc": 3}, {"ssid": "venus-HQ203587TYP-031", "rssi": -91, "chan": 1, "enc": 3}], "wifi_connect": {"ok": true, "line": "OK WIFI_CONNECT"}, "wifi_status_after": {"connected": true, "status": 3, "ssid": "Les cils", "ip": "192.168.0.103", "rssi": -72, "channel": 1, "mode": "STA"}, "espnow_status": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}}` |
| http_endpoints | PASS | `{"base_url": "http://192.168.0.103", "status": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "IDLE", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "audio_frames_requested": 0, "audio_frames_read": 0, "audio_drop_frames": 0, "audio_underrun_count": 0, "audio_last_latency_ms": 0, "audio_max_latency_ms": 0, "audio": {"full_duplex": true, "dial_tone_active": false, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "wifi": {"connected": true, "status": 3, "ssid": "Les cils", "ip": "192.168.0.103", "rssi": -75, "channel": 1, "mode": "STA"}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "control_call": {"ok": true, "code": "CALL"}}` |
| manual_hook_transition | MANUAL_SKIP | `{"note": "validated manually"}` |
| manual_ring_behavior | MANUAL_SKIP | `{"note": "validated manually"}` |
| manual_audio_path | MANUAL_SKIP | `{"note": "validated manually"}` |
| manual_hfp_pairing | MANUAL_SKIP | `{"note": "validated manually"}` |
@@ -1,69 +0,0 @@
# Plan de délégation agents — RTC_BL_PHONE
## Objectif
Structurer le développement, laudit, la CI, les tests et la documentation par agents spécialisés pour chaque stack.
---
### 1. Agent Web
- Implémente endpoints HTTP, pages de monitoring/configuration.
- Rédige plan de développement, tests fonctionnels, audit sécurité.
- CI : vérification endpoints, logs, tests automatisés.
### 2. Agent RTOS
- Structure les tâches FreeRTOS, priorités, synchronisation.
- Rédige plan multitâche, tests unitaires, audit robustesse.
- CI : validation des tâches, stress tests.
### 3. Agent Energie
- Implémente la surveillance batterie, gestion sleep/wakeup.
- Rédige plan d’économie, tests hardware, audit fiabilité.
- CI : tests ADC, deep sleep, wakeup.
### 4. Agent Bluetooth
- Implémente HFP, BLE, pairing, streaming.
- Rédige plan de connectivité, tests pairing, audit compatibilité.
- CI : tests HFP, BLE, logs pairing.
### 5. Agent WiFi
- Implémente connexion réseau, OTA, logs.
- Rédige plan réseau, tests OTA, audit sécurité.
- CI : tests connexion, OTA, logs.
### 6. Agent Firmware
- Intègre toutes les stacks, assure cohérence.
- Rédige plan dintégration, tests globaux, audit firmware.
- CI : build, tests, validation hardware.
### 7. Agent Documentation
- Rédige, met à jour et garantit cohérence des docs, README, fiches agents.
- Plan de documentation, audit liens, tests de clarté.
---
## RC (Release Criteria)
- Chaque stack doit passer les tests unitaires, fonctionnels et CI.
- Documentation complète, plans et audits validés.
- Validation croisée par agents (Web ↔ RTOS ↔ Energie ↔ Bluetooth ↔ WiFi ↔ Firmware ↔ Documentation).
---
## Audit
- Audit sécurité, robustesse, performance pour chaque stack.
- Audit dintégration globale.
---
## CI (Intégration Continue)
- Build automatique, tests, logs, validation hardware.
- Rapport CI par agent.
---
## Tests
- Tests unitaires, fonctionnels, hardware, stress tests.
- Validation par agent dédié.
---
**Version :** 2026-02-17
@@ -1,30 +0,0 @@
# Plan multitâche RTOS — Agent RTOS
## Objectif
Définir larchitecture multitâche, la synchronisation et la robustesse du firmware.
---
### Architecture multitâche
- 5 tâches principales : audio, web, batterie, bluetooth, wifi
- Priorités définies selon criticité
- Stack size adapté à chaque tâche
### Synchronisation
- Queues pour communication (ex : audio ↔ batterie)
- Mutex pour accès partagé (logs, config)
- Sémaphores pour événements (wake, OTA)
### Robustesse
- Watchdog sur tâches critiques (audio, batterie)
- Tests de stress (charge, interruption)
- Gestion des erreurs (reboot, logs)
### Plan de validation
- Tests unitaires sur chaque tâche
- Tests de communication inter-tâches
- Tests de stress multitâche
---
**Version :** 2026-02-17
@@ -1,56 +0,0 @@
# Plan de tests — RTC_BL_PHONE (livraison finale)
## 1. Objectif
Valider la robustesse, la couverture fonctionnelle et la conformité de la livraison RTC_BL_PHONE: téléphonie, web, MQTT, ESP-NOW, DTMF, tests, CI/CD, documentation.
## 2. Environnements
- ESP32 DevKitC (esp32dev)
- ESP32-S3-DevKitC-1 (esp32-s3-devkitc-1)
## 3. Tests unitaires (PlatformIO/Unity)
- Lancer `pio test` sur chaque environnement
- Vérifier le passage de tous les tests (DTMF, props routing, AudioManager, SLIC, etc.)
- Générer le rapport de couverture (`scripts/gen_coverage.sh`)
## 4. Tests fonctionnels
- Appels téléphoniques (émission, réception, raccrochage, décrochage)
- Contrôle via web UI (initier appel, voir statut, contacts)
- Contrôle via MQTT (topics in/out, payload JSON)
- Contrôle via ESP-NOW (commande locale, broadcast)
- Détection DTMF logicielle (Goertzel)
- Routage audio (lecture MP3, volume, mute)
- Gestion WiFi (connexion, déconnexion, fallback)
## 5. Tests web/HTTP
- Endpoints `/`, `/status`, `/config`, `/logs` : code 200, format JSON
- Tests de charge (requêtes multiples)
- Tests daccès non autorisé
- Vérification sécurisation API: accès POST sans authentification (401/403 attendu), test CORS, automatisé par `scenario_api_security`
## 6. Robustesse & sécurité
- Scénarios de coupure WiFi, reboot, perte agent MQTT/ESP-NOW
- Validation fallback config SPIFFS/NVS
- Logs derreur et de récupération
### Couverture automatisée réseau (WiFi/BLE)
- Scan WiFi/BLE: détection des réseaux et périphériques à proximité
- Connexion WiFi/BLE: test explicite de connexion (SSID, mot de passe, nom BLE)
- Coupure/rétablissement WiFi: test de déconnexion/reconnexion automatique, validation de la reprise de service
- Coupure WiFi + fallback BLE: test de bascule automatique sur BLE si WiFi indisponible
## 7. Limitations connues
- Bluetooth Classic non supporté sur ESP32-S3 (fallback BLE)
- Warnings `DynamicJsonDocument` (non bloquant, documenté)
## 8. Documentation
- Vérifier la complétude des guides README, fiches agents, rapports CI, plans de test
## 9. Critères de succès
- Tous les tests passent sur les deux cibles
- Fonctionnalités principales validées (téléphonie, web, MQTT, ESP-NOW, DTMF)
- Documentation complète et à jour
- Aucun blocage critique non documenté
---
**Version :** 2026-02-18
@@ -1,57 +0,0 @@
# Protocole de test automatisé — Agent QA & Moniteur Série
## Objectif
Décrire la procédure de test automatisée et semi-automatisée pour RTC_BL_PHONE, en sappuyant sur un agent QA dédié et lutilisation du moniteur série pour la traçabilité et la validation.
---
## 1. Préparation
- Brancher ESP32 Audio Kit et ESP32-S3-DevKitC-1
- Flasher le firmware sur chaque carte
- Ouvrir le moniteur série à 115200 bauds
- Préparer le script de capture des logs série (ex: `screen`, `minicom`, ou script Python)
## 2. Agent QA — Rôle
- Supervise lexécution des tests hardware
- Déclenche les scénarios (commande série, web, MQTT, ESP-NOW)
- Observe et enregistre les logs série
- Valide les critères de succès pour chaque test
- Remplit le rapport de validation en temps réel
## 3. Protocole de test
### a. Initialisation
- Vérifier le boot, la détection hardware, laffichage du statut sur le moniteur série
- Noter toute erreur ou warning au démarrage
### b. Exécution séquentielle
Pour chaque test:
1. Déclencher laction (commande série, web, MQTT, ESP-NOW)
2. Observer la réponse sur le moniteur série
3. Vérifier la conformité du log (statut, événement, erreur)
4. Noter le résultat dans le rapport
### c. Exemples de commandes série
- `h` : aide
- `s` : statut runtime
- `p <mac>` : configurer la MAC
- `m <numero>` : émission dappel
- `a` : décrocher
- `e` : raccrocher
- `v <0..15>` : volume
### d. Capture automatique
- Utiliser un script pour enregistrer tous les logs série dans un fichier horodaté
- Marquer chaque début/fin de test dans le log (ex: `=== TEST AUDIO START ===`)
## 4. Validation et traçabilité
- Chaque test est validé si le log série confirme laction attendue sans erreur
- Les logs sont archivés avec le rapport de validation
- Toute anomalie est documentée immédiatement
## 5. Agent QA — Spécialisation
- Peut être un opérateur humain, un script Python, ou un outil dautomatisation (ex: PySerial)
- Doit pouvoir envoyer des commandes, lire et parser les logs, générer un rapport automatique
---
**Version :** 2026-02-18
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,24 +0,0 @@
# Répartition des rôles — RTC_BL_PHONE
Ce document propose une structure de rôles inspirée du kit Zacus, adaptée à lutilisation du téléphone RTC expérimental.
## Rôles principaux
- **Opérateur principal** : gère la prise dappel, le décrochage/raccrochage, et la navigation dans les commandes série.
- **Chronométreur** : surveille la durée des appels/tests, annonce les transitions (ex : passage à lappel suivant).
- **Analyste technique** : supervise la connexion Bluetooth, vérifie l’état HFP, et diagnostique les problèmes matériels.
- **Archiviste** : note les résultats des tests, consigne les logs et les adresses MAC utilisées.
- **Témoin narrateur** : explique à voix haute chaque étape, relit les consignes de sécurité et dutilisation.
- **Gardien du combiné** : veille à la manipulation correcte du combiné RTC et à la sécurité du matériel.
## Rôles optionnels
- **Ambassadeur** : communique avec dautres équipes ou utilisateurs pour valider la réussite des tests.
- **Cartographe** : documente le câblage, les ports utilisés et la configuration matérielle.
- **Journaliste** : rédige un compte-rendu synthétique de la session.
## Utilisation
- Affecter un rôle à chaque participant lors des tests collaboratifs ou des démonstrations.
- Adapter la liste selon la taille de l’équipe et le contexte (atelier, démo, QA).
- Utiliser ce document comme support pour lassistance IA ou la génération de checklists personnalisées.
@@ -1,20 +0,0 @@
# Tests fonctionnels WebUI RTC_BL_PHONE
## Objectif
Valider laffichage, le rafraîchissement des données et linteraction du webUI embarqué.
## Scénario
- Accès à http://<ip_esp32>/
- Affichage du dashboard (état, batterie, audio, SLIC, Bluetooth, WiFi)
- Rafraîchissement via bouton (appel /api/status)
- Vérification du retour JSON et mise à jour dynamique
- Test erreur de connexion (serveur non disponible)
## Résultats attendus
- Affichage correct des données
- Rafraîchissement instantané
- Gestion des erreurs (message affiché)
---
_Agent WebUI Tests générés automatiquement._
@@ -1,6 +0,0 @@
version: "1.1.3"
description: "PicoTTS Text-to-Speech engine"
license: "Apache-2.0"
maintainers:
- Jade Mattsson <jmattsson@dius.com.au>
url: "https://github.com/DiUS/esp-picotts"
@@ -1,205 +0,0 @@
/*
* Copyright (C) 2008-2009 SVOX AG, Baslerstr. 30, 8048 Zuerich, Switzerland
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file picoacph.h
*
* Copyright (C) 2008-2009 SVOX AG, Baslerstr. 30, 8048 Zuerich, Switzerland
* All rights reserved.
*
* History:
* - 2009-04-20 -- initial version
*
*/
/**
* @addtogroup picoacph
*
itemtype, iteminfo1, iteminfo2, content -> TYPE(INFO1,INFO2)content
in the following
items input
===========
processed by sa (POS disambiguation):
- WORDGRAPH(POSes,NA)graph
- WORDINDEX(POSes,NA)POS|1ind1...POSN|indN
- CMD(PICODATA_ITEMINFO1_CMD_FLUSH,PICODATA_ITEMINFO2_NA)
processed by sa (Phrasing, Accentuation):
- PUNC(PUNCtype,PUNCsubtype)
unprocessed:
- all other item types are forwarded through the PU without modification:
CMD
minimal input size (before processing starts)
==================
processing (POS disambiguation, g2p, lexind, phrasing, accentuation)
is possible with
- one punctuation-phrase, consisting of a sequence (see below for
limits) of items terminated by a PUNC item.
(possible but not implemented: as long as the internal buffer is
empty, non-processed item types can be processed immediately)
Ensuring terminal PUNC item:
- when reading items from the external buffer a CMD(...FLUSH...) is
converted to a PUNC(...FLUSH...) item
- If needed, a PUNC(PHRASE) is artificially added to ensure a phrase
fits in the PUs memory and processing can start.
items processed and output
==========================
precondition:
CMD(...FLUSH...) already converted to PUNC(...FLUSH...) and trailing
PUNC item enforced if necessary.
----
-# PROCESS_POSD: processing input WORDGRAPH or WORDINDEX items, after
POS disambiguation (POSes -> POS), results in a sequence of:
-
- WORDGRAPH(POS,NA)graph
- WORDINDEX(POS,NA)POS|ind
-
.
-# PROCESS_WPHO: then, after lex-index lookup and G2P in a
sequence of:
- WORDPHON(POS,NA)phon
(phon containing primary and secondary word-level stress)
----
3. PROCESS_PHR: then, after processing these WORDPHON items,
together with the trailing PUNC item results in:
-> BOUND(BOUNDstrength,BOUNDtype)
being added in the sequence of WORDPHON (respectively inserted instead
of the PUNC). All PUNC, incl PUNC(...FLUSH...) now gone.
----
4. PROCESS_ACC: then, after processing the WORDPHON and BOUND items
results in:
-> WORDPHON(POS,ACC)phon
A postprocessing step of accentuation is hard-coded in the
accentuation module: In case the whole word does not have any stress
at all (primary or secondary or both) then do the following mapping:
ACC0 nostress -> ACC0
ACC1 nostress -> ACC3
ACC2 nostress -> ACC3
ACC3 nostress -> ACC3
----
- POS
a single, unambiguous POS
cf. picodata.h for
- ACC (sentence-level accent (aka prominence)) %d
- PICODATA_ACC0
- PICODATA_ACC1
- PICODATA_ACC2 (<- maybe mapped to ACC1, ie. no ACC2 in output)
- PICODATA_ACC3
- BOUNDstrength %d
- PICODATA_ITEMINFO1_BOUND_SBEG (at sentence start)
- PICODATA_ITEMINFO1_BOUND_SEND (at sentence end)
- PICODATA_ITEMINFO1_BOUND_TERM (replaces a flush)
- PICODATA_ITEMINFO1_BOUND_PHR1 (primary boundary)
- PICODATA_ITEMINFO1_BOUND_PHR2 (short break)
- PICODATA_ITEMINFO1_BOUND_PHR3 (secondary phrase boundary, no break)
- PICODATA_ITEMINFO1_BOUND_PHR0 (no break, not produced by sa, not existing
BOUND in item sequence equals PHR0 bound strength)
- BOUNDtype (created in sa base on punctuation, indicates type of phrase
following the boundary) %d
- PICODATA_ITEMINFO2_BOUNDTYPE_P
- PICODATA_ITEMINFO2_BOUNDTYPE_T
- PICODATA_ITEMINFO2_BOUNDTYPE_Q
- PICODATA_ITEMINFO2_BOUNDTYPE_E
output sequence (without CMDs):
<output> = { BOUND(BOUND_SBEG,PHRASEtype) <sentence> BOUND(BOUND_SEND,..)} BOUND(BOUND_TERM,..)
<sentence> = <phrase> { BOUND(BOUND_PHR1|2|3,BOUNDtype) <phrase> }
<phrase> = WORDPHON(POS,ACC)phon { WORDPHON(POS,ACC)phon }
Done in later PU: mapping ACC & word-level stress to syllable accent value
- ACC0 prim -> 0
- ACC1 prim -> 1
- ACC2 prim -> 2
- ACC3 prim -> 3
- ACC0 sec -> 0
- ACC1 sec -> 4
- ACC2 sec -> 4
- ACC3 sec -> 4
other limitations
=================
- item size: header plus len=256 (valid for Pico in general)
- see defines below for max nr of items. Item heads plus ref. to contents
buffer are stored in array with fixed size elements. Two restrictions:
- MAXNR_HEADX (max nr elements==items in headx array)
- CONTENTSSIZE (max size of all contents together
*/
#ifndef PICOACPH_H_
#define PICOACPH_H_
#include "picoos.h"
#include "picodata.h"
#include "picorsrc.h"
#ifdef __cplusplus
extern "C" {
#endif
#if 0
}
#endif
/* nr item restriction: maximum number of extended item heads in headx */
#define PICOACPH_MAXNR_HEADX 60
/* nr item restriction: maximum size of all item contents together in cont */
#define PICOACPH_MAXSIZE_CBUF 7680
picodata_ProcessingUnit picoacph_newAccPhrUnit(
picoos_MemoryManager mm,
picoos_Common common,
picodata_CharBuffer cbIn,
picodata_CharBuffer cbOut,
picorsrc_Voice voice);
#ifdef __cplusplus
}
#endif
#endif /*PICOACPH_H_*/
@@ -1,5 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x270000,
ffat, data, fat, 0x280000, 0x180000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x270000
5 ffat data fat 0x280000 0x180000
@@ -1,5 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
app0, app, ota_0, 0x10000, 0x640000,
spiffs, data, spiffs, 0x650000, 0x9A0000,
coredump, data, coredump,0xFF0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 app0 app ota_0 0x10000 0x640000
4 spiffs data spiffs 0x650000 0x9A0000
5 coredump data coredump 0xFF0000 0x10000
@@ -1,5 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
app0, app, ota_0, 0x10000, 0x640000,
usbmsc, data, fat, 0x650000, 0x9A0000,
coredump, data, coredump, 0xFF0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 app0 app ota_0 0x10000 0x640000
4 usbmsc data fat 0x650000 0x9A0000
5 coredump data coredump 0xFF0000 0x10000
@@ -1,141 +0,0 @@
; PlatformIO Project Configuration File
[platformio]
default_envs = esp32dev
build_dir = .pio/build
[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
; Host-specific serial ports are intentionally unset for portability.
test_build_src = yes
build_flags =
-DCORE_DEBUG_LEVEL=1
lib_deps =
bblanchon/ArduinoJson@^7.0.4
throwtheswitch/Unity@^2.6.1
ESP32Async/AsyncTCP@^3.3.2
ESP32Async/ESPAsyncWebServer@^3.6.0
https://github.com/pschatzmann/arduino-audio-tools.git
https://github.com/pschatzmann/arduino-libhelix.git
earlephilhower/ESP8266Audio@^1.9.7
https://github.com/bitluni/OsciDisplay.git
lib_ignore =
ESPAsyncTCP
RPAsyncTCP
[env:esp32dev]
board = esp32dev
board_build.partitions = partitions/esp32_a252_ffat_4MB.csv
extra_scripts = scripts/upload_ffat.py
build_flags =
${env.build_flags}
-DBOARD_PROFILE_A252
build_src_filter =
+<main.cpp>
+<audio/AudioEngine.cpp>
+<audio/ToneCatalog.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<web/WebServerManager.cpp>
[env:test]
platform = espressif32
board = esp32dev
framework = arduino
test_build_src = yes
lib_deps =
bblanchon/ArduinoJson@^7.0.4
ESP32Async/AsyncTCP@^3.3.2
ESP32Async/ESPAsyncWebServer@^3.6.0
throwtheswitch/Unity@^2.6.1
https://github.com/luisllamasbinaburo/Arduino-List.git#master
https://github.com/pschatzmann/arduino-audio-tools.git
earlephilhower/ESP8266Audio@^1.9.7
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
board_build.partitions = partitions/esp32s3_no_ota_spiffs_16MB.csv
build_flags =
${env.build_flags}
-DBOARD_PROFILE_ESP32_S3
build_src_filter =
+<main.cpp>
+<audio/AudioEngine.cpp>
+<audio/ToneCatalog.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<web/WebServerManager.cpp>
[env:esp32-s3-usb-host]
board = esp32s3usbotg
board_build.partitions = partitions/esp32s3_no_ota_spiffs_16MB.csv
build_flags =
${env.build_flags}
-DBOARD_PROFILE_ESP32_S3
-DUSB_HOST_BOOT_ENABLE
build_src_filter =
+<main.cpp>
+<audio/AudioEngine.cpp>
+<audio/ToneCatalog.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<web/WebServerManager.cpp>
[env:esp32-s3-usb-msc]
board = esp32-s3-devkitc-1
board_build.partitions = partitions/esp32s3_no_ota_spiffs_usb_msc_16MB.csv
extra_scripts = scripts/upload_usbmsc.py
build_unflags =
-DARDUINO_USB_MODE=1
build_flags =
${env.build_flags}
-DBOARD_PROFILE_ESP32_S3
-DARDUINO_USB_MODE=0
-DUSB_MSC_BOOT_ENABLE
build_src_filter =
+<main.cpp>
+<usb/UsbMassStorageRuntime.cpp>
+<audio/AudioEngine.cpp>
+<audio/ToneCatalog.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<web/WebServerManager.cpp>
@@ -1,181 +0,0 @@
#!/usr/bin/env bash
set -uo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
A252_PORT="${A252_PORT:-/dev/cu.usbserial-0001}"
ZEROCLAW_ORCH="${ZEROCLAW_ORCH:-http://127.0.0.1:8788}"
ZEROCLAW_BIN="${ZEROCLAW_BIN:-zeroclaw}"
A252_HOOK_OBSERVE_SECONDS="${A252_HOOK_OBSERVE_SECONDS:-45}"
A252_REQUIRE_HOOK_TOGGLE="${A252_REQUIRE_HOOK_TOGGLE:-1}"
A252_IGNORE_ZEROCLAW="${A252_IGNORE_ZEROCLAW:-1}"
HW_REPORT_JSON="artifacts/hw_validation_a252_report.json"
HW_REPORT_MD="docs/hw_validation_a252_report.md"
ZEROCLAW_REPORT_JSON="artifacts/zeroclaw_orchestrator_health.json"
SUMMARY_MD="docs/a252_strict_gate_summary.md"
STATUS_PREFLIGHT="NOT_RUN"
STATUS_ZEROCLAW="NOT_RUN"
STATUS_BRANCH_GATE="NOT_RUN"
STATUS_HW_VALIDATION="NOT_RUN"
OVERALL="FAIL"
log() {
echo "[a252-strict-gate] $*"
}
is_true() {
case "${1,,}" in
1|true|yes|on)
return 0
;;
*)
return 1
;;
esac
}
write_summary() {
STATUS_PREFLIGHT="${STATUS_PREFLIGHT}" \
STATUS_ZEROCLAW="${STATUS_ZEROCLAW}" \
STATUS_BRANCH_GATE="${STATUS_BRANCH_GATE}" \
STATUS_HW_VALIDATION="${STATUS_HW_VALIDATION}" \
OVERALL="${OVERALL}" \
ZEROCLAW_ORCH="${ZEROCLAW_ORCH}" \
A252_PORT="${A252_PORT}" \
HW_REPORT_JSON="${HW_REPORT_JSON}" \
ZEROCLAW_REPORT_JSON="${ZEROCLAW_REPORT_JSON}" \
SUMMARY_MD="${SUMMARY_MD}" \
python3 - <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
summary_path = Path(os.environ["SUMMARY_MD"])
summary_path.parent.mkdir(parents=True, exist_ok=True)
def load_json(path: Path):
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
hw_payload = load_json(Path(os.environ["HW_REPORT_JSON"]))
zc_payload = load_json(Path(os.environ["ZEROCLAW_REPORT_JSON"]))
lines = [
"# A252 Strict Gate Summary",
"",
f"- Date UTC: {datetime.now(timezone.utc).isoformat()}",
f"- Verdict global: {os.environ['OVERALL']}",
f"- Port A252: `{os.environ['A252_PORT']}`",
f"- ZeroClaw: `{os.environ['ZEROCLAW_ORCH']}`",
"",
"## Étapes",
"",
"| Étape | État |",
"|---|---|",
f"| zeroclaw_hw_preflight | {os.environ['STATUS_PREFLIGHT']} |",
f"| zeroclaw_orchestrator_health | {os.environ['STATUS_ZEROCLAW']} |",
f"| branch_gate_profile_a252 | {os.environ['STATUS_BRANCH_GATE']} |",
f"| hw_validation_a252 | {os.environ['STATUS_HW_VALIDATION']} |",
]
if hw_payload:
lines.extend([
"",
"## Stacks A252 (hw_validation)",
"",
"| Stack/Scénario | État |",
"|---|---|",
])
for item in hw_payload.get("results", []):
name = item.get("name", "")
state = item.get("state", "")
lines.append(f"| {name} | {state} |")
if zc_payload:
lines.extend([
"",
"## ZeroClaw Docker",
"",
"| Check | État |",
"|---|---|",
])
for item in zc_payload.get("results", []):
name = item.get("name", "")
state = item.get("state", "")
lines.append(f"| {name} | {state} |")
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
}
run_step() {
local step_var="$1"
local description="$2"
shift 2
log "${description}"
if "$@"; then
printf -v "${step_var}" "PASS"
return 0
fi
printf -v "${step_var}" "FAIL"
OVERALL="FAIL"
write_summary
return 1
}
HOOK_TOGGLE_FLAG="--no-require-hook-toggle"
if is_true "${A252_REQUIRE_HOOK_TOGGLE}"; then
HOOK_TOGGLE_FLAG="--require-hook-toggle"
fi
if [[ -z "${A252_WIFI_SSID:-}" || -z "${A252_WIFI_PASSWORD:-}" ]]; then
log "erreur: A252_WIFI_SSID et A252_WIFI_PASSWORD sont requis"
write_summary
exit 2
fi
if is_true "${A252_IGNORE_ZEROCLAW}"; then
STATUS_PREFLIGHT="MANUAL_SKIP"
STATUS_ZEROCLAW="MANUAL_SKIP"
log "ZeroClaw ignoré (A252_IGNORE_ZEROCLAW=${A252_IGNORE_ZEROCLAW})"
else
run_step STATUS_PREFLIGHT "ZeroClaw hardware preflight" \
python3 scripts/zeroclaw_hw_preflight.py \
--zeroclaw-bin "${ZEROCLAW_BIN}" \
--require-port \
--port "${A252_PORT}" || exit 1
run_step STATUS_ZEROCLAW "ZeroClaw orchestrator health + provider_scan" \
python3 scripts/zeroclaw_orchestrator_health.py \
--base-url "${ZEROCLAW_ORCH}" \
--report-json "${ZEROCLAW_REPORT_JSON}" || exit 1
fi
run_step STATUS_BRANCH_GATE "Branch gate profile a252" \
bash scripts/branch_gate.sh --profile a252 || exit 1
run_step STATUS_HW_VALIDATION "Hardware validation A252 strict" \
pio run -e esp32dev -t upload_ffat --upload-port "${A252_PORT}" && \
python3 scripts/hw_validation.py \
--port-a252 "${A252_PORT}" \
--flash \
--wifi-ssid "${A252_WIFI_SSID}" \
--wifi-password "${A252_WIFI_PASSWORD}" \
--strict-serial-smoke \
--allow-capture-fail-when-disabled \
"${HOOK_TOGGLE_FLAG}" \
--hook-observe-seconds "${A252_HOOK_OBSERVE_SECONDS}" \
--report-json "${HW_REPORT_JSON}" \
--report-md "${HW_REPORT_MD}" || exit 1
OVERALL="PASS"
write_summary
log "gate A252 strict terminé avec succès"
@@ -1,218 +0,0 @@
{
"SCENE_CAM": {
"1.wav": {
"profile": "gentle",
"text": "Caméra activée.",
"voice": "Jacques"
},
"2.wav": {
"profile": "aggressive",
"text": "Caméra en cours d'analyse.",
"voice": "Thomas"
},
"3.wav": {
"profile": "gentle",
"text": "Caméra terminée.",
"voice": "Amélie"
}
},
"SCENE_FOU_DETECTOR": {
"1.wav": {
"profile": "aggressive",
"text": "Détecteur FOU actif.",
"voice": "Rocko"
},
"2.wav": {
"profile": "gentle",
"text": "Détecteur FOU étape deux.",
"voice": "Thomas"
},
"3.wav": {
"profile": "aggressive",
"text": "Détecteur FOU terminé.",
"voice": "Reed"
}
},
"SCENE_LA_DETECTOR": {
"1.wav": {
"profile": "aggressive",
"text": "Détecteur LA actif.",
"voice": "Eddy"
},
"2.wav": {
"profile": "gentle",
"text": "Détecteur LA étape deux.",
"voice": "Grandma"
},
"3.wav": {
"profile": "aggressive",
"text": "Détecteur LA terminé.",
"voice": "Rocko"
}
},
"SCENE_LA_OK": {
"1.wav": {
"profile": "gentle",
"text": "La détection est validée.",
"voice": "Sandy"
},
"2.wav": {
"profile": "gentle",
"text": "Étape LA OK suivante.",
"voice": "Flo"
},
"3.wav": {
"profile": "aggressive",
"text": "Succès LA OK.",
"voice": "Rocko"
}
},
"SCENE_LOCKED": {
"1.wav": {
"profile": "gentle",
"text": "Scène verrouillée. Étape 1.",
"voice": "Thomas"
},
"2.wav": {
"profile": "aggressive",
"text": "Scène verrouillée. Étape 2.",
"voice": "Rocko"
},
"3.wav": {
"profile": "gentle",
"text": "Scène verrouillée. Étape 3.",
"voice": "Sandy"
}
},
"SCENE_WINNER": {
"1.wav": {
"profile": "aggressive",
"text": "Félicitations, vous gagnez.",
"voice": "Jacques"
},
"2.wav": {
"profile": "gentle",
"text": "Bravo. Prochaine étape.",
"voice": "Thomas"
},
"3.wav": {
"profile": "gentle",
"text": "Fin de la séquence.",
"voice": "Sandy"
}
},
"SCENE_WIN_ETAPE": {
"1.wav": {
"profile": "aggressive",
"text": "Scène WIN étape 1.",
"voice": "Thomas"
},
"2.wav": {
"profile": "aggressive",
"text": "Scène WIN étape 2.",
"voice": "Amélie"
},
"3.wav": {
"profile": "gentle",
"text": "Scène WIN étape 3.",
"voice": "Reed"
}
},
"global": {
"bip.wav": {
"profile": "aggressive",
"text": "Bip.",
"voice": "Thomas"
},
"la_busy.wav": {
"text": "Ligne occupée. Veuillez réessayer.",
"voice": "Flo"
},
"la_ok.wav": {
"text": "Message d'acquittement détecté.",
"voice": "Amélie"
},
"musique.wav": {
"profile": "aggressive",
"text": "Musique d'attente.",
"voice": "Reed"
},
"note.wav": {
"profile": "gentle",
"text": "Note de service.",
"voice": "Jacques"
},
"radio.wav": {
"profile": "gentle",
"text": "Signal radio.",
"voice": "Rocko"
},
"souffle.wav": {
"profile": "gentle",
"text": "Souffle léger.",
"voice": "Sandy"
},
"welcome.wav": {
"text": "Bienvenue. Fichier de test audio.",
"voice": "Thomas"
}
},
"meta": {
"default_channels": 1,
"default_profile": "gentle",
"default_rate": 180,
"default_sample_rate": 16000,
"default_voice": "Thomas",
"scenes": [
"SCENE_LOCKED",
"SCENE_LA_DETECTOR",
"SCENE_WIN_ETAPE",
"SCENE_LA_OK",
"SCENE_WINNER",
"SCENE_FOU_DETECTOR",
"SCENE_CAM"
]
},
"profiles": {
"aggressive": {
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46
},
"rate": 210,
"sample_rate": 22050,
"voice": "Thomas"
},
"gentle": {
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52
},
"rate": 170,
"sample_rate": 16000,
"voice": "Thomas"
}
}
}
@@ -1,96 +0,0 @@
#!/usr/bin/env python3
"""
Orchestrateur universel d'upload PlatformIO/Arduino/STM32/RP2040
- Mapping rôle → regex signature (VID:PID, description, serial, etc)
- Détection auto via pio device list --json-output
- Support RP2040 (UF2), STM32 (st-link), etc.
- Force release du port série (lsof/kill) avant upload
"""
import argparse, json, os, re, subprocess, sys, time
# --- Mapping rôle → regex signature ---
ROLE_MAP = {
"esp32_audio": r"10c4:ea60|SLAB_USBtoUART|CP210",
"esp8266_oled": r"1a86:7523|wchusbserial|CH340",
"s3": r"esp32s3|S3|usbmodem|USB JTAG",
"rp2040": r"RPI-RP2|RP2040|2e8a:"
# Ajoute d'autres rôles ici
}
# --- Utilitaires shell ---
def sh(*cmd):
return subprocess.check_output(cmd, text=True)
def pio_devices():
raw = sh("pio", "device", "list", "--json-output")
return json.loads(raw)
def pick_port(devs, pattern):
rx = re.compile(pattern, re.I)
for d in devs:
blob = f"{d.get('port','')} {d.get('description','')} {d.get('hwid','')} {d.get('serial_number','')}"
if rx.search(blob):
return d["port"]
return None
def force_release_port(port):
# Tuer tout process tenant le port ouvert (macOS/Linux)
try:
out = sh("lsof", port)
for line in out.splitlines()[1:]:
pid = int(line.split()[1])
print(f"[FORCE RELEASE] kill -9 {pid} sur {port}")
os.kill(pid, 9)
time.sleep(2)
except subprocess.CalledProcessError:
pass # Rien ne tient le port
except Exception as e:
print(f"[WARN] force_release_port: {e}")
def upload_pio(env, port, target="upload"):
force_release_port(port)
print(f"[UPLOAD] PlatformIO env={env} port={port}")
subprocess.check_call(["pio", "run", "-e", env, "-t", target, "--upload-port", port])
def upload_rp2040(build_dir=".pio/build/rp2040/firmware.uf2"):
# Cherche le disque UF2
for vol in os.listdir("/Volumes"):
if "RPI-RP2" in vol or "RP2040" in vol:
dest = f"/Volumes/{vol}/firmware.uf2"
print(f"[UPLOAD] Copie UF2 vers {dest}")
subprocess.check_call(["cp", build_dir, dest])
return
print("[ERROR] Aucun disque UF2 RP2040 détecté.")
sys.exit(3)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--role", required=True, help="Nom logique du device (clé du mapping)")
ap.add_argument("--env", help="Nom de l'env PlatformIO (si différent du rôle)")
ap.add_argument("--target", default="upload", help="Target PlatformIO (upload, program, etc)")
ap.add_argument("--build-dir", default=".pio/build/rp2040/firmware.uf2", help="Chemin UF2 pour RP2040")
args = ap.parse_args()
role = args.role
env = args.env or role
pattern = ROLE_MAP.get(role)
if not pattern:
print(f"[ERROR] Rôle inconnu : {role}")
sys.exit(1)
if role.startswith("rp2040"):
upload_rp2040(args.build_dir)
return
devs = pio_devices()
port = pick_port(devs, pattern)
if not port:
print("[ERROR] Aucun port ne matche. Devices vus:", file=sys.stderr)
for d in devs:
print(f"- {d.get('port')} | {d.get('description')} | {d.get('hwid')} | {d.get('serial_number')}", file=sys.stderr)
sys.exit(2)
upload_pio(env, port, args.target)
if __name__ == "__main__":
main()
@@ -1,167 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
DEFAULT_BUILD_ENVS_FULL=(
"esp32dev"
"esp32-s3-devkitc-1"
"esp32-s3-usb-host"
"esp32-s3-usb-msc"
)
DEFAULT_BUILD_ENVS_A252=(
"esp32dev"
)
REPORT_JSON="${ARTIFACT_REPORT_PATH:-artifacts/route_parity_report.json}"
BUILD_ENVS=()
SKIP_BUILDS=0
PROFILE="a252"
log() {
echo "[branch-gate] $*"
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "[branch-gate] erreur: commande manquante: $1" >&2
exit 1
fi
}
usage() {
cat <<'EOF'
Usage: scripts/branch_gate.sh [options]
Exécute la chaîne de validation de branche dans un ordre déterministe.
Options:
--profile <a252|full> Profil de build par défaut (a252: esp32dev seulement).
--skip-builds Ignore la phase de build PlatformIO.
--build-env <env> Ajouter explicitement un env PlatformIO (peut se répéter).
--build-envs <env1,env2> Ajouter plusieurs envs en une fois (séparés par des virgules).
--report-json <path> Emplacement du rapport parity JSON (défaut: artifacts/route_parity_report.json).
--help Affiche cette aide.
Sans --build-env ni --build-envs, la séquence build cible :
- profile a252: esp32dev
- profile full: esp32dev, esp32-s3-devkitc-1, esp32-s3-usb-host, esp32-s3-usb-msc
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--profile)
if [[ $# -lt 2 ]]; then
usage
exit 1
fi
case "$2" in
a252|full)
PROFILE="$2"
;;
*)
echo "[branch-gate] profile invalide: $2 (attendu: a252|full)" >&2
exit 1
;;
esac
shift 2
;;
--skip-builds)
SKIP_BUILDS=1
shift
;;
--build-env)
if [[ $# -lt 2 ]]; then
usage
exit 1
fi
BUILD_ENVS+=("$2")
shift 2
;;
--build-envs)
if [[ $# -lt 2 ]]; then
usage
exit 1
fi
IFS=',' read -r -a extra_envs <<< "$2"
for env_name in "${extra_envs[@]}"; do
if [[ -n "${env_name}" ]]; then
BUILD_ENVS+=("${env_name}")
fi
done
shift 2
;;
--report-json)
if [[ $# -lt 2 ]]; then
usage
exit 1
fi
REPORT_JSON="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "[branch-gate] option inconnue: $1" >&2
usage
exit 1
;;
esac
done
run_checks() {
log "vérification syntaxe scripts (python)"
python3 -m py_compile scripts/hw_validation.py
log "tests unitaires PlatformIO (esp32dev, mode host)"
platformio test --without-uploading --without-testing -e esp32dev
log "tests host DTMF"
mkdir -p .pio/host
c++ -std=c++17 -Wall -Wextra -pedantic -Isrc test/host/test_dtmf_host.cpp src/telephony/DtmfDecoder.cpp -o .pio/host/test_dtmf_host
.pio/host/test_dtmf_host
log "tests contrat parity/runtime/hw_validation"
python3 -m unittest \
scripts/test_check_web_route_parity.py \
scripts/test_runtime_contracts.py \
scripts/test_hw_validation_contracts.py
log "contrôle route/command parity avec rapport JSON"
mkdir -p "$(dirname "${REPORT_JSON}")"
python3 scripts/check_web_route_parity.py --report-json "${REPORT_JSON}"
}
run_builds() {
if (( SKIP_BUILDS )); then
log "phase build ignorée (--skip-builds)"
return
fi
if (( ${#BUILD_ENVS[@]} == 0 )); then
if [[ "${PROFILE}" == "full" ]]; then
BUILD_ENVS=("${DEFAULT_BUILD_ENVS_FULL[@]}")
else
BUILD_ENVS=("${DEFAULT_BUILD_ENVS_A252[@]}")
fi
fi
log "profil build actif: ${PROFILE}"
for env_name in "${BUILD_ENVS[@]}"; do
log "build PlatformIO: ${env_name}"
platformio run -e "${env_name}"
done
}
require_cmd python3
require_cmd platformio
require_cmd c++
run_checks
run_builds
log "validation de branche terminée avec succès"
@@ -1,373 +0,0 @@
#!/usr/bin/env python3
"""Check parity between frontend calls and backend routes/commands."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
Route = tuple[str, str]
@dataclass(frozen=True)
class RouteCommand:
route: Route
command: Optional[str]
dynamic: bool
BACKEND_ROUTE_RE = re.compile(
r'server_\.on\(\s*"(?P<path>/api/[^\"]+)"\s*,\s*HTTP_(?P<method>[A-Z]+)'
)
BACKEND_DISPATCH_RE = re.compile(
r'handleDispatch\(\s*request\s*,\s*"(?P<command>[A-Z0-9_]+)'
)
BACKEND_DISPATCH_DYNAMIC_RE = re.compile(
r'handleDispatch\(\s*request\s*,\s*[A-Za-z_][A-Za-z0-9_]*'
)
COMMAND_REG_RE = re.compile(r'registerCommand\(\s*"(?P<command>[A-Z0-9_]+)"')
FRONTEND_CALL_START_RE = re.compile(r"\brequestJson\(")
FRONTEND_PATH_ARG_RE = re.compile(r"^\s*([\"'])(?P<path>/api/[^\"']+)\1")
METHOD_RE = re.compile(r"method\s*:\s*[\"'](?P<method>[A-Z]+)[\"']")
def find_matching_delim(source: str, open_index: int, open_delim: str, close_delim: str) -> int:
if open_index < 0 or source[open_index] != open_delim:
return -1
depth = 1
in_string: str | None = None
in_single_line_comment = False
in_multi_line_comment = False
escaped = False
for index in range(open_index + 1, len(source)):
char = source[index]
next_char = source[index + 1] if index + 1 < len(source) else ""
if in_single_line_comment:
if char == "\n":
in_single_line_comment = False
continue
if in_multi_line_comment:
if char == "*" and next_char == "/":
in_multi_line_comment = False
continue
if in_string is not None:
if escaped:
escaped = False
continue
if char == "\\":
escaped = True
continue
if char == in_string:
in_string = None
continue
if char in ('"', "'"):
in_string = char
continue
if char == "/" and next_char == "/":
in_single_line_comment = True
continue
if char == "/" and next_char == "*":
in_multi_line_comment = True
continue
if char == open_delim:
depth += 1
continue
if char == close_delim:
depth -= 1
if depth == 0:
return index
return -1
def parse_backend_route_commands(source: str) -> dict[Route, RouteCommand]:
routes: dict[Route, RouteCommand] = {}
for match in BACKEND_ROUTE_RE.finditer(source):
route = (match.group("method"), match.group("path"))
if route in routes:
continue
callback_open = source.find("{", match.end())
if callback_open < 0:
routes[route] = RouteCommand(route=route, command=None, dynamic=False)
continue
callback_close = find_matching_delim(source, callback_open, "{", "}")
if callback_close < 0:
routes[route] = RouteCommand(route=route, command=None, dynamic=False)
continue
callback_block = source[callback_open:callback_close]
cmd_match = BACKEND_DISPATCH_RE.search(callback_block)
if cmd_match:
routes[route] = RouteCommand(
route=route,
command=cmd_match.group("command"),
dynamic=False,
)
continue
dynamic_match = BACKEND_DISPATCH_DYNAMIC_RE.search(callback_block)
routes[route] = RouteCommand(route=route, command=None, dynamic=bool(dynamic_match))
return routes
def parse_backend_routes(source: str) -> set[Route]:
return set(parse_backend_route_commands(source).keys())
def parse_frontend_routes(source: str) -> set[Route]:
routes: set[Route] = set()
for match in FRONTEND_CALL_START_RE.finditer(source):
open_paren = match.end() - 1
open_paren = source.find("(", match.start())
if open_paren < 0:
continue
close_paren = find_matching_delim(source, open_paren, "(", ")")
if close_paren < 0:
continue
args = source[open_paren + 1 : close_paren]
path_match = FRONTEND_PATH_ARG_RE.match(args)
if not path_match:
continue
path = path_match.group("path")
method_match = METHOD_RE.search(args)
method = method_match.group("method").upper() if method_match else "GET"
routes.add((method, path))
return routes
def parse_registered_commands(source: str) -> set[str]:
return {match.group("command") for match in COMMAND_REG_RE.finditer(source)}
def format_routes(routes: Iterable[Route]) -> str:
ordered = sorted(routes, key=lambda route: (route[1], route[0]))
return "\n".join(f" - {method} {path}" for method, path in ordered)
def routes_to_payload(routes: Iterable[Route]) -> list[dict[str, str]]:
ordered = sorted(routes, key=lambda route: (route[1], route[0]))
return [{"method": method, "path": path} for method, path in ordered]
def missing_commands_to_payload(missing: Iterable[RouteCommand]) -> list[dict[str, object]]:
ordered = sorted(missing, key=lambda rc: (rc.route[1], rc.route[0]))
payload = []
for item in ordered:
payload.append({
"method": item.route[0],
"path": item.route[1],
"command": item.command or "",
"dynamic": item.dynamic,
})
return payload
def build_report(
backend_routes: set[Route],
frontend_routes: set[Route],
missing_in_backend: set[Route],
unused_backend: set[Route],
missing_commands: set[RouteCommand],
strict_unused_backend: bool,
status: str,
) -> dict[str, object]:
dynamic_routes = sorted(
[rc for rc in missing_commands if rc.dynamic],
key=lambda rc: (rc.route[1], rc.route[0]),
)
missing_static_commands = [rc for rc in missing_commands if not rc.dynamic]
return {
"backend_count": len(backend_routes),
"frontend_count": len(frontend_routes),
"backend_routes": routes_to_payload(backend_routes),
"frontend_routes": routes_to_payload(frontend_routes),
"missing_in_backend": routes_to_payload(missing_in_backend),
"unused_backend": routes_to_payload(unused_backend),
"missing_mapped_commands": missing_commands_to_payload(set(missing_static_commands)),
"dynamic_routes": missing_commands_to_payload(set(dynamic_routes)),
"strict_unused_backend": strict_unused_backend,
"status": status,
}
def collect_missing_commands(
backend_command_map: dict[Route, RouteCommand],
registered_commands: set[str],
) -> set[RouteCommand]:
missing: set[RouteCommand] = set()
for item in backend_command_map.values():
if item.command is None:
continue
if item.command in registered_commands:
continue
missing.add(item)
return missing
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Validate that every WebUI API call is backed by a firmware HTTP route and command binding."
)
)
parser.add_argument(
"--backend",
default="src/web/WebServerManager.cpp",
help="Path to backend route source file.",
)
parser.add_argument(
"--frontend",
default="data/webui/script.js",
help="Path to frontend source file.",
)
parser.add_argument(
"--commands",
default="src/main.cpp",
help="Source file containing registerCommand() calls.",
)
parser.add_argument(
"--strict-unused-backend",
action="store_true",
help="Fail if backend API routes are not used by the WebUI.",
)
parser.add_argument(
"--report-json",
default="",
help="Optional path to write a JSON parity report.",
)
args = parser.parse_args()
backend_path = Path(args.backend)
frontend_path = Path(args.frontend)
command_path = Path(args.commands)
backend_source = load_text(backend_path)
frontend_source = load_text(frontend_path)
command_source = load_text(command_path)
backend_route_commands = parse_backend_route_commands(backend_source)
backend_routes = set(backend_route_commands.keys())
frontend_routes = parse_frontend_routes(frontend_source)
registered_commands = parse_registered_commands(command_source)
missing_in_backend = frontend_routes - backend_routes
unused_backend = backend_routes - frontend_routes
missing_commands = collect_missing_commands(backend_route_commands, registered_commands)
if not backend_routes:
print("[route-parity] no backend /api routes detected", file=sys.stderr)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend=missing_in_backend,
unused_backend=unused_backend,
missing_commands=missing_commands,
strict_unused_backend=args.strict_unused_backend,
status="fail",
)
write_report_json(Path(args.report_json), report)
return 2
if not frontend_routes:
print("[route-parity] no frontend /api requestJson() calls detected", file=sys.stderr)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend=missing_in_backend,
unused_backend=unused_backend,
missing_commands=missing_commands,
strict_unused_backend=args.strict_unused_backend,
status="fail",
)
write_report_json(Path(args.report_json), report)
return 2
if missing_in_backend:
print("[route-parity] missing backend routes for frontend calls:", file=sys.stderr)
print(format_routes(missing_in_backend), file=sys.stderr)
if missing_commands:
print("[route-parity] backend routes mapped to unregistered commands:", file=sys.stderr)
for item in sorted(missing_commands, key=lambda rc: (rc.route[1], rc.route[0])):
print(f" - {item.route[0]} {item.route[1]} -> {item.command}", file=sys.stderr)
if args.strict_unused_backend and unused_backend:
print("[route-parity] backend routes currently unused by WebUI:", file=sys.stderr)
print(format_routes(unused_backend), file=sys.stderr)
print(
f"[route-parity] backend routes: {len(backend_routes)} | frontend routes: {len(frontend_routes)}"
)
if missing_in_backend or (args.strict_unused_backend and unused_backend) or missing_commands:
status = "fail"
if args.report_json:
write_report_json(
Path(args.report_json),
build_report(
backend_routes,
frontend_routes,
missing_in_backend=missing_in_backend,
unused_backend=unused_backend,
missing_commands=missing_commands,
strict_unused_backend=args.strict_unused_backend,
status=status,
),
)
return 1
if args.report_json:
write_report_json(
Path(args.report_json),
build_report(
backend_routes,
frontend_routes,
missing_in_backend=missing_in_backend,
unused_backend=unused_backend,
missing_commands=missing_commands,
strict_unused_backend=args.strict_unused_backend,
status="pass",
),
)
print("[route-parity] parity check passed")
return 0
def load_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
print(f"[route-parity] missing file: {path}", file=sys.stderr)
raise
def write_report_json(path: Path, report: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
if __name__ == "__main__":
sys.exit(main())
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""
Diagnostic interactif des ports série ESP32/ESP32-S3 (sans commande ID)
- Tente un reset auto bootloader (DTR/RTS)
- Utilise esptool pour identifier le chip
- Affiche le résultat détaillé pour chaque port
"""
import glob
import time
import sys
PORT_PATTERNS = [
'/dev/tty.usbserial*', '/dev/cu.usbserial*', '/dev/ttyUSB*', '/dev/ttyACM*',
'/dev/cu.wchusbserial*', '/dev/cu.SLAB_USBtoUART*', '/dev/cu.usbmodem*', '/dev/tty.usbmodem*',
]
def detect_serial_ports():
ports = []
for pat in PORT_PATTERNS:
ports.extend(glob.glob(pat))
return ports
def reset_to_bootloader(port):
try:
import serial
with serial.Serial(port, baudrate=115200) as ser:
ser.dtr = False
ser.rts = True
time.sleep(0.1)
ser.dtr = True
ser.rts = False
time.sleep(0.1)
ser.dtr = False
ser.rts = False
time.sleep(0.1)
except Exception as e:
print(f" [WARN] Reset bootloader échoué: {e}")
def try_esptool(port):
try:
import esptool
# 1. esptool >=3.3: detect_chip
if hasattr(esptool.ESPLoader, 'detect_chip'):
chip = esptool.ESPLoader.detect_chip(port=port, baud=115200)
desc = chip.get_chip_description()
mac = chip.read_mac()
return f"OK: {desc}, MAC: {':'.join(f'{b:02X}' for b in mac)}"
# 2. esptool >=4.0: get_default_connected_device (API très changeante)
elif hasattr(esptool, 'get_default_connected_device'):
# Version qui exige port, serial_list, connect_attempts, initial_baud
dev = esptool.get_default_connected_device(
port=port, serial_list=[port], connect_attempts=1, initial_baud=115200
)
desc = dev.get_chip_description()
mac = dev.read_mac()
return f"OK: {desc}, MAC: {':'.join(f'{b:02X}' for b in mac)}"
# 3. Fallback: instanciation manuelle (pour anciennes versions)
else:
# Peut échouer si API trop ancienne
loader = esptool.ESPLoader
with open(port, 'rb+') as ser:
esp = loader(ser, False)
esp.connect()
desc = esp.get_chip_description()
mac = esp.read_mac()
return f"OK: {desc}, MAC: {':'.join(f'{b:02X}' for b in mac)}"
except Exception as e:
return f"ECHEC: {e}"
def main():
ports = detect_serial_ports()
if not ports:
print("Aucun port série détecté.")
sys.exit(1)
print(f"Ports détectés: {ports}")
for port in ports:
print(f"\nTest du port {port} ...")
reset_to_bootloader(port)
time.sleep(0.2)
result = try_esptool(port)
print(f" Résultat: {result}")
if __name__ == "__main__":
main()
@@ -1,489 +0,0 @@
#!/usr/bin/env python3
"""Send reliable ESP-NOW control commands from controller board to HOTLINE_PHONE."""
from __future__ import annotations
import argparse
import glob
import json
import sys
import time
from typing import Any, Dict, Optional
try:
import serial # type: ignore
except ImportError: # pragma: no cover
serial = None
def parse_json_line(line: str) -> Optional[Any]:
line = line.strip()
if not line:
return None
if line[0] not in ("{", "[") or line[-1] not in ("}", "]"):
return None
try:
return json.loads(line)
except json.JSONDecodeError:
return None
class SerialEndpoint:
def __init__(self, port: str, baud: int, timeout_s: float = 0.5) -> None:
if serial is None:
raise RuntimeError("pyserial is required (pip install pyserial)")
self.port = port
self.baud = baud
self.timeout_s = timeout_s
self._ser: Optional[serial.Serial] = None
def __enter__(self) -> "SerialEndpoint":
self._ser = serial.Serial(self.port, self.baud, timeout=self.timeout_s)
time.sleep(0.8)
self._ser.reset_input_buffer()
self._ser.reset_output_buffer()
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self._ser and self._ser.is_open:
self._ser.close()
def command(self, cmd: str, timeout_s: float = 4.0, expect: str = "any") -> Dict[str, Any]:
if not self._ser or not self._ser.is_open:
raise RuntimeError("serial port not open")
self._ser.reset_input_buffer()
self._ser.write((cmd + "\r\n").encode())
self._ser.flush()
deadline = time.time() + timeout_s
last_line = ""
while time.time() < deadline:
raw = self._ser.readline()
if not raw:
continue
line = raw.decode("utf-8", errors="ignore").strip()
if not line:
continue
last_line = line
parsed = parse_json_line(line)
if parsed is not None:
if expect in {"any", "json"} and isinstance(parsed, dict):
return parsed
continue
if line.startswith("OK ") or line.startswith("ERR "):
if expect in {"any", "ack"}:
return {"ok": line.startswith("OK "), "line": line}
continue
if line == "PONG":
if expect in {"any", "pong", "ack"}:
return {"ok": True, "result": "PONG"}
continue
raise RuntimeError(f"timeout on command '{cmd}' last='{last_line}'")
def sync(self, retries: int = 6) -> None:
last_error = ""
for _ in range(retries):
try:
self.command("PING", timeout_s=2.0, expect="pong")
return
except Exception as exc: # pragma: no cover - hardware timing
last_error = str(exc)
time.sleep(0.4)
raise RuntimeError(f"serial sync failed: {last_error}")
def resolve_port(explicit_port: str | None) -> str:
if explicit_port:
return explicit_port
for pattern in ("/dev/cu.usbserial*", "/dev/tty.usbserial*"):
candidates = sorted(glob.glob(pattern))
if candidates:
return candidates[0]
raise RuntimeError("No serial port found. Provide --port explicitly.")
def build_command(action: str, custom_cmd: str) -> str:
action = action.lower()
if action == "ring":
return "RING"
if action == "status":
return "STATUS"
if action == "hotline1":
return "HOTLINE_TRIGGER 1 pulse"
if action == "hotline2":
return "HOTLINE_TRIGGER 2 pulse"
if action == "hotline3":
return "HOTLINE_TRIGGER 3 pulse"
if action == "discover":
return "ESPNOW_DEVICE_NAME_GET"
if action == "custom":
cmd = custom_cmd.strip()
if not cmd:
raise ValueError("--cmd is required when --action custom")
return cmd
raise ValueError(f"unsupported action: {action}")
def make_envelope(command_line: str, msg_id: str, seq: int) -> Dict[str, Any]:
return {
"msg_id": msg_id,
"seq": seq,
"type": "command",
"ack": True,
"payload": command_line,
}
def parse_ack(last_rx_payload: str, expected_msg_id: str, expected_seq: int) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(last_rx_payload)
except json.JSONDecodeError:
return None
if not isinstance(obj, dict):
return None
if str(obj.get("type", "")).lower() != "ack":
return None
if str(obj.get("msg_id", "")) != expected_msg_id:
return None
try:
seq = int(obj.get("seq", -1))
except (TypeError, ValueError):
return None
if seq != expected_seq:
return None
payload = obj.get("payload")
return payload if isinstance(payload, dict) else None
def extract_device_name_from_ack_payload(ack_payload: Dict[str, Any]) -> str:
data = ack_payload.get("data")
if not isinstance(data, dict):
return ""
device_name = str(data.get("device_name", "")).strip()
return device_name
def send_with_retry(
dev: SerialEndpoint,
target: str,
command_line: str,
retries: int,
ack_timeout_s: float,
poll_interval_s: float,
) -> Dict[str, Any]:
seq = int(time.time()) & 0xFFFFFFFF
last_error = "ack_timeout"
last_send_line = ""
for attempt in range(1, retries + 1):
status_before = dev.command("ESPNOW_STATUS", expect="json", timeout_s=3.0)
rx_before = int(status_before.get("rx_count", 0))
msg_id = f"host-{int(time.time() * 1000)}-{attempt}"
envelope = make_envelope(command_line, msg_id, seq)
wire = json.dumps(envelope, separators=(",", ":"))
send_ack = dev.command(f"ESPNOW_SEND {target} {wire}", expect="ack", timeout_s=4.0)
if not send_ack.get("ok", False):
last_send_line = str(send_ack.get("line", ""))
last_error = "send_failed"
print(f"[espnow] send failed attempt {attempt}/{retries}: {last_send_line}")
continue
last_error = "ack_timeout"
print(f"[espnow] sent attempt {attempt}/{retries}, waiting ack msg_id={msg_id} seq={seq}")
deadline = time.monotonic() + ack_timeout_s
while time.monotonic() < deadline:
status_now = dev.command("ESPNOW_STATUS", expect="json", timeout_s=2.5)
rx_now = int(status_now.get("rx_count", 0))
if rx_now <= rx_before:
time.sleep(poll_interval_s)
continue
ack_payload = parse_ack(str(status_now.get("last_rx_payload", "")), msg_id, seq)
if ack_payload is not None:
return {
"ok": bool(ack_payload.get("ok", False)),
"attempt": attempt,
"msg_id": msg_id,
"seq": seq,
"target": target,
"command": command_line,
"source_mac": str(status_now.get("last_rx_mac", "")),
"ack_payload": ack_payload,
}
rx_before = rx_now
time.sleep(poll_interval_s)
print(f"[espnow] ack timeout attempt {attempt}/{retries}")
return {
"ok": False,
"attempt": retries,
"msg_id": "",
"seq": seq,
"target": target,
"command": command_line,
"error": last_error,
"send_line": last_send_line,
}
def discover_target_mac(
dev: SerialEndpoint,
target_name: str,
retries: int,
rounds: int,
broadcast_window_s: float,
ack_timeout_s: float,
poll_interval_s: float,
) -> Dict[str, Any]:
normalized_target_name = target_name.strip().upper()
discovery: Dict[str, Any] = {
"mode": "broadcast+discovery",
"target_name": target_name,
"broadcast_rounds": [],
"known_peers": [],
"peer_probes": [],
"candidates": [],
"matches": [],
"resolved_mac": "",
}
candidates_by_mac: Dict[str, Dict[str, Any]] = {}
status = dev.command("ESPNOW_STATUS", expect="json", timeout_s=3.0)
rx_cursor = int(status.get("rx_count", 0))
peers = status.get("peers", [])
known_peers = [str(item).strip().upper() for item in peers if str(item).strip()]
discovery["known_peers"] = known_peers
for round_index in range(1, max(1, rounds) + 1):
seq = (int(time.time()) + round_index) & 0xFFFFFFFF
msg_id = f"discover-{int(time.time() * 1000)}-{round_index}"
envelope = make_envelope("ESPNOW_DEVICE_NAME_GET", msg_id, seq)
wire = json.dumps(envelope, separators=(",", ":"))
send_ack = dev.command(f"ESPNOW_SEND broadcast {wire}", expect="ack", timeout_s=4.0)
round_info: Dict[str, Any] = {
"round": round_index,
"ok": bool(send_ack.get("ok", False)),
"line": str(send_ack.get("line", "")),
"msg_id": msg_id,
"seq": seq,
"hits": [],
}
discovery["broadcast_rounds"].append(round_info)
if not send_ack.get("ok", False):
continue
deadline = time.monotonic() + max(0.4, broadcast_window_s)
while time.monotonic() < deadline:
status_now = dev.command("ESPNOW_STATUS", expect="json", timeout_s=2.5)
rx_now = int(status_now.get("rx_count", 0))
if rx_now <= rx_cursor:
time.sleep(poll_interval_s)
continue
rx_cursor = rx_now
ack_payload = parse_ack(str(status_now.get("last_rx_payload", "")), msg_id, seq)
if ack_payload is None:
time.sleep(poll_interval_s)
continue
source_mac = str(status_now.get("last_rx_mac", "")).strip().upper()
device_name = extract_device_name_from_ack_payload(ack_payload)
hit = {
"mac": source_mac,
"device_name": device_name,
"ok": bool(ack_payload.get("ok", False)),
"code": str(ack_payload.get("code", "")),
}
round_info["hits"].append(hit)
if source_mac:
candidates_by_mac[source_mac] = {
"mac": source_mac,
"device_name": device_name,
"path": "broadcast",
"ok": True,
}
time.sleep(poll_interval_s)
for peer in known_peers:
probe = send_with_retry(
dev=dev,
target=peer,
command_line="ESPNOW_DEVICE_NAME_GET",
retries=max(1, retries),
ack_timeout_s=ack_timeout_s,
poll_interval_s=poll_interval_s,
)
probe["peer"] = peer
discovery["peer_probes"].append(probe)
if not probe.get("ok", False):
continue
source_mac = str(probe.get("source_mac", "")).strip().upper() or peer
ack_payload = probe.get("ack_payload")
device_name = ""
if isinstance(ack_payload, dict):
device_name = extract_device_name_from_ack_payload(ack_payload)
candidates_by_mac[source_mac] = {
"mac": source_mac,
"device_name": device_name,
"path": "peer_scan",
"ok": True,
}
candidates = sorted(candidates_by_mac.values(), key=lambda item: str(item.get("mac", "")))
discovery["candidates"] = candidates
matches = []
for candidate in candidates:
device_name = str(candidate.get("device_name", "")).strip().upper()
if not normalized_target_name:
matches.append(candidate)
continue
if device_name == normalized_target_name:
matches.append(candidate)
discovery["matches"] = matches
if matches:
discovery["resolved_mac"] = str(matches[0].get("mac", ""))
elif len(candidates) == 1:
discovery["resolved_mac"] = str(candidates[0].get("mac", ""))
discovery["resolved_mode"] = "single_candidate_fallback"
else:
discovery["resolved_mac"] = ""
return discovery
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Controller script for ESP-NOW -> HOTLINE_PHONE")
parser.add_argument("--port", default="", help="controller serial port (default: first /dev/*usbserial*)")
parser.add_argument("--baud", type=int, default=115200, help="serial baudrate")
parser.add_argument(
"--target",
default="broadcast",
help="ESP-NOW target MAC, 'broadcast', or 'broadcast+discovery' (default: broadcast)",
)
parser.add_argument(
"--target-name",
default="HOTLINE_PHONE",
help="expected remote logical name for operator context only (default: HOTLINE_PHONE)",
)
parser.add_argument(
"--action",
default="ring",
choices=("ring", "status", "hotline1", "hotline2", "hotline3", "discover", "custom"),
help="high-level action (default: ring)",
)
parser.add_argument("--cmd", default="", help="raw command line used with --action custom")
parser.add_argument("--retries", type=int, default=3, help="send retries on ack timeout/failure")
parser.add_argument("--ack-timeout", type=float, default=3.0, help="seconds to wait for each ACK")
parser.add_argument("--poll-ms", type=int, default=120, help="ACK polling interval in ms")
parser.add_argument(
"--discover",
action="store_true",
help="resolve target MAC by device_name before sending command",
)
parser.add_argument(
"--discover-retries",
type=int,
default=1,
help="retry count per peer during discovery (default: 1)",
)
parser.add_argument(
"--discover-rounds",
type=int,
default=3,
help="number of broadcast discovery rounds (default: 3)",
)
parser.add_argument(
"--ensure-peer",
action="store_true",
help="add target MAC as peer before send (ignored for broadcast)",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
port = resolve_port(args.port.strip() or None)
target = args.target.strip()
poll_interval_s = max(0.05, float(args.poll_ms) / 1000.0)
target_lower = target.lower()
try:
command_line = build_command(args.action, args.cmd)
except ValueError as exc:
print(f"[espnow] FAIL: {exc}")
return 2
print(
f"[espnow] controller_port={port} target={target} target_name={args.target_name} "
f"action={args.action} command='{command_line}'"
)
try:
with SerialEndpoint(port, args.baud) as dev:
dev.sync()
dev.command("ESPNOW_ON", expect="ack", timeout_s=3.0)
local_name_resp = dev.command("ESPNOW_DEVICE_NAME_GET", expect="json", timeout_s=3.0)
local_name = str(local_name_resp.get("device_name", ""))
print(f"[espnow] local device_name={local_name}")
wants_discovery = bool(args.discover or args.action == "discover" or target_lower == "broadcast+discovery")
discovery: Dict[str, Any] = {}
if wants_discovery:
discovery = discover_target_mac(
dev=dev,
target_name=str(args.target_name),
retries=max(1, int(args.discover_retries)),
rounds=max(1, int(args.discover_rounds)),
broadcast_window_s=max(0.4, float(args.ack_timeout)),
ack_timeout_s=max(0.5, float(args.ack_timeout)),
poll_interval_s=poll_interval_s,
)
print(f"[espnow] discovery={json.dumps(discovery, ensure_ascii=True)}")
resolved_mac = str(discovery.get("resolved_mac", "")).strip()
if args.action == "discover":
if discovery.get("candidates"):
print(json.dumps({"ok": True, "discovery": discovery}, ensure_ascii=True))
return 0
print(json.dumps({"ok": False, "discovery": discovery}, ensure_ascii=True))
return 1
if not resolved_mac:
print(json.dumps({"ok": False, "error": "discovery_failed", "discovery": discovery}, ensure_ascii=True))
return 1
target = resolved_mac
print(f"[espnow] resolved target={target} for target_name={args.target_name}")
if args.ensure_peer and target.lower() != "broadcast":
peer_res = dev.command(f"ESPNOW_PEER_ADD {target}", expect="ack", timeout_s=3.0)
print(f"[espnow] ensure_peer -> {peer_res.get('line', '')}")
result = send_with_retry(
dev=dev,
target=target,
command_line=command_line,
retries=max(1, int(args.retries)),
ack_timeout_s=max(0.5, float(args.ack_timeout)),
poll_interval_s=poll_interval_s,
)
if wants_discovery:
result["target_name"] = args.target_name
except Exception as exc:
print(f"[espnow] FAIL: {exc}")
return 1
print(json.dumps(result, ensure_ascii=True))
if not result.get("ok", False):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,27 +0,0 @@
#!/bin/bash
# Script pour générer le rapport de couverture avec gcov/lcov
BUILD_DIR=.pio/build
COVERAGE_DIR=coverage
# Nettoyage
rm -rf $COVERAGE_DIR
mkdir $COVERAGE_DIR
# Compilation avec les flags de couverture
gcov_flags="-fprofile-arcs -ftest-coverage"
# Compilation (adapter selon votre environnement PlatformIO)
pio run -e esp32s3 --project-option="build_flags=$gcov_flags"
# Génération des fichiers gcov
gcov -o $BUILD_DIR $BUILD_DIR/**/*.gcda $BUILD_DIR/**/*.gcno
# Génération du rapport lcov
lcov --capture --directory $BUILD_DIR --output-file $COVERAGE_DIR/coverage.info
lcov --list $COVERAGE_DIR/coverage.info
# Génération du rapport HTML
genhtml $COVERAGE_DIR/coverage.info --output-directory $COVERAGE_DIR/html
echo "Rapport de couverture généré dans $COVERAGE_DIR/html"
File diff suppressed because it is too large Load Diff
@@ -1,191 +0,0 @@
#!/usr/bin/env python3
"""Live monitor for pulse-dial hotline routes on A252."""
from __future__ import annotations
import argparse
import glob
import json
import sys
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
try:
import serial # type: ignore
except ImportError: # pragma: no cover
serial = None
@dataclass
class Snapshot:
hook: str = ""
dial_buffer: str = ""
hotline_active: bool = False
hotline_current_key: str = ""
hotline_pending_restart: bool = False
playing: bool = False
class SerialEndpoint:
def __init__(self, port: str, baud: int, timeout_s: float = 0.5) -> None:
if serial is None:
raise RuntimeError("pyserial is required (pip install pyserial)")
self.port = port
self.baud = baud
self.timeout_s = timeout_s
self._ser: Optional[serial.Serial] = None
def __enter__(self) -> "SerialEndpoint":
self._ser = serial.Serial(self.port, self.baud, timeout=self.timeout_s)
time.sleep(0.8)
self._ser.reset_input_buffer()
self._ser.reset_output_buffer()
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self._ser and self._ser.is_open:
self._ser.close()
def command(self, cmd: str, timeout_s: float = 3.0, expect: str = "any") -> Dict[str, Any]:
if not self._ser or not self._ser.is_open:
raise RuntimeError("serial port not open")
self._ser.reset_input_buffer()
self._ser.write((cmd + "\r\n").encode())
self._ser.flush()
deadline = time.time() + timeout_s
last_line = ""
while time.time() < deadline:
raw = self._ser.readline()
if not raw:
continue
line = raw.decode("utf-8", errors="ignore").strip()
if not line:
continue
last_line = line
if line and line[0] in ("{", "[") and line[-1] in ("}", "]"):
if expect in {"any", "json"}:
try:
return json.loads(line)
except json.JSONDecodeError:
continue
continue
if line.startswith("OK ") or line.startswith("ERR "):
if expect in {"any", "ack"}:
return {"ok": line.startswith("OK "), "line": line}
continue
if line == "PONG":
if expect in {"any", "pong", "ack"}:
return {"ok": True, "result": "PONG"}
continue
raise RuntimeError(f"timeout on command '{cmd}' last='{last_line}'")
def sync(self, retries: int = 6) -> None:
last_error = ""
for _ in range(retries):
try:
self.command("PING", timeout_s=2.0, expect="pong")
return
except Exception as exc: # pragma: no cover - hardware timing
last_error = str(exc)
time.sleep(0.4)
raise RuntimeError(f"serial sync failed: {last_error}")
def resolve_port(explicit_port: str | None) -> str:
if explicit_port:
return explicit_port
for pattern in ("/dev/cu.usbserial*", "/dev/tty.usbserial*"):
candidates = sorted(glob.glob(pattern))
if candidates:
return candidates[0]
raise RuntimeError("No serial port found. Provide --port explicitly.")
def build_snapshot(status: Dict[str, Any], hotline: Dict[str, Any]) -> Snapshot:
telephony = status.get("telephony", {}) if isinstance(status, dict) else {}
audio = status.get("audio", {}) if isinstance(status, dict) else {}
hotline_obj = hotline if isinstance(hotline, dict) else {}
return Snapshot(
hook=str(telephony.get("hook", "")),
dial_buffer=str(telephony.get("dial_buffer", "")),
hotline_active=bool(hotline_obj.get("active", telephony.get("hotline_active", False))),
hotline_current_key=str(hotline_obj.get("current_key", telephony.get("hotline_current_key", ""))),
hotline_pending_restart=bool(hotline_obj.get("pending_restart", False)),
playing=bool(audio.get("playing", False)),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Live hotline monitor for pulse dialing 1/2/3 on A252")
parser.add_argument("--port", default="", help="serial port (default: first /dev/*usbserial*)")
parser.add_argument("--baud", type=int, default=115200, help="serial baudrate")
parser.add_argument("--duration", type=float, default=180.0, help="monitoring window in seconds")
parser.add_argument("--interval-ms", type=int, default=100, help="poll interval in milliseconds")
parser.add_argument("--expect", default="1,2,3", help="expected hotline keys, comma-separated")
return parser.parse_args()
def main() -> int:
args = parse_args()
expected_keys = [item.strip() for item in str(args.expect).split(",") if item.strip()]
seen = {key: False for key in expected_keys}
port = resolve_port(args.port.strip() or None)
interval_s = max(0.05, float(args.interval_ms) / 1000.0)
print(f"[monitor] port={port} baud={args.baud} duration={args.duration:.1f}s interval={interval_s:.3f}s")
print(f"[monitor] expected hotline keys: {', '.join(expected_keys)}")
last = Snapshot()
start = time.monotonic()
interrupted = False
try:
with SerialEndpoint(port, args.baud) as dev:
dev.sync()
print("[monitor] sync OK; start dialing (pulse) 1, then 2, then 3")
while (time.monotonic() - start) < float(args.duration):
status = dev.command("STATUS", expect="json", timeout_s=3.0)
hotline = dev.command("HOTLINE_STATUS", expect="json", timeout_s=3.0)
snap = build_snapshot(status, hotline)
if snap.hotline_active and snap.hotline_current_key in seen:
seen[snap.hotline_current_key] = True
if snap != last:
elapsed = time.monotonic() - start
print(
f"[{elapsed:7.3f}s] hook={snap.hook:<8} dial={snap.dial_buffer:<8} "
f"hotline_active={str(snap.hotline_active).lower():<5} "
f"key={snap.hotline_current_key:<3} pending_restart={str(snap.hotline_pending_restart).lower():<5} "
f"playing={str(snap.playing).lower():<5}"
)
last = snap
if expected_keys and all(seen.values()):
print("[monitor] all expected hotline keys observed; stopping early")
break
time.sleep(interval_s)
except KeyboardInterrupt:
interrupted = True
except Exception as exc:
print(f"[monitor] FAIL: {exc}")
return 1
missing = [key for key, ok in seen.items() if not ok]
if missing:
print(f"[monitor] FAIL missing hotline keys: {', '.join(missing)}")
if interrupted:
print("[monitor] interrupted by user before all keys were observed")
return 1
print("[monitor] PASS hotline keys observed:", ", ".join(expected_keys))
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
import serial
import threading
import time
# Ports à adapter selon votre configuration
PORTS = [
('/dev/cu.SLAB_USBtoUART', 'logs_esp32_audio_kit.txt'),
('/dev/cu.usbmodem5B5E0508431', 'logs_esp32s3.txt'),
]
BAUDRATE = 115200
def monitor_port(port, logfile):
with serial.Serial(port, BAUDRATE, timeout=2) as ser, open(logfile, 'w') as log:
print(f"[MONITOR] {port} -> {logfile}")
while True:
try:
line = ser.readline().decode(errors='replace')
if line:
print(f"[{port}] {line.strip()}")
log.write(line)
log.flush()
except Exception as e:
print(f"[ERROR] {port}: {e}")
break
def main():
threads = []
for port, logfile in PORTS:
t = threading.Thread(target=monitor_port, args=(port, logfile), daemon=True)
t.start()
threads.append(t)
print("Capture des logs en cours. Ctrl+C pour arrêter.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Arrêt de la capture des logs.")
if __name__ == '__main__':
main()
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[pre_merge] this script is deprecated, use scripts/branch_gate.sh"
exec "${SCRIPT_DIR}/branch_gate.sh" "$@"
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""Provision FFat media files over serial for A252."""
from __future__ import annotations
import argparse
import base64
import time
from pathlib import Path
import serial # type: ignore
def send_cmd(ser: serial.Serial, cmd: str, timeout_s: float = 8.0) -> str:
ser.reset_input_buffer()
ser.write((cmd + "\r\n").encode("utf-8"))
ser.flush()
deadline = time.time() + timeout_s
last = ""
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if not line:
continue
last = line
if line.startswith("OK ") or line.startswith("ERR ") or line == "PONG":
return line
raise RuntimeError(f"timeout on '{cmd}' last='{last}'")
def normalize_remote_path(local_path: Path, root: Path) -> str:
rel = local_path.relative_to(root)
return "/" + str(rel).replace("\\", "/")
def provision_file(ser: serial.Serial, local_path: Path, remote_path: str, chunk_size: int) -> None:
response = send_cmd(ser, f"FFAT_RESET {remote_path}")
if not response.startswith("OK "):
raise RuntimeError(f"FFAT_RESET failed for {remote_path}: {response}")
raw = local_path.read_bytes()
for offset in range(0, len(raw), chunk_size):
chunk = raw[offset : offset + chunk_size]
payload = base64.b64encode(chunk).decode("ascii")
last_error = ""
for _ in range(3):
try:
response = send_cmd(ser, f"FFAT_APPEND_B64 {remote_path} {payload}", timeout_s=20.0)
except Exception as exc:
last_error = str(exc)
time.sleep(0.2)
continue
if response.startswith("OK "):
last_error = ""
break
last_error = response
time.sleep(0.2)
if last_error:
raise RuntimeError(f"FFAT_APPEND_B64 failed for {remote_path}: {last_error}")
response = send_cmd(ser, f"FFAT_EXISTS {remote_path}")
if not response.startswith("OK "):
raise RuntimeError(f"FFAT_EXISTS failed for {remote_path}: {response}")
def main() -> int:
parser = argparse.ArgumentParser(description="Provision FFat media over serial")
parser.add_argument("--port", default="/dev/cu.usbserial-0001")
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--root", default="data/audio", help="local media root directory")
parser.add_argument("--chunk-bytes", type=int, default=48, help="raw bytes per base64 serial frame")
parser.add_argument("--ext", default=".wav", help="comma-separated file extensions to provision (default: .wav)")
args = parser.parse_args()
root = Path(args.root).resolve()
if not root.exists():
raise RuntimeError(f"media root not found: {root}")
allowed_ext = {item.strip().lower() for item in args.ext.split(",") if item.strip()}
files = sorted(
p
for p in root.rglob("*")
if p.is_file() and (not allowed_ext or p.suffix.lower() in allowed_ext)
)
if not files:
raise RuntimeError(f"no files found in {root}")
with serial.Serial(args.port, args.baud, timeout=0.5) as ser:
time.sleep(0.9)
if send_cmd(ser, "PING", timeout_s=3.0) != "PONG":
raise RuntimeError("device ping failed")
for index, local_path in enumerate(files, start=1):
remote_path = normalize_remote_path(local_path, root)
print(f"[{index}/{len(files)}] {remote_path}")
provision_file(ser, local_path, remote_path, args.chunk_bytes)
print("FFat media provision complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""Unit tests for web route / command parity checker."""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from scripts.check_web_route_parity import (
RouteCommand,
build_report,
collect_missing_commands,
parse_backend_route_commands,
parse_frontend_routes,
parse_registered_commands,
write_report_json,
)
class RouteParsingTest(unittest.TestCase):
def test_detects_frontend_request_calls(self) -> None:
source = """
const [wifi] = await Promise.all([
requestJson("/api/network/wifi"),
]);
"""
routes = parse_frontend_routes(source)
self.assertIn(("GET", "/api/network/wifi"), routes)
def test_detects_frontend_payload_requests(self) -> None:
source = """
await requestJson("/api/network/espnow/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: { cmd: "STATUS" } }),
});
"""
routes = parse_frontend_routes(source)
self.assertIn(("POST", "/api/network/espnow/send"), routes)
def test_detects_single_quotes(self) -> None:
source = """
await requestJson('/api/network/wifi/scan', { method: 'POST' });
"""
routes = parse_frontend_routes(source)
self.assertIn(("POST", "/api/network/wifi/scan"), routes)
class BackendParsingTest(unittest.TestCase):
def test_backend_route_mapping_extracts_command_id(self) -> None:
source = """
server_.on("/api/network/wifi/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
handleDispatch(request, "WIFI_CONNECT " + quoteArg(ssid));
});
"""
routes = parse_backend_route_commands(source)
self.assertEqual(
routes.get(("POST", "/api/network/wifi/connect")),
RouteCommand(route=("POST", "/api/network/wifi/connect"), command="WIFI_CONNECT", dynamic=False),
)
def test_backend_route_detects_dynamic_dispatch(self) -> None:
source = """
server_.on("/api/dispatch", HTTP_POST, [this](AsyncWebServerRequest* request) {
handleDispatch(request, command_line);
});
"""
routes = parse_backend_route_commands(source)
self.assertEqual(
routes.get(("POST", "/api/dispatch")),
RouteCommand(route=("POST", "/api/dispatch"), command=None, dynamic=True),
)
def test_registered_command_detection(self) -> None:
source = """
g_dispatcher.registerCommand("WIFI_CONNECT", [](const String&) {});
g_dispatcher.registerCommand("PLAY", [](const String&) {});
"""
commands = parse_registered_commands(source)
self.assertEqual(commands, {"WIFI_CONNECT", "PLAY"})
class ParityReportTest(unittest.TestCase):
def test_collects_missing_static_commands(self) -> None:
backend = {
("POST", "/api/network/wifi/connect"): RouteCommand(
route=("POST", "/api/network/wifi/connect"), command="WIFI_CONNECT", dynamic=False
),
("POST", "/api/control"): RouteCommand(
route=("POST", "/api/control"), command="UNKNOWN", dynamic=False
),
("POST", "/api/relay"): RouteCommand(
route=("POST", "/api/relay"), command="ESPNOW_SEND", dynamic=True
),
}
registered = {"WIFI_CONNECT"}
missing = collect_missing_commands(backend, registered)
command_payload = {m.command for m in missing if not m.dynamic}
dynamic_payload = {m.route for m in missing if m.dynamic}
self.assertIn("UNKNOWN", command_payload)
self.assertIn(("POST", "/api/relay"), dynamic_payload)
def test_build_report_includes_mapping_fields(self) -> None:
backend = {
("GET", "/api/status"),
}
frontend = {
("GET", "/api/status"),
("POST", "/api/network/wifi/scan"),
}
missing_in_backend = frontend - backend
backend_commands = {
("GET", "/api/status"): RouteCommand(("GET", "/api/status"), "STATUS", False),
("POST", "/api/network/wifi/scan"): RouteCommand(
("POST", "/api/network/wifi/scan"), "WIFI_SCAN", False
),
("POST", "/api/network/espnow/send"): RouteCommand(
("POST", "/api/network/espnow/send"), "ESPNOW_SEND", False
),
}
missing_commands = collect_missing_commands(backend_commands, {"STATUS"})
report = build_report(
backend_routes=backend,
frontend_routes=frontend,
missing_in_backend=missing_in_backend,
unused_backend=set(),
missing_commands=missing_commands,
strict_unused_backend=False,
status="fail",
)
self.assertIn("missing_mapped_commands", report)
self.assertIn("dynamic_routes", report)
self.assertEqual(
report["missing_mapped_commands"],
[
{
"command": "ESPNOW_SEND",
"dynamic": False,
"method": "POST",
"path": "/api/network/espnow/send",
},
{
"command": "WIFI_SCAN",
"dynamic": False,
"method": "POST",
"path": "/api/network/wifi/scan",
}
],
)
self.assertEqual(report["status"], "fail")
def test_write_report_json_writes_json(self) -> None:
report = build_report(
backend_routes={("GET", "/api/status")},
frontend_routes={("GET", "/api/status")},
missing_in_backend=set(),
unused_backend=set(),
missing_commands=set(),
strict_unused_backend=False,
status="pass",
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "route_parity_report.json"
write_report_json(path, report)
loaded = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(loaded["status"], "pass")
self.assertEqual(loaded["backend_count"], 1)
self.assertEqual(loaded["missing_mapped_commands"], [])
if __name__ == "__main__":
unittest.main()
@@ -1,915 +0,0 @@
#!/usr/bin/env python3
"""Unit tests for hw_validation serial smoke contract behavior."""
from __future__ import annotations
import unittest
from scripts.hw_validation import (
evaluate_serial_smoke_contract,
scenario_serial_audio_format_chain,
scenario_serial_firmware_contract,
scenario_serial_hotline_defaults,
scenario_serial_hook_ring_audio,
scenario_serial_media_routing,
scenario_serial_network,
)
def _ack(ok: bool, command: str) -> dict[str, object]:
return {"ok": ok, "line": ("OK " if ok else "ERR ") + command}
def _make_serial_details(
*,
enable_capture: bool,
capture_start_ok: bool,
capture_stop_ok: bool = True,
ping_ok: bool = True,
) -> dict[str, object]:
return {
"ping": {"ok": ping_ok, "result": "PONG" if ping_ok else "ERR"},
"status": {
"telephony": {"state": "IDLE"},
"config": {
"audio": {
"enable_capture": enable_capture,
}
},
},
"capture_start": _ack(capture_start_ok, "CAPTURE_START"),
"capture_stop": _ack(capture_stop_ok, "CAPTURE_STOP"),
"reset_metrics": _ack(True, "RESET_METRICS"),
}
class SerialSmokeContractTest(unittest.TestCase):
def test_capture_start_failure_fails_when_capture_enabled(self) -> None:
details = _make_serial_details(enable_capture=True, capture_start_ok=False)
state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract(
details,
strict_serial_smoke=True,
allow_capture_fail_when_disabled=True,
)
self.assertEqual(state, "FAIL")
self.assertIn("CAPTURE_START", required_checks)
self.assertIn("CAPTURE_START", failed_checks)
self.assertEqual(warnings, [])
def test_capture_start_failure_passes_with_warning_when_capture_disabled(self) -> None:
details = _make_serial_details(enable_capture=False, capture_start_ok=False)
state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract(
details,
strict_serial_smoke=True,
allow_capture_fail_when_disabled=True,
)
self.assertEqual(state, "PASS")
self.assertNotIn("CAPTURE_START", required_checks)
self.assertNotIn("CAPTURE_START", failed_checks)
self.assertIn("capture_start_failed_capture_disabled", warnings)
def test_capture_start_failure_can_be_forced_to_fail_when_capture_disabled(self) -> None:
details = _make_serial_details(enable_capture=False, capture_start_ok=False)
state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract(
details,
strict_serial_smoke=True,
allow_capture_fail_when_disabled=False,
)
self.assertEqual(state, "FAIL")
self.assertIn("CAPTURE_START", required_checks)
self.assertIn("CAPTURE_START", failed_checks)
self.assertIn("capture_start_required_even_when_capture_disabled", warnings)
def test_non_strict_mode_warns_but_can_pass_non_critical_failures(self) -> None:
details = _make_serial_details(enable_capture=True, capture_start_ok=True, capture_stop_ok=False)
state, _, failed_checks, warnings = evaluate_serial_smoke_contract(
details,
strict_serial_smoke=False,
allow_capture_fail_when_disabled=True,
)
self.assertEqual(state, "PASS")
self.assertIn("CAPTURE_STOP", failed_checks)
self.assertIn("strict_serial_smoke_disabled", warnings)
def test_non_strict_mode_still_fails_on_minimum_contract(self) -> None:
details = _make_serial_details(enable_capture=True, capture_start_ok=True, ping_ok=False)
state, _, failed_checks, warnings = evaluate_serial_smoke_contract(
details,
strict_serial_smoke=False,
allow_capture_fail_when_disabled=True,
)
self.assertEqual(state, "FAIL")
self.assertIn("PING", failed_checks)
self.assertNotIn("strict_serial_smoke_disabled", warnings)
class _FakeSerialEndpoint:
def __init__(self, responses: list[object]) -> None:
self._responses = responses
self._index = 0
def command(self, cmd: str, timeout_s: float = 6.0, expect: str = "any") -> dict[str, object]:
(timeout_s, expect) # silence unused
if self._index >= len(self._responses):
raise RuntimeError(f"no response configured for command: {cmd}")
value = self._responses[self._index]
self._index += 1
if isinstance(value, Exception):
raise value
return value
def _status_audio_contract_payload() -> dict[str, object]:
return {
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 8000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 8000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": False,
"playback_loudness_gain_db": 0.0,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 0,
"playback_copy_accepted_bytes": 0,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
class SerialFirmwareContractTest(unittest.TestCase):
def test_firmware_contract_passes_when_required_blocks_exist(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"firmware": {
"build_id": "Feb 26",
"git_sha": "abc123",
"contract_version": "A252_AUDIO_CHAIN_V4",
},
"audio": _status_audio_contract_payload(),
}
]
)
result = scenario_serial_firmware_contract(fake, required_contract_version="A252_AUDIO_CHAIN_V4")
self.assertEqual(result.state, "PASS")
self.assertEqual(result.name, "serial_firmware_contract")
def test_firmware_contract_fails_when_version_mismatch(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"firmware": {
"build_id": "Feb 26",
"git_sha": "abc123",
"contract_version": "OLD_CONTRACT",
},
"audio": _status_audio_contract_payload(),
}
]
)
result = scenario_serial_firmware_contract(fake, required_contract_version="A252_AUDIO_CHAIN_V4")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("contract_version_matches", True))
class SerialNetworkContractTest(unittest.TestCase):
def test_wifi_connect_failure_marks_network_fail(self) -> None:
fake = _FakeSerialEndpoint(
[
{"connected": False, "ssid": "", "status": 6},
[{"ssid": "Les cils", "rssi": -42, "chan": 11, "enc": 4}],
{"ok": False, "line": "ERR WIFI_CONNECT failed"},
{"connected": False, "ssid": "", "status": 6},
{"ok": True, "result": "PONG"},
{"ready": True, "peer_count": 1},
]
)
result = scenario_serial_network(fake, "Les cils", "mascarade")
self.assertEqual(result.state, "FAIL")
self.assertEqual(result.name, "serial_network_stack")
self.assertEqual(result.details.get("wifi_connect", {}).get("ok"), False)
def test_wifi_connect_success_marks_network_pass(self) -> None:
fake = _FakeSerialEndpoint(
[
{"connected": False, "ssid": "", "status": 6},
[{"ssid": "Les cils", "rssi": -42, "chan": 11, "enc": 4}],
{"ok": True, "line": "OK WIFI_CONNECT"},
{"connected": True, "ssid": "Les cils", "status": 3, "ip": "192.168.1.42"},
{"ok": True, "result": "PONG"},
{"ready": True, "peer_count": 1},
]
)
result = scenario_serial_network(fake, "Les cils", "mascarade")
self.assertEqual(result.state, "PASS")
self.assertEqual(result.name, "serial_network_stack")
def test_wifi_connect_success_but_ssid_mismatch_fails(self) -> None:
fake = _FakeSerialEndpoint(
[
{"connected": False, "ssid": "", "status": 6},
[{"ssid": "Les cils", "rssi": -42, "chan": 11, "enc": 4}],
{"ok": True, "line": "OK WIFI_CONNECT"},
{"connected": True, "ssid": "OtherSSID", "status": 3, "ip": "192.168.1.42"},
{"ok": True, "result": "PONG"},
{"ready": True, "peer_count": 1},
]
)
result = scenario_serial_network(fake, "Les cils", "mascarade")
self.assertEqual(result.state, "FAIL")
class SerialHookRingAudioContractTest(unittest.TestCase):
def test_tone_on_failure_marks_hook_ring_audio_fail(self) -> None:
fake = _FakeSerialEndpoint(
[
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
{"ok": False, "line": "ERR TONE_ON audio_not_ready"},
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
{"ok": True, "line": "OK TONE_OFF"},
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
]
)
result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=False, hook_observe_seconds=0)
self.assertEqual(result.state, "FAIL")
self.assertEqual(result.name, "serial_hook_ring_audio")
self.assertEqual(result.details.get("checks", {}).get("tone_on_ok"), False)
def test_hook_toggle_is_required_when_enabled(self) -> None:
fake = _FakeSerialEndpoint(
[
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
{"ok": True, "line": "OK TONE_ON"},
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": True, "tone_event": "dial"}},
{"ok": True, "line": "OK TONE_OFF"},
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
]
)
result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=True, hook_observe_seconds=0)
self.assertEqual(result.state, "FAIL")
self.assertEqual(result.details.get("checks", {}).get("hook_ok"), False)
def test_hook_toggle_passes_when_both_states_are_seen(self) -> None:
fake = _FakeSerialEndpoint(
[
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
{"ok": True, "line": "OK TONE_ON"},
{"telephony": {"hook": "OFF_HOOK"}, "audio": {"tone_active": True, "tone_event": "dial"}},
{"ok": True, "line": "OK TONE_OFF"},
{"telephony": {"hook": "OFF_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
]
)
result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=True, hook_observe_seconds=0)
self.assertEqual(result.state, "PASS")
self.assertEqual(result.name, "serial_hook_ring_audio")
def test_hook_not_required_passes_when_hooks_not_seen(self) -> None:
fake = _FakeSerialEndpoint(
[
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
{"ok": True, "line": "OK TONE_ON"},
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": True, "tone_event": "dial"}},
{"ok": True, "line": "OK TONE_OFF"},
{"telephony": {"hook": "ON_HOOK"}, "audio": {"tone_active": False, "tone_event": "none"}},
]
)
result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=False, hook_observe_seconds=0)
self.assertEqual(result.state, "PASS")
self.assertEqual(result.name, "serial_hook_ring_audio")
self.assertEqual(result.details.get("checks", {}).get("hook_ok"), True)
self.assertEqual(result.details.get("hook_validation_mode"), "BYPASSED_NON_PRESENTIEL")
class SerialHotlineDefaultsContractTest(unittest.TestCase):
def test_hotline_defaults_pass_with_forced_123_mapping(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"1": {"kind": "file", "path": "/welcome.wav", "source": "AUTO"},
"2": {"kind": "file", "path": "/souffle.wav", "source": "AUTO"},
"3": {"kind": "file", "path": "/radio.wav", "source": "AUTO"},
}
]
)
result = scenario_serial_hotline_defaults(fake)
self.assertEqual(result.state, "PASS")
def test_hotline_defaults_fail_when_order_or_path_is_wrong(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"1": {"kind": "file", "path": "/welcome.wav", "source": "AUTO"},
"2": {"kind": "file", "path": "/radio.wav", "source": "AUTO"},
"3": {"kind": "file", "path": "/souffle.wav", "source": "AUTO"},
}
]
)
result = scenario_serial_hotline_defaults(fake)
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("dial_2_souffle", True))
class SerialMediaRoutingContractTest(unittest.TestCase):
def test_media_routing_status_contract_passes(self) -> None:
fake = _FakeSerialEndpoint(
[
{"ok": True, "line": "OK DIAL_MEDIA_MAP_SET_VOLATILE"},
{"0123456789": {"kind": "tone", "profile": "FR_FR", "event": "ringback"}},
{"ok": True, "line": "OK ESPNOW_CALL_MAP_SET_VOLATILE"},
{"ok": False, "line": "ERR ESPNOW_CALL_MAP_SET_VOLATILE tone_wav_deprecated_use_kind_tone LA_BUSY"},
{"LA_OK": {"kind": "tone", "profile": "FR_FR", "event": "busy"}},
{"ok": False, "line": "ERR PLAY tone_wav_deprecated_use_TONE_PLAY"},
{"ok": True, "line": "OK TONE_PLAY"},
{
"audio": {
"storage_default_policy": "SD_THEN_LITTLEFS",
"storage_last_source": "NONE",
"storage_last_path": "",
"tone_route_active": True,
"tone_rendering": True,
"tone_active": True,
"tone_profile": "FR_FR",
"tone_event": "busy",
"tone_engine": "CODE",
"playback_sample_rate": 8000,
"playback_bits_per_sample": 16,
"playback_channels": 2,
"playback_format_overridden": False,
},
"config": {
"audio": {
"sample_rate": 8000,
"bits_per_sample": 16,
},
"dial_media_map": {"0123456789": {"kind": "tone", "profile": "FR_FR", "event": "ringback"}},
"espnow_call_map": {"LA_OK": {"kind": "tone", "profile": "FR_FR", "event": "busy"}},
},
},
{"ok": True, "line": "OK TONE_STOP"},
{
"audio": {
"tone_route_active": False,
"tone_rendering": False,
"tone_active": False,
"tone_profile": "NONE",
"tone_event": "none",
"tone_engine": "NONE",
}
},
{"ok": True, "line": "OK DIAL_MEDIA_MAP_RESET_VOLATILE"},
{"ok": True, "line": "OK ESPNOW_CALL_MAP_RESET_VOLATILE"},
]
)
result = scenario_serial_media_routing(fake)
self.assertEqual(result.state, "PASS")
def test_media_routing_fails_when_legacy_tone_path_is_not_rejected(self) -> None:
fake = _FakeSerialEndpoint(
[
{"ok": True, "line": "OK DIAL_MEDIA_MAP_SET_VOLATILE"},
{"0123456789": {"kind": "tone", "profile": "FR_FR", "event": "ringback"}},
{"ok": True, "line": "OK ESPNOW_CALL_MAP_SET_VOLATILE"},
{"ok": False, "line": "ERR ESPNOW_CALL_MAP_SET_VOLATILE tone_wav_deprecated_use_kind_tone LA_BUSY"},
{"LA_OK": {"kind": "tone", "profile": "FR_FR", "event": "busy"}},
{"ok": True, "line": "OK PLAY"},
{
"audio": {
"storage_default_policy": "SD_THEN_LITTLEFS",
"storage_last_source": "LITTLEFS",
"storage_last_path": "/assets/wav/FR_FR/dial.wav",
"tone_route_active": True,
"tone_rendering": True,
"tone_active": True,
"tone_profile": "FR_FR",
"tone_event": "busy",
"tone_engine": "CODE",
"playback_sample_rate": 8000,
"playback_bits_per_sample": 16,
"playback_channels": 2,
"playback_format_overridden": False,
},
"config": {
"audio": {
"sample_rate": 8000,
"bits_per_sample": 16,
},
"dial_media_map": {"0123456789": {"kind": "tone", "profile": "FR_FR", "event": "ringback"}},
"espnow_call_map": {"LA_OK": {"kind": "tone", "profile": "FR_FR", "event": "busy"}},
},
},
{"ok": True, "line": "OK TONE_STOP"},
{
"audio": {
"tone_route_active": False,
"tone_rendering": False,
"tone_active": False,
"tone_profile": "NONE",
"tone_event": "none",
"tone_engine": "NONE",
}
},
{"ok": True, "line": "OK DIAL_MEDIA_MAP_RESET_VOLATILE"},
{"ok": True, "line": "OK ESPNOW_CALL_MAP_RESET_VOLATILE"},
]
)
result = scenario_serial_media_routing(fake)
self.assertEqual(result.state, "FAIL")
class SerialAudioFormatChainContractTest(unittest.TestCase):
def test_audio_format_chain_passes_with_probe_and_status_alignment(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "AUTO_NORMALIZE_LIMITER",
"wav_target_rms_dbfs": -18,
"wav_limiter_ceiling_dbfs": -2,
"wav_limiter_attack_ms": 8,
"wav_limiter_release_ms": 120,
},
{
"ok": True,
"path": "/welcome.wav",
"source": "LITTLEFS",
"input_sample_rate": 16000,
"input_bits_per_sample": 16,
"input_channels": 1,
"output_sample_rate": 16000,
"output_bits_per_sample": 16,
"output_channels": 2,
"resampler_active": False,
"channel_upmix_active": True,
"loudness_auto": True,
"loudness_gain_db": 2.5,
"limiter_active": False,
"rate_fallback": 0,
"data_size_bytes": 3200,
"duration_ms": 100,
},
{"ok": True, "line": "OK PLAY"},
{
"audio": {
"playing": True,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 16000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 16000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 2.5,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
{
"audio": {
"playing": False,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 16000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 16000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 2.5,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "PASS")
self.assertTrue(result.details.get("checks", {}).get("status_matches_probe_rate"))
self.assertTrue(result.details.get("checks", {}).get("status_playback_window_observed"))
self.assertTrue(result.details.get("checks", {}).get("status_playback_duration_within_tolerance"))
def test_audio_format_chain_fails_when_probe_not_available(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "AUTO_NORMALIZE_LIMITER",
},
{"ok": False, "line": "ERR AUDIO_PROBE file_not_found"},
{"ok": False, "line": "ERR AUDIO_PROBE file_not_found"},
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("audio_probe_found_supported_file", True))
def test_audio_format_chain_fails_when_playback_window_is_missed(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "AUTO_NORMALIZE_LIMITER",
},
{
"ok": True,
"path": "/welcome.wav",
"source": "LITTLEFS",
"input_sample_rate": 16000,
"input_bits_per_sample": 16,
"input_channels": 1,
"output_sample_rate": 16000,
"output_bits_per_sample": 16,
"output_channels": 2,
"resampler_active": False,
"channel_upmix_active": True,
"loudness_auto": True,
"loudness_gain_db": 1.0,
"limiter_active": False,
"rate_fallback": 0,
"data_size_bytes": 3200,
"duration_ms": 100,
},
{"ok": True, "line": "OK PLAY"},
*[
{
"audio": {
"playing": False,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 0,
"playback_input_bits_per_sample": 0,
"playback_input_channels": 0,
"playback_output_sample_rate": 16000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 0.0,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 0,
"playback_copy_accepted_bytes": 0,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
}
for _ in range(20)
],
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("status_playback_window_observed", True))
def test_audio_format_chain_fails_on_copy_loss_counters(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "AUTO_NORMALIZE_LIMITER",
},
{
"ok": True,
"path": "/welcome.wav",
"source": "LITTLEFS",
"input_sample_rate": 22050,
"input_bits_per_sample": 16,
"input_channels": 1,
"output_sample_rate": 22050,
"output_bits_per_sample": 16,
"output_channels": 2,
"resampler_active": False,
"channel_upmix_active": True,
"loudness_auto": True,
"loudness_gain_db": 1.0,
"limiter_active": False,
"rate_fallback": 0,
"data_size_bytes": 4410,
"duration_ms": 100,
},
{"ok": True, "line": "OK PLAY"},
{
"audio": {
"playing": True,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 22050,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 22050,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 1.0,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 900,
"playback_copy_loss_bytes": 124,
"playback_copy_loss_events": 1,
"playback_last_error": "copy_output_backpressure_drop",
}
},
{
"audio": {
"playing": False,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 22050,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 22050,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 1.0,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 900,
"playback_copy_loss_bytes": 124,
"playback_copy_loss_events": 1,
"playback_last_error": "copy_output_backpressure_drop",
}
},
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("status_copy_loss_events_zero", True))
def test_audio_format_chain_fails_when_playback_duration_is_out_of_tolerance(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "AUTO_NORMALIZE_LIMITER",
},
{
"ok": True,
"path": "/welcome.wav",
"source": "LITTLEFS",
"input_sample_rate": 16000,
"input_bits_per_sample": 16,
"input_channels": 1,
"output_sample_rate": 16000,
"output_bits_per_sample": 16,
"output_channels": 2,
"resampler_active": False,
"channel_upmix_active": True,
"loudness_auto": True,
"loudness_gain_db": 1.0,
"limiter_active": False,
"rate_fallback": 0,
"data_size_bytes": 3200,
"duration_ms": 400,
},
{"ok": True, "line": "OK PLAY"},
{
"audio": {
"playing": True,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 16000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 16000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 1.0,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
{
"audio": {
"playing": False,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 16000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 16000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 1.0,
"playback_limiter_active": False,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("status_playback_duration_within_tolerance", True))
def test_audio_format_chain_fails_when_loudness_policy_not_applied(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "FIXED_GAIN_ONLY",
},
{
"ok": True,
"path": "/welcome.wav",
"source": "LITTLEFS",
"input_sample_rate": 32000,
"input_bits_per_sample": 16,
"input_channels": 1,
"output_sample_rate": 32000,
"output_bits_per_sample": 16,
"output_channels": 2,
"resampler_active": False,
"channel_upmix_active": True,
"loudness_auto": True,
"loudness_gain_db": 3.0,
"limiter_active": True,
"rate_fallback": 0,
"data_size_bytes": 6400,
"duration_ms": 100,
},
{"ok": True, "line": "OK PLAY"},
{
"audio": {
"playing": True,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 32000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 32000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 3.0,
"playback_limiter_active": True,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
{
"audio": {
"playing": False,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 32000,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 1,
"playback_output_sample_rate": 32000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": False,
"playback_channel_upmix_active": True,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 3.0,
"playback_limiter_active": True,
"playback_rate_fallback": 0,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("probe_loudness_matches_policy", True))
def test_audio_format_chain_fails_when_adaptive_rate_is_not_honored(self) -> None:
fake = _FakeSerialEndpoint(
[
{
"clock_policy": "HYBRID_TELCO",
"wav_loudness_policy": "AUTO_NORMALIZE_LIMITER",
},
{
"ok": True,
"path": "/welcome.wav",
"source": "LITTLEFS",
"input_sample_rate": 44100,
"input_bits_per_sample": 16,
"input_channels": 2,
"output_sample_rate": 8000,
"output_bits_per_sample": 16,
"output_channels": 2,
"resampler_active": True,
"channel_upmix_active": False,
"loudness_auto": True,
"loudness_gain_db": 0.5,
"limiter_active": False,
"rate_fallback": 8000,
"data_size_bytes": 8820,
"duration_ms": 100,
},
{"ok": True, "line": "OK PLAY"},
{
"audio": {
"playing": True,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 44100,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 2,
"playback_output_sample_rate": 8000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": True,
"playback_channel_upmix_active": False,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 0.5,
"playback_limiter_active": False,
"playback_rate_fallback": 8000,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
{
"audio": {
"playing": False,
"tone_route_active": False,
"tone_rendering": False,
"playback_input_sample_rate": 44100,
"playback_input_bits_per_sample": 16,
"playback_input_channels": 2,
"playback_output_sample_rate": 8000,
"playback_output_bits_per_sample": 16,
"playback_output_channels": 2,
"playback_resampler_active": True,
"playback_channel_upmix_active": False,
"playback_loudness_auto": True,
"playback_loudness_gain_db": 0.5,
"playback_limiter_active": False,
"playback_rate_fallback": 8000,
"playback_copy_source_bytes": 1024,
"playback_copy_accepted_bytes": 1024,
"playback_copy_loss_bytes": 0,
"playback_copy_loss_events": 0,
"playback_last_error": "",
}
},
]
)
result = scenario_serial_audio_format_chain(fake, "/welcome.wav")
self.assertEqual(result.state, "FAIL")
self.assertFalse(result.details.get("checks", {}).get("audio_probe_adaptive_rate_expected", True))
if __name__ == "__main__":
unittest.main()
@@ -1,144 +0,0 @@
#!/usr/bin/env python3
"""Extra contract tests for firmware runtime/API guards."""
from __future__ import annotations
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read_source(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
def extract_route_block(source: str, route: str) -> str:
marker = f'server_.on("{route}"'
start = source.find(marker)
assert start >= 0, f"route {route} not found"
brace_start = source.find("{", start)
if brace_start < 0:
return source[start : start + 4096]
depth = 0
in_line_comment = False
in_block_comment = False
in_string = None
in_char = False
escaped = False
end = brace_start
for index in range(brace_start, len(source)):
ch = source[index]
nxt = source[index + 1] if index + 1 < len(source) else ""
if in_line_comment:
if ch == "\n":
in_line_comment = False
continue
if in_block_comment:
if ch == "*" and nxt == "/":
in_block_comment = False
continue
if in_string is not None:
if escaped:
escaped = False
continue
if ch == "\\":
escaped = True
continue
if ch == '"':
in_string = None
continue
if in_char:
if escaped:
escaped = False
continue
if ch == "\\":
escaped = True
continue
if ch == "'":
in_char = False
continue
if ch == '/' and nxt == '/':
in_line_comment = True
continue
if ch == '/' and nxt == '*':
in_block_comment = True
continue
if ch == '"':
in_string = '"'
continue
if ch == "'":
in_char = True
continue
if ch == '{':
depth += 1
continue
if ch == '}':
depth -= 1
if depth == 0:
end = index
break
return source[start : end + 1]
def read_main() -> str:
return read_source("src", "main.cpp")
def read_webserver() -> str:
return read_source("src", "web", "WebServerManager.cpp")
def read_dispatcher() -> str:
return read_source("src", "config", "A252ConfigStore.cpp")
class RuntimeContractTests(unittest.TestCase):
def test_unknown_dispatched_command_is_rejected(self) -> None:
src = read_webserver()
self.assertIn("!isCommandRegistered(command_line, command_validator_)", src)
self.assertIn("unsupported_command", src)
def test_espnow_send_requires_explicit_mac(self) -> None:
src = read_webserver()
block = extract_route_block(src, "/api/network/espnow/send")
self.assertIn('const String mac = doc["mac"] | "";', block)
self.assertIn("isValidInput(mac, 32)", block)
self.assertIn("ESPNOW_SEND", block)
def test_wifi_loop_is_invoked(self) -> None:
src = read_main()
self.assertRegex(src, r"\bg_wifi\.loop\(\);")
def test_auth_is_disabled_for_dispatch_paths_by_default(self) -> None:
main = read_main()
self.assertIn("kWebAuthEnabledByDefault", main)
web = read_webserver()
self.assertIn("kWebAuthEnabledByDefault", main)
self.assertIn("kWebAuthEnabledByDefault = false", main)
self.assertIn("authenticateRequest(request)", web)
def test_dev_auth_override_is_local_flagged(self) -> None:
main = read_main()
self.assertIn("RTC_WEB_AUTH_DEV_DISABLE", main)
self.assertIn("!kWebAuthLocalDisableEnabled", main)
def test_gpio_validation_blocks_invalid_values_in_source(self) -> None:
src = read_dispatcher()
self.assertIn("const int required_pins[]", src)
self.assertIn("pin < 0", src)
self.assertIn("cfg.slic_adc_in", src)
if __name__ == "__main__":
unittest.main()
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/branch_gate.sh" --skip-builds "$@"
@@ -1,223 +0,0 @@
"""PlatformIO custom target: create and flash FFat image for A252."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
def _parse_int(value: str) -> int:
value = value.strip()
if value.lower().startswith("0x"):
return int(value, 16)
return int(value)
def _normalize_flash_freq(value: str) -> str:
if not value:
return "80m"
normalized = value.strip().lower()
if normalized.endswith("l"):
normalized = normalized[:-1]
if normalized.isdigit():
freq_hz = int(normalized)
if freq_hz % 1_000_000 == 0:
return f"{freq_hz // 1_000_000}m"
return str(freq_hz)
return normalized
def _is_true(value: str) -> bool:
return (value or "").lower() in {"1", "true", "yes", "on"}
def _should_skip_tone_wavs(audio_dir: Path, path: Path, include_tone_wavs: bool) -> bool:
if include_tone_wavs:
return False
relative_parts = path.relative_to(audio_dir).parts
return len(relative_parts) >= 3 and tuple(relative_parts[:2]) == ("assets", "wav")
def _run(command):
print("[upload_ffat] $ {}".format(" ".join(command)))
result = subprocess.run(command, check=False)
if result.returncode:
raise RuntimeError(f"Command failed ({command[0]}): {result.returncode}")
def _find_tool(tool: str, project_dir: Path) -> str:
path = shutil.which(tool)
if path:
return path
platformio_home = Path.home() / ".platformio" / "packages"
project_package = Path(os.environ.get("PIO_PACKAGES_DIR", str(project_dir / ".pio" / "packages")))
if tool == "mkfatfs":
candidates = [
project_package / "tool-mkfatfs" / "mkfatfs",
platformio_home / "tool-mkfatfs" / "mkfatfs",
]
elif tool == "esptool.py":
candidates = [
project_package / "tool-esptoolpy" / "esptool.py",
platformio_home / "tool-esptoolpy" / "esptool.py",
]
else:
candidates = [Path(tool)]
for candidate in candidates:
if candidate.exists():
return str(candidate)
raise RuntimeError(f"Unable to locate '{tool}'.")
def _find_partition(partitions_path: Path, part_name: str) -> tuple[int, int]:
if not partitions_path.exists():
raise FileNotFoundError(f"Partition table not found: {partitions_path}")
for line in partitions_path.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
parts = [segment.strip() for segment in re.split(r",\s*", line) if segment.strip()]
if len(parts) < 5:
continue
name, _part_type, _sub_type, offset, size = parts[:5]
if name == part_name:
return _parse_int(offset), _parse_int(size)
raise RuntimeError(f"Could not find '{part_name}' partition in partition table.")
def _build_ffat_image(mkfatfs: str, webui_dir: Path, audio_dir: Path, image_path: Path, image_size: int) -> None:
include_tone_wavs = _is_true(os.environ.get("A252_FFAT_INCLUDE_TONE_WAV", "0"))
with tempfile.TemporaryDirectory(prefix="ffat_", dir=None) as staging_root:
staging_dir = Path(staging_root)
include_webui = os.environ.get("A252_FFAT_INCLUDE_WEBUI", "").lower() in {"1", "true", "yes", "on"}
if include_webui and webui_dir.is_dir():
shutil.copytree(webui_dir, staging_dir / "webui")
if audio_dir.is_dir():
# Keep same runtime paths for media playback.
# By default, keep tone assets out of FFat (now code-synthesized) and
# retain non-tone media only.
# Set A252_FFAT_INCLUDE_TONE_WAV=1 to keep tone WAV assets.
for source_file in audio_dir.rglob("*"):
if not source_file.is_file():
continue
if _should_skip_tone_wavs(audio_dir, source_file, include_tone_wavs):
continue
relative = source_file.relative_to(audio_dir)
target = staging_dir / relative
target.parent.mkdir(parents=True, exist_ok=True);
shutil.copy2(source_file, target)
if image_path.exists():
image_path.unlink()
_run([mkfatfs, "-c", str(staging_dir), "-t", "fatfs", "-s", str(image_size), str(image_path)])
def _upload_image(
esptool: str,
python_exec: str,
port: str,
flash_args: dict,
offset: int,
image_path: Path,
) -> None:
command = [
python_exec,
esptool,
"--port",
port,
"--baud",
flash_args["baud"],
"--before",
flash_args["before"],
"--after",
flash_args["after"],
"write_flash",
"-z",
"--flash_mode",
flash_args["mode"],
"--flash_freq",
flash_args["freq"],
"--flash_size",
flash_args["size"],
hex(offset),
str(image_path),
]
_run(command)
def _target_upload_ffat(source, target, env):
project_dir = Path(env.subst("${PROJECT_DIR}"))
build_dir = Path(env.subst("${BUILD_DIR}"))
build_dir.mkdir(parents=True, exist_ok=True)
board = env.BoardConfig()
partition_path_raw = env.GetProjectConfig().get("env:%s" % env["PIOENV"], "board_build.partitions")
if not partition_path_raw:
partition_path_raw = board.get("build", {}).get("partitions")
if not partition_path_raw:
raise RuntimeError("No partition table configured for this environment.")
partitions_path = Path(partition_path_raw)
if not partitions_path.is_absolute():
partitions_path = project_dir / partitions_path
partition_offset, partition_size = _find_partition(partitions_path, "ffat")
webui_dir = project_dir / "data" / "webui"
audio_dir = project_dir / "data" / "audio"
image_path = build_dir / "ffat.bin"
mkfatfs = _find_tool("mkfatfs", project_dir)
_build_ffat_image(mkfatfs, webui_dir, audio_dir, image_path, partition_size)
print(
"[upload_ffat] built image "
f"{image_path} ({image_path.stat().st_size} bytes, partition 0x{partition_offset:06x} / 0x{partition_size:06x})"
)
upload_port = (
os.environ.get("PIO_UPLOAD_PORT")
or os.environ.get("UPLOAD_PORT")
or env.get("UPLOAD_PORT")
or env.GetProjectOption("upload_port", None)
)
if not upload_port:
print("[upload_ffat] skipped flash step (no upload port), image ready")
return
esptool = _find_tool("esptool.py", project_dir)
python_exec = env.get("PYTHONEXE", shutil.which("python3") or "python3")
board_upload = board.get("upload", {})
board_build = board.get("build", {})
flash_args = {
"baud": str(board_upload.get("speed", env.get("UPLOAD_SPEED", "460800"))),
"before": env.get("UPLOAD_BEFORE", "default_reset"),
"after": env.get("UPLOAD_AFTER", "hard_reset"),
"mode": env.get("FLASH_MODE", board_build.get("flash_mode", "dio")),
"freq": _normalize_flash_freq(board_build.get("f_flash", env.get("F_FLASH", "80m"))),
"size": board_upload.get("flash_size", env.get("FLASH_SIZE", "4MB")),
}
_upload_image(esptool, python_exec, upload_port, flash_args, partition_offset, image_path)
print(f"[upload_ffat] flashed {image_path.name} at 0x{partition_offset:06x}")
Import("env")
if hasattr(env, "AddCustomTarget"):
env.AddCustomTarget(
name="upload_ffat",
dependencies=[],
actions=[_target_upload_ffat],
title="Upload FFat",
description="Build and flash FFat image for A252",
)
@@ -1,250 +0,0 @@
"""PlatformIO custom target: create and flash FFat USB-MSC image."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
def _parse_int(value: str) -> int:
value = value.strip()
if value.lower().startswith("0x"):
return int(value, 16)
return int(value)
def _normalize_flash_freq(value: str) -> str:
if not value:
return "80m"
normalized = value.strip().lower()
if normalized.endswith("l"):
normalized = normalized[:-1]
if normalized.isdigit():
freq_hz = int(normalized)
if freq_hz % 1_000_000 == 0:
return f"{freq_hz // 1_000_000}m"
return str(freq_hz)
return normalized
def _run(command):
print("[upload_usbmsc] $ {}".format(" ".join(command)))
result = subprocess.run(command, check=False)
if result.returncode:
raise RuntimeError(f"Command failed ({command[0]}): {result.returncode}")
def _find_tool(tool: str, project_dir: Path, env) -> str:
path = shutil.which(tool)
if path:
return path
platformio_home = Path.home() / ".platformio" / "packages"
project_package = Path(os.environ.get("PIO_PACKAGES_DIR", str(project_dir / ".pio" / "packages")))
candidates = []
if tool == "mkfatfs":
candidates.extend(
[
project_package / "tool-mkfatfs" / "mkfatfs",
platformio_home / "tool-mkfatfs" / "mkfatfs",
]
)
elif tool == "esptool.py":
candidates.extend(
[
project_package / "tool-esptoolpy" / "esptool.py",
platformio_home / "tool-esptoolpy" / "esptool.py",
]
)
else:
candidates.append(Path(tool))
for candidate in candidates:
if candidate.exists():
return str(candidate)
raise RuntimeError(f"Unable to locate '{tool}'. Install PlatformIO package dependencies first.")
def _find_usbmsc_partition(partitions_path: Path) -> tuple[int, int]:
if not partitions_path.exists():
raise FileNotFoundError(f"Partition table not found: {partitions_path}")
for line in partitions_path.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
parts = [segment.strip() for segment in re.split(r",\s*", line) if segment.strip()]
if len(parts) < 5:
continue
name, _part_type, _sub_type, offset, size = parts[:5]
if name == "usbmsc":
return _parse_int(offset), _parse_int(size)
raise RuntimeError("Could not find 'usbmsc' partition in partition table.")
def _build_usbmsc_image(
mkfatfs: str, webui_dir: Path, audio_dir: Path, image_path: Path, image_size: int
) -> None:
with tempfile.TemporaryDirectory(prefix="usbmsc_", dir=None) as staging_root:
staging_dir = Path(staging_root)
if webui_dir.is_dir():
shutil.copytree(webui_dir, staging_dir / "webui")
else:
raise RuntimeError(f"WebUI folder not found: {webui_dir}")
if audio_dir.is_dir():
for item in audio_dir.iterdir():
target = staging_dir / item.name
if item.is_dir():
if target.exists():
# Merge directories when both roots share a path.
for sub in item.rglob("*"):
rel = sub.relative_to(item)
if sub.is_dir():
(target / rel).mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(sub, target / rel)
else:
shutil.copytree(item, target)
elif item.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
if image_path.exists():
image_path.unlink()
_run([mkfatfs, "-c", str(staging_dir), "-t", "fatfs", "-s", str(image_size), str(image_path)])
def _upload_usbmsc_image(
esptool: str,
python_exec: str,
port: str,
flash_args: dict,
offset: int,
image_path: Path,
) -> None:
command = [
python_exec,
esptool,
"--port",
port,
"--baud",
flash_args["baud"],
"--before",
flash_args["before"],
"--after",
flash_args["after"],
"write_flash",
"-z",
"--flash_mode",
flash_args["mode"],
"--flash_freq",
flash_args["freq"],
"--flash_size",
flash_args["size"],
hex(offset),
str(image_path),
]
_run(command)
def _target_upload_usbmsc(source, target, env):
project_dir = Path(env.subst("${PROJECT_DIR}"))
build_dir = Path(env.subst("${BUILD_DIR}"))
build_dir.mkdir(parents=True, exist_ok=True)
board = env.BoardConfig()
partition_path_raw = env.GetProjectConfig().get("env:%s" % env["PIOENV"], "board_build.partitions")
if not partition_path_raw:
partition_path_raw = board.get("build", {}).get("partitions")
if not partition_path_raw:
raise RuntimeError("No partition table configured for this environment.")
partitions_path = Path(partition_path_raw)
if not partitions_path.is_absolute():
partitions_path = project_dir / partitions_path
partition_offset, partition_size = _find_usbmsc_partition(partitions_path)
if partition_size <= 0:
raise RuntimeError("USB-MSC partition has invalid size.")
webui_dir = project_dir / "data" / "webui"
audio_dir = project_dir / "data" / "audio"
image_path = build_dir / "usbmsc.bin"
mkfatfs = _find_tool("mkfatfs", project_dir, env)
_build_usbmsc_image(mkfatfs, webui_dir, audio_dir, image_path, partition_size)
print(
"[upload_usbmsc] built image "
f"{image_path} ({image_path.stat().st_size} bytes, partition 0x{partition_offset:06x} / 0x{partition_size:06x})"
)
dry_run = os.environ.get("USB_MSC_DRY_RUN", "").lower() in {"1", "true", "yes", "on"}
if dry_run:
print("[upload_usbmsc] dry run enabled, skipping flash step")
return
upload_port = (
env.GetProjectOption("upload_port", None)
or env.get("UPLOAD_PORT")
or os.environ.get("PIO_UPLOAD_PORT")
or os.environ.get("UPLOAD_PORT")
)
if not upload_port:
print("[upload_usbmsc] skipped flash step (no upload port), image ready")
return
esptool = _find_tool("esptool.py", project_dir, env)
python_exec = env.get("PYTHONEXE", shutil.which("python3") or "python3")
board_upload = board.get("upload", {})
board_build = board.get("build", {})
flash_args = {
"baud": str(board_upload.get("speed", env.get("UPLOAD_SPEED", "460800"))),
"before": env.get("UPLOAD_BEFORE", "default_reset"),
"after": env.get("UPLOAD_AFTER", "hard_reset"),
"mode": env.get("FLASH_MODE", board_build.get("flash_mode", "dio")),
"freq": _normalize_flash_freq(board_build.get("f_flash", env.get("F_FLASH", "80m"))),
"size": board_upload.get("flash_size", env.get("FLASH_SIZE", "8MB")),
}
_upload_usbmsc_image(esptool, python_exec, upload_port, flash_args, partition_offset, image_path)
print(f"[upload_usbmsc] flashed {image_path.name} at 0x{partition_offset:06x}")
def _target_upload_usbmsc_dryrun(source, target, env):
previous = os.environ.get("USB_MSC_DRY_RUN")
os.environ["USB_MSC_DRY_RUN"] = "1"
try:
_target_upload_usbmsc(source, target, env)
finally:
if previous is None:
os.environ.pop("USB_MSC_DRY_RUN", None)
else:
os.environ["USB_MSC_DRY_RUN"] = previous
Import("env")
if hasattr(env, "AddCustomTarget"):
env.AddCustomTarget(
name="upload_usbmsc",
dependencies=[],
actions=[_target_upload_usbmsc],
title="Upload USB-MSC",
description="Build and flash FFat USB-MSC webui image",
)
env.AddCustomTarget(
name="upload_usbmsc_dryrun",
dependencies=[],
actions=[_target_upload_usbmsc_dryrun],
title="Upload USB-MSC (dry run)",
description="Build FFat USB-MSC webui image without flashing",
)
@@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Run ZeroClaw USB discovery/introspection before hardware actions."""
from __future__ import annotations
import argparse
import glob
import os
import shutil
import subprocess
import sys
from pathlib import Path
def resolve_bin(requested: str) -> str:
if os.path.sep in requested:
if not Path(requested).exists():
raise FileNotFoundError(f"ZeroClaw binary not found: {requested}")
return requested
resolved = shutil.which(requested)
if not resolved:
raise FileNotFoundError(
"ZeroClaw binary not found in PATH. Set --zeroclaw-bin explicitly."
)
return resolved
def default_ports() -> list[str]:
patterns = [
"/dev/cu.usbserial*",
"/dev/tty.usbserial-*",
"/dev/tty.SLAB_USBtoUART",
"/dev/tty.usbmodem*",
]
ports: list[str] = []
for pattern in patterns:
ports.extend(glob.glob(pattern))
return sorted(set(ports))
def run_command(cmd: list[str]) -> int:
print(f"$ {' '.join(cmd)}")
proc = subprocess.run(cmd, check=False)
return proc.returncode
def main() -> int:
parser = argparse.ArgumentParser(
description="ZeroClaw preflight for RTC hardware sessions."
)
parser.add_argument(
"--zeroclaw-bin",
default=os.environ.get("ZEROCLAW_BIN", "zeroclaw"),
help="Path/name of zeroclaw binary (default: zeroclaw in PATH).",
)
parser.add_argument(
"--port",
action="append",
default=[],
help="Explicit serial port to introspect (repeatable).",
)
parser.add_argument(
"--require-port",
action="store_true",
help="Fail if no serial port candidate is detected.",
)
args = parser.parse_args()
try:
zc_bin = resolve_bin(args.zeroclaw_bin)
except FileNotFoundError as exc:
print(f"[fail] {exc}", file=sys.stderr)
return 2
if run_command([zc_bin, "hardware", "discover"]) != 0:
print("[fail] zeroclaw hardware discover failed", file=sys.stderr)
return 3
ports = args.port if args.port else default_ports()
if not ports:
message = "[warn] no candidate serial ports detected."
if args.require_port:
print(message, file=sys.stderr)
return 4
print(message)
return 0
failures = 0
for port in ports:
rc = run_command([zc_bin, "hardware", "introspect", port])
if rc != 0:
failures += 1
if failures:
print(f"[fail] introspection failed on {failures} port(s).", file=sys.stderr)
return 5
print("[ok] zeroclaw hardware preflight passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""ZeroClaw Docker orchestrator health check for A252 strict gate."""
from __future__ import annotations
import argparse
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
from urllib import error, request
@dataclass
class StepResult:
name: str
ok: bool
details: Dict[str, Any]
def ensure_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def request_json(method: str, url: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
body = None
headers: Dict[str, str] = {}
if payload is not None:
body = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = request.Request(url, method=method, data=body, headers=headers)
with request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8")
if not raw:
return {}
return json.loads(raw)
def run_step(name: str, method: str, url: str, payload: Optional[Dict[str, Any]] = None) -> StepResult:
try:
data = request_json(method, url, payload)
return StepResult(name=name, ok=True, details={"url": url, "response": data})
except error.HTTPError as exc:
details: Dict[str, Any] = {"url": url, "error": f"HTTP {exc.code}"}
try:
details["body"] = exc.read().decode("utf-8")
except Exception:
details["body"] = ""
return StepResult(name=name, ok=False, details=details)
except Exception as exc:
return StepResult(name=name, ok=False, details={"url": url, "error": str(exc)})
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="ZeroClaw orchestrator health check")
parser.add_argument(
"--base-url",
default=os.environ.get("ZEROCLAW_ORCH", "http://127.0.0.1:8788"),
help="ZeroClaw orchestrator base URL (default: $ZEROCLAW_ORCH or http://127.0.0.1:8788)",
)
parser.add_argument(
"--report-json",
default="artifacts/zeroclaw_orchestrator_health.json",
help="JSON report output path",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
base_url = args.base_url.rstrip("/")
steps = [
run_step("status", "GET", f"{base_url}/api/status"),
run_step("agents", "GET", f"{base_url}/api/agents"),
run_step("workflows", "GET", f"{base_url}/api/workflows"),
run_step("provider_scan", "POST", f"{base_url}/api/run", {"action": "provider_scan"}),
]
overall_ok = all(step.ok for step in steps)
report = {
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"base_url": base_url,
"overall_passed": overall_ok,
"results": [
{"name": step.name, "state": "PASS" if step.ok else "FAIL", "details": step.details} for step in steps
],
}
report_path = Path(args.report_json)
ensure_parent(report_path)
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return 0 if overall_ok else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,108 +0,0 @@
#include "AudioCodec.h"
#include <driver/i2s.h>
#include <Wire.h>
#include "config/a1s_board_pins.h"
// Documentation technique :
// ES8388 : Codec I2S + I2C, contrôle volume/mute/routage via registres.
// PCM5102 : Codec I2S, volume/mute via atténuation ou pin externe.
// Routage audio : géré par setRoute, peut impliquer multiplexeur/relais.
// Extensibilité : ajouter un nouveau codec = nouvelle classe dérivée.
// Testabilité : mock des méthodes, logs sur chaque action.
// --- ES8388Codec ---
bool ES8388Codec::init() {
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_RX),
.sample_rate = 16000,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0,
.dma_buf_count = 8,
.dma_buf_len = 64,
.use_apll = false,
.tx_desc_auto_clear = true,
.fixed_mclk = 0
};
i2s_pin_config_t pin_config = {
.bck_io_num = A1S_I2S_BCLK,
.ws_io_num = A1S_I2S_LRCK,
.data_out_num = A1S_I2S_DOUT,
.data_in_num = A1S_I2S_DIN
};
esp_err_t err = i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
if (err != ESP_OK) return false;
err = i2s_set_pin(I2S_NUM_0, &pin_config);
if (err != ESP_OK) return false;
Wire.begin(A1S_I2C_SDA, A1S_I2C_SCL);
setVolume(80);
return true;
}
bool ES8388Codec::setVolume(uint8_t volume) {
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
Wire.write(0x2B);
Wire.write(volume);
Wire.endTransmission();
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
Wire.write(0x2C);
Wire.write(volume);
Wire.endTransmission();
return true;
}
bool ES8388Codec::mute(bool state) {
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
Wire.write(0x2F);
Wire.write(state ? 0x01 : 0x00);
Wire.endTransmission();
return true;
}
bool ES8388Codec::setRoute(AudioRoute route) {
Wire.beginTransmission(A1S_ES8388_I2C_ADDR);
Wire.write(0x30);
Wire.write(route == ROUTE_BLUETOOTH ? 0x01 : 0x00);
Wire.endTransmission();
return true;
}
bool PCM5102Codec::init() {
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = 16000,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0,
.dma_buf_count = 8,
.dma_buf_len = 64,
.use_apll = false,
.tx_desc_auto_clear = true,
.fixed_mclk = 0
};
i2s_pin_config_t pin_config = {
.bck_io_num = A1S_I2S_BCLK,
.ws_io_num = A1S_I2S_LRCK,
.data_out_num = A1S_I2S_DOUT,
.data_in_num = A1S_I2S_DIN
};
esp_err_t err = i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
if (err != ESP_OK) return false;
err = i2s_set_pin(I2S_NUM_0, &pin_config);
if (err != ESP_OK) return false;
setVolume(80);
return true;
}
bool PCM5102Codec::setVolume(uint8_t volume) {
return true;
}
bool PCM5102Codec::mute(bool state) {
return true;
}
bool PCM5102Codec::setRoute(AudioRoute route) {
return true;
}
@@ -1,49 +0,0 @@
// Interface générique pour codec audio (I2S)
// Permet d'abstraire ES8388, PCM5102, et une interface générique
#ifndef AUDIO_CODEC_H
#define AUDIO_CODEC_H
#include <Arduino.h>
enum AudioRoute {
ROUTE_RTC,
ROUTE_BLUETOOTH,
ROUTE_NONE
};
class AudioCodec {
public:
virtual bool init() = 0;
virtual bool setVolume(uint8_t volume) = 0;
virtual bool mute(bool state) = 0;
virtual bool setRoute(AudioRoute route) = 0;
virtual ~AudioCodec() {}
};
class ES8388Codec : public AudioCodec {
public:
bool init() override;
bool setVolume(uint8_t volume) override;
bool mute(bool state) override;
bool setRoute(AudioRoute route) override;
};
class PCM5102Codec : public AudioCodec {
public:
bool init() override;
bool setVolume(uint8_t volume) override;
bool mute(bool state) override;
bool setRoute(AudioRoute route) override;
};
class GenericCodec : public AudioCodec {
public:
bool init() override { return true; }
bool setVolume(uint8_t) override { return true; }
bool mute(bool) override { return true; }
bool setRoute(AudioRoute) override { return true; }
};
#endif // AUDIO_CODEC_H
@@ -1,42 +0,0 @@
#include "AudioFilePlayer.h"
AudioFilePlayer::AudioFilePlayer()
: sd_ready_(false), playing_(false), play_until_ms_(0), current_file_("") {}
bool AudioFilePlayer::begin() {
sd_ready_ = SD.begin();
if (!sd_ready_) {
Serial.println("[AudioFilePlayer] SD init failed");
}
return sd_ready_;
}
bool AudioFilePlayer::play(const char* filename) {
if (!sd_ready_ || filename == nullptr || filename[0] == '\0') {
return false;
}
if (!SD.exists(filename)) {
Serial.printf("[AudioFilePlayer] File not found: %s\n", filename);
return false;
}
current_file_ = filename;
playing_ = true;
play_until_ms_ = millis() + 3000;
Serial.printf("[AudioFilePlayer] Playing %s\n", filename);
return true;
}
void AudioFilePlayer::loop() {
if (playing_ && millis() >= play_until_ms_) {
playing_ = false;
Serial.printf("[AudioFilePlayer] Playback finished: %s\n", current_file_.c_str());
}
}
void AudioFilePlayer::stop() {
playing_ = false;
}
bool AudioFilePlayer::isPlaying() const {
return playing_;
}
@@ -1,23 +0,0 @@
#ifndef AUDIO_FILE_PLAYER_H
#define AUDIO_FILE_PLAYER_H
#include <Arduino.h>
#include <SD.h>
class AudioFilePlayer {
public:
AudioFilePlayer();
bool begin();
bool play(const char* filename);
void loop();
void stop();
bool isPlaying() const;
private:
bool sd_ready_;
bool playing_;
uint32_t play_until_ms_;
String current_file_;
};
#endif // AUDIO_FILE_PLAYER_H
@@ -1,31 +0,0 @@
#include "SlicK50835F.h"
SlicK50835F::SlicK50835F(uint8_t pinHook, uint8_t pinRingCmd, uint8_t pinLineSense)
: _pinHook(pinHook), _pinRingCmd(pinRingCmd), _pinLineSense(pinLineSense), _hookState(false), _lineState(false) {}
void SlicK50835F::begin() {
pinMode(_pinHook, INPUT_PULLUP);
pinMode(_pinRingCmd, OUTPUT);
pinMode(_pinLineSense, INPUT);
digitalWrite(_pinRingCmd, LOW);
}
void SlicK50835F::setRing(bool enable) {
digitalWrite(_pinRingCmd, enable ? HIGH : LOW);
}
bool SlicK50835F::isHookOn() {
_hookState = digitalRead(_pinHook) == LOW;
return _hookState;
}
bool SlicK50835F::isLineActive() {
_lineState = digitalRead(_pinLineSense) == HIGH;
return _lineState;
}
void SlicK50835F::loop() {
// Mettre à jour les états, ajouter logique avancée si besoin
isHookOn();
isLineActive();
}
@@ -1,26 +0,0 @@
// Classe d'abstraction pour le SLIC K50835F (AG1171S)
// Permet de piloter la ligne RTC, le hook, la sonnerie et la détection d'état
#ifndef SLIC_K50835F_H
#define SLIC_K50835F_H
#include <Arduino.h>
class SlicK50835F {
public:
SlicK50835F(uint8_t pinHook, uint8_t pinRingCmd, uint8_t pinLineSense);
void begin();
void setRing(bool enable);
bool isHookOn();
bool isLineActive();
void loop();
private:
uint8_t _pinHook;
uint8_t _pinRingCmd;
uint8_t _pinLineSense;
bool _hookState;
bool _lineState;
};
#endif // SLIC_K50835F_H
File diff suppressed because it is too large Load Diff
@@ -1,341 +0,0 @@
#ifndef AUDIO_ENGINE_H
#define AUDIO_ENGINE_H
#include <Arduino.h>
#include <AudioTools.h>
#include <FS.h>
#include <driver/i2s.h>
#include <memory>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#include "core/PlatformProfile.h"
#include "audio/ToneCatalog.h"
#include "media/MediaRouting.h"
class AudioFileSourceFS;
class AudioGeneratorMP3;
struct AudioConfig {
i2s_port_t port = I2S_NUM_0;
uint32_t sample_rate = 16000;
i2s_bits_per_sample_t bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
i2s_channel_fmt_t channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT;
int bck_pin = 27;
int ws_pin = 25;
int data_out_pin = 26;
int data_in_pin = 35;
int capture_adc_pin = -1;
bool enable_capture = true;
bool adc_dsp_enabled = true;
bool adc_fft_enabled = true;
uint8_t adc_dsp_fft_downsample = 2U;
uint16_t adc_fft_ignore_low_bin = 1U;
uint16_t adc_fft_ignore_high_bin = 1U;
uint8_t dma_buf_count = 8;
uint16_t dma_buf_len = 256;
bool hybrid_telco_clock_policy = true;
bool wav_auto_normalize_limiter = true;
int16_t wav_target_rms_dbfs = -18;
int16_t wav_limiter_ceiling_dbfs = -2;
uint16_t wav_limiter_attack_ms = 8;
uint16_t wav_limiter_release_ms = 120;
};
struct AudioRuntimeMetrics {
uint32_t frames_requested = 0;
uint32_t frames_read = 0;
uint32_t drop_frames = 0;
uint32_t underrun_count = 0;
uint32_t last_latency_ms = 0;
uint32_t max_latency_ms = 0;
uint16_t adc_fft_peak_bin = 0;
uint16_t adc_fft_probe_rate_hz = 0;
float adc_fft_peak_freq_hz = 0.0f;
float adc_fft_peak_magnitude = 0.0f;
uint32_t tone_jitter_us_max = 0;
uint32_t tone_write_miss_count = 0;
};
struct AudioPlaybackProbeResult {
bool ok = false;
String error;
String path;
MediaSource source = MediaSource::AUTO;
uint32_t input_sample_rate = 0;
uint8_t input_bits_per_sample = 0;
uint8_t input_channels = 0;
uint32_t output_sample_rate = 0;
uint8_t output_bits_per_sample = 0;
uint8_t output_channels = 0;
bool resampler_active = false;
bool channel_upmix_active = false;
bool loudness_auto = false;
float loudness_gain_db = 0.0f;
bool limiter_active = false;
uint32_t rate_fallback = 0;
uint32_t data_size_bytes = 0;
uint32_t duration_ms = 0;
};
AudioConfig defaultAudioConfigForProfile(BoardProfile profile);
class AudioEngine {
public:
enum CaptureClient : uint8_t {
CAPTURE_CLIENT_GENERIC = 0x01,
CAPTURE_CLIENT_TELEPHONY = 0x02,
CAPTURE_CLIENT_BLUETOOTH = 0x04,
};
virtual ~AudioEngine();
AudioEngine();
virtual bool begin(const AudioConfig& config);
virtual void end();
virtual bool playFile(const char* path);
virtual bool playFileFromSource(const char* path, MediaSource source);
virtual bool playFileWithPolicy(const char* path);
virtual void stopPlayback();
virtual bool requestCapture(CaptureClient client);
virtual void releaseCapture(CaptureClient client);
virtual bool startCapture();
virtual size_t readCaptureFrame(int16_t* dst, size_t samples);
virtual size_t readCaptureFrameNonBlocking(int16_t* dst, size_t samples);
virtual size_t writePlaybackFrame(const int16_t* src, size_t samples);
virtual void stopCapture();
virtual bool playTone(ToneProfile profile, ToneEvent event);
virtual void stopTone();
virtual bool isToneActive() const;
virtual bool isToneRouteActive() const;
virtual bool isToneRenderingActive() const;
virtual ToneProfile activeToneProfile() const;
virtual ToneEvent activeToneEvent() const;
virtual bool startDialTone();
virtual void stopDialTone();
virtual uint16_t playbackInputSampleRate() const;
virtual uint8_t playbackInputBitsPerSample() const;
virtual uint8_t playbackInputChannels() const;
virtual uint16_t playbackOutputSampleRate() const;
virtual uint8_t playbackOutputBitsPerSample() const;
virtual uint8_t playbackOutputChannels() const;
virtual bool playbackResamplerActive() const;
virtual bool playbackChannelUpmixActive() const;
virtual bool playbackLoudnessAuto() const;
virtual float playbackLoudnessGainDb() const;
virtual bool playbackLimiterActive() const;
virtual uint32_t playbackRateFallback() const;
virtual uint32_t playbackCopySourceBytes() const;
virtual uint32_t playbackCopyAcceptedBytes() const;
virtual uint32_t playbackCopyLossBytes() const;
virtual uint32_t playbackCopyLossEvents() const;
virtual String playbackLastError() const;
virtual uint16_t playbackSampleRate() const;
virtual uint8_t playbackBitsPerSample() const;
virtual uint8_t playbackChannels() const;
virtual bool playbackFormatOverridden() const;
virtual uint32_t toneJitterUsMax() const;
virtual uint32_t toneWriteMissCount() const;
virtual bool isDialToneActive() const;
virtual bool supportsFullDuplex() const;
virtual bool isPlaying() const;
virtual bool isSdReady() const;
virtual bool isLittleFsReady() const;
virtual bool isReady() const;
virtual MediaSource lastStorageSource() const;
virtual String lastStoragePath() const;
virtual AudioRuntimeMetrics metrics() const;
virtual void resetMetrics();
virtual bool probePlaybackFileFromSource(const char* path, MediaSource source, AudioPlaybackProbeResult& out);
virtual void tick();
const AudioConfig& config() const;
private:
class BlockingOutput : public Print {
public:
void setOutput(Print* out);
size_t write(uint8_t b) override;
size_t write(const uint8_t* data, size_t len) override;
int availableForWrite() override;
private:
Print* out_ = nullptr;
};
static size_t activeChannelCount(i2s_channel_fmt_t channel_format);
static void audioTaskFn(void* arg);
size_t captureFromAdc(int16_t* dst, size_t samples, bool blocking);
void initAdcDspChain(uint32_t sample_rate_hz);
int16_t processAdcSample(int16_t raw_sample);
void resetAdcDspState();
float applyDcBlocker(float sample);
float applyFirNoiseReduction(float sample);
int16_t applyBiquadChain(float sample);
void appendAdcFftSample(float sample);
void runAdcFftProbe();
void initAdcFftDspBackend();
void deinitAdcFftDspBackend();
void startTask();
void stopTask();
bool lockI2s() const;
void unlockI2s() const;
bool lockPlaybackState(TickType_t timeout_ticks) const;
void unlockPlaybackState() const;
bool ensureSdMounted();
bool ensureLittleFsMounted();
bool ensureStorageForSource(MediaSource source);
bool openPlaybackFileForSource(const char* path, MediaSource source, fs::FS*& out_fs, MediaSource& out_source);
void stopPlaybackFile();
void stopPlaybackFileUnlocked();
bool prepareWavPlayback(File& file, const char* path);
bool prepareMp3Playback(File& file, const char* path);
bool isMp3Path(const char* path) const;
bool readMp3HeaderInfo(File& file, audio_tools::AudioInfo& info, uint32_t* out_bitrate = nullptr) const;
bool readWavHeaderInfo(
File& file,
audio_tools::AudioInfo& info,
uint32_t* out_data_offset = nullptr,
uint32_t* out_data_size = nullptr) const;
bool isPlaybackAudioInfoSupported(const audio_tools::AudioInfo& info) const;
audio_tools::AudioInfo resolvePlaybackFormat(const audio_tools::AudioInfo& input);
uint32_t resolveStableSampleRate(uint32_t requested_rate_hz, uint32_t& fallback_rate_hz) const;
void applyPlaybackAudioInfo(const audio_tools::AudioInfo& info);
float analyzeWavLoudnessGainDb(
File& file,
const audio_tools::AudioInfo& input,
uint32_t data_offset,
uint32_t data_size,
bool& out_limiter_active) const;
bool decodePcmSample(const uint8_t* bytes, uint8_t bits_per_sample, int32_t& out) const;
void updateToneJitter(uint32_t now_ms);
void restorePlaybackAudioInfo();
bool streamPlaybackChunk();
bool advanceToneStep();
bool configureWavPlaybackPipeline(const audio_tools::AudioInfo& input, const audio_tools::AudioInfo& output);
bool configureMp3PlaybackPipeline(const audio_tools::AudioInfo& input, const audio_tools::AudioInfo& output);
bool loadTonePattern(ToneProfile profile, ToneEvent event);
int16_t sampleToneWave(float& phase, uint16_t freq_hz) const;
void updateAdcDspConfig(const AudioConfig& cfg);
void clearToneStateIfIdle();
bool driver_installed_ = false;
bool capture_active_ = false;
uint8_t capture_clients_mask_ = 0;
bool playing_ = false;
bool tone_active_ = false;
bool tone_route_active_ = false;
uint32_t tone_state_seq_ = 0U;
ToneProfile tone_profile_ = ToneProfile::NONE;
ToneEvent tone_event_ = ToneEvent::NONE;
TonePattern tone_pattern_;
ToneStep tone_step_;
uint8_t tone_step_index_ = 0U;
uint32_t tone_step_remaining_frames_ = 0U;
float tone_phase_a_ = 0.0f;
float tone_phase_b_ = 0.0f;
volatile bool running_task_ = false;
float dial_tone_gain_ = 0.0f;
uint32_t next_dial_tone_push_ms_ = 0;
static constexpr size_t kToneLutSize = 1024U;
bool tone_lut_ready_ = false;
int16_t tone_lut_[kToneLutSize] = {0};
bool sd_mount_attempted_ = false;
bool sd_ready_ = false;
fs::FS* sd_fs_ = nullptr;
bool littlefs_mount_attempted_ = false;
bool littlefs_ready_ = false;
MediaSource last_storage_source_ = MediaSource::AUTO;
enum class PlaybackCodec : uint8_t {
NONE = 0,
WAV,
MP3,
};
PlaybackCodec playback_codec_ = PlaybackCodec::NONE;
String last_storage_path_;
File playback_file_;
String playback_path_;
uint32_t playback_data_remaining_ = 0;
uint16_t playback_input_channels_ = 0;
bool playback_audio_info_overridden_ = false;
uint32_t playback_data_offset_ = 0;
bool playback_wav_direct_mode_ = false;
uint32_t playback_mp3_bitrate_bps_ = 0U;
audio_tools::AudioInfo playback_input_audio_info_;
audio_tools::AudioInfo default_playback_audio_info_;
audio_tools::AudioInfo active_playback_audio_info_;
bool playback_resampler_active_ = false;
bool playback_channel_upmix_active_ = false;
bool playback_loudness_auto_ = false;
float playback_loudness_gain_db_ = 0.0f;
bool playback_limiter_active_ = false;
uint32_t playback_rate_fallback_ = 0;
uint32_t playback_copy_source_bytes_ = 0U;
uint32_t playback_copy_accepted_bytes_ = 0U;
uint32_t playback_copy_loss_bytes_ = 0U;
uint32_t playback_copy_loss_events_ = 0U;
String playback_last_error_;
uint32_t playback_next_chunk_ms_ = 0U;
AudioConfig _config;
FeatureMatrix features_;
AudioRuntimeMetrics metrics_;
int adc_capture_pin_ = -1;
uint32_t adc_capture_sample_interval_us_ = 0;
uint64_t next_adc_capture_us_ = 0;
bool use_adc_capture_ = false;
bool adc_dsp_chain_enabled_ = false;
bool adc_fft_enabled_ = false;
uint8_t adc_dsp_fft_downsample_ = kAdcDspDefaultFftDownsample;
uint16_t adc_fft_ignore_low_bin_ = 1U;
uint16_t adc_fft_ignore_high_bin_ = 1U;
static constexpr uint32_t kAdcDspDefaultSampleRateHz = 16000U;
static constexpr uint8_t kAdcDspDefaultFftDownsample = 2U;
float adc_dsp_prev_input_ = 0.0f;
float adc_dsp_prev_output_ = 0.0f;
float adc_dsp_fir_state_[5U] = {0.0f};
uint8_t adc_dsp_fir_pos_ = 0U;
float adc_dsp_biquad_hp_b0_ = 1.0f;
float adc_dsp_biquad_hp_b1_ = 0.0f;
float adc_dsp_biquad_hp_b2_ = 0.0f;
float adc_dsp_biquad_hp_a1_ = 0.0f;
float adc_dsp_biquad_hp_a2_ = 0.0f;
float adc_dsp_biquad_hp_z1_ = 0.0f;
float adc_dsp_biquad_hp_z2_ = 0.0f;
float adc_dsp_biquad_lp_b0_ = 1.0f;
float adc_dsp_biquad_lp_b1_ = 0.0f;
float adc_dsp_biquad_lp_b2_ = 0.0f;
float adc_dsp_biquad_lp_a1_ = 0.0f;
float adc_dsp_biquad_lp_a2_ = 0.0f;
float adc_dsp_biquad_lp_z1_ = 0.0f;
float adc_dsp_biquad_lp_z2_ = 0.0f;
static constexpr size_t kAdcDspFftWindowSamples = 64U;
float adc_dsp_fft_buffer_[kAdcDspFftWindowSamples] = {0.0f};
uint8_t adc_dsp_fft_head_ = 0U;
uint8_t adc_dsp_fft_fill_ = 0U;
uint8_t adc_dsp_fft_decimator_ = 0U;
float adc_dsp_fft_complex_buffer_[kAdcDspFftWindowSamples * 2U] = {0.0f};
bool adc_dsp_fft_probe_enabled_ = false;
bool adc_dsp_fft_probe_backend_ready_ = false;
audio_tools::I2SStream i2s_stream_;
BlockingOutput playback_blocking_output_;
audio_tools::VolumeStream playback_volume_stream_;
std::unique_ptr<audio_tools::ConverterScaler<int16_t>> playback_gain_scaler_;
audio_tools::ConverterStream<int16_t> playback_gain_stream_;
audio_tools::ResampleStream playback_resample_stream_;
audio_tools::ChannelFormatConverterStream playback_channel_converter_stream_;
audio_tools::WAVDecoder wav_decoder_;
audio_tools::EncodedAudioStream wav_stream_;
audio_tools::StreamCopy wav_copy_;
AudioGeneratorMP3* mp3_decoder_ = nullptr;
AudioFileSourceFS* mp3_source_ = nullptr;
void* mp3_output_ = nullptr;
Print* mp3_pcm_sink_ = nullptr;
uint32_t mp3_source_last_pos_ = 0U;
mutable SemaphoreHandle_t i2s_io_mutex_ = nullptr;
mutable SemaphoreHandle_t playback_state_mutex_ = nullptr;
TaskHandle_t task_handle_ = nullptr;
static constexpr uint16_t kAudioTaskStackWords = 4096;
static constexpr uint8_t kAudioTaskPriority = 1;
portMUX_TYPE capture_lock_ = portMUX_INITIALIZER_UNLOCKED;
};
#endif // AUDIO_ENGINE_H
@@ -1,66 +0,0 @@
#include "audio/AudioManager.h"
#include "core/AgentSupervisor.h"
#include <Arduino.h>
AudioManager::AudioManager() : initialized_(false) {}
void notifyAudio(const std::string& state, const std::string& error = "") {
AgentStatus status{state, error, millis()};
AgentSupervisor::instance().notify("audio", status);
}
bool AudioManager::begin(const AudioConfig& config) {
initialized_ = engine_.begin(config);
notifyAudio(initialized_ ? "initialized" : "init_failed", initialized_ ? "" : "init error");
return initialized_;
}
bool AudioManager::playFile(const char* path) {
bool ok = initialized_ && engine_.playFile(path);
notifyAudio(ok ? "playing" : "play_failed", ok ? "" : "play error");
return ok;
}
bool AudioManager::startCapture() {
bool ok = initialized_ && engine_.startCapture();
notifyAudio(ok ? "capture" : "capture_failed", ok ? "" : "capture error");
return ok;
}
size_t AudioManager::readCaptureFrame(int16_t* dst, size_t samples) {
if (!initialized_) {
return 0;
}
return engine_.readCaptureFrame(dst, samples);
}
void AudioManager::stopCapture() {
if (!initialized_) {
return;
}
engine_.stopCapture();
notifyAudio("stopped");
}
bool AudioManager::supportsFullDuplex() const {
return initialized_ && engine_.supportsFullDuplex();
}
bool AudioManager::isPlaying() const {
return initialized_ && engine_.isPlaying();
}
AudioRuntimeMetrics AudioManager::metrics() const {
return engine_.metrics();
}
void AudioManager::resetMetrics() {
engine_.resetMetrics();
}
void AudioManager::tick() {
if (!initialized_) {
return;
}
engine_.tick();
}
@@ -1,25 +0,0 @@
#ifndef AUDIO_AUDIO_MANAGER_H
#define AUDIO_AUDIO_MANAGER_H
#include "audio/AudioEngine.h"
class AudioManager {
public:
AudioManager();
bool begin(const AudioConfig& config);
bool playFile(const char* path);
bool startCapture();
size_t readCaptureFrame(int16_t* dst, size_t samples);
void stopCapture();
bool supportsFullDuplex() const;
bool isPlaying() const;
AudioRuntimeMetrics metrics() const;
void resetMetrics();
void tick();
private:
AudioEngine engine_;
bool initialized_;
};
#endif // AUDIO_AUDIO_MANAGER_H
@@ -1,157 +0,0 @@
#include "audio/Es8388Driver.h"
#include <Wire.h>
#include <algorithm>
#include <cmath>
#include <initializer_list>
namespace {
constexpr float kEs8388VolumeDbMin = -96.0f;
constexpr float kEs8388VolumeDbMax = 0.0f;
float percentToVolumeDb(uint8_t percent) {
const float clamped = static_cast<float>(std::min<uint8_t>(100U, percent));
const float normalized = clamped / 100.0f;
// Linear in dB (perceptually logarithmic gain); 0%= -96 dB, 100%=0 dB.
return kEs8388VolumeDbMin + (kEs8388VolumeDbMax - kEs8388VolumeDbMin) * normalized;
}
uint8_t dbToVolumeReg(float db) {
const float clamped_db = std::max(kEs8388VolumeDbMin, std::min(kEs8388VolumeDbMax, db));
// ES8388: 0x00 = 0 dB, 0xC0 = -96 dB (0.5 dB/step).
return static_cast<uint8_t>(std::lround((-clamped_db) * 2.0f));
}
constexpr uint8_t kEs8388DacUnmuted = 0x32; // DACCONTROL3 unmute (spec baseline)
constexpr uint8_t kEs8388DacMuted = 0x36; // DACCONTROL3 mute (spec example)
constexpr uint8_t kEs8388DacRoute = 0xB8; // DAC->mixer baseline path
constexpr uint8_t kEs8388Output0dB = 0x1E; // LOUT/ROUT driver volume 0dB
} // namespace
bool Es8388Driver::begin(int sda_pin, int scl_pin, uint8_t address) {
address_ = address;
Wire.begin(sda_pin, scl_pin);
Wire.setClock(100000);
const auto write_sequence = [&](std::initializer_list<std::pair<uint8_t, uint8_t>> seq) {
bool ok = true;
for (const auto& it : seq) {
ok &= writeReg(it.first, it.second);
}
return ok;
};
// ES8388 setup aligned with A1S board spec:
// - I2C 100 kHz on SDA=33/SCL=32
// - full-duplex I2S slave mode
// - 16-bit, ratio 256
// - conservative output driver values (0x1E = 0 dB)
const bool ok = write_sequence(
{
{0x19, 0x04}, // DACCONTROL3: mute during init.
{0x01, 0x50}, // CONTROL2
{0x02, 0x00}, // CHIPPOWER normal mode
{0x35, 0xA0}, // Disable internal DLL for low sample rates stability.
{0x37, 0xD0},
{0x39, 0xD0},
{0x08, 0x00}, // MASTERMODE: codec slave
{0x04, 0xC0}, // DACPOWER: DAC outputs disabled while configuring
{0x00, 0x12}, // CONTROL1: play+record mode
{0x17, 0x18}, // DACCONTROL1: 16-bit I2S
{0x18, 0x02}, // DACCONTROL2: single speed, ratio 256
{0x26, 0x00}, // DACCONTROL16: DAC to mixer
{0x27, kEs8388DacRoute}, // DACCONTROL17: DAC -> mixer path (spec baseline 0xB8)
{0x2A, kEs8388DacRoute}, // DACCONTROL20: DAC -> mixer path (spec baseline 0xB8)
{0x2B, 0x80}, // DACCONTROL21
{0x2D, 0x00}, // DACCONTROL23
{0x2E, kEs8388Output0dB}, // DACCONTROL24: LOUT1 volume = 0dB
{0x2F, kEs8388Output0dB}, // DACCONTROL25: ROUT1 volume = 0dB
{0x30, 0x00}, // DACCONTROL26
{0x31, 0x00}, // DACCONTROL27
{0x04, 0x3C}, // DACPOWER: enable LOUT/ROUT
{0x03, 0xFF}, // ADCPOWER: power down before ADC config
{0x09, 0xBB}, // ADCCONTROL1: PGA gain defaults
{0x0A, 0x00}, // ADCCONTROL2: LIN1/RIN1
{0x0B, 0x02}, // ADCCONTROL3
{0x0C, 0x0C}, // ADCCONTROL4: 16-bit I2S
{0x0D, 0x02}, // ADCCONTROL5: single speed, ratio 256
{0x10, 0x00}, // ADCCONTROL8: 0 dB
{0x11, 0x00}, // ADCCONTROL9: 0 dB
{0x03, 0x09}, // ADCPOWER: enable ADC path
});
ready_ = ok;
if (!ready_) {
return false;
}
setMute(true);
setVolume(volume_);
setMute(muted_);
setRoute(route_);
return true;
}
bool Es8388Driver::setVolume(uint8_t percent) {
volume_ = std::min<uint8_t>(100, percent);
if (!ready_) {
return false;
}
const float db = percentToVolumeDb(volume_);
const uint8_t reg = dbToVolumeReg(db);
// DAC digital volume controls.
return writeReg(0x1A, reg) && writeReg(0x1B, reg);
}
bool Es8388Driver::setMute(bool enabled) {
muted_ = enabled;
if (!ready_) {
return false;
}
// DACCONTROL3 bit2 is mute; use spec baseline values.
return writeReg(0x19, static_cast<uint8_t>(enabled ? kEs8388DacMuted : kEs8388DacUnmuted));
}
bool Es8388Driver::setRoute(const String& route) {
route_ = route;
route_.toLowerCase();
if (!ready_) {
return false;
}
// Keep route as metadata and ensure output path is enabled for RTC.
if (route_ == "rtc") {
return writeReg(0x26, 0x00) && writeReg(0x27, kEs8388DacRoute) && writeReg(0x2A, kEs8388DacRoute) &&
writeReg(0x04, 0x3C);
}
if (route_ == "none") {
return writeReg(0x04, 0xC0);
}
route_ = "rtc";
return writeReg(0x26, 0x00) && writeReg(0x27, kEs8388DacRoute) && writeReg(0x2A, kEs8388DacRoute) &&
writeReg(0x04, 0x3C);
}
bool Es8388Driver::isReady() const {
return ready_;
}
uint8_t Es8388Driver::volume() const {
return volume_;
}
bool Es8388Driver::muted() const {
return muted_;
}
String Es8388Driver::route() const {
return route_;
}
bool Es8388Driver::writeReg(uint8_t reg, uint8_t value) {
Wire.beginTransmission(address_);
Wire.write(reg);
Wire.write(value);
return Wire.endTransmission() == 0;
}
@@ -1,30 +0,0 @@
#ifndef AUDIO_ES8388_DRIVER_H
#define AUDIO_ES8388_DRIVER_H
#include <Arduino.h>
#include "config/a1s_board_pins.h"
class Es8388Driver {
public:
bool begin(int sda_pin, int scl_pin, uint8_t address = A1S_ES8388_I2C_ADDR);
bool setVolume(uint8_t percent);
bool setMute(bool enabled);
bool setRoute(const String& route);
bool isReady() const;
uint8_t volume() const;
bool muted() const;
String route() const;
private:
bool writeReg(uint8_t reg, uint8_t value);
bool ready_ = false;
uint8_t address_ = A1S_ES8388_I2C_ADDR;
uint8_t volume_ = 60;
bool muted_ = false;
String route_ = "rtc";
};
#endif // AUDIO_ES8388_DRIVER_H
@@ -1,149 +0,0 @@
#include "audio/ToneCatalog.h"
namespace {
#define TONE_ON1(freq_hz, ms) \
ToneStep { static_cast<uint16_t>(freq_hz), 0U, static_cast<uint16_t>(ms), false }
#define TONE_ON2(freq_a_hz, freq_b_hz, ms) \
ToneStep { static_cast<uint16_t>(freq_a_hz), static_cast<uint16_t>(freq_b_hz), static_cast<uint16_t>(ms), false }
#define TONE_OFF(ms) \
ToneStep { 0U, 0U, static_cast<uint16_t>(ms), true }
constexpr ToneStep kEtsiDial[] = {TONE_ON1(425, 1000)};
constexpr ToneStep kEtsiSecondaryDial[] = {TONE_ON1(425, 1000)};
constexpr ToneStep kEtsiSpecialDialStutter[] = {TONE_ON1(425, 500), TONE_OFF(50)};
constexpr ToneStep kEtsiRecallDial[] = {TONE_ON1(425, 1000)};
constexpr ToneStep kEtsiRingback[] = {TONE_ON1(425, 1000), TONE_OFF(4000)};
constexpr ToneStep kEtsiBusy[] = {TONE_ON1(425, 500), TONE_OFF(500)};
constexpr ToneStep kEtsiCongestion[] = {TONE_ON1(425, 250), TONE_OFF(250)};
constexpr ToneStep kEtsiCallWaiting[] = {TONE_ON1(425, 200), TONE_OFF(200), TONE_ON1(425, 200), TONE_OFF(3000)};
constexpr ToneStep kEtsiConfirmation[] = {
TONE_ON1(425, 100), TONE_OFF(100), TONE_ON1(425, 100), TONE_OFF(100), TONE_ON1(425, 100), TONE_OFF(1000)};
constexpr ToneStep kEtsiSitIntercept[] = {
TONE_ON1(950, 330), TONE_OFF(30), TONE_ON1(1400, 330), TONE_OFF(30), TONE_ON1(1800, 330), TONE_OFF(1000)};
constexpr ToneStep kFrDial[] = {TONE_ON1(440, 1000)};
constexpr ToneStep kFrSecondaryDial[] = {TONE_ON1(440, 1000)};
constexpr ToneStep kFrSpecialDialStutter[] = {TONE_ON1(440, 500), TONE_OFF(50)};
constexpr ToneStep kFrRecallDial[] = {TONE_ON1(440, 1000)};
constexpr ToneStep kFrRingback[] = {TONE_ON1(440, 1500), TONE_OFF(3500)};
constexpr ToneStep kFrBusy[] = {TONE_ON1(440, 500), TONE_OFF(500)};
constexpr ToneStep kFrCongestion[] = {TONE_ON1(440, 250), TONE_OFF(250)};
constexpr ToneStep kFrCallWaiting[] = {TONE_ON1(440, 300), TONE_OFF(10000)};
constexpr ToneStep kFrConfirmation[] = {
TONE_ON1(440, 100), TONE_OFF(100), TONE_ON1(440, 100), TONE_OFF(100), TONE_ON1(440, 100), TONE_OFF(1000)};
constexpr ToneStep kFrSitIntercept[] = {
TONE_ON1(950, 300), TONE_OFF(30), TONE_ON1(1400, 300), TONE_OFF(30), TONE_ON1(1800, 300), TONE_OFF(1000)};
constexpr ToneStep kUkDial[] = {TONE_ON2(350, 440, 1000)};
constexpr ToneStep kUkSecondaryDial[] = {TONE_ON2(350, 440, 1000)};
constexpr ToneStep kUkSpecialDialStutter[] = {TONE_ON2(350, 440, 100), TONE_OFF(100)};
constexpr ToneStep kUkRecallDial[] = {TONE_ON2(350, 440, 1000)};
constexpr ToneStep kUkRingback[] = {TONE_ON2(400, 450, 400), TONE_OFF(200), TONE_ON2(400, 450, 400), TONE_OFF(2000)};
constexpr ToneStep kUkBusy[] = {TONE_ON2(400, 450, 375), TONE_OFF(375)};
constexpr ToneStep kUkCongestion[] = {TONE_ON2(400, 450, 400), TONE_OFF(400)};
constexpr ToneStep kUkCallWaiting[] = {TONE_ON2(400, 450, 100), TONE_OFF(100), TONE_ON2(400, 450, 100), TONE_OFF(9700)};
constexpr ToneStep kUkSitIntercept[] = {
TONE_ON1(950, 330), TONE_OFF(30), TONE_ON1(1400, 330), TONE_OFF(30), TONE_ON1(1800, 330), TONE_OFF(1000)};
constexpr ToneStep kNaDial[] = {TONE_ON2(350, 440, 1000)};
constexpr ToneStep kNaSecondaryDial[] = {TONE_ON2(350, 440, 1000)};
constexpr ToneStep kNaSpecialDialStutter[] = {TONE_ON2(350, 440, 100), TONE_OFF(100)};
constexpr ToneStep kNaRecallDial[] = {TONE_ON2(350, 440, 1000)};
constexpr ToneStep kNaRingback[] = {TONE_ON2(440, 480, 2000), TONE_OFF(4000)};
constexpr ToneStep kNaBusy[] = {TONE_ON2(480, 620, 500), TONE_OFF(500)};
constexpr ToneStep kNaCongestion[] = {TONE_ON2(480, 620, 250), TONE_OFF(250)};
constexpr ToneStep kNaCallWaiting[] = {TONE_ON1(440, 300), TONE_OFF(9700)};
constexpr ToneStep kNaConfirmation[] = {TONE_ON2(350, 440, 100), TONE_OFF(100), TONE_ON2(350, 440, 100), TONE_OFF(900)};
constexpr ToneStep kNaSitIntercept[] = {
TONE_ON1(950, 330), TONE_OFF(30), TONE_ON1(1400, 330), TONE_OFF(30), TONE_ON1(1800, 330), TONE_OFF(1000)};
struct PatternEntry {
ToneProfile profile;
ToneEvent event;
const ToneStep* steps;
uint8_t count;
bool loop;
uint8_t loop_start;
};
#define ENTRY(profile, event, arr, should_loop) \
PatternEntry { profile, event, arr, static_cast<uint8_t>(sizeof(arr) / sizeof(arr[0])), should_loop, 0U }
constexpr PatternEntry kPatternTable[] = {
ENTRY(ToneProfile::ETSI_EU, ToneEvent::DIAL, kEtsiDial, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::SECONDARY_DIAL, kEtsiSecondaryDial, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::SPECIAL_DIAL_STUTTER, kEtsiSpecialDialStutter, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::RECALL_DIAL, kEtsiRecallDial, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::RINGBACK, kEtsiRingback, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::BUSY, kEtsiBusy, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::CONGESTION, kEtsiCongestion, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::CALL_WAITING, kEtsiCallWaiting, true),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::CONFIRMATION, kEtsiConfirmation, false),
ENTRY(ToneProfile::ETSI_EU, ToneEvent::SIT_INTERCEPT, kEtsiSitIntercept, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::DIAL, kFrDial, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::SECONDARY_DIAL, kFrSecondaryDial, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::SPECIAL_DIAL_STUTTER, kFrSpecialDialStutter, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::RECALL_DIAL, kFrRecallDial, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::RINGBACK, kFrRingback, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::BUSY, kFrBusy, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::CONGESTION, kFrCongestion, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::CALL_WAITING, kFrCallWaiting, true),
ENTRY(ToneProfile::FR_FR, ToneEvent::CONFIRMATION, kFrConfirmation, false),
ENTRY(ToneProfile::FR_FR, ToneEvent::SIT_INTERCEPT, kFrSitIntercept, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::DIAL, kUkDial, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::SECONDARY_DIAL, kUkSecondaryDial, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::SPECIAL_DIAL_STUTTER, kUkSpecialDialStutter, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::RECALL_DIAL, kUkRecallDial, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::RINGBACK, kUkRingback, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::BUSY, kUkBusy, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::CONGESTION, kUkCongestion, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::CALL_WAITING, kUkCallWaiting, true),
ENTRY(ToneProfile::UK_GB, ToneEvent::SIT_INTERCEPT, kUkSitIntercept, true),
ENTRY(ToneProfile::NA_US, ToneEvent::DIAL, kNaDial, true),
ENTRY(ToneProfile::NA_US, ToneEvent::SECONDARY_DIAL, kNaSecondaryDial, true),
ENTRY(ToneProfile::NA_US, ToneEvent::SPECIAL_DIAL_STUTTER, kNaSpecialDialStutter, true),
ENTRY(ToneProfile::NA_US, ToneEvent::RECALL_DIAL, kNaRecallDial, true),
ENTRY(ToneProfile::NA_US, ToneEvent::RINGBACK, kNaRingback, true),
ENTRY(ToneProfile::NA_US, ToneEvent::BUSY, kNaBusy, true),
ENTRY(ToneProfile::NA_US, ToneEvent::CONGESTION, kNaCongestion, true),
ENTRY(ToneProfile::NA_US, ToneEvent::CALL_WAITING, kNaCallWaiting, true),
ENTRY(ToneProfile::NA_US, ToneEvent::CONFIRMATION, kNaConfirmation, false),
ENTRY(ToneProfile::NA_US, ToneEvent::SIT_INTERCEPT, kNaSitIntercept, true),
};
bool lookupPattern(ToneProfile profile, ToneEvent event, TonePattern& out_pattern) {
for (const PatternEntry& entry : kPatternTable) {
if (entry.profile != profile || entry.event != event) {
continue;
}
out_pattern.steps = entry.steps;
out_pattern.step_count = entry.count;
out_pattern.loop = entry.loop;
out_pattern.loop_start_index = entry.loop_start;
return true;
}
return false;
}
} // namespace
bool ToneCatalog::resolve(ToneProfile profile, ToneEvent event, TonePattern& out_pattern) {
out_pattern = TonePattern{};
if (event == ToneEvent::NONE) {
return false;
}
if (profile == ToneProfile::NONE) {
profile = ToneProfile::FR_FR;
}
if (lookupPattern(profile, event, out_pattern)) {
return true;
}
if (lookupPattern(ToneProfile::ETSI_EU, event, out_pattern)) {
return true;
}
return false;
}
@@ -1,32 +0,0 @@
#ifndef AUDIO_TONE_CATALOG_H
#define AUDIO_TONE_CATALOG_H
#include <Arduino.h>
#include <stdint.h>
#include "media/MediaRouting.h"
struct ToneStep {
uint16_t freq_a_hz = 0;
uint16_t freq_b_hz = 0;
uint16_t duration_ms = 0;
bool silence = true;
constexpr ToneStep() = default;
constexpr ToneStep(uint16_t freq_a, uint16_t freq_b, uint16_t duration, bool is_silence)
: freq_a_hz(freq_a), freq_b_hz(freq_b), duration_ms(duration), silence(is_silence) {}
};
struct TonePattern {
const ToneStep* steps = nullptr;
uint8_t step_count = 0;
bool loop = false;
uint8_t loop_start_index = 0;
};
class ToneCatalog {
public:
static bool resolve(ToneProfile profile, ToneEvent event, TonePattern& out_pattern);
};
#endif // AUDIO_TONE_CATALOG_H
@@ -1,954 +0,0 @@
#include "config/A252ConfigStore.h"
#include <Preferences.h>
#include <algorithm>
#include "core/PlatformProfile.h"
namespace {
constexpr const char* kPinsNs = "a252-pins";
constexpr const char* kAudioNs = "a252-audio";
constexpr const char* kEspNowNs = "espnow";
constexpr const char* kEspNowCallMapNs = "espnow-call";
constexpr const char* kDialMediaMapNs = "dial-media";
constexpr uint16_t kMaxPlaybackPauseMs = 10000U;
constexpr const char* kEspNowKeyPeers = "peers";
constexpr const char* kEspNowKeyDeviceName = "dev_name";
constexpr const char* kDefaultEspNowDeviceName = "HOTLINE_PHONE";
// NVS keys are limited to 15 visible chars on ESP32 Preferences/NVS.
constexpr const char* kAudioKeySampleRate = "sr";
constexpr const char* kAudioKeyBitsPerSample = "bits";
constexpr const char* kAudioKeyEnableCapture = "capture";
constexpr const char* kAudioKeyAdcDspEnabled = "adc_dsp";
constexpr const char* kAudioKeyAdcFftEnabled = "adc_fft";
constexpr const char* kAudioKeyAdcDspFftDownsample = "adc_fft_ds";
constexpr const char* kAudioKeyAdcFftIgnoreLowBin = "adc_fft_lo";
constexpr const char* kAudioKeyAdcFftIgnoreHighBin = "adc_fft_hi";
constexpr const char* kAudioKeyVolume = "vol";
constexpr const char* kAudioKeyRoute = "route";
constexpr const char* kAudioKeyMute = "mute";
constexpr const char* kAudioKeyClockPolicy = "clock_policy";
constexpr const char* kAudioKeyWavLoudnessPolicy = "wav_loud_pol";
constexpr const char* kAudioKeyWavTargetRmsDbfs = "wav_rms_dbfs";
constexpr const char* kAudioKeyWavLimiterCeilingDbfs = "wav_ceil_db";
constexpr const char* kAudioKeyWavLimiterAttackMs = "wav_attack_ms";
constexpr const char* kAudioKeyWavLimiterReleaseMs = "wav_release_ms";
constexpr int kMaxGpioA252 = 39;
constexpr int kMaxGpioS3 = 48;
int maxAllowedPinForProfile(BoardProfile profile) {
return profile == BoardProfile::ESP32_S3 ? kMaxGpioS3 : kMaxGpioA252;
}
bool saveString(Preferences& prefs, const char* key, const String& value) {
return prefs.putString(key, value) > 0U;
}
bool saveUChar(Preferences& prefs, const char* key, uint8_t value) {
return prefs.putUChar(key, value) == 1U;
}
bool saveUInt(Preferences& prefs, const char* key, uint32_t value) {
return prefs.putUInt(key, value) == sizeof(uint32_t);
}
bool saveInt(Preferences& prefs, const char* key, int32_t value) {
return prefs.putInt(key, value) == sizeof(int32_t);
}
bool saveBool(Preferences& prefs, const char* key, bool value) {
return prefs.putBool(key, value) == 1U;
}
bool loadJsonArray(const String& raw, JsonDocument& doc) {
if (raw.isEmpty()) {
doc.to<JsonArray>();
return true;
}
const auto err = deserializeJson(doc, raw);
return err == DeserializationError::Ok && doc.is<JsonArray>();
}
bool loadJsonObject(const String& raw, JsonDocument& doc) {
if (raw.isEmpty()) {
doc.to<JsonObject>();
return true;
}
const auto err = deserializeJson(doc, raw);
return err == DeserializationError::Ok && doc.is<JsonObject>();
}
String normalizeEspNowCallKeyword(const String& keyword) {
String normalized = keyword;
normalized.trim();
normalized.toUpperCase();
return normalized;
}
void mergeCallMapEntry(EspNowCallMap& map, const String& keyword, const MediaRouteEntry& route) {
const String normalized_keyword = normalizeEspNowCallKeyword(keyword);
if (normalized_keyword.isEmpty()) {
return;
}
if (!mediaRouteHasPayload(route)) {
return;
}
for (EspNowCallMapEntry& entry : map) {
if (entry.keyword == normalized_keyword) {
entry.route = route;
return;
}
}
EspNowCallMapEntry created;
created.keyword = normalized_keyword;
created.route = route;
map.push_back(created);
}
void mergeDialMediaMapEntry(DialMediaMap& map, const String& number, const MediaRouteEntry& route) {
String normalized_number = number;
normalized_number.trim();
if (normalized_number.isEmpty()) {
return;
}
if (!mediaRouteHasPayload(route)) {
return;
}
for (DialMediaMapEntry& entry : map) {
if (entry.number == normalized_number) {
entry.route = route;
return;
}
}
DialMediaMapEntry created;
created.number = normalized_number;
created.route = route;
map.push_back(created);
}
bool parseMediaRouteEntry(JsonVariantConst value, MediaRouteEntry& out) {
out = MediaRouteEntry{};
if (value.is<const char*>()) {
out.kind = MediaRouteKind::FILE;
out.path = sanitizeMediaPath(value.as<const char*>());
out.source = MediaSource::AUTO;
return !out.path.isEmpty();
}
if (!value.is<JsonObjectConst>()) {
return false;
}
JsonObjectConst obj = value.as<JsonObjectConst>();
MediaRouteKind kind = MediaRouteKind::FILE;
if (obj["kind"].is<const char*>()) {
if (!parseMediaRouteKind(obj["kind"].as<const char*>(), kind)) {
return false;
}
}
out.kind = kind;
if (kind == MediaRouteKind::TONE) {
if (!obj["profile"].is<const char*>() || !obj["event"].is<const char*>()) {
return false;
}
if (!parseToneProfile(obj["profile"].as<const char*>(), out.tone.profile)) {
return false;
}
if (!parseToneEvent(obj["event"].as<const char*>(), out.tone.event)) {
return false;
}
if (out.tone.profile == ToneProfile::NONE || out.tone.event == ToneEvent::NONE) {
return false;
}
out.path = "";
out.source = MediaSource::AUTO;
return true;
}
if (!obj["path"].is<const char*>()) {
return false;
}
out.path = sanitizeMediaPath(obj["path"].as<const char*>());
if (out.path.isEmpty()) {
return false;
}
out.source = MediaSource::AUTO;
if (obj["source"].is<const char*>()) {
MediaSource parsed = MediaSource::AUTO;
if (!parseMediaSource(obj["source"].as<const char*>(), parsed)) {
return false;
}
out.source = parsed;
}
bool loop = false;
if (obj["playback"]["loop"].is<bool>()) {
loop = obj["playback"]["loop"].as<bool>();
} else if (obj["loop"].is<bool>()) {
loop = obj["loop"].as<bool>();
}
uint16_t pause_ms = 0U;
if (obj["playback"]["pause_ms"].is<int>()) {
const int raw = obj["playback"]["pause_ms"].as<int>();
if (raw < 0 || raw > static_cast<int>(kMaxPlaybackPauseMs)) {
return false;
}
pause_ms = static_cast<uint16_t>(raw);
} else if (obj["pause_ms"].is<int>()) {
const int raw = obj["pause_ms"].as<int>();
if (raw < 0 || raw > static_cast<int>(kMaxPlaybackPauseMs)) {
return false;
}
pause_ms = static_cast<uint16_t>(raw);
}
out.playback.loop = loop;
out.playback.pause_ms = pause_ms;
return true;
}
void writeMediaRouteToObject(JsonObject obj, const char* key, const MediaRouteEntry& route) {
if (key == nullptr || key[0] == '\0') {
return;
}
if (route.kind == MediaRouteKind::TONE) {
JsonObject tone_obj = obj[key].to<JsonObject>();
tone_obj["kind"] = "tone";
tone_obj["profile"] = toneProfileToString(route.tone.profile);
tone_obj["event"] = toneEventToString(route.tone.event);
return;
}
const bool has_playback_policy = route.playback.loop || route.playback.pause_ms > 0U;
if (route.source == MediaSource::AUTO && !has_playback_policy) {
obj[key] = route.path;
return;
}
JsonObject route_obj = obj[key].to<JsonObject>();
route_obj["kind"] = "file";
route_obj["path"] = route.path;
if (route.source != MediaSource::AUTO) {
route_obj["source"] = mediaSourceToString(route.source);
}
if (has_playback_policy) {
JsonObject playback = route_obj["playback"].to<JsonObject>();
playback["loop"] = route.playback.loop;
playback["pause_ms"] = route.playback.pause_ms;
}
}
} // namespace
A252PinsConfig A252ConfigStore::defaultPins() {
A252PinsConfig cfg;
if (detectBoardProfile() == BoardProfile::ESP32_S3) {
cfg.i2s_bck = 40;
cfg.i2s_ws = 41;
cfg.i2s_dout = 42;
cfg.i2s_din = 39;
cfg.es8388_sda = -1;
cfg.es8388_scl = -1;
cfg.slic_rm = 32;
cfg.slic_fr = 5;
cfg.slic_shk = 23;
cfg.slic_pd = 14;
cfg.slic_adc_in = 34;
cfg.hook_active_high = true;
cfg.pcm_flt = -1;
cfg.pcm_demp = -1;
cfg.pcm_xsmt = -1;
cfg.pcm_fmt = -1;
}
return cfg;
}
S3PinsConfig A252ConfigStore::defaultS3Pins() {
return defaultPins();
}
A252AudioConfig A252ConfigStore::defaultAudio() {
return A252AudioConfig{};
}
S3AudioConfig A252ConfigStore::defaultS3Audio() {
return defaultAudio();
}
bool A252ConfigStore::loadPins(A252PinsConfig& out) {
out = defaultPins();
Preferences prefs;
if (!prefs.begin(kPinsNs, false)) {
return false;
}
out.i2s_bck = prefs.getInt("i2s_bck", out.i2s_bck);
out.i2s_ws = prefs.getInt("i2s_ws", out.i2s_ws);
out.i2s_dout = prefs.getInt("i2s_dout", out.i2s_dout);
out.i2s_din = prefs.getInt("i2s_din", out.i2s_din);
out.es8388_sda = prefs.getInt("i2c_sda", out.es8388_sda);
out.es8388_scl = prefs.getInt("i2c_scl", out.es8388_scl);
out.slic_rm = prefs.getInt("slic_rm", out.slic_rm);
out.slic_fr = prefs.getInt("slic_fr", out.slic_fr);
out.slic_shk = prefs.getInt("slic_shk", out.slic_shk);
out.slic_line = prefs.getInt("slic_line", out.slic_line);
out.slic_pd = prefs.getInt("slic_pd", out.slic_pd);
out.slic_adc_in = prefs.getInt("slic_adc_in", out.slic_adc_in);
out.hook_active_high = prefs.getBool("hook_hi", out.hook_active_high);
out.pcm_flt = prefs.getInt("pcm_flt", out.pcm_flt);
out.pcm_demp = prefs.getInt("pcm_demp", out.pcm_demp);
out.pcm_xsmt = prefs.getInt("pcm_xsmt", out.pcm_xsmt);
out.pcm_fmt = prefs.getInt("pcm_fmt", out.pcm_fmt);
prefs.end();
String error;
if (!validatePins(out, error)) {
out = defaultPins();
return false;
}
return true;
}
bool A252ConfigStore::loadS3Pins(S3PinsConfig& out) {
return loadPins(out);
}
bool A252ConfigStore::saveS3Pins(const S3PinsConfig& cfg, String* error) {
return savePins(cfg, error);
}
bool A252ConfigStore::savePins(const A252PinsConfig& cfg, String* error) {
String local_error;
if (!validatePins(cfg, local_error)) {
if (error) {
*error = local_error;
}
return false;
}
Preferences prefs;
if (!prefs.begin(kPinsNs, false)) {
if (error) {
*error = "nvs_open_failed";
}
return false;
}
prefs.putInt("i2s_bck", cfg.i2s_bck);
prefs.putInt("i2s_ws", cfg.i2s_ws);
prefs.putInt("i2s_dout", cfg.i2s_dout);
prefs.putInt("i2s_din", cfg.i2s_din);
prefs.putInt("i2c_sda", cfg.es8388_sda);
prefs.putInt("i2c_scl", cfg.es8388_scl);
prefs.putInt("slic_rm", cfg.slic_rm);
prefs.putInt("slic_fr", cfg.slic_fr);
prefs.putInt("slic_shk", cfg.slic_shk);
prefs.putInt("slic_line", cfg.slic_line);
prefs.putInt("slic_pd", cfg.slic_pd);
prefs.putInt("slic_adc_in", cfg.slic_adc_in);
prefs.putBool("hook_hi", cfg.hook_active_high);
prefs.putInt("pcm_flt", cfg.pcm_flt);
prefs.putInt("pcm_demp", cfg.pcm_demp);
prefs.putInt("pcm_xsmt", cfg.pcm_xsmt);
prefs.putInt("pcm_fmt", cfg.pcm_fmt);
prefs.end();
return true;
}
bool A252ConfigStore::loadAudio(A252AudioConfig& out) {
out = defaultAudio();
Preferences prefs;
if (!prefs.begin(kAudioNs, false)) {
return false;
}
out.sample_rate = prefs.getUInt(kAudioKeySampleRate, out.sample_rate);
out.bits_per_sample = static_cast<uint8_t>(prefs.getUChar(kAudioKeyBitsPerSample, out.bits_per_sample));
out.enable_capture = prefs.getBool(kAudioKeyEnableCapture, out.enable_capture);
out.adc_dsp_enabled = prefs.getBool(kAudioKeyAdcDspEnabled, out.adc_dsp_enabled);
out.adc_fft_enabled = prefs.getBool(kAudioKeyAdcFftEnabled, out.adc_fft_enabled);
out.adc_dsp_fft_downsample = static_cast<uint8_t>(prefs.getUChar(kAudioKeyAdcDspFftDownsample, out.adc_dsp_fft_downsample));
out.adc_fft_ignore_low_bin =
static_cast<uint16_t>(prefs.getUInt(kAudioKeyAdcFftIgnoreLowBin, out.adc_fft_ignore_low_bin));
out.adc_fft_ignore_high_bin =
static_cast<uint16_t>(prefs.getUInt(kAudioKeyAdcFftIgnoreHighBin, out.adc_fft_ignore_high_bin));
out.volume = static_cast<uint8_t>(prefs.getUChar(kAudioKeyVolume, out.volume));
out.mute = prefs.getBool(kAudioKeyMute, out.mute);
if (prefs.isKey(kAudioKeyRoute)) {
out.route = prefs.getString(kAudioKeyRoute, out.route);
}
if (prefs.isKey(kAudioKeyClockPolicy)) {
out.clock_policy = prefs.getString(kAudioKeyClockPolicy, out.clock_policy);
}
if (prefs.isKey(kAudioKeyWavLoudnessPolicy)) {
out.wav_loudness_policy = prefs.getString(kAudioKeyWavLoudnessPolicy, out.wav_loudness_policy);
}
out.wav_target_rms_dbfs = static_cast<int16_t>(prefs.getInt(kAudioKeyWavTargetRmsDbfs, out.wav_target_rms_dbfs));
out.wav_limiter_ceiling_dbfs =
static_cast<int16_t>(prefs.getInt(kAudioKeyWavLimiterCeilingDbfs, out.wav_limiter_ceiling_dbfs));
out.wav_limiter_attack_ms = static_cast<uint16_t>(prefs.getUInt(kAudioKeyWavLimiterAttackMs, out.wav_limiter_attack_ms));
out.wav_limiter_release_ms = static_cast<uint16_t>(prefs.getUInt(kAudioKeyWavLimiterReleaseMs, out.wav_limiter_release_ms));
prefs.end();
String error;
if (!validateAudio(out, error)) {
out = defaultAudio();
return false;
}
return true;
}
bool A252ConfigStore::loadS3Audio(S3AudioConfig& out) {
return loadAudio(out);
}
bool A252ConfigStore::saveS3Audio(const S3AudioConfig& cfg, String* error) {
return saveAudio(cfg, error);
}
bool A252ConfigStore::saveAudio(const A252AudioConfig& cfg, String* error) {
String local_error;
if (!validateAudio(cfg, local_error)) {
if (error) {
*error = local_error;
}
return false;
}
Preferences prefs;
if (!prefs.begin(kAudioNs, false)) {
if (error) {
*error = "nvs_open_failed";
}
return false;
}
bool ok = true;
ok = ok && saveUInt(prefs, kAudioKeySampleRate, cfg.sample_rate);
ok = ok && saveUChar(prefs, kAudioKeyBitsPerSample, cfg.bits_per_sample);
ok = ok && saveBool(prefs, kAudioKeyEnableCapture, cfg.enable_capture);
ok = ok && saveBool(prefs, kAudioKeyAdcDspEnabled, cfg.adc_dsp_enabled);
ok = ok && saveBool(prefs, kAudioKeyAdcFftEnabled, cfg.adc_fft_enabled);
ok = ok && saveUChar(prefs, kAudioKeyAdcDspFftDownsample, cfg.adc_dsp_fft_downsample);
ok = ok && saveUInt(prefs, kAudioKeyAdcFftIgnoreLowBin, cfg.adc_fft_ignore_low_bin);
ok = ok && saveUInt(prefs, kAudioKeyAdcFftIgnoreHighBin, cfg.adc_fft_ignore_high_bin);
ok = ok && saveUChar(prefs, kAudioKeyVolume, cfg.volume);
ok = ok && saveString(prefs, kAudioKeyRoute, cfg.route);
ok = ok && saveBool(prefs, kAudioKeyMute, cfg.mute);
ok = ok && saveString(prefs, kAudioKeyClockPolicy, cfg.clock_policy);
ok = ok && saveString(prefs, kAudioKeyWavLoudnessPolicy, cfg.wav_loudness_policy);
ok = ok && saveInt(prefs, kAudioKeyWavTargetRmsDbfs, cfg.wav_target_rms_dbfs);
ok = ok && saveInt(prefs, kAudioKeyWavLimiterCeilingDbfs, cfg.wav_limiter_ceiling_dbfs);
ok = ok && saveUInt(prefs, kAudioKeyWavLimiterAttackMs, cfg.wav_limiter_attack_ms);
ok = ok && saveUInt(prefs, kAudioKeyWavLimiterReleaseMs, cfg.wav_limiter_release_ms);
prefs.end();
if (!ok) {
if (error) {
*error = "nvs_write_failed";
}
return false;
}
return true;
}
bool A252ConfigStore::loadEspNowPeers(EspNowPeerStore& out) {
out.peers.clear();
out.device_name = kDefaultEspNowDeviceName;
Preferences prefs;
if (!prefs.begin(kEspNowNs, false)) {
return false;
}
const String raw = prefs.isKey(kEspNowKeyPeers) ? prefs.getString(kEspNowKeyPeers, "[]") : String("[]");
if (prefs.isKey(kEspNowKeyDeviceName)) {
const String normalized_name = normalizeDeviceName(prefs.getString(kEspNowKeyDeviceName, kDefaultEspNowDeviceName));
if (!normalized_name.isEmpty()) {
out.device_name = normalized_name;
}
}
prefs.end();
JsonDocument doc;
if (!loadJsonArray(raw, doc)) {
return false;
}
for (const JsonVariantConst item : doc.as<JsonArrayConst>()) {
if (!item.is<const char*>()) {
continue;
}
const String norm = normalizeMac(item.as<const char*>());
if (norm.isEmpty()) {
continue;
}
if (std::find(out.peers.begin(), out.peers.end(), norm) == out.peers.end()) {
out.peers.push_back(norm);
}
}
return true;
}
bool A252ConfigStore::loadEspNowCallMap(EspNowCallMap& out) {
out.clear();
Preferences prefs;
if (!prefs.begin(kEspNowCallMapNs, false)) {
return false;
}
const String raw = prefs.isKey("mappings") ? prefs.getString("mappings", "{}") : String("{}");
prefs.end();
JsonDocument doc;
if (!loadJsonObject(raw, doc)) {
return false;
}
JsonObject obj = doc.as<JsonObject>();
for (JsonPair item : obj) {
MediaRouteEntry route;
if (!parseMediaRouteEntry(item.value(), route)) {
continue;
}
const String key = item.key().c_str();
mergeCallMapEntry(out, key, route);
}
return true;
}
bool A252ConfigStore::saveEspNowCallMap(const EspNowCallMap& map, String* error) {
JsonDocument doc;
JsonObject obj = doc.to<JsonObject>();
for (const EspNowCallMapEntry& entry : map) {
if (entry.keyword.isEmpty() || !mediaRouteHasPayload(entry.route)) {
continue;
}
writeMediaRouteToObject(obj, entry.keyword.c_str(), entry.route);
}
String raw;
serializeJson(obj, raw);
Preferences prefs;
if (!prefs.begin(kEspNowCallMapNs, false)) {
if (error) {
*error = "nvs_open_failed";
}
return false;
}
const bool ok = prefs.putString("mappings", raw) >= 0;
prefs.end();
return ok;
}
bool A252ConfigStore::loadDialMediaMap(DialMediaMap& out) {
out.clear();
Preferences prefs;
if (!prefs.begin(kDialMediaMapNs, false)) {
return false;
}
const String raw = prefs.isKey("mappings") ? prefs.getString("mappings", "{}") : String("{}");
prefs.end();
JsonDocument doc;
if (!loadJsonObject(raw, doc)) {
return false;
}
JsonObject obj = doc.as<JsonObject>();
for (JsonPair item : obj) {
MediaRouteEntry route;
if (!parseMediaRouteEntry(item.value(), route)) {
continue;
}
mergeDialMediaMapEntry(out, item.key().c_str(), route);
}
return true;
}
bool A252ConfigStore::saveDialMediaMap(const DialMediaMap& map, String* error) {
JsonDocument doc;
JsonObject obj = doc.to<JsonObject>();
for (const DialMediaMapEntry& entry : map) {
if (entry.number.isEmpty() || !mediaRouteHasPayload(entry.route)) {
continue;
}
writeMediaRouteToObject(obj, entry.number.c_str(), entry.route);
}
String raw;
serializeJson(obj, raw);
Preferences prefs;
if (!prefs.begin(kDialMediaMapNs, false)) {
if (error) {
*error = "nvs_open_failed";
}
return false;
}
const bool ok = prefs.putString("mappings", raw) >= 0;
prefs.end();
return ok;
}
void A252ConfigStore::espNowCallMapToJson(const EspNowCallMap& map, JsonObject obj) {
for (const EspNowCallMapEntry& entry : map) {
if (entry.keyword.isEmpty() || !mediaRouteHasPayload(entry.route)) {
continue;
}
writeMediaRouteToObject(obj, entry.keyword.c_str(), entry.route);
}
}
void A252ConfigStore::dialMediaMapToJson(const DialMediaMap& map, JsonObject obj) {
for (const DialMediaMapEntry& entry : map) {
if (entry.number.isEmpty() || !mediaRouteHasPayload(entry.route)) {
continue;
}
writeMediaRouteToObject(obj, entry.number.c_str(), entry.route);
}
}
bool A252ConfigStore::saveEspNowPeers(const EspNowPeerStore& store, String* error) {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (const String& peer : store.peers) {
if (!normalizeMac(peer).isEmpty()) {
arr.add(normalizeMac(peer));
}
}
String raw;
serializeJson(arr, raw);
Preferences prefs;
if (!prefs.begin(kEspNowNs, false)) {
if (error) {
*error = "nvs_open_failed";
}
return false;
}
const String normalized_name = normalizeDeviceName(store.device_name);
const String device_name = normalized_name.isEmpty() ? String(kDefaultEspNowDeviceName) : normalized_name;
bool ok = true;
ok = ok && saveString(prefs, kEspNowKeyPeers, raw);
ok = ok && saveString(prefs, kEspNowKeyDeviceName, device_name);
prefs.end();
if (!ok) {
if (error) {
*error = "nvs_write_failed";
}
return false;
}
return true;
}
bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
std::vector<int> used;
used.reserve(14);
const int max_gpio = maxAllowedPinForProfile(detectBoardProfile());
const int required_pins[] = {
cfg.i2s_bck,
cfg.i2s_ws,
cfg.i2s_dout,
cfg.i2s_din,
cfg.slic_rm,
cfg.slic_fr,
cfg.slic_shk,
cfg.slic_pd,
};
const int optional_pins[] = {
cfg.slic_adc_in,
cfg.pcm_flt,
cfg.pcm_demp,
cfg.pcm_xsmt,
cfg.pcm_fmt,
};
for (int pin : required_pins) {
if (pin < 0 || pin > max_gpio) {
error = "invalid_pin_range";
return false;
}
if (std::find(used.begin(), used.end(), pin) != used.end()) {
error = "pin_conflict";
return false;
}
used.push_back(pin);
}
for (int pin : optional_pins) {
if (pin == -1) {
continue;
}
if (pin < 0 || pin > max_gpio) {
error = "invalid_pin_range";
return false;
}
if (std::find(used.begin(), used.end(), pin) != used.end()) {
error = "pin_conflict";
return false;
}
used.push_back(pin);
}
if (detectBoardProfile() == BoardProfile::ESP32_A252) {
if (cfg.es8388_sda < 0 || cfg.es8388_scl < 0) {
error = "invalid_pin_range";
return false;
}
if (cfg.es8388_sda == cfg.es8388_scl) {
error = "pin_conflict";
return false;
}
if (cfg.es8388_sda < 0 || cfg.es8388_sda > max_gpio || cfg.es8388_scl < 0 || cfg.es8388_scl > max_gpio) {
error = "invalid_pin_range";
return false;
}
if (std::find(used.begin(), used.end(), cfg.es8388_sda) != used.end() ||
std::find(used.begin(), used.end(), cfg.es8388_scl) != used.end()) {
error = "pin_conflict";
return false;
}
used.push_back(cfg.es8388_sda);
used.push_back(cfg.es8388_scl);
} else {
if (cfg.es8388_sda >= 0) {
if (cfg.es8388_sda > max_gpio || std::find(used.begin(), used.end(), cfg.es8388_sda) != used.end()) {
error = cfg.es8388_sda > max_gpio ? "invalid_pin_range" : "pin_conflict";
return false;
}
used.push_back(cfg.es8388_sda);
}
if (cfg.es8388_scl >= 0) {
if (cfg.es8388_scl > max_gpio || std::find(used.begin(), used.end(), cfg.es8388_scl) != used.end()) {
error = cfg.es8388_scl > max_gpio ? "invalid_pin_range" : "pin_conflict";
return false;
}
used.push_back(cfg.es8388_scl);
}
}
// Optional legacy line-enable pin, retired by default (-1).
if (cfg.slic_line != -1) {
if (cfg.slic_line < 0 || cfg.slic_line > max_gpio) {
error = "invalid_pin_range";
return false;
}
if (std::find(used.begin(), used.end(), cfg.slic_line) != used.end()) {
error = "pin_conflict";
return false;
}
used.push_back(cfg.slic_line);
}
error = "";
return true;
}
bool A252ConfigStore::validateAudio(const A252AudioConfig& cfg, String& error) {
if (cfg.sample_rate < 8000 || cfg.sample_rate > 48000) {
error = "invalid_sample_rate";
return false;
}
if (!(cfg.bits_per_sample == 16 || cfg.bits_per_sample == 24 || cfg.bits_per_sample == 32)) {
error = "invalid_bits_per_sample";
return false;
}
if (cfg.adc_dsp_fft_downsample == 0U || cfg.adc_dsp_fft_downsample > 64U) {
error = "invalid_adc_dsp_fft_downsample";
return false;
}
if (cfg.adc_fft_ignore_low_bin > 32U) {
error = "invalid_adc_fft_ignore_low_bin";
return false;
}
if (cfg.adc_fft_ignore_high_bin > 32U) {
error = "invalid_adc_fft_ignore_high_bin";
return false;
}
if (cfg.volume > 100) {
error = "invalid_volume";
return false;
}
String route = cfg.route;
route.trim();
route.toLowerCase();
if (!(route == "rtc" || route == "none")) {
error = "invalid_route";
return false;
}
String clock_policy = cfg.clock_policy;
clock_policy.trim();
clock_policy.toUpperCase();
if (!(clock_policy == "HYBRID_TELCO" || clock_policy == "FIXED_TELCO")) {
error = "invalid_clock_policy";
return false;
}
String wav_policy = cfg.wav_loudness_policy;
wav_policy.trim();
wav_policy.toUpperCase();
if (!(wav_policy == "AUTO_NORMALIZE_LIMITER" || wav_policy == "FIXED_GAIN_ONLY")) {
error = "invalid_wav_loudness_policy";
return false;
}
if (cfg.wav_target_rms_dbfs < -36 || cfg.wav_target_rms_dbfs > -6) {
error = "invalid_wav_target_rms_dbfs";
return false;
}
if (cfg.wav_limiter_ceiling_dbfs < -12 || cfg.wav_limiter_ceiling_dbfs > 0) {
error = "invalid_wav_limiter_ceiling_dbfs";
return false;
}
if (cfg.wav_limiter_attack_ms < 1 || cfg.wav_limiter_attack_ms > 1000) {
error = "invalid_wav_limiter_attack_ms";
return false;
}
if (cfg.wav_limiter_release_ms < 1 || cfg.wav_limiter_release_ms > 5000) {
error = "invalid_wav_limiter_release_ms";
return false;
}
error = "";
return true;
}
void A252ConfigStore::pinsToJson(const A252PinsConfig& cfg, JsonObject obj) {
JsonObject i2s = obj["i2s"].to<JsonObject>();
i2s["bck"] = cfg.i2s_bck;
i2s["ws"] = cfg.i2s_ws;
i2s["dout"] = cfg.i2s_dout;
i2s["din"] = cfg.i2s_din;
JsonObject i2c = obj["codec_i2c"].to<JsonObject>();
i2c["sda"] = cfg.es8388_sda;
i2c["scl"] = cfg.es8388_scl;
JsonObject slic = obj["slic"].to<JsonObject>();
slic["rm"] = cfg.slic_rm;
slic["fr"] = cfg.slic_fr;
slic["shk"] = cfg.slic_shk;
slic["line"] = cfg.slic_line;
slic["pd"] = cfg.slic_pd;
slic["adc_in"] = cfg.slic_adc_in;
slic["hook_active_high"] = cfg.hook_active_high;
JsonObject pcm = obj["pcm"].to<JsonObject>();
pcm["flt"] = cfg.pcm_flt;
pcm["demp"] = cfg.pcm_demp;
pcm["xsmt"] = cfg.pcm_xsmt;
pcm["fmt"] = cfg.pcm_fmt;
}
void A252ConfigStore::audioToJson(const A252AudioConfig& cfg, JsonObject obj) {
obj["sample_rate"] = cfg.sample_rate;
obj["bits_per_sample"] = cfg.bits_per_sample;
obj["enable_capture"] = cfg.enable_capture;
obj["adc_dsp_enabled"] = cfg.adc_dsp_enabled;
obj["adc_fft_enabled"] = cfg.adc_fft_enabled;
obj["adc_dsp_fft_downsample"] = cfg.adc_dsp_fft_downsample;
obj["adc_fft_ignore_low_bin"] = cfg.adc_fft_ignore_low_bin;
obj["adc_fft_ignore_high_bin"] = cfg.adc_fft_ignore_high_bin;
obj["volume"] = cfg.volume;
obj["mute"] = cfg.mute;
obj["route"] = cfg.route;
obj["clock_policy"] = cfg.clock_policy;
obj["wav_loudness_policy"] = cfg.wav_loudness_policy;
obj["wav_target_rms_dbfs"] = cfg.wav_target_rms_dbfs;
obj["wav_limiter_ceiling_dbfs"] = cfg.wav_limiter_ceiling_dbfs;
obj["wav_limiter_attack_ms"] = cfg.wav_limiter_attack_ms;
obj["wav_limiter_release_ms"] = cfg.wav_limiter_release_ms;
}
void A252ConfigStore::peersToJson(const EspNowPeerStore& store, JsonArray arr) {
for (const String& peer : store.peers) {
arr.add(peer);
}
}
String A252ConfigStore::normalizeMac(const String& value) {
String mac = value;
mac.trim();
mac.toUpperCase();
String compact;
compact.reserve(12);
for (size_t i = 0; i < mac.length(); ++i) {
const char c = mac[i];
if (c == ':' || c == '-' || c == ' ') {
continue;
}
const bool is_hex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F');
if (!is_hex) {
return "";
}
compact += c;
}
if (compact.length() != 12) {
return "";
}
String formatted;
formatted.reserve(17);
for (int i = 0; i < 12; i += 2) {
if (i > 0) {
formatted += ':';
}
formatted += compact.substring(i, i + 2);
}
return formatted;
}
String A252ConfigStore::normalizeDeviceName(const String& value) {
String name = value;
name.trim();
name.toUpperCase();
if (name.isEmpty()) {
return "";
}
constexpr size_t kMaxDeviceNameLength = 24;
String normalized;
normalized.reserve(std::min<size_t>(name.length(), kMaxDeviceNameLength));
for (size_t i = 0; i < name.length(); ++i) {
const char c = name[i];
const bool is_alpha = (c >= 'A' && c <= 'Z');
const bool is_digit = (c >= '0' && c <= '9');
const bool is_allowed_symbol = (c == '_' || c == '-');
if (!(is_alpha || is_digit || is_allowed_symbol)) {
return "";
}
if (normalized.length() >= kMaxDeviceNameLength) {
break;
}
normalized += c;
}
return normalized;
}
bool A252ConfigStore::parseMac(const String& value, uint8_t out[6]) {
const String formatted = normalizeMac(value);
if (formatted.isEmpty()) {
return false;
}
for (int i = 0; i < 6; ++i) {
const String chunk = formatted.substring(i * 3, i * 3 + 2);
out[i] = static_cast<uint8_t>(strtoul(chunk.c_str(), nullptr, 16));
}
return true;
}
@@ -1,124 +0,0 @@
#ifndef CONFIG_A252_CONFIG_STORE_H
#define CONFIG_A252_CONFIG_STORE_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <vector>
#include "config/a1s_board_pins.h"
#include "media/MediaRouting.h"
struct A252PinsConfig {
int i2s_bck = A1S_I2S_BCLK;
int i2s_ws = A1S_I2S_LRCK;
int i2s_dout = A1S_I2S_DOUT;
int i2s_din = A1S_I2S_DIN;
int es8388_sda = A1S_I2C_SDA;
int es8388_scl = A1S_I2C_SCL;
// A252 bench defaults.
int slic_rm = A1S_SLIC_RM;
int slic_fr = A1S_SLIC_FR;
int slic_shk = A1S_SLIC_SHK;
int slic_line = -1;
int slic_pd = A1S_SLIC_PD;
int slic_adc_in = -1;
bool hook_active_high = true;
int pcm_flt = -1;
int pcm_demp = -1;
int pcm_xsmt = -1;
int pcm_fmt = -1;
};
// Intentional aliases for board-centric naming in S3-focused firmware branches.
using S3PinsConfig = A252PinsConfig;
struct A252AudioConfig {
uint32_t sample_rate = 8000;
uint8_t bits_per_sample = 16;
bool enable_capture = false;
bool adc_dsp_enabled = false;
bool adc_fft_enabled = false;
uint8_t adc_dsp_fft_downsample = 2U;
uint16_t adc_fft_ignore_low_bin = 1U;
uint16_t adc_fft_ignore_high_bin = 1U;
uint8_t volume = 100;
bool mute = false;
String route = "rtc";
String clock_policy = "HYBRID_TELCO";
String wav_loudness_policy = "FIXED_GAIN_ONLY";
int16_t wav_target_rms_dbfs = -18;
int16_t wav_limiter_ceiling_dbfs = -2;
uint16_t wav_limiter_attack_ms = 8;
uint16_t wav_limiter_release_ms = 120;
};
// Intentional aliases for board-centric naming in S3-focused firmware branches.
using S3AudioConfig = A252AudioConfig;
struct EspNowCallMapEntry {
String keyword;
MediaRouteEntry route;
};
using EspNowCallMap = std::vector<EspNowCallMapEntry>;
struct DialMediaMapEntry {
String number;
MediaRouteEntry route;
};
using DialMediaMap = std::vector<DialMediaMapEntry>;
struct EspNowPeerStore {
std::vector<String> peers;
String device_name = "HOTLINE_PHONE";
};
class A252ConfigStore {
public:
// Legacy board-agnostic interface.
static A252PinsConfig defaultPins();
static A252AudioConfig defaultAudio();
// S3/board-clarity façade.
static S3PinsConfig defaultS3Pins();
static S3AudioConfig defaultS3Audio();
static bool loadPins(A252PinsConfig& out);
static bool savePins(const A252PinsConfig& cfg, String* error = nullptr);
static bool loadS3Pins(S3PinsConfig& out);
static bool saveS3Pins(const S3PinsConfig& cfg, String* error = nullptr);
static bool loadAudio(A252AudioConfig& out);
static bool saveAudio(const A252AudioConfig& cfg, String* error = nullptr);
static bool loadS3Audio(S3AudioConfig& out);
static bool saveS3Audio(const S3AudioConfig& cfg, String* error = nullptr);
static bool loadEspNowPeers(EspNowPeerStore& out);
static bool saveEspNowPeers(const EspNowPeerStore& store, String* error = nullptr);
static bool loadEspNowCallMap(EspNowCallMap& out);
static bool saveEspNowCallMap(const EspNowCallMap& map, String* error = nullptr);
static bool loadDialMediaMap(DialMediaMap& out);
static bool saveDialMediaMap(const DialMediaMap& map, String* error = nullptr);
static bool validatePins(const A252PinsConfig& cfg, String& error);
static bool validateAudio(const A252AudioConfig& cfg, String& error);
static void pinsToJson(const A252PinsConfig& cfg, JsonObject obj);
static void audioToJson(const A252AudioConfig& cfg, JsonObject obj);
static void peersToJson(const EspNowPeerStore& store, JsonArray arr);
static void espNowCallMapToJson(const EspNowCallMap& map, JsonObject obj);
static void dialMediaMapToJson(const DialMediaMap& map, JsonObject obj);
static String normalizeMac(const String& value);
static String normalizeDeviceName(const String& value);
static bool parseMac(const String& value, uint8_t out[6]);
};
#endif // CONFIG_A252_CONFIG_STORE_H
@@ -1,47 +0,0 @@
#pragma once
// Board: Ai-Thinker ESP32 Audio Kit v2.2 + ESP32-A1S (ES8388)
// Memory: N4R8 => 4MB Flash, 8MB PSRAM
// DIP: 1 OFF, 2 ON, 3 ON, 4 OFF, 5 OFF
// Result: SD(SPI) active, KEY2 unavailable, JTAG disconnected.
#include <driver/i2c.h>
#include <driver/i2s.h>
// ===== ES8388 control (I2C) =====
#define A1S_I2C_PORT I2C_NUM_0
#define A1S_I2C_SCL 32
#define A1S_I2C_SDA 33
#define A1S_ES8388_I2C_ADDR 0x10 // 7-bit address
// ===== Audio data (I2S) =====
#define A1S_I2S_PORT I2S_NUM_0
#define A1S_I2S_MCLK 0
#define A1S_I2S_BCLK 27
#define A1S_I2S_LRCK 25
#define A1S_I2S_DOUT 26
#define A1S_I2S_DIN 35 // input-only pin
// ===== Speaker amp + headphone detect =====
#define A1S_PA_ENABLE 21
#define A1S_HP_DETECT 39 // input-only; typical active LOW
// ===== SLIC / telephony front-end wiring (project-specific on A252) =====
#define A1S_SLIC_RM 18
#define A1S_SLIC_FR 5
#define A1S_SLIC_SHK 23
#define A1S_SLIC_PD 19
// ===== SD card (SPI) =====
#define A1S_SD_CS 13
#define A1S_SD_MISO 2
#define A1S_SD_MOSI 15
#define A1S_SD_SCK 14
// ===== Keys =====
#define A1S_KEY1 36
#define A1S_KEY2 13 // NOT AVAILABLE with DIP1=OFF when SD CS is active (DIP2=ON)
#define A1S_KEY3 19
#define A1S_KEY4 23
#define A1S_KEY5 18
#define A1S_KEY6 5
@@ -1,33 +0,0 @@
// AgentSupervisor.cpp
#include "core/AgentSupervisor.h"
#include <Arduino.h>
AgentSupervisor& AgentSupervisor::instance() {
static AgentSupervisor inst;
return inst;
}
void AgentSupervisor::notify(const std::string& agent, const AgentStatus& status) {
statusMap_[agent] = status;
publishEvent("status_update", agent, status);
}
AgentStatus AgentSupervisor::getStatus(const std::string& agent) const {
auto it = statusMap_.find(agent);
if (it != statusMap_.end()) return it->second;
return {"unknown", "", 0};
}
std::map<std::string, AgentStatus> AgentSupervisor::getAllStatus() const {
return statusMap_;
}
void AgentSupervisor::subscribe(const std::string& event, std::function<void(const std::string&, const AgentStatus&)> cb) {
subscribers_[event].push_back(cb);
}
void AgentSupervisor::publishEvent(const std::string& event, const std::string& agent, const AgentStatus& status) {
for (auto& cb : subscribers_[event]) {
cb(agent, status);
}
}
@@ -1,27 +0,0 @@
// AgentSupervisor.h
// Superviseur central pour la coordination des agents RTC_BL_PHONE
#pragma once
#include <map>
#include <string>
#include <functional>
#include <vector>
struct AgentStatus {
std::string state;
std::string lastError;
unsigned long lastUpdate;
};
class AgentSupervisor {
public:
static AgentSupervisor& instance();
void notify(const std::string& agent, const AgentStatus& status);
AgentStatus getStatus(const std::string& agent) const;
std::map<std::string, AgentStatus> getAllStatus() const;
void subscribe(const std::string& event, std::function<void(const std::string&, const AgentStatus&)> cb);
void publishEvent(const std::string& event, const std::string& agent, const AgentStatus& status);
private:
AgentSupervisor() = default;
std::map<std::string, AgentStatus> statusMap_;
std::map<std::string, std::vector<std::function<void(const std::string&, const AgentStatus&)>>> subscribers_;
};
@@ -1,71 +0,0 @@
#include "core/CommandDispatcher.h"
#include <algorithm>
void CommandDispatcher::registerCommand(const String& name, Handler handler) {
const String key = normalizeCommand(name);
if (key.isEmpty() || !handler) {
return;
}
if (handlers_.find(key) == handlers_.end()) {
order_.push_back(key);
}
handlers_[key] = std::move(handler);
}
DispatchResponse CommandDispatcher::dispatch(const String& line) const {
String input = line;
input.trim();
if (input.isEmpty()) {
DispatchResponse resp;
resp.ok = false;
resp.code = "EMPTY_COMMAND";
return resp;
}
const int sep = input.indexOf(' ');
const String cmd = normalizeCommand(sep > 0 ? input.substring(0, sep) : input);
const String args = sep > 0 ? input.substring(sep + 1) : "";
const auto it = handlers_.find(cmd);
if (it == handlers_.end()) {
DispatchResponse resp;
resp.ok = false;
resp.code = "unsupported_command";
if (!cmd.isEmpty()) {
resp.code += ' ';
resp.code += cmd;
}
return resp;
}
return it->second(args);
}
bool CommandDispatcher::hasCommand(const String& name) const {
return handlers_.find(normalizeCommand(name)) != handlers_.end();
}
String CommandDispatcher::helpText() const {
String out;
out.reserve(order_.size() * 24);
for (size_t i = 0; i < order_.size(); ++i) {
out += order_[i];
if (i + 1 < order_.size()) {
out += '\n';
}
}
return out;
}
std::vector<String> CommandDispatcher::commands() const {
return order_;
}
String CommandDispatcher::normalizeCommand(const String& name) {
String out = name;
out.trim();
out.toUpperCase();
return out;
}
@@ -1,34 +0,0 @@
#ifndef CORE_COMMAND_DISPATCHER_H
#define CORE_COMMAND_DISPATCHER_H
#include <Arduino.h>
#include <functional>
#include <map>
#include <vector>
struct DispatchResponse {
bool ok = true;
String code = "";
String json;
String raw;
};
class CommandDispatcher {
public:
using Handler = std::function<DispatchResponse(const String& args)>;
void registerCommand(const String& name, Handler handler);
DispatchResponse dispatch(const String& line) const;
bool hasCommand(const String& name) const;
String helpText() const;
std::vector<String> commands() const;
private:
std::map<String, Handler> handlers_;
std::vector<String> order_;
static String normalizeCommand(const String& name);
};
#endif // CORE_COMMAND_DISPATCHER_H
@@ -1,37 +0,0 @@
#include "core/PlatformProfile.h"
BoardProfile detectBoardProfile() {
#if defined(BOARD_PROFILE_ESP32_S3) || defined(CONFIG_IDF_TARGET_ESP32S3)
return BoardProfile::ESP32_S3;
#else
return BoardProfile::ESP32_A252;
#endif
}
FeatureMatrix getFeatureMatrix(BoardProfile profile) {
switch (profile) {
case BoardProfile::ESP32_A252:
return FeatureMatrix{
.has_full_duplex_i2s = true,
};
case BoardProfile::ESP32_S3:
return FeatureMatrix{
.has_full_duplex_i2s = false,
};
default:
return FeatureMatrix{
.has_full_duplex_i2s = false,
};
}
}
const char* boardProfileToString(BoardProfile profile) {
switch (profile) {
case BoardProfile::ESP32_A252:
return "ESP32_A252";
case BoardProfile::ESP32_S3:
return "ESP32_S3";
default:
return "UNKNOWN";
}
}
@@ -1,19 +0,0 @@
#ifndef CORE_PLATFORM_PROFILE_H
#define CORE_PLATFORM_PROFILE_H
#include <Arduino.h>
enum class BoardProfile : uint8_t {
ESP32_A252 = 0,
ESP32_S3 = 1
};
struct FeatureMatrix {
bool has_full_duplex_i2s;
};
BoardProfile detectBoardProfile();
FeatureMatrix getFeatureMatrix(BoardProfile profile);
const char* boardProfileToString(BoardProfile profile);
#endif // CORE_PLATFORM_PROFILE_H
@@ -1,29 +0,0 @@
#include "lecture_audio/LectureAudioManager.h"
LectureAudioManager::LectureAudioManager() : initialized_(false) {}
bool LectureAudioManager::begin(BoardProfile profile) {
initialized_ = audio_.begin(defaultAudioConfigForProfile(profile));
return initialized_;
}
bool LectureAudioManager::playFile(const char* filename) {
if (!initialized_) {
return false;
}
return audio_.playFile(filename);
}
void LectureAudioManager::controlPlayback() {
if (!initialized_) {
return;
}
audio_.tick();
}
bool LectureAudioManager::isPlaying() const {
if (!initialized_) {
return false;
}
return audio_.isPlaying();
}
@@ -1,20 +0,0 @@
#ifndef LECTURE_AUDIO_MANAGER_H
#define LECTURE_AUDIO_MANAGER_H
#include "audio/AudioManager.h"
#include "core/PlatformProfile.h"
class LectureAudioManager {
public:
LectureAudioManager();
bool begin(BoardProfile profile);
bool playFile(const char* filename);
void controlPlayback();
bool isPlaying() const;
private:
AudioManager audio_;
bool initialized_;
};
#endif // LECTURE_AUDIO_MANAGER_H
File diff suppressed because it is too large Load Diff
@@ -1,322 +0,0 @@
#ifndef MEDIA_MEDIA_ROUTING_H
#define MEDIA_MEDIA_ROUTING_H
#include <Arduino.h>
enum class MediaSource : uint8_t {
AUTO = 0,
SD,
LITTLEFS,
};
enum class MediaRouteKind : uint8_t {
FILE = 0,
TONE,
};
enum class ToneProfile : uint8_t {
FR_FR = 0,
ETSI_EU,
UK_GB,
NA_US,
NONE,
};
enum class ToneEvent : uint8_t {
DIAL = 0,
SECONDARY_DIAL,
SPECIAL_DIAL_STUTTER,
RECALL_DIAL,
RINGBACK,
BUSY,
CONGESTION,
CALL_WAITING,
CONFIRMATION,
SIT_INTERCEPT,
NONE,
};
inline String sanitizeMediaPath(const String& raw_path) {
String path = raw_path;
path.trim();
if (path.isEmpty()) {
return "";
}
if (path.length() >= 2U && path[0] == '"' && path[path.length() - 1U] == '"') {
path = path.substring(1U, path.length() - 1U);
}
path.trim();
if (path.isEmpty()) {
return "";
}
String lower = path;
lower.toLowerCase();
if (lower == "null" || path.startsWith("{") || path.startsWith("[")) {
return "";
}
if (!path.startsWith("/")) {
path = "/" + path;
}
String lower_path = path;
lower_path.toLowerCase();
if (!lower_path.endsWith(".wav") && !lower_path.endsWith(".mp3")) {
path += ".wav";
}
return path;
}
struct ToneRouteEntry {
ToneProfile profile = ToneProfile::FR_FR;
ToneEvent event = ToneEvent::DIAL;
};
struct FilePlaybackPolicy {
bool loop = false;
uint16_t pause_ms = 0U;
};
struct MediaRouteEntry {
MediaRouteKind kind = MediaRouteKind::FILE;
ToneRouteEntry tone{};
String path;
MediaSource source = MediaSource::AUTO;
FilePlaybackPolicy playback{};
};
inline const char* mediaSourceToString(MediaSource source) {
switch (source) {
case MediaSource::SD:
return "SD";
case MediaSource::LITTLEFS:
return "LITTLEFS";
case MediaSource::AUTO:
default:
return "AUTO";
}
}
inline bool parseMediaSource(const String& raw, MediaSource& out) {
String value = raw;
value.trim();
value.toLowerCase();
if (value.isEmpty() || value == "auto") {
out = MediaSource::AUTO;
return true;
}
if (value == "sd") {
out = MediaSource::SD;
return true;
}
if (value == "littlefs" || value == "ffat" || value == "flash") {
out = MediaSource::LITTLEFS;
return true;
}
return false;
}
inline const char* mediaRouteKindToString(MediaRouteKind kind) {
switch (kind) {
case MediaRouteKind::TONE:
return "tone";
case MediaRouteKind::FILE:
default:
return "file";
}
}
inline bool parseMediaRouteKind(const String& raw, MediaRouteKind& out) {
String value = raw;
value.trim();
value.toLowerCase();
if (value == "tone") {
out = MediaRouteKind::TONE;
return true;
}
if (value.isEmpty() || value == "file") {
out = MediaRouteKind::FILE;
return true;
}
return false;
}
inline const char* toneProfileToString(ToneProfile profile) {
switch (profile) {
case ToneProfile::FR_FR:
return "FR_FR";
case ToneProfile::ETSI_EU:
return "ETSI_EU";
case ToneProfile::UK_GB:
return "UK_GB";
case ToneProfile::NA_US:
return "NA_US";
case ToneProfile::NONE:
default:
return "NONE";
}
}
inline bool parseToneProfile(const String& raw, ToneProfile& out) {
String value = raw;
value.trim();
value.toLowerCase();
if (value == "fr_fr" || value == "fr") {
out = ToneProfile::FR_FR;
return true;
}
if (value == "etsi_eu" || value == "eu" || value == "etsi") {
out = ToneProfile::ETSI_EU;
return true;
}
if (value == "uk_gb" || value == "uk" || value == "gb") {
out = ToneProfile::UK_GB;
return true;
}
if (value == "na_us" || value == "us" || value == "na") {
out = ToneProfile::NA_US;
return true;
}
if (value == "none") {
out = ToneProfile::NONE;
return true;
}
return false;
}
inline const char* toneEventToString(ToneEvent event) {
switch (event) {
case ToneEvent::DIAL:
return "dial";
case ToneEvent::SECONDARY_DIAL:
return "secondary_dial";
case ToneEvent::SPECIAL_DIAL_STUTTER:
return "special_dial_stutter";
case ToneEvent::RECALL_DIAL:
return "recall_dial";
case ToneEvent::RINGBACK:
return "ringback";
case ToneEvent::BUSY:
return "busy";
case ToneEvent::CONGESTION:
return "congestion";
case ToneEvent::CALL_WAITING:
return "call_waiting";
case ToneEvent::CONFIRMATION:
return "confirmation";
case ToneEvent::SIT_INTERCEPT:
return "sit_intercept";
case ToneEvent::NONE:
default:
return "none";
}
}
inline bool parseToneEvent(const String& raw, ToneEvent& out) {
String value = raw;
value.trim();
value.toLowerCase();
if (value == "dial") {
out = ToneEvent::DIAL;
return true;
}
if (value == "secondary_dial") {
out = ToneEvent::SECONDARY_DIAL;
return true;
}
if (value == "special_dial_stutter" || value == "special_dial_mwi_stutter") {
out = ToneEvent::SPECIAL_DIAL_STUTTER;
return true;
}
if (value == "recall_dial") {
out = ToneEvent::RECALL_DIAL;
return true;
}
if (value == "ringback") {
out = ToneEvent::RINGBACK;
return true;
}
if (value == "busy") {
out = ToneEvent::BUSY;
return true;
}
if (value == "congestion" || value == "reorder") {
out = ToneEvent::CONGESTION;
return true;
}
if (value == "call_waiting") {
out = ToneEvent::CALL_WAITING;
return true;
}
if (value == "confirmation") {
out = ToneEvent::CONFIRMATION;
return true;
}
if (value == "sit_intercept") {
out = ToneEvent::SIT_INTERCEPT;
return true;
}
if (value == "none") {
out = ToneEvent::NONE;
return true;
}
return false;
}
inline bool isLegacyToneWavPath(const String& raw_path, ToneRouteEntry* out_tone = nullptr) {
String path = raw_path;
path.trim();
if (path.isEmpty()) {
return false;
}
if (path.length() >= 2U && path[0] == '"' && path[path.length() - 1U] == '"') {
path = path.substring(1, path.length() - 1);
}
path.trim();
if (path.isEmpty()) {
return false;
}
if (!path.startsWith("/")) {
path = "/" + path;
}
path.toLowerCase();
if (!path.startsWith("/assets/wav/") || !path.endsWith(".wav")) {
return false;
}
const int profile_begin = static_cast<int>(strlen("/assets/wav/"));
const int profile_end = path.indexOf('/', profile_begin);
if (profile_end <= profile_begin) {
return false;
}
const int event_begin = profile_end + 1;
const int ext_pos = path.lastIndexOf('.');
if (ext_pos <= event_begin) {
return false;
}
const String profile_raw = path.substring(profile_begin, profile_end);
const String event_raw = path.substring(event_begin, ext_pos);
ToneProfile profile = ToneProfile::NONE;
ToneEvent event = ToneEvent::NONE;
if (!parseToneProfile(profile_raw, profile) || !parseToneEvent(event_raw, event)) {
return false;
}
if (out_tone != nullptr) {
out_tone->profile = profile;
out_tone->event = event;
}
return true;
}
inline bool mediaRouteHasPayload(const MediaRouteEntry& route) {
if (route.kind == MediaRouteKind::TONE) {
return route.tone.profile != ToneProfile::NONE && route.tone.event != ToneEvent::NONE;
}
return !route.path.isEmpty();
}
#endif // MEDIA_MEDIA_ROUTING_H
@@ -1,25 +0,0 @@
#include "power/PowerManager.h"
#include <driver/gpio.h>
#include <esp_sleep.h>
PowerManager::PowerManager() = default;
void PowerManager::monitorBattery(uint8_t pin) {
const float voltage = getBatteryVoltage(pin);
Serial.printf("[PowerManager] battery=%.2fV\n", voltage);
}
void PowerManager::enterDeepSleep(uint32_t ms) {
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(ms) * 1000ULL);
esp_deep_sleep_start();
}
void PowerManager::wakeupOnPin(uint8_t pin) {
esp_sleep_enable_ext0_wakeup(static_cast<gpio_num_t>(pin), 0);
}
float PowerManager::getBatteryVoltage(uint8_t pin) {
const int raw = analogRead(pin);
return static_cast<float>(raw) * (3.3f / 4095.0f) * 2.0f;
}
@@ -1,18 +0,0 @@
// PowerManager.h
// Gestion batterie, deep sleep, wakeup
#ifndef POWERMANAGER_H
#define POWERMANAGER_H
#include <Arduino.h>
class PowerManager {
public:
PowerManager();
void monitorBattery(uint8_t pin);
void enterDeepSleep(uint32_t ms);
void wakeupOnPin(uint8_t pin);
float getBatteryVoltage(uint8_t pin);
};
#endif // POWERMANAGER_H
@@ -1,359 +0,0 @@
#include "props/EspNowBridge.h"
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <algorithm>
EspNowBridge* EspNowBridge::instance_ = nullptr;
namespace {
constexpr size_t kEspNowMaxPayloadBytes = 240;
constexpr size_t kEspNowMaxPeers = 16;
constexpr const char* kDefaultEspNowDeviceName = "HOTLINE_PHONE";
void enforceEspNowCoexPolicy() {
WiFi.setSleep(true);
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) {
Serial.printf("[EspNowBridge] warn: esp_wifi_set_ps(min_modem) failed err=0x%04x\n",
static_cast<unsigned>(err));
}
}
bool isBroadcastTarget(const String& target) {
return target.equalsIgnoreCase("broadcast");
}
bool parseTargetMac(const String& target, uint8_t out[6], bool& is_broadcast) {
const String normalized = A252ConfigStore::normalizeMac(target);
is_broadcast = false;
if (isBroadcastTarget(target)) {
is_broadcast = true;
return true;
}
if (normalized.isEmpty()) {
return false;
}
return A252ConfigStore::parseMac(normalized, out);
}
String normalizeOrDefaultDeviceName(const String& name) {
const String normalized = A252ConfigStore::normalizeDeviceName(name);
return normalized.isEmpty() ? String(kDefaultEspNowDeviceName) : normalized;
}
bool ensurePeerRegistered(const uint8_t mac[6]) {
if (mac == nullptr) {
return false;
}
if (esp_now_is_peer_exist(mac)) {
return true;
}
esp_now_peer_info_t peer_info = {};
memcpy(peer_info.peer_addr, mac, 6);
peer_info.channel = 0;
peer_info.encrypt = false;
const esp_err_t err = esp_now_add_peer(&peer_info);
return err == ESP_OK || err == ESP_ERR_ESPNOW_EXIST;
}
}
EspNowBridge::EspNowBridge() {
instance_ = this;
}
bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
if (ready_) {
return true;
}
store_ = initial_peers;
store_.device_name = normalizeOrDefaultDeviceName(store_.device_name);
const wifi_mode_t current_mode = WiFi.getMode();
if (current_mode == WIFI_MODE_NULL) {
WiFi.mode(WIFI_STA);
delay(5);
} else if (current_mode == WIFI_MODE_AP) {
WiFi.mode(WIFI_AP_STA);
delay(5);
}
enforceEspNowCoexPolicy();
if (esp_now_init() != ESP_OK) {
ready_ = false;
return false;
}
enforceEspNowCoexPolicy();
esp_now_register_recv_cb(onDataRecv);
esp_now_register_send_cb(onDataSent);
ready_ = true;
std::vector<String> peers_copy = store_.peers;
store_.peers.clear();
for (const String& mac : peers_copy) {
addPeerInternal(mac, false);
}
return true;
}
bool EspNowBridge::stop() {
if (!ready_) {
return true;
}
const esp_err_t err = esp_now_deinit();
ready_ = false;
return err == ESP_OK;
}
void EspNowBridge::tick() {
// ESP-NOW uses callbacks, no polling required.
}
bool EspNowBridge::addPeer(const String& mac) {
return addPeerInternal(mac, true);
}
bool EspNowBridge::deletePeer(const String& mac) {
return deletePeerInternal(mac, true);
}
const std::vector<String>& EspNowBridge::peers() const {
return store_.peers;
}
const String& EspNowBridge::deviceName() const {
return store_.device_name;
}
bool EspNowBridge::setDeviceName(const String& name, bool persist) {
const String normalized = A252ConfigStore::normalizeDeviceName(name);
if (normalized.isEmpty()) {
return false;
}
store_.device_name = normalized;
if (persist) {
return A252ConfigStore::saveEspNowPeers(store_);
}
return true;
}
bool EspNowBridge::sendJson(const String& target, const String& json_payload) {
if (!ready_) {
Serial.printf("[EspNowBridge] send rejected: bridge not started\n");
tx_fail_++;
return false;
}
String normalized_target = target;
normalized_target.trim();
if (normalized_target.isEmpty()) {
Serial.printf("[EspNowBridge] send rejected: empty target\n");
tx_fail_++;
return false;
}
if (json_payload.length() > kEspNowMaxPayloadBytes) {
Serial.printf("[EspNowBridge] send rejected: payload too large=%u bytes\n",
static_cast<unsigned>(json_payload.length()));
tx_fail_++;
return false;
}
bool is_broadcast = false;
uint8_t target_mac[6] = {0};
if (!parseTargetMac(normalized_target, target_mac, is_broadcast)) {
Serial.printf("[EspNowBridge] send rejected: invalid target '%s'\n", normalized_target.c_str());
tx_fail_++;
return false;
}
if (!is_broadcast) {
const String normalized_source = A252ConfigStore::normalizeMac(normalized_target);
if (std::find(store_.peers.begin(), store_.peers.end(), normalized_source) == store_.peers.end()) {
Serial.printf("[EspNowBridge] send rejected: target not configured '%s'\n", normalized_source.c_str());
tx_fail_++;
return false;
}
}
if (is_broadcast) {
const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
return sendToMac(broadcast_mac, json_payload);
}
return sendToMac(target_mac, json_payload);
}
bool EspNowBridge::isReady() const {
return ready_;
}
void EspNowBridge::setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb) {
command_callback_ = std::move(cb);
}
void EspNowBridge::statusToJson(JsonObject obj) const {
obj["ready"] = ready_;
obj["device_name"] = store_.device_name;
obj["peer_count"] = static_cast<uint32_t>(store_.peers.size());
obj["tx_ok"] = tx_ok_;
obj["tx_fail"] = tx_fail_;
obj["rx_count"] = rx_count_;
obj["last_rx_mac"] = last_rx_mac_;
obj["last_rx_payload"] = last_rx_payload_;
JsonArray peers = obj["peers"].to<JsonArray>();
for (const String& peer : store_.peers) {
peers.add(peer);
}
}
bool EspNowBridge::addPeerInternal(const String& mac, bool persist) {
if (!ready_) {
return false;
}
const String normalized = A252ConfigStore::normalizeMac(mac);
if (normalized.isEmpty()) {
return false;
}
if (std::find(store_.peers.begin(), store_.peers.end(), normalized) != store_.peers.end()) {
return true;
}
if (store_.peers.size() >= kEspNowMaxPeers) {
Serial.println("[EspNowBridge] peer rejected: max peers reached");
return false;
}
uint8_t peer_mac[6] = {0};
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
return false;
}
esp_now_peer_info_t peer_info = {};
memcpy(peer_info.peer_addr, peer_mac, 6);
peer_info.channel = 0;
peer_info.encrypt = false;
if (esp_now_add_peer(&peer_info) != ESP_OK) {
return false;
}
store_.peers.push_back(normalized);
if (persist) {
A252ConfigStore::saveEspNowPeers(store_);
}
return true;
}
bool EspNowBridge::deletePeerInternal(const String& mac, bool persist) {
if (!ready_) {
return false;
}
const String normalized = A252ConfigStore::normalizeMac(mac);
if (normalized.isEmpty()) {
return false;
}
uint8_t peer_mac[6] = {0};
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
return false;
}
esp_now_del_peer(peer_mac);
const auto it = std::remove(store_.peers.begin(), store_.peers.end(), normalized);
const bool removed = it != store_.peers.end();
store_.peers.erase(it, store_.peers.end());
if (removed && persist) {
A252ConfigStore::saveEspNowPeers(store_);
}
return removed;
}
bool EspNowBridge::sendToMac(const uint8_t mac[6], const String& payload) {
if (!ready_) {
return false;
}
if (payload.length() > kEspNowMaxPayloadBytes) {
tx_fail_++;
return false;
}
if (!ensurePeerRegistered(mac)) {
tx_fail_++;
return false;
}
const esp_err_t err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length());
if (err != ESP_OK) {
tx_fail_++;
return false;
}
return true;
}
void EspNowBridge::onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len) {
if (!instance_) {
return;
}
char mac_buf[18] = {0};
snprintf(mac_buf,
sizeof(mac_buf),
"%02X:%02X:%02X:%02X:%02X:%02X",
mac_addr[0],
mac_addr[1],
mac_addr[2],
mac_addr[3],
mac_addr[4],
mac_addr[5]);
if (len <= 0 || len > static_cast<int>(kEspNowMaxPayloadBytes)) {
Serial.printf("[EspNowBridge] rx dropped: invalid len=%d (max=%u)\n",
len,
static_cast<unsigned>(kEspNowMaxPayloadBytes));
return;
}
String payload;
payload.reserve(len + 1);
for (int i = 0; i < len; ++i) {
payload += static_cast<char>(data[i]);
}
instance_->rx_count_++;
instance_->last_rx_mac_ = mac_buf;
instance_->last_rx_payload_ = payload;
if (!instance_->command_callback_) {
return;
}
JsonDocument doc;
if (deserializeJson(doc, payload) != DeserializationError::Ok) {
doc.clear();
doc["raw"] = payload;
}
instance_->command_callback_(String(mac_buf), doc.as<JsonVariantConst>());
}
void EspNowBridge::onDataSent(const uint8_t* mac_addr, esp_now_send_status_t status) {
if (!instance_) {
return;
}
if (status == ESP_NOW_SEND_SUCCESS) {
instance_->tx_ok_++;
} else {
instance_->tx_fail_++;
}
}
@@ -1,55 +0,0 @@
#ifndef PROPS_ESPNOW_BRIDGE_H
#define PROPS_ESPNOW_BRIDGE_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <esp_now.h>
#include <functional>
#include <vector>
#include "config/A252ConfigStore.h"
class EspNowBridge {
public:
EspNowBridge();
bool begin(const EspNowPeerStore& initial_peers);
bool stop();
void tick();
bool addPeer(const String& mac);
bool deletePeer(const String& mac);
const std::vector<String>& peers() const;
const String& deviceName() const;
bool setDeviceName(const String& name, bool persist = true);
bool sendJson(const String& target, const String& json_payload);
bool isReady() const;
void setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb);
void statusToJson(JsonObject obj) const;
private:
bool addPeerInternal(const String& normalized_mac, bool persist);
bool deletePeerInternal(const String& normalized_mac, bool persist);
bool sendToMac(const uint8_t mac[6], const String& payload);
static void onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len);
static void onDataSent(const uint8_t* mac_addr, esp_now_send_status_t status);
static EspNowBridge* instance_;
bool ready_ = false;
EspNowPeerStore store_;
std::function<void(const String&, const JsonVariantConst&)> command_callback_;
uint32_t tx_ok_ = 0;
uint32_t tx_fail_ = 0;
uint32_t rx_count_ = 0;
String last_rx_mac_;
String last_rx_payload_;
};
#endif // PROPS_ESPNOW_BRIDGE_H
@@ -1,82 +0,0 @@
#include <Arduino.h>
#include "core/AgentSupervisor.h"
void notifyRTOS(const std::string& state, const std::string& error = "") {
AgentStatus status{state, error, millis()};
AgentSupervisor::instance().notify("rtos", status);
}
#include "RTOSManager.h"
#include <Arduino.h>
#include <cstdlib>
#include <esp_idf_version.h>
RTOSManager::RTOSManager() {}
bool RTOSManager::begin() {
initialized = true;
Serial.println("RTOSManager: Initialisation OK");
notifyRTOS("initialized");
return initialized;
}
bool RTOSManager::createTask(const char* name, void (*taskFunc)(void*), uint16_t stackSize, void* params, UBaseType_t priority) {
BaseType_t res = xTaskCreate(taskFunc, name, stackSize, params, priority, nullptr);
if (res != pdPASS) {
Serial.printf("RTOSManager: Échec création tâche %s\n", name);
notifyRTOS("task_failed", name);
return false;
}
Serial.printf("RTOSManager: Tâche %s créée\n", name);
notifyRTOS("task_created", name);
return true;
}
void RTOSManager::startScheduler() {
Serial.println("RTOSManager: Scheduler FreeRTOS démarré");
// Scheduler déjà géré par ESP32
}
void RTOSManager::auditTasks() {
Serial.println("RTOSManager: Audit des tâches en cours...");
TaskStatus_t* pxTaskStatusArray;
UBaseType_t uxArraySize = uxTaskGetNumberOfTasks();
pxTaskStatusArray = (TaskStatus_t*)malloc(uxArraySize * sizeof(TaskStatus_t));
if (pxTaskStatusArray != nullptr) {
uxArraySize = uxTaskGetSystemState(pxTaskStatusArray, uxArraySize, nullptr);
for (UBaseType_t i = 0; i < uxArraySize; i++) {
Serial.printf("Tâche: %s, Etat: %d, Priorité: %d\n", pxTaskStatusArray[i].pcTaskName, pxTaskStatusArray[i].eCurrentState, pxTaskStatusArray[i].uxCurrentPriority);
}
free(pxTaskStatusArray);
}
}
void RTOSManager::logStatus() {
Serial.printf("RTOSManager: init=%s, watchdog=%s, timeout=%lu ms\n", initialized ? "true" : "false", watchdogEnabled ? "true" : "false", watchdogTimeout);
}
void RTOSManager::enableWatchdog(uint32_t timeoutMs) {
#if ESP_IDF_VERSION_MAJOR >= 5
esp_task_wdt_config_t config = {
.timeout_ms = timeoutMs,
.idle_core_mask = static_cast<uint32_t>((1U << portNUM_PROCESSORS) - 1U),
.trigger_panic = true,
};
esp_task_wdt_init(&config);
#else
esp_task_wdt_init(timeoutMs / 1000, true);
#endif
esp_task_wdt_add(nullptr);
watchdogEnabled = true;
watchdogTimeout = timeoutMs;
Serial.printf("RTOSManager: Watchdog activé (%lu ms)\n", watchdogTimeout);
notifyRTOS("watchdog_enabled");
}
void RTOSManager::feedWatchdog() {
if (watchdogEnabled) {
esp_task_wdt_reset();
Serial.println("RTOSManager: Watchdog feed");
notifyRTOS("watchdog_feed");
}
}
@@ -1,27 +0,0 @@
// RTOSManager.h
// Gestion des tâches FreeRTOS
#ifndef RTOSMANAGER_H
#define RTOSMANAGER_H
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_task_wdt.h>
class RTOSManager {
public:
RTOSManager();
bool begin();
bool createTask(const char* name, void (*taskFunc)(void*), uint16_t stackSize, void* params, UBaseType_t priority);
void startScheduler();
void auditTasks();
void logStatus();
void enableWatchdog(uint32_t timeoutMs);
void feedWatchdog();
private:
bool initialized = false;
bool watchdogEnabled = false;
uint32_t watchdogTimeout = 0;
};
#endif // RTOSMANAGER_H
@@ -1,108 +0,0 @@
#include "slic/Ks0835SlicController.h"
Ks0835SlicController::Ks0835SlicController()
: initialized_(false), ring_enabled_(false), power_down_enabled_(false), fr_state_(false), last_fr_toggle_ms_(0) {
pins_ = {0, 0, 0, -1, -1, true};
}
bool Ks0835SlicController::begin(const SlicPins& pins) {
pins_ = pins;
pinMode(pins_.pin_rm, OUTPUT);
pinMode(pins_.pin_fr, OUTPUT);
pinMode(pins_.pin_shk, INPUT_PULLUP);
digitalWrite(pins_.pin_rm, LOW);
digitalWrite(pins_.pin_fr, LOW);
if (pins_.pin_line_enable >= 0) {
pinMode(pins_.pin_line_enable, OUTPUT);
digitalWrite(pins_.pin_line_enable, LOW);
}
// Keep PD in open-drain released state (HIGH => high-impedance).
if (pins_.pin_pd >= 0) {
pinMode(pins_.pin_pd, OUTPUT_OPEN_DRAIN);
digitalWrite(pins_.pin_pd, HIGH);
}
initialized_ = true;
ring_enabled_ = false;
power_down_enabled_ = false;
fr_state_ = false;
last_fr_toggle_ms_ = millis();
return true;
}
void Ks0835SlicController::setRing(bool enabled) {
if (!initialized_) {
return;
}
ring_enabled_ = enabled;
digitalWrite(pins_.pin_rm, enabled ? HIGH : LOW);
if (!enabled) {
fr_state_ = false;
digitalWrite(pins_.pin_fr, LOW);
}
}
void Ks0835SlicController::setLineEnabled(bool enabled) {
if (!initialized_ || pins_.pin_line_enable < 0) {
return;
}
digitalWrite(pins_.pin_line_enable, enabled ? HIGH : LOW);
}
bool Ks0835SlicController::isHookOff() const {
if (!initialized_) {
return false;
}
const int level = digitalRead(pins_.pin_shk);
return pins_.hook_active_high ? (level == HIGH) : (level == LOW);
}
void Ks0835SlicController::setPowerDown(bool enabled) {
if (!initialized_ || pins_.pin_pd < 0) {
return;
}
if (power_down_enabled_ == enabled) {
return;
}
power_down_enabled_ = enabled;
if (enabled) {
ring_enabled_ = false;
digitalWrite(pins_.pin_rm, LOW);
fr_state_ = false;
digitalWrite(pins_.pin_fr, LOW);
if (pins_.pin_line_enable >= 0) {
digitalWrite(pins_.pin_line_enable, LOW);
}
pinMode(pins_.pin_pd, OUTPUT_OPEN_DRAIN);
digitalWrite(pins_.pin_pd, HIGH);
} else {
pinMode(pins_.pin_pd, OUTPUT_OPEN_DRAIN);
digitalWrite(pins_.pin_pd, HIGH);
if (pins_.pin_line_enable >= 0) {
digitalWrite(pins_.pin_line_enable, HIGH);
}
}
}
bool Ks0835SlicController::isPowerDownEnabled() const {
return power_down_enabled_;
}
void Ks0835SlicController::tick() {
if (!initialized_ || power_down_enabled_ || !ring_enabled_) {
return;
}
const uint32_t now = millis();
if (now - last_fr_toggle_ms_ >= 20) {
fr_state_ = !fr_state_;
digitalWrite(pins_.pin_fr, fr_state_ ? HIGH : LOW);
last_fr_toggle_ms_ = now;
}
}
@@ -1,26 +0,0 @@
#ifndef KS0835_SLIC_CONTROLLER_H
#define KS0835_SLIC_CONTROLLER_H
#include "slic/SlicController.h"
class Ks0835SlicController : public SlicController {
public:
Ks0835SlicController();
bool begin(const SlicPins& pins) override;
void setRing(bool enabled) override;
void setLineEnabled(bool enabled) override;
bool isHookOff() const override;
void setPowerDown(bool enabled) override;
bool isPowerDownEnabled() const override;
void tick() override;
private:
SlicPins pins_;
bool initialized_;
bool ring_enabled_;
bool power_down_enabled_;
bool fr_state_;
uint32_t last_fr_toggle_ms_;
};
#endif // KS0835_SLIC_CONTROLLER_H
@@ -1,80 +0,0 @@
#include "core/AgentSupervisor.h"
#include <Arduino.h>
void notifySLIC(const std::string& state, const std::string& error = "") {
AgentStatus status{state, error, millis()};
AgentSupervisor::instance().notify("slic", status);
}
#include "slic/SLICManager.h"
SLICManager::SLICManager(SlicController* controller)
: controller_(controller), state_(SLICLineState::UNINITIALIZED), incoming_ring_(false) {}
void SLICManager::attachController(SlicController* controller) {
controller_ = controller;
}
void SLICManager::begin() {
if (controller_ == nullptr) {
state_ = SLICLineState::UNINITIALIZED;
notifySLIC("uninitialized", "no controller");
return;
}
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
}
bool SLICManager::begin(const SlicPins& pins) {
if (controller_ == nullptr || !controller_->begin(pins)) {
state_ = SLICLineState::UNINITIALIZED;
notifySLIC("uninitialized", "begin failed");
return false;
}
begin();
return true;
}
void SLICManager::monitorLine() {
if (controller_ == nullptr) {
state_ = SLICLineState::UNINITIALIZED;
notifySLIC("uninitialized", "no controller");
return;
}
controller_->tick();
if (incoming_ring_) {
state_ = SLICLineState::RINGING;
notifySLIC("ringing");
} else {
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
}
}
void SLICManager::controlCall() {
controlCall(incoming_ring_);
}
void SLICManager::controlCall(bool incoming_ring) {
incoming_ring_ = incoming_ring;
if (controller_ == nullptr) {
notifySLIC("uninitialized", "no controller");
return;
}
if (incoming_ring_) {
controller_->setRing(true);
state_ = SLICLineState::RINGING;
notifySLIC("ringing");
} else {
controller_->setRing(false);
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
}
}
SLICLineState SLICManager::state() const {
return state_;
}
bool SLICManager::isHookOff() const {
return controller_ != nullptr && controller_->isHookOff();
}
@@ -1,33 +0,0 @@
#ifndef SLIC_MANAGER_H
#define SLIC_MANAGER_H
#include <Arduino.h>
#include "slic/SlicController.h"
enum class SLICLineState : uint8_t {
UNINITIALIZED = 0,
ON_HOOK,
OFF_HOOK,
RINGING
};
class SLICManager {
public:
explicit SLICManager(SlicController* controller = nullptr);
void attachController(SlicController* controller);
void begin();
bool begin(const SlicPins& pins);
void monitorLine();
void controlCall();
void controlCall(bool incoming_ring);
SLICLineState state() const;
bool isHookOff() const;
private:
SlicController* controller_;
SLICLineState state_;
bool incoming_ring_;
};
#endif // SLIC_MANAGER_H
@@ -1,27 +0,0 @@
#ifndef SLIC_CONTROLLER_H
#define SLIC_CONTROLLER_H
#include <Arduino.h>
struct SlicPins {
uint8_t pin_rm;
uint8_t pin_fr;
uint8_t pin_shk;
int8_t pin_line_enable;
int8_t pin_pd;
bool hook_active_high;
};
class SlicController {
public:
virtual ~SlicController() = default;
virtual bool begin(const SlicPins& pins) = 0;
virtual void setRing(bool enabled) = 0;
virtual void setLineEnabled(bool enabled) = 0;
virtual bool isHookOff() const = 0;
virtual void setPowerDown(bool enabled) = 0;
virtual bool isPowerDownEnabled() const = 0;
virtual void tick() = 0;
};
#endif // SLIC_CONTROLLER_H
@@ -1,30 +0,0 @@
#include "telephone_sfp/TelephoneSFPManager.h"
TelephoneSFPManager::TelephoneSFPManager() : service_(nullptr) {}
void TelephoneSFPManager::attachService(TelephonyService* service) {
service_ = service;
}
void TelephoneSFPManager::begin() {}
void TelephoneSFPManager::triggerIncomingCall() {
if (service_ == nullptr) {
return;
}
service_->triggerIncomingRing();
}
void TelephoneSFPManager::monitorState() {
if (service_ == nullptr) {
return;
}
service_->tick();
}
TelephonyState TelephoneSFPManager::state() const {
if (service_ == nullptr) {
return TelephonyState::IDLE;
}
return service_->state();
}
@@ -1,19 +0,0 @@
#ifndef TELEPHONE_SFP_MANAGER_H
#define TELEPHONE_SFP_MANAGER_H
#include "telephony/TelephonyService.h"
class TelephoneSFPManager {
public:
TelephoneSFPManager();
void attachService(TelephonyService* service);
void begin();
void triggerIncomingCall();
void monitorState();
TelephonyState state() const;
private:
TelephonyService* service_;
};
#endif // TELEPHONE_SFP_MANAGER_H
@@ -1,148 +0,0 @@
#include "DtmfDecoder.h"
#include <algorithm>
#include <array>
#include <cmath>
namespace {
constexpr std::array<double, 4> kLowFreq = {{697.0, 770.0, 852.0, 941.0}};
constexpr std::array<double, 4> kHighFreq = {{1209.0, 1336.0, 1477.0, 1633.0}};
constexpr char kDigitMap[4][4] = {
{'1', '2', '3', 'A'},
{'4', '5', '6', 'B'},
{'7', '8', '9', 'C'},
{'*', '0', '#', 'D'},
};
constexpr double kPi = 3.14159265358979323846;
constexpr double kDominanceRatio = 1.8;
double goertzelPower(const int16_t* samples, size_t count, double freqHz, uint16_t sampleRateHz) {
if (samples == nullptr || count == 0 || sampleRateHz == 0U) {
return 0.0;
}
const double omega = 2.0 * kPi * freqHz / static_cast<double>(sampleRateHz);
const double coeff = 2.0 * std::cos(omega);
double q0 = 0.0;
double q1 = 0.0;
double q2 = 0.0;
for (size_t i = 0; i < count; ++i) {
q0 = coeff * q1 - q2 + static_cast<double>(samples[i]);
q2 = q1;
q1 = q0;
}
return q1 * q1 + q2 * q2 - coeff * q1 * q2;
}
template <size_t N>
size_t indexOfMax(const std::array<double, N>& values) {
size_t idx = 0;
for (size_t i = 1; i < N; ++i) {
if (values[i] > values[idx]) {
idx = i;
}
}
return idx;
}
template <size_t N>
double secondBest(const std::array<double, N>& values, size_t bestIndex) {
double second = 0.0;
for (size_t i = 0; i < N; ++i) {
if (i == bestIndex) {
continue;
}
second = std::max(second, values[i]);
}
return second;
}
} // namespace
DtmfDecoder::DtmfDecoder()
: DtmfDecoder(8000U, 160U) {}
DtmfDecoder::DtmfDecoder(uint16_t sampleRateHz, size_t windowSize)
: onDigit(nullptr),
sampleRateHz_(sampleRateHz == 0U ? 8000U : sampleRateHz),
windowSize_(windowSize < 80U ? 80U : windowSize),
lastCandidate_('\0'),
stableCount_(0U),
latchedDigit_('\0') {}
void DtmfDecoder::setDigitCallback(DigitCallback cb) {
onDigit = cb;
}
char DtmfDecoder::detectDigit(const int16_t* samples, size_t count) const {
if (samples == nullptr || count < (windowSize_ / 2U)) {
return '\0';
}
std::array<double, 4> lowPower = {{0.0, 0.0, 0.0, 0.0}};
std::array<double, 4> highPower = {{0.0, 0.0, 0.0, 0.0}};
for (size_t i = 0; i < 4; ++i) {
lowPower[i] = goertzelPower(samples, count, kLowFreq[i], sampleRateHz_);
highPower[i] = goertzelPower(samples, count, kHighFreq[i], sampleRateHz_);
}
const size_t lowIdx = indexOfMax(lowPower);
const size_t highIdx = indexOfMax(highPower);
const double lowBest = lowPower[lowIdx];
const double highBest = highPower[highIdx];
const double lowSecond = secondBest(lowPower, lowIdx);
const double highSecond = secondBest(highPower, highIdx);
const double lowSum = lowPower[0] + lowPower[1] + lowPower[2] + lowPower[3];
const double highSum = highPower[0] + highPower[1] + highPower[2] + highPower[3];
if (lowBest <= 0.0 || highBest <= 0.0) {
return '\0';
}
if (lowSecond > 0.0 && (lowBest / lowSecond) < kDominanceRatio) {
return '\0';
}
if (highSecond > 0.0 && (highBest / highSecond) < kDominanceRatio) {
return '\0';
}
if ((lowBest / (lowSum + 1.0)) < 0.55 || (highBest / (highSum + 1.0)) < 0.55) {
return '\0';
}
return kDigitMap[lowIdx][highIdx];
}
void DtmfDecoder::feedAudioSamples(const int16_t* samples, size_t count) {
if (samples == nullptr || count == 0U) {
return;
}
for (size_t offset = 0; offset < count; offset += windowSize_) {
const size_t frameSize = std::min(windowSize_, count - offset);
if (frameSize < (windowSize_ / 2U)) {
continue;
}
const char candidate = detectDigit(samples + offset, frameSize);
if (candidate == '\0') {
lastCandidate_ = '\0';
stableCount_ = 0U;
latchedDigit_ = '\0';
continue;
}
if (candidate == lastCandidate_) {
if (stableCount_ < 255U) {
++stableCount_;
}
} else {
lastCandidate_ = candidate;
stableCount_ = 1U;
}
if (stableCount_ >= 2U && candidate != latchedDigit_) {
latchedDigit_ = candidate;
if (onDigit) {
onDigit(candidate);
}
}
}
}
@@ -1,22 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
class DtmfDecoder {
public:
using DigitCallback = std::function<void(char)>;
DtmfDecoder();
explicit DtmfDecoder(uint16_t sampleRateHz, size_t windowSize = 160);
void feedAudioSamples(const int16_t* samples, size_t count);
void setDigitCallback(DigitCallback cb);
private:
char detectDigit(const int16_t* samples, size_t count) const;
DigitCallback onDigit;
uint16_t sampleRateHz_;
size_t windowSize_;
char lastCandidate_;
uint8_t stableCount_;
char latchedDigit_;
};
@@ -1,801 +0,0 @@
#include "telephony/TelephonyService.h"
namespace {
constexpr uint16_t kDtmfFrameSamples = 160U;
constexpr uint32_t kHookHangupMs = 50U; // Reduced from 300ms for cleaner state transitions
constexpr uint32_t kHookStabilizeMs = 40U;
constexpr uint32_t kPulseInterDigitGapMs = 700U;
constexpr uint32_t kPulseEdgeDebounceMs = 22U;
constexpr uint32_t kPulseBreakMinMs = 28U;
constexpr uint32_t kPulseBreakMaxMs = 220U;
constexpr uint32_t kPulseMakeMinMs = 28U;
constexpr uint32_t kPulseDtmfGuardMs = 900U;
// Keep this short so the first rotary digit is not lost when users dial
// immediately after lifting the handset.
constexpr uint32_t kIdleHookOffEnterDebounceMs = 80U;
constexpr size_t kDialMaxDigits = 20U;
constexpr uint32_t kDialExactPendingCommitMs = 1200U;
constexpr uint32_t kDtmfCaptureStartDelayMs = 0U;
constexpr uint32_t kDtmfReadPeriodMs = 12U;
constexpr uint32_t kTelephonyIdlePowerDownDelayMs = 2500U;
constexpr uint32_t kTelephonyPowerProbeIntervalMs = 1200U;
constexpr uint32_t kTelephonyPowerProbeWindowMs = 180U;
constexpr uint8_t kDialSourceNone = 0U;
constexpr uint8_t kDialSourceDtmf = 1U;
constexpr uint8_t kDialSourcePulse = 2U;
constexpr ToneProfile kDialToneProfiles[] = {
ToneProfile::FR_FR,
ToneProfile::ETSI_EU,
ToneProfile::UK_GB,
ToneProfile::NA_US,
};
constexpr size_t kDialToneProfileCount = sizeof(kDialToneProfiles) / sizeof(kDialToneProfiles[0]);
}
const char* telephonyStateToString(TelephonyState state) {
switch (state) {
case TelephonyState::IDLE:
return "IDLE";
case TelephonyState::RINGING:
return "RINGING";
case TelephonyState::PLAYING_MESSAGE:
return "PLAYING_MESSAGE";
case TelephonyState::OFF_HOOK:
return "OFF_HOOK";
default:
return "UNKNOWN";
}
}
const char* dialMatchStateToString(DialMatchState state) {
switch (state) {
case DialMatchState::PREFIX:
return "PREFIX";
case DialMatchState::EXACT_PENDING:
return "EXACT_PENDING";
case DialMatchState::TRIGGERED:
return "TRIGGERED";
case DialMatchState::NONE:
default:
return "NONE";
}
}
TelephonyService::TelephonyService()
: profile_(BoardProfile::ESP32_A252),
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
slic_(nullptr),
audio_(nullptr),
dial_callback_(nullptr),
dial_match_callback_(nullptr),
answer_callback_(nullptr),
dtmf_(8000U, kDtmfFrameSamples),
state_(TelephonyState::IDLE),
incoming_ring_(false),
ring_phase_on_(false),
ring_cycle_start_ms_(0),
telephony_powered_(true),
power_probe_active_(false),
idle_since_ms_(0),
next_power_probe_ms_(0),
power_probe_end_ms_(0),
capture_active_(false),
pulse_hook_initialized_(false),
pulse_last_hook_off_(false),
pulse_collecting_(false),
pulse_count_(0),
last_hook_edge_ms_(0),
pulse_break_start_ms_(0),
pulse_make_start_ms_(0),
idle_hook_off_since_ms_(0),
last_pulse_ms_(0),
dtmf_capture_start_ms_(0),
next_dtmf_read_ms_(0),
off_hook_enter_ms_(0),
last_pulse_edge_ms_(0),
suppress_dial_tone_(false),
dialing_started_(false),
dial_lock_until_on_hook_(false),
dial_source_(kDialSourceNone),
dial_match_state_(DialMatchState::NONE),
dial_buffer_(""),
last_digit_ms_(0),
dial_exact_pending_since_ms_(0),
last_dial_error_(""),
message_path_("/welcome.wav"),
off_hook_dial_profile_(ToneProfile::FR_FR) {}
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
profile_ = profile;
features_ = getFeatureMatrix(profile);
slic_ = &slic;
audio_ = &audio;
state_ = TelephonyState::IDLE;
incoming_ring_ = false;
ring_phase_on_ = false;
ring_cycle_start_ms_ = millis();
telephony_powered_ = true;
power_probe_active_ = false;
idle_since_ms_ = ring_cycle_start_ms_;
next_power_probe_ms_ = ring_cycle_start_ms_ + kTelephonyPowerProbeIntervalMs;
power_probe_end_ms_ = 0;
capture_active_ = false;
pulse_hook_initialized_ = false;
pulse_last_hook_off_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_hook_edge_ms_ = 0;
pulse_break_start_ms_ = 0;
pulse_make_start_ms_ = 0;
idle_hook_off_since_ms_ = 0;
last_pulse_ms_ = 0;
dtmf_capture_start_ms_ = 0;
next_dtmf_read_ms_ = 0;
off_hook_enter_ms_ = 0;
last_pulse_edge_ms_ = 0;
suppress_dial_tone_ = false;
dial_tone_suppressed_until_ms_ = 0U;
dialing_started_ = false;
dial_lock_until_on_hook_ = false;
dial_source_ = kDialSourceNone;
dial_match_state_ = DialMatchState::NONE;
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_exact_pending_since_ms_ = 0;
last_dial_error_ = "";
off_hook_dial_profile_ = ToneProfile::FR_FR;
dtmf_.setDigitCallback([this](char digit) {
onDialDigit(digit, false);
});
slic_->setRing(false);
setTelephonyPower(true);
if (!forcePowerOnPolicy()) {
setTelephonyPower(false);
}
return true;
}
void TelephonyService::setDialCallback(DialCallback cb) {
dial_callback_ = cb;
}
void TelephonyService::setDialMatchCallback(DialMatchCallback cb) {
dial_match_callback_ = cb;
}
void TelephonyService::setAnswerCallback(AnswerCallback cb) {
answer_callback_ = cb;
}
void TelephonyService::triggerIncomingRing() {
incoming_ring_ = true;
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0;
}
void TelephonyService::setIncomingRing(bool active) {
incoming_ring_ = active;
if (active) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0;
}
}
void TelephonyService::forceTelephonyPower(bool enabled) {
if (forcePowerOnPolicy()) {
enabled = true;
}
setTelephonyPower(enabled);
power_probe_active_ = false;
if (enabled) {
idle_since_ms_ = 0;
} else {
idle_since_ms_ = millis();
next_power_probe_ms_ = idle_since_ms_ + kTelephonyPowerProbeIntervalMs;
}
}
bool TelephonyService::forcePowerOnPolicy() const {
return profile_ == BoardProfile::ESP32_A252;
}
void TelephonyService::setTelephonyPower(bool enabled) {
if (slic_ == nullptr) {
return;
}
if (forcePowerOnPolicy() && !enabled) {
enabled = true;
}
if (telephony_powered_ == enabled) {
return;
}
if (enabled) {
slic_->setPowerDown(false);
slic_->setLineEnabled(true);
} else {
if (ring_phase_on_) {
ring_phase_on_ = false;
slic_->setRing(false);
}
slic_->setLineEnabled(false);
slic_->setPowerDown(true);
}
telephony_powered_ = enabled;
Serial.printf("[Telephony] slic_power=%s\n", enabled ? "on" : "off");
}
void TelephonyService::applyPowerPolicyPreTick(uint32_t now) {
if (slic_ == nullptr) {
return;
}
if (forcePowerOnPolicy()) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0U;
return;
}
const bool keep_power_for_audio =
(audio_ != nullptr) && (audio_->isToneRenderingActive() || audio_->isPlaying());
if (keep_power_for_audio) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0;
return;
}
if (state_ != TelephonyState::IDLE || incoming_ring_) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0;
return;
}
if (telephony_powered_) {
if (idle_since_ms_ == 0U) {
idle_since_ms_ = now;
}
if (!power_probe_active_ && (now - idle_since_ms_) >= kTelephonyIdlePowerDownDelayMs) {
setTelephonyPower(false);
next_power_probe_ms_ = now + kTelephonyPowerProbeIntervalMs;
power_probe_end_ms_ = 0U;
}
return;
}
if (now >= next_power_probe_ms_) {
setTelephonyPower(true);
power_probe_active_ = true;
power_probe_end_ms_ = now + kTelephonyPowerProbeWindowMs;
}
}
void TelephonyService::applyPowerPolicyPostTick(bool hook_off, uint32_t now) {
if (slic_ == nullptr) {
return;
}
if (forcePowerOnPolicy()) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0U;
return;
}
const bool keep_power_for_audio =
(audio_ != nullptr) && (audio_->isToneRenderingActive() || audio_->isPlaying());
if (keep_power_for_audio) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0;
return;
}
if (state_ != TelephonyState::IDLE || incoming_ring_ || hook_off) {
setTelephonyPower(true);
power_probe_active_ = false;
idle_since_ms_ = 0;
return;
}
if (telephony_powered_ && idle_since_ms_ == 0U) {
idle_since_ms_ = now;
}
if (power_probe_active_ && telephony_powered_ && now >= power_probe_end_ms_) {
setTelephonyPower(false);
power_probe_active_ = false;
next_power_probe_ms_ = now + kTelephonyPowerProbeIntervalMs;
}
}
void TelephonyService::onDialDigit(char digit, bool from_pulse) {
if (digit < '0' || digit > '9') {
return;
}
if (dial_lock_until_on_hook_) {
return;
}
const uint32_t now = millis();
if (!from_pulse) {
// Rotary pulse has priority: suppress DTMF captures while pulse edges are active/recent.
const bool pulse_recent =
pulse_collecting_ || pulse_count_ > 0U ||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseDtmfGuardMs);
if (pulse_recent) {
return;
}
}
const uint8_t source = from_pulse ? kDialSourcePulse : kDialSourceDtmf;
if (dial_source_ == kDialSourceNone) {
dial_source_ = source;
} else if (dial_source_ != source) {
// Allow pulse to override an early DTMF false-start (typically tone bleed).
if (from_pulse && dial_source_ == kDialSourceDtmf && dial_buffer_.length() <= 1U) {
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_source_ = source;
} else {
// Keep strict ordering by ignoring mixed-source digits in the same session.
return;
}
}
if (audio_ != nullptr && dial_buffer_.isEmpty() && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
dialing_started_ = true;
if (dial_buffer_.length() >= kDialMaxDigits) {
dial_buffer_ = "";
dial_match_state_ = DialMatchState::NONE;
dial_exact_pending_since_ms_ = 0U;
dial_source_ = kDialSourceNone;
}
dial_buffer_ += digit;
last_digit_ms_ = now;
Serial.printf("[Telephony] digit=%c source=%s buffer=%s\n",
digit,
from_pulse ? "pulse" : "dtmf",
dial_buffer_.c_str());
evaluateDialBuffer(now, from_pulse ? "digit_pulse" : "digit_dtmf");
}
void TelephonyService::updatePulseDecode(bool hook_off, uint32_t now) {
if (!pulse_hook_initialized_) {
pulse_hook_initialized_ = true;
pulse_last_hook_off_ = hook_off;
last_hook_edge_ms_ = now;
pulse_break_start_ms_ = 0U;
pulse_make_start_ms_ = now;
return;
}
if (hook_off == pulse_last_hook_off_) {
return;
}
if ((now - last_pulse_edge_ms_) < kPulseEdgeDebounceMs) {
return;
}
last_pulse_edge_ms_ = now;
// Any valid hook edge during OFF_HOOK indicates dialing activity start.
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
dialing_started_ = true;
if (pulse_last_hook_off_ && !hook_off) {
// Make -> Break
const uint32_t make_ms = (pulse_make_start_ms_ == 0U) ? 0U : (now - pulse_make_start_ms_);
if (make_ms >= kPulseMakeMinMs) {
if (!pulse_collecting_) {
pulse_collecting_ = true;
pulse_count_ = 0;
// Stop dial tone as soon as rotary dialing starts (first pulse edge),
// not only after the first full decoded digit.
if (audio_ != nullptr && audio_->isToneRenderingActive()) {
audio_->stopTone();
}
}
pulse_break_start_ms_ = now;
}
} else if (!pulse_last_hook_off_ && hook_off) {
// Break -> Make
pulse_make_start_ms_ = now;
const uint32_t break_ms = (pulse_break_start_ms_ == 0U) ? 0U : (now - pulse_break_start_ms_);
if (pulse_collecting_ && pulse_count_ < 20U && break_ms >= kPulseBreakMinMs && break_ms <= kPulseBreakMaxMs) {
++pulse_count_;
last_pulse_ms_ = now;
Serial.printf("[Telephony] pulse_count=%u break_ms=%u\n", pulse_count_, break_ms);
}
}
pulse_last_hook_off_ = hook_off;
last_hook_edge_ms_ = now;
}
void TelephonyService::commitDialBuffer(const char* reason) {
if (dial_buffer_.isEmpty()) {
return;
}
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
const String number = dial_buffer_;
const bool from_pulse = (dial_source_ == kDialSourcePulse);
const bool ok = dial_callback_ ? dial_callback_(number, from_pulse) : false;
if (ok) {
// Freeze dialing once a hotline route is launched; unlock only on hangup.
dial_lock_until_on_hook_ = true;
}
last_dial_error_ = ok ? "" : "dial_failed";
dial_match_state_ = DialMatchState::TRIGGERED;
Serial.printf("[Telephony] dial_trigger reason=%s number=%s ok=%s\n",
reason != nullptr ? reason : "unknown",
number.c_str(),
ok ? "true" : "false");
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_exact_pending_since_ms_ = 0U;
dial_source_ = kDialSourceNone;
}
void TelephonyService::evaluateDialBuffer(uint32_t now, const char* reason) {
if (dial_buffer_.isEmpty()) {
dial_match_state_ = DialMatchState::NONE;
dial_exact_pending_since_ms_ = 0U;
return;
}
if (!dial_match_callback_) {
dial_match_state_ = DialMatchState::PREFIX;
if (dial_buffer_.length() >= 10U) {
commitDialBuffer(reason != nullptr ? reason : "legacy_len10");
}
return;
}
const DialRouteMatch match = dial_match_callback_(dial_buffer_);
switch (match) {
case DialRouteMatch::NONE:
Serial.printf("[Telephony] dial_no_match buffer=%s reset\n", dial_buffer_.c_str());
dial_buffer_ = "";
last_digit_ms_ = 0U;
dial_exact_pending_since_ms_ = 0U;
dial_match_state_ = DialMatchState::NONE;
dial_source_ = kDialSourceNone;
dialing_started_ = false;
return;
case DialRouteMatch::PREFIX:
dial_match_state_ = DialMatchState::PREFIX;
dial_exact_pending_since_ms_ = 0U;
return;
case DialRouteMatch::EXACT:
commitDialBuffer(reason != nullptr ? reason : "exact");
return;
case DialRouteMatch::EXACT_AND_PREFIX:
dial_match_state_ = DialMatchState::EXACT_PENDING;
if (dial_exact_pending_since_ms_ == 0U) {
dial_exact_pending_since_ms_ = now;
}
return;
default:
return;
}
}
void TelephonyService::clearDialSession() {
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
if (audio_ != nullptr && capture_active_) {
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
}
capture_active_ = false;
dtmf_capture_start_ms_ = 0;
next_dtmf_read_ms_ = 0;
off_hook_enter_ms_ = 0;
pulse_hook_initialized_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_hook_edge_ms_ = 0;
pulse_break_start_ms_ = 0;
pulse_make_start_ms_ = 0;
last_pulse_ms_ = 0;
last_pulse_edge_ms_ = 0;
dial_source_ = kDialSourceNone;
dial_match_state_ = DialMatchState::NONE;
dialing_started_ = false;
dial_lock_until_on_hook_ = false;
suppress_dial_tone_ = false;
dial_tone_suppressed_until_ms_ = 0U;
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_exact_pending_since_ms_ = 0U;
off_hook_dial_profile_ = ToneProfile::FR_FR;
}
void TelephonyService::selectRandomDialProfile() {
const uint32_t entropy = millis() ^ micros() ^ last_hook_edge_ms_;
const size_t index = static_cast<size_t>(entropy % static_cast<uint32_t>(kDialToneProfileCount));
off_hook_dial_profile_ = kDialToneProfiles[index];
Serial.printf("[Telephony] off_hook dial profile=%s\n", toneProfileToString(off_hook_dial_profile_));
}
bool TelephonyService::startSelectedDialTone() {
ToneProfile profile = off_hook_dial_profile_;
if (profile == ToneProfile::NONE) {
profile = ToneProfile::FR_FR;
}
return audio_ != nullptr && audio_->playTone(profile, ToneEvent::DIAL);
}
void TelephonyService::suppressDialToneForMs(uint32_t duration_ms) {
if (duration_ms == 0U) {
dial_tone_suppressed_until_ms_ = 0U;
return;
}
dial_tone_suppressed_until_ms_ = millis() + duration_ms;
}
void TelephonyService::clearDialToneSuppression() {
dial_tone_suppressed_until_ms_ = 0U;
}
bool TelephonyService::isDialToneSuppressed(uint32_t now_ms) const {
return dial_tone_suppressed_until_ms_ != 0U && now_ms < dial_tone_suppressed_until_ms_;
}
void TelephonyService::tick() {
if (slic_ == nullptr || audio_ == nullptr) {
return;
}
const uint32_t now = millis();
applyPowerPolicyPreTick(now);
slic_->tick();
const bool hook_off = telephony_powered_ ? slic_->isHookOff() : false;
const bool tone_suppressed = suppress_dial_tone_ || isDialToneSuppressed(now);
const TelephonyState prev_state = state_;
switch (state_) {
case TelephonyState::IDLE:
if (incoming_ring_ && !hook_off) {
ring_cycle_start_ms_ = millis();
ring_phase_on_ = true;
slic_->setRing(true);
state_ = TelephonyState::RINGING;
idle_hook_off_since_ms_ = 0;
} else if (hook_off) {
if (idle_hook_off_since_ms_ == 0U) {
idle_hook_off_since_ms_ = now;
} else if ((now - idle_hook_off_since_ms_) >= kIdleHookOffEnterDebounceMs) {
state_ = TelephonyState::OFF_HOOK;
idle_hook_off_since_ms_ = 0;
}
} else {
idle_hook_off_since_ms_ = 0;
}
break;
case TelephonyState::RINGING: {
if (hook_off) {
incoming_ring_ = false;
ring_phase_on_ = false;
slic_->setRing(false);
const bool answered = answer_callback_ ? answer_callback_() : false;
// Keep dial tone muted while transitioning from incoming ring to call answer.
suppress_dial_tone_ = true;
suppressDialToneForMs(3000U);
last_dial_error_ = answered ? "" : "answer_failed";
state_ = TelephonyState::OFF_HOOK;
break;
}
if (!incoming_ring_) {
ring_phase_on_ = false;
slic_->setRing(false);
state_ = TelephonyState::IDLE;
break;
}
const uint32_t elapsed = (millis() - ring_cycle_start_ms_) % 5000U;
const bool should_ring = elapsed < 1000U;
if (should_ring != ring_phase_on_) {
ring_phase_on_ = should_ring;
slic_->setRing(ring_phase_on_);
}
break;
}
case TelephonyState::PLAYING_MESSAGE:
if (!audio_->isPlaying()) {
state_ = hook_off ? TelephonyState::OFF_HOOK : TelephonyState::IDLE;
}
break;
case TelephonyState::OFF_HOOK:
// While dial is locked, pulse decoder is disabled; keep hangup edge timing
// in sync without touching normal pulse decoding flow.
if (dial_lock_until_on_hook_ && hook_off != pulse_last_hook_off_) {
last_hook_edge_ms_ = now;
pulse_last_hook_off_ = hook_off;
}
if (!dial_lock_until_on_hook_ && (now - off_hook_enter_ms_) >= kHookStabilizeMs) {
updatePulseDecode(hook_off, now);
}
if (!hook_off) {
// Mute immediately on first on-hook edge so audio does not continue
// while the hangup debounce window is still running.
if (audio_ != nullptr && audio_->isToneRenderingActive()) {
audio_->stopTone();
}
if (audio_ != nullptr && audio_->isPlaying()) {
audio_->stopPlayback();
}
const bool hangup_confirmed = (now - last_hook_edge_ms_) >= kHookHangupMs;
if (hangup_confirmed) {
if (audio_ != nullptr && capture_active_) {
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
capture_active_ = false;
}
incoming_ring_ = false;
state_ = TelephonyState::IDLE;
}
break;
}
if (dial_lock_until_on_hook_) {
if (audio_ != nullptr && capture_active_) {
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
capture_active_ = false;
}
if (!dial_buffer_.isEmpty() || dial_source_ != kDialSourceNone || dial_match_state_ != DialMatchState::NONE) {
dial_buffer_ = "";
last_digit_ms_ = 0U;
dial_source_ = kDialSourceNone;
dial_match_state_ = DialMatchState::NONE;
dial_exact_pending_since_ms_ = 0U;
}
break;
}
if (pulse_collecting_ && pulse_count_ > 0U && (now - last_pulse_ms_) >= kPulseInterDigitGapMs) {
const uint8_t count = pulse_count_;
pulse_collecting_ = false;
pulse_count_ = 0;
const char digit = (count == 10U) ? '0' : ((count >= 1U && count <= 9U) ? static_cast<char>('0' + count)
: '\0');
if (digit != '\0') {
onDialDigit(digit, true);
}
}
if (dial_match_state_ == DialMatchState::EXACT_PENDING &&
dial_exact_pending_since_ms_ != 0U &&
(now - last_digit_ms_) >= kDialExactPendingCommitMs) {
commitDialBuffer("exact_pending_timeout");
}
if (!capture_active_ && now >= dtmf_capture_start_ms_) {
capture_active_ = audio_->requestCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
}
if (capture_active_ && now >= next_dtmf_read_ms_) {
int16_t frame[kDtmfFrameSamples] = {0};
const size_t samples_read = audio_->readCaptureFrameNonBlocking(frame, kDtmfFrameSamples);
if (samples_read > 0U) {
dtmf_.feedAudioSamples(frame, samples_read);
}
next_dtmf_read_ms_ = now + kDtmfReadPeriodMs;
}
if (suppress_dial_tone_ && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
const bool pulse_dial_in_progress =
pulse_collecting_ || pulse_count_ > 0U ||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseInterDigitGapMs);
if (!tone_suppressed && !dialing_started_ && dial_buffer_.isEmpty() && !audio_->isDialToneActive() &&
!audio_->isToneRenderingActive() && !audio_->isPlaying() &&
!pulse_dial_in_progress) {
startSelectedDialTone();
}
if (!dial_buffer_.isEmpty() && (now - last_digit_ms_) >= 10000U) {
// Drop stale partial numbers instead of dialing an incomplete value.
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_match_state_ = DialMatchState::NONE;
dial_exact_pending_since_ms_ = 0U;
dial_source_ = kDialSourceNone;
}
break;
}
if (prev_state != state_) {
if (state_ == TelephonyState::OFF_HOOK) {
off_hook_enter_ms_ = now;
pulse_hook_initialized_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_hook_edge_ms_ = now;
pulse_last_hook_off_ = hook_off;
pulse_break_start_ms_ = 0U;
pulse_make_start_ms_ = now;
last_pulse_ms_ = 0;
last_pulse_edge_ms_ = 0;
dial_source_ = kDialSourceNone;
dial_match_state_ = DialMatchState::NONE;
dialing_started_ = false;
dial_lock_until_on_hook_ = false;
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_exact_pending_since_ms_ = 0U;
dtmf_capture_start_ms_ = now + kDtmfCaptureStartDelayMs;
next_dtmf_read_ms_ = now;
if (audio_ != nullptr && !tone_suppressed && !audio_->isPlaying()) {
selectRandomDialProfile();
startSelectedDialTone();
}
}
if (prev_state == TelephonyState::OFF_HOOK && state_ != TelephonyState::OFF_HOOK) {
clearDialSession();
}
}
applyPowerPolicyPostTick(hook_off, now);
}
TelephonyState TelephonyService::state() const {
return state_;
}
bool TelephonyService::isTelephonyPowered() const {
return telephony_powered_;
}
bool TelephonyService::isPowerProbeActive() const {
return power_probe_active_;
}
const String& TelephonyService::dialBuffer() const {
return dial_buffer_;
}
const char* TelephonyService::dialSource() const {
switch (dial_source_) {
case kDialSourceDtmf:
return "DTMF";
case kDialSourcePulse:
return "PULSE";
case kDialSourceNone:
default:
return "NONE";
}
}
DialMatchState TelephonyService::dialMatchState() const {
return dial_match_state_;
}
bool TelephonyService::dialingStarted() const {
return dialing_started_;
}
@@ -1,119 +0,0 @@
#ifndef TELEPHONY_SERVICE_H
#define TELEPHONY_SERVICE_H
#include <functional>
#include "audio/AudioEngine.h"
#include "core/PlatformProfile.h"
#include "slic/SlicController.h"
#include "telephony/DtmfDecoder.h"
enum class TelephonyState : uint8_t {
IDLE = 0,
RINGING,
PLAYING_MESSAGE,
OFF_HOOK
};
enum class DialRouteMatch : uint8_t {
NONE = 0,
PREFIX,
EXACT,
EXACT_AND_PREFIX,
};
enum class DialMatchState : uint8_t {
NONE = 0,
PREFIX,
EXACT_PENDING,
TRIGGERED,
};
const char* telephonyStateToString(TelephonyState state);
const char* dialMatchStateToString(DialMatchState state);
class TelephonyService {
public:
using DialCallback = std::function<bool(const String&, bool from_pulse)>;
using DialMatchCallback = std::function<DialRouteMatch(const String&)>;
using AnswerCallback = std::function<bool()>;
TelephonyService();
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
void setDialCallback(DialCallback cb);
void setDialMatchCallback(DialMatchCallback cb);
void setAnswerCallback(AnswerCallback cb);
void triggerIncomingRing();
void setIncomingRing(bool active);
void forceTelephonyPower(bool enabled);
void tick();
TelephonyState state() const;
bool isTelephonyPowered() const;
bool isPowerProbeActive() const;
void suppressDialToneForMs(uint32_t duration_ms);
void clearDialToneSuppression();
bool isDialToneSuppressed(uint32_t now_ms) const;
const String& dialBuffer() const;
const char* dialSource() const;
DialMatchState dialMatchState() const;
bool dialingStarted() const;
private:
bool forcePowerOnPolicy() const;
void setTelephonyPower(bool enabled);
void applyPowerPolicyPreTick(uint32_t now);
void applyPowerPolicyPostTick(bool hook_off, uint32_t now);
void onDialDigit(char digit, bool from_pulse);
void updatePulseDecode(bool hook_off, uint32_t now);
void evaluateDialBuffer(uint32_t now, const char* reason);
void commitDialBuffer(const char* reason);
void clearDialSession();
void selectRandomDialProfile();
bool startSelectedDialTone();
BoardProfile profile_;
FeatureMatrix features_;
SlicController* slic_;
AudioEngine* audio_;
DialCallback dial_callback_;
DialMatchCallback dial_match_callback_;
AnswerCallback answer_callback_;
DtmfDecoder dtmf_;
TelephonyState state_;
bool incoming_ring_;
bool ring_phase_on_;
uint32_t ring_cycle_start_ms_;
bool telephony_powered_;
bool power_probe_active_;
uint32_t idle_since_ms_;
uint32_t next_power_probe_ms_;
uint32_t power_probe_end_ms_;
bool capture_active_;
bool pulse_hook_initialized_;
bool pulse_last_hook_off_;
bool pulse_collecting_;
uint8_t pulse_count_;
uint32_t last_hook_edge_ms_;
uint32_t pulse_break_start_ms_;
uint32_t pulse_make_start_ms_;
uint32_t idle_hook_off_since_ms_;
uint32_t last_pulse_ms_;
uint32_t dtmf_capture_start_ms_;
uint32_t next_dtmf_read_ms_;
uint32_t off_hook_enter_ms_;
uint32_t last_pulse_edge_ms_;
bool suppress_dial_tone_;
uint32_t dial_tone_suppressed_until_ms_ = 0U;
bool dialing_started_;
bool dial_lock_until_on_hook_;
uint8_t dial_source_;
DialMatchState dial_match_state_;
String dial_buffer_;
uint32_t last_digit_ms_;
uint32_t dial_exact_pending_since_ms_;
String last_dial_error_;
const char* message_path_;
ToneProfile off_hook_dial_profile_;
};
#endif // TELEPHONY_SERVICE_H
@@ -1,18 +0,0 @@
#pragma once
#include <Arduino.h>
namespace usb_host_runtime {
inline bool enableHostPortPower() {
#if defined(ARDUINO_ESP32_S3_USB_OTG) && defined(USB_HOST_EN) && \
defined(USB_HOST_POWER_VBUS) && defined(USB_HOST_POWER_OFF)
usbHostEnable(true);
usbHostPower(USB_HOST_POWER_VBUS);
return true;
#else
return false;
#endif
}
} // namespace usb_host_runtime
@@ -1,228 +0,0 @@
#include <Arduino.h>
#include <USB.h>
#include <USBMSC.h>
#include <esp_log.h>
#include <esp_partition.h>
#include "usb/UsbMassStorageRuntime.h"
namespace {
constexpr uint32_t kUsbMscBlockSize = 512;
constexpr uint32_t kSectorBytes = 4096;
constexpr char kUsbMscPartitionLabel[] = "usbmsc";
constexpr char kUsbMscVendorId[] = "ESP32";
constexpr char kUsbMscProductId[] = "USB_MSC";
constexpr char kUsbMscProductRevision[] = "1.0";
constexpr char kUsbMscLogTag[] = "USB_MSC";
USBMSC g_usb_msc;
const esp_partition_t* g_msc_partition = nullptr;
uint32_t g_msc_blocks = 0;
bool g_msc_ready = false;
uint32_t alignDown(uint32_t value, uint32_t align) {
return value & ~(align - 1U);
}
uint32_t alignUp(uint32_t value, uint32_t align) {
return (value + align - 1U) & ~(align - 1U);
}
bool isInRange(uint32_t offset, uint32_t size) {
if (g_msc_partition == nullptr) {
return false;
}
if (offset > g_msc_partition->size) {
return false;
}
return (static_cast<uint64_t>(offset) + size) <= g_msc_partition->size;
}
bool eraseRange(uint32_t offset, uint32_t size) {
if (!isInRange(offset, size)) {
return false;
}
const uint32_t aligned_offset = alignDown(offset, kSectorBytes);
const uint32_t aligned_size = alignUp(size, kSectorBytes);
const uint64_t partition_end = g_msc_partition->size;
if (aligned_offset >= partition_end) {
return false;
}
if (aligned_offset + aligned_size > partition_end) {
return false;
}
const esp_err_t err = esp_partition_erase_range(g_msc_partition, aligned_offset, aligned_size);
if (err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag, "erase_range failed off=%lu size=%lu err=%s", aligned_offset, aligned_size, esp_err_to_name(err));
return false;
}
return true;
}
bool onStartStop(uint8_t power_condition, bool start, bool load_eject) {
(void)power_condition;
g_msc_ready = start || !load_eject;
ESP_LOGI(kUsbMscLogTag, "start_stop power_condition=%u start=%d eject=%d", power_condition, static_cast<int>(start),
static_cast<int>(load_eject));
return true;
}
int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t* buffer, uint32_t bufsize) {
if (!g_msc_ready || g_msc_partition == nullptr) {
return 0;
}
if (offset >= kUsbMscBlockSize) {
return 0;
}
const uint64_t block_offset = static_cast<uint64_t>(lba) * kUsbMscBlockSize;
const uint64_t bytes_offset_64 = block_offset + offset;
const uint64_t max_writable = g_msc_partition->size;
if (bytes_offset_64 >= max_writable) {
return 0;
}
const uint32_t bytes_offset = static_cast<uint32_t>(bytes_offset_64);
const uint32_t max_chunk = static_cast<uint32_t>(max_writable - bytes_offset);
const uint32_t write_size = (bufsize > max_chunk) ? max_chunk : bufsize;
if (!isInRange(bytes_offset, write_size)) {
return 0;
}
uint32_t written = 0;
while (written < write_size) {
const uint32_t dst_offset = bytes_offset + written;
const uint32_t sector_start = alignDown(dst_offset, kSectorBytes);
const uint32_t sector_end = sector_start + kSectorBytes;
const uint32_t chunk_end = (bytes_offset + write_size < sector_end) ? (bytes_offset + write_size) : sector_end;
const uint32_t copy_len = chunk_end - dst_offset;
const uint32_t sector_pos = dst_offset - sector_start;
uint8_t sector[kSectorBytes];
const esp_err_t read_err = esp_partition_read(g_msc_partition, sector_start, sector, kSectorBytes);
if (read_err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag, "sector read failed addr=%lu err=%s", sector_start, esp_err_to_name(read_err));
return 0;
}
memcpy(sector + sector_pos, buffer + written, copy_len);
if (!eraseRange(sector_start, kSectorBytes)) {
return 0;
}
const esp_err_t write_err = esp_partition_write(g_msc_partition, sector_start, sector, kSectorBytes);
if (write_err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag,
"write failed lba=%lu offset=%lu size=%lu err=%s",
lba,
offset,
write_size,
esp_err_to_name(write_err));
return static_cast<int32_t>(written);
}
written += copy_len;
}
return static_cast<int32_t>(written);
}
int32_t onRead(uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize) {
if (!g_msc_ready || g_msc_partition == nullptr) {
return 0;
}
if (offset >= kUsbMscBlockSize) {
return 0;
}
const uint64_t block_offset = static_cast<uint64_t>(lba) * kUsbMscBlockSize;
const uint64_t bytes_offset_64 = block_offset + offset;
if (bytes_offset_64 >= g_msc_partition->size) {
return 0;
}
const uint32_t bytes_offset = static_cast<uint32_t>(bytes_offset_64);
const uint32_t max_chunk = static_cast<uint32_t>(g_msc_partition->size - bytes_offset);
const uint32_t read_size = (bufsize > max_chunk) ? max_chunk : bufsize;
if (!isInRange(bytes_offset, read_size)) {
return 0;
}
const esp_err_t err = esp_partition_read(g_msc_partition, bytes_offset, buffer, read_size);
if (err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag, "read failed lba=%lu offset=%lu size=%lu err=%s", lba, offset, read_size, esp_err_to_name(err));
return 0;
}
return static_cast<int32_t>(read_size);
}
void onUsbEvent(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) {
(void)arg;
(void)event_data;
if (event_base != ARDUINO_USB_EVENTS) {
return;
}
switch (event_id) {
case ARDUINO_USB_STARTED_EVENT:
ESP_LOGI(kUsbMscLogTag, "USB plugged");
break;
case ARDUINO_USB_STOPPED_EVENT:
ESP_LOGI(kUsbMscLogTag, "USB unplugged");
break;
default:
break;
}
}
} // namespace
namespace usb_msc_runtime {
bool beginUsbMassStorage() {
g_msc_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, kUsbMscPartitionLabel);
if (g_msc_partition == nullptr) {
ESP_LOGE(kUsbMscLogTag, "partition '%s' not found", kUsbMscPartitionLabel);
return false;
}
g_msc_blocks = static_cast<uint32_t>(g_msc_partition->size / kUsbMscBlockSize);
g_usb_msc.onStartStop(onStartStop);
g_usb_msc.onRead(onRead);
g_usb_msc.onWrite(onWrite);
g_usb_msc.vendorID(kUsbMscVendorId);
g_usb_msc.productID(kUsbMscProductId);
g_usb_msc.productRevision(kUsbMscProductRevision);
g_usb_msc.mediaPresent(true);
if (!g_usb_msc.begin(g_msc_blocks, kUsbMscBlockSize)) {
ESP_LOGE(kUsbMscLogTag, "USBMSC begin failed");
g_msc_partition = nullptr;
return false;
}
USB.onEvent(onUsbEvent);
USB.begin();
g_msc_ready = true;
ESP_LOGI(kUsbMscLogTag,
"started: blocks=%lu size=%luKB label=%s",
g_msc_blocks,
g_msc_partition->size / 1024U,
g_msc_partition->label);
return true;
}
} // namespace usb_msc_runtime
@@ -1,7 +0,0 @@
#pragma once
namespace usb_msc_runtime {
bool beginUsbMassStorage();
} // namespace usb_msc_runtime
@@ -1,126 +0,0 @@
#include "ScopeDisplay.h"
#include <math.h>
#if defined(CONFIG_IDF_TARGET_ESP32)
#include <driver/dac.h>
#endif
namespace {
constexpr uint8_t kDefaultAmplitude = 48U;
constexpr uint16_t kDefaultFrequencyHz = 1200U;
constexpr uint16_t kMinFrequencyHz = 60U;
constexpr uint16_t kMaxFrequencyHz = 5000U;
} // namespace
ScopeDisplay::ScopeDisplay()
: initialized_(false),
configured_(false),
enabled_(false),
supported_(false),
frequency_hz_(kDefaultFrequencyHz),
amplitude_(kDefaultAmplitude),
last_tick_us_(0),
phase_(0.0f) {}
bool ScopeDisplay::supported() const {
return supported_;
}
bool ScopeDisplay::enabled() const {
return initialized_ && enabled_;
}
uint16_t ScopeDisplay::frequency() const {
return frequency_hz_;
}
uint8_t ScopeDisplay::amplitude() const {
return amplitude_;
}
bool ScopeDisplay::begin() {
#if defined(CONFIG_IDF_TARGET_ESP32)
dac_output_enable(DAC_CHANNEL_1);
dac_output_enable(DAC_CHANNEL_2);
initialized_ = true;
supported_ = true;
configured_ = true;
enabled_ = true;
last_tick_us_ = micros();
phase_ = 0.0f;
return true;
#else
initialized_ = false;
supported_ = false;
configured_ = false;
enabled_ = false;
return false;
#endif
}
void ScopeDisplay::end() {
if (!initialized_) {
return;
}
enabled_ = false;
#if defined(CONFIG_IDF_TARGET_ESP32)
dac_output_disable(DAC_CHANNEL_1);
dac_output_disable(DAC_CHANNEL_2);
#endif
initialized_ = false;
}
bool ScopeDisplay::configure(uint16_t frequency_hz, uint8_t amplitude) {
if (frequency_hz < kMinFrequencyHz || frequency_hz > kMaxFrequencyHz) {
return false;
}
if (amplitude == 0U) {
return false;
}
frequency_hz_ = frequency_hz;
amplitude_ = amplitude;
configured_ = true;
return true;
}
void ScopeDisplay::enable(bool value) {
if (!configured_ || !supported_) {
return;
}
enabled_ = value;
if (enabled_) {
if (!initialized_) {
begin();
}
}
}
void ScopeDisplay::tick() {
if (!initialized_ || !enabled_ || !configured_) {
return;
}
#if defined(CONFIG_IDF_TARGET_ESP32)
const uint32_t now = micros();
if ((now - last_tick_us_) < kTickIntervalUs) {
return;
}
last_tick_us_ = now;
const float step = kTau * static_cast<float>(frequency_hz_) * (kTickIntervalUs / 1000000.0f);
phase_ += step;
if (phase_ >= kTau) {
phase_ -= kTau;
}
const float x = sinf(phase_);
const float y = cosf(phase_);
const int v1 = 128 + static_cast<int>(x * static_cast<float>(amplitude_));
const int v2 = 128 + static_cast<int>(y * static_cast<float>(amplitude_));
const uint8_t sample1 = static_cast<uint8_t>(constrain(v1, 0, 255));
const uint8_t sample2 = static_cast<uint8_t>(constrain(v2, 0, 255));
dac_output_voltage(DAC_CHANNEL_1, sample1);
dac_output_voltage(DAC_CHANNEL_2, sample2);
#endif
}
@@ -1,35 +0,0 @@
#ifndef VISUAL_SCOPE_DISPLAY_H
#define VISUAL_SCOPE_DISPLAY_H
#include <Arduino.h>
class ScopeDisplay {
public:
ScopeDisplay();
bool begin();
void end();
bool supported() const;
bool enabled() const;
bool configure(uint16_t frequency_hz, uint8_t amplitude);
void enable(bool value);
void tick();
uint16_t frequency() const;
uint8_t amplitude() const;
private:
static constexpr uint32_t kTickIntervalUs = 300;
static constexpr float kTau = 6.283185307179586f;
bool initialized_;
bool configured_;
bool enabled_;
bool supported_;
uint16_t frequency_hz_;
uint8_t amplitude_;
uint32_t last_tick_us_;
float phase_;
};
#endif // VISUAL_SCOPE_DISPLAY_H
@@ -1,495 +0,0 @@
#include "web/WebServerManager.h"
#include <FFat.h>
#include <SPIFFS.h>
namespace {
constexpr bool kForceAuthDisabled = false;
constexpr bool kEnableRealtimeEvents = true;
String quoteArg(const String& value) {
String escaped = value;
escaped.replace("\\", "\\\\");
escaped.replace("\"", "\\\"");
return String("\"") + escaped + "\"";
}
}
WebServerManager::WebServerManager(uint16_t port)
: server_(port),
events_("/api/events"),
rate_limit_ms_(250),
last_status_push_ms_(0),
status_cache_json_(""),
status_cache_ready_(false),
status_cache_mux_(portMUX_INITIALIZER_UNLOCKED),
auth_enabled_(true),
auth_user_("admin"),
auth_pass_("admin") {}
void WebServerManager::begin() {
#if defined(BOARD_PROFILE_A252)
if (FFat.begin(false) || FFat.begin(true)) {
server_.serveStatic("/", FFat, "/webui/").setDefaultFile("index.html");
} else {
Serial.println("[WebServerManager] FFat mount failed");
}
#elif defined(USB_MSC_BOOT_ENABLE)
if (FFat.begin(false, "/usbmsc", 10, "usbmsc") || FFat.begin(true, "/usbmsc", 10, "usbmsc")) {
server_.serveStatic("/", FFat, "/webui/").setDefaultFile("index.html");
} else {
Serial.println("[WebServerManager] FFat mount failed (label usbmsc)");
}
#else
if (!SPIFFS.begin(false) && !SPIFFS.begin(true)) {
Serial.println("[WebServerManager] SPIFFS mount failed");
} else {
server_.serveStatic("/", SPIFFS, "/webui/").setDefaultFile("index.html");
}
#endif
registerRoutes();
server_.begin();
Serial.println("[WebServerManager] HTTP server started");
}
void WebServerManager::handle() {
const uint32_t now = millis();
if (now - last_status_push_ms_ >= 1000U) {
last_status_push_ms_ = now;
refreshStatusCache();
publishRealtimeStatus();
}
}
void WebServerManager::setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs) {
(void)persist_to_nvs;
if (!isValidInput(user, 32) || !isValidInput(pass, 64)) {
return;
}
auth_user_ = user;
auth_pass_ = pass;
}
void WebServerManager::setAuthEnabled(bool enabled) {
if (kForceAuthDisabled) {
auth_enabled_ = false;
auth_override_set_ = true;
return;
}
auth_override_set_ = true;
auth_enabled_ = enabled;
}
bool WebServerManager::isAuthEnabled() const {
if (kForceAuthDisabled && !auth_override_set_) {
return false;
}
return auth_enabled_;
}
void WebServerManager::setCommandValidator(std::function<bool(const String&)> callback) {
command_validator_ = std::move(callback);
}
void WebServerManager::setRateLimitMs(uint32_t rate_limit_ms) {
rate_limit_ms_ = rate_limit_ms;
}
void WebServerManager::setStatusCallback(std::function<void(JsonObject)> callback) {
status_callback_ = std::move(callback);
}
void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const String&)> callback) {
command_executor_ = std::move(callback);
}
void WebServerManager::registerRoutes() {
if (kEnableRealtimeEvents) {
events_.onConnect([this](AsyncEventSourceClient* client) {
JsonDocument hello;
hello["transport"] = "sse";
hello["connected"] = true;
hello["ts"] = millis();
const String payload = toJsonString(hello);
client->send(payload.c_str(), "hello", millis());
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
client->send(cached.c_str(), "status", millis());
}
});
server_.addHandler(&events_);
}
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
request->send(200, "application/json", cached);
return;
}
JsonDocument warmup;
warmup["auth_enabled"] = isAuthEnabled();
warmup["state"] = "status_warmup";
request->send(200, "application/json", toJsonString(warmup));
});
server_.on("/api/control", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String action = doc["action"] | "";
if (!isValidInput(action, 128)) {
request->send(400, "application/json", "{\"error\":\"invalid action\"}");
return;
}
handleDispatch(request, action);
});
// A252 config endpoints.
server_.on("/api/config/pins", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "SLIC_CONFIG_GET"); });
server_.on("/api/config/pins", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
String payload;
serializeJson(doc, payload);
handleDispatch(request, "SLIC_CONFIG_SET " + payload);
});
server_.on("/api/config/audio", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "AUDIO_CONFIG_GET"); });
server_.on("/api/config/audio", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
String payload;
serializeJson(doc, payload);
handleDispatch(request, "AUDIO_CONFIG_SET " + payload);
});
// WiFi.
server_.on("/api/network/wifi", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_STATUS"); });
server_.on("/api/network/wifi/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String ssid = doc["ssid"] | "";
const String pass = doc["pass"] | "";
if (!isValidInput(ssid, 64)) {
request->send(400, "application/json", "{\"error\":\"invalid ssid\"}");
return;
}
handleDispatch(request, "WIFI_CONNECT " + quoteArg(ssid) + " " + quoteArg(pass));
});
server_.on("/api/network/wifi/disconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_DISCONNECT"); });
server_.on("/api/network/wifi/reconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_RECONNECT"); });
server_.on("/api/network/wifi/scan", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_SCAN"); });
// ESP-NOW.
server_.on("/api/network/espnow", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_STATUS"); });
server_.on("/api/network/espnow/on", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_ON"); });
server_.on("/api/network/espnow/off", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_OFF"); });
server_.on("/api/network/espnow/peer", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_PEER_LIST"); });
server_.on("/api/network/espnow/peer", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String mac = doc["mac"] | "";
if (!isValidInput(mac, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
return;
}
handleDispatch(request, "ESPNOW_PEER_ADD " + mac);
});
server_.on("/api/network/espnow/peer", HTTP_DELETE, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String mac = doc["mac"] | "";
if (!isValidInput(mac, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
return;
}
handleDispatch(request, "ESPNOW_PEER_DEL " + mac);
});
server_.on("/api/network/espnow/send", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String mac = doc["mac"] | "";
if (!isValidInput(mac, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
return;
}
String payload;
JsonVariantConst payload_variant = doc["payload"].as<JsonVariantConst>();
bool already_enveloped = false;
if (payload_variant.is<JsonObjectConst>()) {
JsonObjectConst payload_obj = payload_variant.as<JsonObjectConst>();
already_enveloped = payload_obj["msg_id"].is<const char*>() && payload_obj["type"].is<const char*>();
}
if (already_enveloped) {
serializeJson(payload_variant, payload);
} else {
JsonDocument envelope;
envelope["msg_id"] = String("web-") + String(millis());
envelope["seq"] = millis();
envelope["type"] = "command";
envelope["ack"] = true;
if (!payload_variant.isNull()) {
envelope["payload"].set(payload_variant);
} else {
envelope["payload"].to<JsonObject>();
}
serializeJson(envelope, payload);
}
handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload);
});
}
bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) const {
if (kForceAuthDisabled || !auth_enabled_) {
return true;
}
if (!request->authenticate(auth_user_.c_str(), auth_pass_.c_str())) {
request->requestAuthentication();
return false;
}
return true;
}
bool WebServerManager::extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc) {
if (request->hasParam("plain", true)) {
const String body = request->getParam("plain", true)->value();
return deserializeJson(doc, body) == DeserializationError::Ok;
}
return false;
}
String WebServerManager::toJsonString(const JsonDocument& doc) {
String out;
serializeJson(doc, out);
return out;
}
bool WebServerManager::isValidInput(const String& value, size_t max_len) {
if (value.isEmpty() || value.length() > max_len) {
return false;
}
for (size_t i = 0; i < value.length(); ++i) {
const char c = value[i];
if (c < 32 || c > 126) {
return false;
}
}
return true;
}
bool WebServerManager::isEffectCommand(const String& command_line) {
String token = command_line;
const int sep = token.indexOf(' ');
if (sep > 0) {
token = token.substring(0, sep);
}
token.trim();
token.toUpperCase();
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP";
}
bool WebServerManager::extractCommandId(const String& command_line, String& command_id) {
command_id = "";
String line = command_line;
line.trim();
if (line.isEmpty()) {
return false;
}
int sep = -1;
const int len = line.length();
bool in_quote = false;
bool escaped = false;
for (int i = 0; i < len; ++i) {
const char c = line[i];
if (in_quote) {
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
in_quote = false;
}
continue;
}
if (c == '"') {
in_quote = true;
continue;
}
if (c == ' ') {
sep = i;
break;
}
}
if (sep < 0) {
command_id = line;
} else {
command_id = line.substring(0, sep);
}
command_id.trim();
command_id.toUpperCase();
return !command_id.isEmpty();
}
bool WebServerManager::isCommandRegistered(const String& command_line,
const std::function<bool(const String&)>& validator) {
if (!validator) {
return true;
}
String command_id;
if (!extractCommandId(command_line, command_id)) {
return false;
}
return validator(command_id);
}
void WebServerManager::refreshStatusCache() {
if (!status_callback_) {
portENTER_CRITICAL(&status_cache_mux_);
status_cache_ready_ = false;
status_cache_json_ = "";
portEXIT_CRITICAL(&status_cache_mux_);
return;
}
JsonDocument doc;
doc["auth_enabled"] = isAuthEnabled();
status_callback_(doc.to<JsonObject>());
const String payload = toJsonString(doc);
portENTER_CRITICAL(&status_cache_mux_);
status_cache_json_ = payload;
status_cache_ready_ = true;
portEXIT_CRITICAL(&status_cache_mux_);
}
String WebServerManager::snapshotStatusCache(bool* ready) {
portENTER_CRITICAL(&status_cache_mux_);
const bool has_data = status_cache_ready_;
const String payload = status_cache_json_;
portEXIT_CRITICAL(&status_cache_mux_);
if (ready != nullptr) {
*ready = has_data;
}
return payload;
}
void WebServerManager::publishRealtimeEvent(const char* event_name, const String& payload_json) {
if (!kEnableRealtimeEvents) {
return;
}
events_.send(payload_json.c_str(), event_name, millis());
}
void WebServerManager::publishRealtimeStatus() {
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (!ready) {
return;
}
publishRealtimeEvent("status", cached);
}
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
JsonDocument doc;
doc["command"] = command_line;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
if (!res.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, res.json) == DeserializationError::Ok) {
doc["json"].set(parsed.as<JsonVariantConst>());
} else {
doc["json_raw"] = res.json;
}
}
const String payload = toJsonString(doc);
publishRealtimeEvent("dispatch", payload);
if (isEffectCommand(command_line)) {
publishRealtimeEvent("effect", payload);
}
}
void WebServerManager::handleDispatch(AsyncWebServerRequest* request,
const String& command_line,
uint16_t success_code,
uint16_t error_code) {
if (!authenticateRequest(request)) {
return;
}
if (!command_executor_) {
request->send(500, "application/json", "{\"error\":\"command executor not configured\"}");
return;
}
if (!isCommandRegistered(command_line, command_validator_)) {
JsonDocument invalid;
invalid["ok"] = false;
invalid["error"] = "unsupported_command";
invalid["command"] = command_line;
invalid["path"] = request->url();
request->send(400, "application/json", toJsonString(invalid));
return;
}
const DispatchResponse res = command_executor_(command_line);
if (!res.json.isEmpty()) {
request->send(res.ok ? success_code : error_code, "application/json", res.json);
} else {
JsonDocument doc;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
request->send(res.ok ? success_code : error_code, "application/json", toJsonString(doc));
}
publishDispatchEvent(command_line, res);
}
@@ -1,64 +0,0 @@
#ifndef WEB_WEB_SERVER_MANAGER_H
#define WEB_WEB_SERVER_MANAGER_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <freertos/FreeRTOS.h>
#include <functional>
#include "core/CommandDispatcher.h"
class WebServerManager {
public:
explicit WebServerManager(uint16_t port = 80);
void begin();
void handle();
void setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs = false);
void setAuthEnabled(bool enabled);
bool isAuthEnabled() const;
void setRateLimitMs(uint32_t rate_limit_ms);
void setStatusCallback(std::function<void(JsonObject)> callback);
void setCommandExecutor(std::function<DispatchResponse(const String&)> callback);
void setCommandValidator(std::function<bool(const String&)> callback);
private:
AsyncWebServer server_;
AsyncEventSource events_;
uint32_t rate_limit_ms_;
uint32_t last_status_push_ms_;
String status_cache_json_;
bool status_cache_ready_;
portMUX_TYPE status_cache_mux_;
bool auth_enabled_;
bool auth_override_set_ = false;
String auth_user_;
String auth_pass_;
std::function<void(JsonObject)> status_callback_;
std::function<DispatchResponse(const String&)> command_executor_;
std::function<bool(const String&)> command_validator_;
static bool extractCommandId(const String& command_line, String& command_id);
static bool isCommandRegistered(const String& command_line,
const std::function<bool(const String&)>& validator);
void registerRoutes();
bool authenticateRequest(AsyncWebServerRequest* request) const;
static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc);
static String toJsonString(const JsonDocument& doc);
static bool isValidInput(const String& value, size_t max_len);
static bool isEffectCommand(const String& command_line);
String snapshotStatusCache(bool* ready = nullptr);
void refreshStatusCache();
void publishRealtimeEvent(const char* event_name, const String& payload_json);
void publishRealtimeStatus();
void publishDispatchEvent(const String& command_line, const DispatchResponse& res);
void handleDispatch(AsyncWebServerRequest* request,
const String& command_line,
uint16_t success_code = 200,
uint16_t error_code = 400);
};
#endif // WEB_WEB_SERVER_MANAGER_H
@@ -1,230 +0,0 @@
let contactsData = [];
function showSection(section) {
const map = {
contacts: "contactsSection",
config: "configSection",
logs: "logsSection",
control: "controlSection",
};
Object.values(map).forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.classList.remove("active");
}
});
const sectionEl = document.getElementById(map[section]);
if (sectionEl) {
sectionEl.classList.add("active");
}
}
async function safeFetchJson(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
async function refreshStatus() {
const status = document.getElementById("status");
try {
const data = await safeFetchJson("/api/status");
status.textContent =
`state=${data.state} board=${data.board_profile || "n/a"} ` +
`telephony=${data.telephony || "n/a"} hook=${data.hook || "n/a"} ` +
`full_duplex=${data.full_duplex} underrun=${data.audio_underrun_count || 0} ` +
`drop=${data.audio_drop_frames || 0}`;
} catch (error) {
status.textContent = `Erreur statut: ${error.message}`;
}
}
async function loadContacts() {
try {
contactsData = await safeFetchJson("/api/contacts");
renderContacts();
} catch (error) {
document.getElementById("contactFeedback").textContent = `Erreur contacts: ${error.message}`;
}
}
function renderContacts() {
const list = document.getElementById("contactsList");
const searchInput = document.getElementById("searchContact");
const search = (searchInput?.value || "").toLowerCase();
list.innerHTML = "";
contactsData
.filter((c) => c.nom.toLowerCase().includes(search) || c.numero.includes(search))
.forEach((c, idx) => {
const card = document.createElement("div");
card.className = "contact-card";
card.innerHTML = `<b>${c.nom}</b><br><span>${c.numero}</span><br><span>${c.type}</span>`;
const actions = document.createElement("div");
actions.className = "contact-actions";
actions.innerHTML =
`<button data-call="${c.numero}">Appeler</button>` +
`<button data-edit="${idx}">Modifier</button>` +
`<button data-delete="${idx}">Supprimer</button>`;
card.appendChild(actions);
list.appendChild(card);
});
}
function editContact(idx) {
const c = contactsData[idx];
if (!c) {
return;
}
const form = document.getElementById("contactForm");
form.nom.value = c.nom;
form.numero.value = c.numero;
form.type.value = c.type;
form.dataset.editIdx = String(idx);
}
async function deleteContact(idx) {
const response = await fetch("/api/contacts", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idx }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await loadContacts();
document.getElementById("contactFeedback").textContent = "Contact supprimé";
}
async function callContact(numero) {
await sendControl("call", { numero });
document.getElementById("contactFeedback").textContent = `Appel lancé vers ${numero}`;
}
async function loadConfig() {
try {
const data = await safeFetchJson("/api/config");
document.getElementById("config").textContent = JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById("config").textContent = `Erreur config: ${error.message}`;
}
}
async function saveConfig(event) {
event.preventDefault();
const form = event.target;
const payload = {
param1: form.param1.value || "valeur1",
param2: form.param2.value || "valeur2",
};
const response = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
document.getElementById("config").textContent = `Erreur config: HTTP ${response.status}`;
return;
}
await loadConfig();
}
async function refreshLogs() {
const response = await fetch("/api/logs");
const logs = response.ok ? await response.text() : `Erreur logs: HTTP ${response.status}`;
document.getElementById("logs").textContent = logs;
}
async function sendControl(action, extraPayload = {}) {
const response = await fetch("/api/control", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...extraPayload }),
});
const body = await response.text();
document.getElementById("controlResult").textContent = body;
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return true;
}
function bindEvents() {
document.querySelectorAll("nav button[data-section]").forEach((button) => {
button.addEventListener("click", () => showSection(button.dataset.section));
});
document.getElementById("refreshStatusBtn").addEventListener("click", refreshStatus);
document.getElementById("refreshLogsBtn").addEventListener("click", refreshLogs);
document.getElementById("searchContact").addEventListener("input", renderContacts);
document.getElementById("configForm").addEventListener("submit", saveConfig);
document.getElementById("contactForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.target;
const editIdxRaw = form.dataset.editIdx;
const hasEditIdx = typeof editIdxRaw !== "undefined";
const payload = {
nom: form.nom.value,
numero: form.numero.value,
type: form.type.value,
};
const method = hasEditIdx ? "PUT" : "POST";
const body = hasEditIdx ? { ...payload, idx: Number(editIdxRaw) } : payload;
const response = await fetch("/api/contacts", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
document.getElementById("contactFeedback").textContent = `Erreur contact: HTTP ${response.status}`;
return;
}
delete form.dataset.editIdx;
form.reset();
document.getElementById("contactFeedback").textContent = hasEditIdx
? "Contact modifié"
: "Contact ajouté";
await loadContacts();
});
document.getElementById("contactsList").addEventListener("click", async (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
try {
if (target.dataset.call) {
await callContact(target.dataset.call);
}
if (target.dataset.edit) {
editContact(Number(target.dataset.edit));
}
if (target.dataset.delete) {
await deleteContact(Number(target.dataset.delete));
}
} catch (error) {
document.getElementById("contactFeedback").textContent = error.message;
}
});
document.querySelectorAll("#controlSection button[data-action]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await sendControl(button.dataset.action);
} catch (error) {
document.getElementById("controlResult").textContent = error.message;
}
});
});
}
document.addEventListener("DOMContentLoaded", async () => {
bindEvents();
await Promise.all([refreshStatus(), loadContacts(), loadConfig(), refreshLogs()]);
showSection("contacts");
});

Some files were not shown because too many files have changed in this diff Show More