fix: remove duplicated hardware/firmware/hardware/ nesting - consolidate to single hardware structure
This commit is contained in:
@@ -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 d’un 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é n’est trouvé,
|
||||
- pause 3s,
|
||||
- boucle jusqu’au 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 d’utilisation
|
||||
- Lecture de fichier audio
|
||||
- Contrôle du volume
|
||||
- Gestion des erreurs
|
||||
|
||||
## Exemple d’inté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 d’utilisation
|
||||
- Connexion HFP
|
||||
- Transmission BLE
|
||||
- Sécurité des échanges
|
||||
|
||||
## Exemple d’inté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, l’audit, 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 d’inté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 d’inté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 l’architecture 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 d’accè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 d’erreur 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 s’appuyant sur un agent QA dédié et l’utilisation 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 l’exé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, l’affichage 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 l’action (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 d’appel
|
||||
- `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 l’action 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 d’automatisation (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 à l’utilisation du téléphone RTC expérimental.
|
||||
|
||||
## Rôles principaux
|
||||
|
||||
- **Opérateur principal** : gère la prise d’appel, 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 à l’appel 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 d’utilisation.
|
||||
- **Gardien du combiné** : veille à la manipulation correcte du combiné RTC et à la sécurité du matériel.
|
||||
|
||||
## Rôles optionnels
|
||||
|
||||
- **Ambassadeur** : communique avec d’autres é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 l’assistance IA ou la génération de checklists personnalisées.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Tests fonctionnels WebUI RTC_BL_PHONE
|
||||
|
||||
## Objectif
|
||||
Valider l’affichage, le rafraîchissement des données et l’interaction 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._
|
||||
-6
@@ -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"
|
||||
-205
@@ -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,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,
|
||||
|
-5
@@ -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,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
Reference in New Issue
Block a user