mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
firmware-readme-update
This commit is contained in:
@@ -176,6 +176,19 @@ static constexpr uint32_t CAPTURE_TASK_STACK_BYTES = 4096;
|
||||
/// Débit série : 921 600 baud = ~92 kB/s, largement au-dessus des 32 kB/s d'audio.
|
||||
static constexpr uint32_t SERIAL_BAUD = 921600;
|
||||
|
||||
// -------------------------------------------------------- Configuration Wi-Fi (portail AP)
|
||||
|
||||
/**
|
||||
* SSID du point d'accès ouvert en mode configuration (premier démarrage ou
|
||||
* bouton PTT tenu pendant WIFI_CONFIG_HOLD_MS au démarrage). La carte sert
|
||||
* un formulaire HTTP sur http://192.168.4.1 tant que l'utilisateur n'a pas
|
||||
* validé les identifiants Wi-Fi + MQTT.
|
||||
*/
|
||||
static constexpr const char* WIFI_CONFIG_AP_SSID = "VoiceAssist-Config";
|
||||
|
||||
/// Durée de maintien du bouton PTT au démarrage pour forcer le portail (ms).
|
||||
static constexpr uint32_t WIFI_CONFIG_HOLD_MS = 3000;
|
||||
|
||||
// ------------------------------------------------------------ Wi-Fi et MQTT (étape 3)
|
||||
|
||||
/// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/...
|
||||
|
||||
@@ -83,6 +83,10 @@ public:
|
||||
/// Nombre de connexions réussies depuis le démarrage.
|
||||
uint32_t connections() const;
|
||||
|
||||
/// Met à jour le port avant le premier appel à begin() (utile si la
|
||||
/// configuration vient de la NVS plutôt que d'une constante de secrets.h).
|
||||
void setPort(uint16_t port) { _port = port; }
|
||||
|
||||
private:
|
||||
PubSubClient _client;
|
||||
const char* _host;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "WifiConfig.h"
|
||||
#include "config.h"
|
||||
|
||||
#include <Preferences.h>
|
||||
#include <WebServer.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
// ------------------------------------------------------------------ NVS keys
|
||||
namespace {
|
||||
|
||||
constexpr const char* NVS_NS = "voice-cfg";
|
||||
constexpr const char* KEY_SSID = "ssid";
|
||||
constexpr const char* KEY_PASS = "pass";
|
||||
constexpr const char* KEY_HOST = "mqttHost";
|
||||
constexpr const char* KEY_PORT = "mqttPort";
|
||||
constexpr const char* KEY_USER = "mqttUser";
|
||||
constexpr const char* KEY_MPWD = "mqttPass";
|
||||
|
||||
// ------------------------------------------------------------------ HTML
|
||||
// Encodage UTF-8 inline ; servi avec charset=utf-8.
|
||||
const char HTML_FORM[] =
|
||||
"<!DOCTYPE html><html lang='fr'><head>"
|
||||
"<meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>Assistant vocal — configuration</title>"
|
||||
"<style>"
|
||||
"body{font-family:sans-serif;max-width:420px;margin:32px auto;padding:0 16px;color:#222}"
|
||||
"h2{color:#0078d4;margin-bottom:4px}"
|
||||
"p.s{color:#666;font-size:.9em;margin:0 0 20px}"
|
||||
"fieldset{border:1px solid #ccc;border-radius:6px;padding:12px;margin-bottom:16px}"
|
||||
"legend{font-weight:bold;padding:0 6px}"
|
||||
"label{display:block;margin-top:10px;font-size:.9em;color:#444}"
|
||||
"input{width:100%;padding:7px 8px;box-sizing:border-box;border:1px solid #bbb;"
|
||||
"border-radius:4px;margin-top:3px;font-size:1em}"
|
||||
"button{width:100%;padding:12px;background:#0078d4;color:#fff;border:none;"
|
||||
"border-radius:6px;font-size:1em;cursor:pointer}"
|
||||
"button:hover{background:#005fa3}"
|
||||
"</style></head><body>"
|
||||
"<h2>Assistant vocal</h2>"
|
||||
"<p class='s'>Renseignez le réseau Wi-Fi et l’adresse du broker MQTT,"
|
||||
" puis cliquez sur <em>Enregistrer</em>.</p>"
|
||||
"<form method='POST' action='/save'>"
|
||||
"<fieldset><legend>Wi-Fi</legend>"
|
||||
"<label>Réseau (SSID)</label>"
|
||||
"<input type='text' name='ssid' value='%SSID%' maxlength='63' required>"
|
||||
"<label>Mot de passe</label>"
|
||||
"<input type='password' name='pass' placeholder='(vide si réseau ouvert)' maxlength='63'>"
|
||||
"</fieldset>"
|
||||
"<fieldset><legend>Broker MQTT</legend>"
|
||||
"<label>Adresse IP</label>"
|
||||
"<input type='text' name='mqttHost' value='%HOST%' placeholder='192.168.1.x' maxlength='63' required>"
|
||||
"<label>Port</label>"
|
||||
"<input type='number' name='mqttPort' value='%PORT%' min='1' max='65535' required>"
|
||||
"<label>Utilisateur (vide = connexion anonyme)</label>"
|
||||
"<input type='text' name='mqttUser' value='%USER%' maxlength='31'>"
|
||||
"<label>Mot de passe MQTT</label>"
|
||||
"<input type='password' name='mqttPass' placeholder='(vide si anonyme)' maxlength='63'>"
|
||||
"</fieldset>"
|
||||
"<button type='submit'>Enregistrer et redémarrer</button>"
|
||||
"</form></body></html>";
|
||||
|
||||
const char HTML_OK[] =
|
||||
"<!DOCTYPE html><html lang='fr'><head><meta charset='utf-8'>"
|
||||
"<title>Sauvegardé</title></head>"
|
||||
"<body style='font-family:sans-serif;text-align:center;padding:60px;color:#222'>"
|
||||
"<p style='font-size:3em'>✓</p>"
|
||||
"<h2 style='color:#107c10'>Configuration sauvegardée</h2>"
|
||||
"<p>L’assistant redémarre dans quelques secondes…</p>"
|
||||
"</body></html>";
|
||||
|
||||
} // namespace
|
||||
|
||||
WifiConfig* WifiConfig::_instance = nullptr;
|
||||
|
||||
WifiConfig::WifiConfig() {
|
||||
_instance = this;
|
||||
// Par défaut, l'utilisateur MQTT est DEVICE_ID : les règles ACL du broker
|
||||
// (pattern voice/%u/...) l'exigent. Ce repli garantit que MqttLink ne
|
||||
// stocke pas un pointeur vide avant que begin() soit appelé.
|
||||
strncpy(_mqttUser, DEVICE_ID, sizeof(_mqttUser) - 1);
|
||||
}
|
||||
|
||||
WifiConfig::~WifiConfig() {
|
||||
delete _server;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ NVS I/O
|
||||
|
||||
bool WifiConfig::loadFromNvs() {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(NVS_NS, true)) {
|
||||
return false;
|
||||
}
|
||||
if (!prefs.isKey(KEY_SSID)) {
|
||||
prefs.end();
|
||||
return false;
|
||||
}
|
||||
prefs.getString(KEY_SSID, _ssid, sizeof(_ssid));
|
||||
prefs.getString(KEY_PASS, _password, sizeof(_password));
|
||||
prefs.getString(KEY_HOST, _mqttHost, sizeof(_mqttHost));
|
||||
_mqttPort = prefs.getUShort(KEY_PORT, 1883);
|
||||
prefs.getString(KEY_USER, _mqttUser, sizeof(_mqttUser));
|
||||
prefs.getString(KEY_MPWD, _mqttPassword, sizeof(_mqttPassword));
|
||||
prefs.end();
|
||||
|
||||
// Utilisateur vide → repli sur DEVICE_ID (requis par le broker ACL).
|
||||
if (_mqttUser[0] == '\0') {
|
||||
strncpy(_mqttUser, DEVICE_ID, sizeof(_mqttUser) - 1);
|
||||
}
|
||||
// SSID et hôte non vides : configuration valide.
|
||||
return _ssid[0] != '\0' && _mqttHost[0] != '\0';
|
||||
}
|
||||
|
||||
void WifiConfig::saveToNvs(const char* ssid, const char* password,
|
||||
const char* mqttHost, uint16_t mqttPort,
|
||||
const char* mqttUser, const char* mqttPassword) {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(NVS_NS, false)) {
|
||||
Serial.println("# config : erreur d'ecriture NVS");
|
||||
return;
|
||||
}
|
||||
prefs.putString(KEY_SSID, ssid);
|
||||
prefs.putString(KEY_PASS, password);
|
||||
prefs.putString(KEY_HOST, mqttHost);
|
||||
prefs.putUShort(KEY_PORT, mqttPort);
|
||||
prefs.putString(KEY_USER, mqttUser);
|
||||
prefs.putString(KEY_MPWD, mqttPassword);
|
||||
prefs.end();
|
||||
Serial.println("# config : identifiants sauvegardes en NVS");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ begin
|
||||
|
||||
void WifiConfig::begin(bool forcePortal,
|
||||
const char* fbSsid, const char* fbPassword,
|
||||
const char* fbMqttHost, uint16_t fbMqttPort,
|
||||
const char* fbMqttUser, const char* fbMqttPass) {
|
||||
if (!forcePortal && loadFromNvs()) {
|
||||
Serial.printf("# config : Wi-Fi \"%s\", broker %s:%u (NVS)\n",
|
||||
_ssid, _mqttHost, _mqttPort);
|
||||
return;
|
||||
}
|
||||
|
||||
// Repli depuis secrets.h : utilisé si les deux champs essentiels sont renseignés,
|
||||
// sans être copié en NVS (l'utilisateur reste maître de la migration).
|
||||
if (!forcePortal && fbSsid[0] != '\0' && fbMqttHost[0] != '\0') {
|
||||
strncpy(_ssid, fbSsid, sizeof(_ssid) - 1);
|
||||
strncpy(_password, fbPassword, sizeof(_password) - 1);
|
||||
strncpy(_mqttHost, fbMqttHost, sizeof(_mqttHost) - 1);
|
||||
_mqttPort = fbMqttPort;
|
||||
const char* user = (fbMqttUser[0] != '\0') ? fbMqttUser : DEVICE_ID;
|
||||
strncpy(_mqttUser, user, sizeof(_mqttUser) - 1);
|
||||
strncpy(_mqttPassword, fbMqttPass, sizeof(_mqttPassword) - 1);
|
||||
Serial.printf("# config : Wi-Fi \"%s\", broker %s:%u (secrets.h)\n",
|
||||
_ssid, _mqttHost, _mqttPort);
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println(forcePortal
|
||||
? "# config : portail force (bouton PTT tenu au demarrage)"
|
||||
: "# config : aucun identifiant disponible, lancement du portail");
|
||||
runPortal(); // ne retourne pas
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Portail
|
||||
|
||||
void WifiConfig::onRoot() {
|
||||
String page = HTML_FORM;
|
||||
page.replace("%SSID%", _instance->_ssid);
|
||||
page.replace("%HOST%", _instance->_mqttHost);
|
||||
char portStr[8];
|
||||
snprintf(portStr, sizeof(portStr), "%u", _instance->_mqttPort);
|
||||
page.replace("%PORT%", portStr);
|
||||
page.replace("%USER%", _instance->_mqttUser);
|
||||
_instance->_server->send(200, "text/html; charset=utf-8", page);
|
||||
}
|
||||
|
||||
void WifiConfig::onSave() {
|
||||
WebServer* srv = _instance->_server;
|
||||
|
||||
const String ssid = srv->arg("ssid");
|
||||
const String mqttHost = srv->arg("mqttHost");
|
||||
|
||||
if (ssid.isEmpty()) {
|
||||
srv->send(400, "text/plain", "SSID manquant");
|
||||
return;
|
||||
}
|
||||
if (mqttHost.isEmpty()) {
|
||||
srv->send(400, "text/plain", "Adresse broker manquante");
|
||||
return;
|
||||
}
|
||||
|
||||
const String pass = srv->arg("pass");
|
||||
const uint16_t port = static_cast<uint16_t>(srv->arg("mqttPort").toInt());
|
||||
const String mqttUser = srv->arg("mqttUser");
|
||||
const String mqttPass = srv->arg("mqttPass");
|
||||
|
||||
_instance->saveToNvs(
|
||||
ssid.c_str(), pass.c_str(),
|
||||
mqttHost.c_str(), port ? port : uint16_t(1883),
|
||||
mqttUser.c_str(), mqttPass.c_str());
|
||||
|
||||
srv->send(200, "text/html; charset=utf-8", HTML_OK);
|
||||
delay(1500);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void WifiConfig::onNotFound() {
|
||||
_instance->_server->sendHeader("Location", "/", true);
|
||||
_instance->_server->send(302, "text/plain", "");
|
||||
}
|
||||
|
||||
void WifiConfig::runPortal() {
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_AP);
|
||||
if (!WiFi.softAP(WIFI_CONFIG_AP_SSID)) {
|
||||
Serial.println("# portail : echec softAP, redemarrage dans 5 s");
|
||||
delay(5000);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
Serial.printf(
|
||||
"# portail : AP \"%s\" actif "
|
||||
"-- connectez-vous au Wi-Fi puis allez sur http://192.168.4.1\n",
|
||||
WIFI_CONFIG_AP_SSID);
|
||||
|
||||
// LED : clignotement rapide (100 ms) pendant toute la durée du portail.
|
||||
// La broche est déjà configurée en OUTPUT par led.begin() avant begin().
|
||||
pinMode(PIN_STATUS_LED, OUTPUT);
|
||||
|
||||
_server = new WebServer(80);
|
||||
_server->on("/", HTTP_GET, onRoot);
|
||||
_server->on("/save", HTTP_POST, onSave);
|
||||
_server->onNotFound(onNotFound);
|
||||
_server->begin();
|
||||
|
||||
Serial.println("# portail : en attente de configuration...");
|
||||
uint32_t lastToggle = 0;
|
||||
bool ledOn = false;
|
||||
for (;;) {
|
||||
_server->handleClient();
|
||||
if (millis() - lastToggle >= 100) {
|
||||
lastToggle = millis();
|
||||
ledOn = !ledOn;
|
||||
digitalWrite(PIN_STATUS_LED, ledOn ? HIGH : LOW);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
/**
|
||||
* @brief Configuration Wi-Fi et MQTT persistée en NVS (Preferences).
|
||||
*
|
||||
* Au premier démarrage (NVS vide, pas de secrets.h), ou si le bouton PTT est
|
||||
* tenu pendant WIFI_CONFIG_HOLD_MS au démarrage, la carte ouvre un point
|
||||
* d'accès Wi-Fi (SSID défini par WIFI_CONFIG_AP_SSID dans config.h) et sert
|
||||
* un formulaire de configuration sur http://192.168.4.1. Après validation,
|
||||
* les identifiants sont sauvegardés en NVS et la carte redémarre.
|
||||
*
|
||||
* En fonctionnement normal, les identifiants viennent de la NVS. Si la NVS
|
||||
* est vide et que secrets.h fournit des valeurs non vides, elles sont utilisées
|
||||
* comme repli sans être copiées en NVS.
|
||||
*
|
||||
* Les accesseurs retournent des pointeurs vers des tampons internes valides
|
||||
* pour toute la durée de vie de l'objet : ils peuvent être passés directement
|
||||
* aux constructeurs de WifiLink et MqttLink (trick pointeur — les tampons sont
|
||||
* remplis par begin() avant que begin() de ces classes ne soit appelé).
|
||||
*/
|
||||
class WifiConfig {
|
||||
public:
|
||||
WifiConfig();
|
||||
~WifiConfig();
|
||||
|
||||
/**
|
||||
* @brief Charge les identifiants depuis la NVS ou le repli (secrets.h).
|
||||
*
|
||||
* Si forcePortal vaut true, ou si aucun identifiant valide n'est disponible,
|
||||
* lance le portail AP + HTTP et ne revient jamais (l'ESP32 redémarre après
|
||||
* sauvegarde).
|
||||
*
|
||||
* @param forcePortal true pour forcer le portail (bouton tenu au démarrage).
|
||||
* @param fbSsid Repli (secrets.h) : SSID — chaîne vide si absent.
|
||||
* @param fbPassword Repli : mot de passe Wi-Fi.
|
||||
* @param fbMqttHost Repli : adresse IP du broker.
|
||||
* @param fbMqttPort Repli : port du broker.
|
||||
* @param fbMqttUser Repli : utilisateur MQTT.
|
||||
* @param fbMqttPass Repli : mot de passe MQTT.
|
||||
*/
|
||||
void begin(bool forcePortal,
|
||||
const char* fbSsid, const char* fbPassword,
|
||||
const char* fbMqttHost, uint16_t fbMqttPort,
|
||||
const char* fbMqttUser, const char* fbMqttPass);
|
||||
|
||||
const char* ssid() const { return _ssid; }
|
||||
const char* password() const { return _password; }
|
||||
const char* mqttHost() const { return _mqttHost; }
|
||||
uint16_t mqttPort() const { return _mqttPort; }
|
||||
const char* mqttUser() const { return _mqttUser; }
|
||||
const char* mqttPassword() const { return _mqttPassword; }
|
||||
|
||||
private:
|
||||
char _ssid[64] = {};
|
||||
char _password[64] = {};
|
||||
char _mqttHost[64] = {};
|
||||
uint16_t _mqttPort = 1883;
|
||||
char _mqttUser[32] = {};
|
||||
char _mqttPassword[64] = {};
|
||||
|
||||
class WebServer* _server = nullptr;
|
||||
|
||||
bool loadFromNvs();
|
||||
void saveToNvs(const char* ssid, const char* password,
|
||||
const char* mqttHost, uint16_t mqttPort,
|
||||
const char* mqttUser, const char* mqttPassword);
|
||||
void runPortal();
|
||||
|
||||
static WifiConfig* _instance;
|
||||
static void onRoot();
|
||||
static void onSave();
|
||||
static void onNotFound();
|
||||
};
|
||||
+40
-7
@@ -34,15 +34,21 @@
|
||||
#include "SerialFramer.h"
|
||||
#include "StatusLed.h"
|
||||
#include "VoiceSession.h"
|
||||
#include "WifiConfig.h"
|
||||
#include "WifiLink.h"
|
||||
#include "config.h"
|
||||
|
||||
// Les secrets (Wi-Fi, broker) ne sont jamais versionnés : on les exige ici, avec
|
||||
// un message clair, plutôt qu'une erreur de compilation obscure plus loin.
|
||||
// secrets.h est optionnel : s'il est présent ses valeurs servent de repli si
|
||||
// la NVS est vide. Sans lui, le portail AP/HTTP est lancé au premier démarrage.
|
||||
#if __has_include("secrets.h")
|
||||
#include "secrets.h"
|
||||
#else
|
||||
#error "firmware/include/secrets.h absent : cp firmware/include/secrets.h.example firmware/include/secrets.h, puis renseignez le Wi-Fi et l'adresse du broker"
|
||||
static constexpr const char* WIFI_SSID = "";
|
||||
static constexpr const char* WIFI_PASSWORD = "";
|
||||
static constexpr const char* MQTT_HOST = "";
|
||||
static constexpr uint16_t MQTT_PORT = 1883;
|
||||
static constexpr const char* MQTT_USER = "";
|
||||
static constexpr const char* MQTT_PASSWORD = "";
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
@@ -63,8 +69,11 @@ DemoDevices devices(PIN_DEMO_LIGHT);
|
||||
SerialFramer framer(Serial);
|
||||
SerialFrameReader reader(Serial);
|
||||
WiFiClient network;
|
||||
WifiLink wifi(WIFI_SSID, WIFI_PASSWORD, DEVICE_ID);
|
||||
MqttLink mqtt(network, MQTT_HOST, MQTT_PORT, DEVICE_ID, MQTT_USER, MQTT_PASSWORD);
|
||||
// cfg DOIT être déclaré avant wifi et mqtt : leurs constructeurs stockent des
|
||||
// pointeurs vers ses tampons internes, remplis plus tard par cfg.begin().
|
||||
WifiConfig cfg;
|
||||
WifiLink wifi(cfg.ssid(), cfg.password(), DEVICE_ID);
|
||||
MqttLink mqtt(network, cfg.mqttHost(), cfg.mqttPort(), DEVICE_ID, cfg.mqttUser(), cfg.mqttPassword());
|
||||
|
||||
/// Chunk en cours d'envoi réseau : 1600 octets, alloué une seule fois.
|
||||
uint8_t outgoingChunk[AUDIO_CHUNK_BYTES];
|
||||
@@ -462,6 +471,29 @@ void setup() {
|
||||
devices.begin();
|
||||
button.begin();
|
||||
|
||||
// Détection du mode configuration : bouton PTT tenu dès le démarrage.
|
||||
// On attend WIFI_CONFIG_HOLD_MS ms ; si relâché avant, démarrage normal.
|
||||
bool forceConfig = false;
|
||||
if (digitalRead(PIN_BUTTON_PTT) == LOW) {
|
||||
Serial.println("# bouton PTT detecte au demarrage, attente pour confirmer...");
|
||||
const uint32_t holdStart = millis();
|
||||
while (digitalRead(PIN_BUTTON_PTT) == LOW) {
|
||||
if (millis() - holdStart >= WIFI_CONFIG_HOLD_MS) {
|
||||
forceConfig = true;
|
||||
break;
|
||||
}
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
// Charge les identifiants Wi-Fi + MQTT depuis la NVS ou secrets.h ;
|
||||
// lance le portail AP/HTTP si aucun n'est disponible ou si forcé.
|
||||
// Ne retourne pas en cas de portail (reboot après sauvegarde).
|
||||
cfg.begin(forceConfig,
|
||||
WIFI_SSID, WIFI_PASSWORD,
|
||||
MQTT_HOST, MQTT_PORT,
|
||||
MQTT_USER, MQTT_PASSWORD);
|
||||
|
||||
Serial.println("\n# --- assistant vocal, etapes 1 a 3 : audio + Wi-Fi + MQTT ---");
|
||||
Serial.printf("# format : %lu Hz, 16 bits, mono, chunk %lu ms = %u octets\n",
|
||||
static_cast<unsigned long>(AUDIO_SAMPLE_RATE),
|
||||
@@ -511,9 +543,10 @@ void setup() {
|
||||
// --- Réseau, en dernier : l'auto-test du micro se fait sans les émissions
|
||||
// radio du Wi-Fi (pointes de 300 mA), qui pourraient fausser la mesure.
|
||||
Serial.printf("# reseau : Wi-Fi \"%s\", broker %s:%u, topics %s/%s/..., compte MQTT %s\n",
|
||||
WIFI_SSID, MQTT_HOST, MQTT_PORT, MQTT_TOPIC_PREFIX, DEVICE_ID,
|
||||
MQTT_PASSWORD[0] != '\0' ? "oui" : "NON (anonyme)");
|
||||
cfg.ssid(), cfg.mqttHost(), cfg.mqttPort(), MQTT_TOPIC_PREFIX, DEVICE_ID,
|
||||
cfg.mqttPassword()[0] != '\0' ? "oui" : "NON (anonyme)");
|
||||
network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS);
|
||||
mqtt.setPort(cfg.mqttPort()); // synchronise _port avec la valeur chargée par cfg
|
||||
if (!mqtt.begin(onMqttMessage, onMqttConnect)) {
|
||||
Serial.println("# ERREUR : tampon MQTT impossible a allouer");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user