- 硬重启等到 PG 后长按电源键 5 s(新状态 hr-boot):电池端上电的小米8 停在关机态,原来的 power_cycle 等于关机。ACT_KEY_MS_ON 2000 → 5000 (实测 2 s 松手即掉电)。NO_PG 时不按。 - 修死锁:执行器持 s_mtx 等 esp-mqtt 的 api_lock,esp-mqtt 任务持 api_lock 分发 DISCONNECTED 时等 s_mtx。热点一断 USB/WiFi 全失联、按键可能松不开。 执行器现在只往 s_outbox 拷贝,由 t_link 交给 esp-mqtt。 - SR-5 欠压一侧 −100 → −250 mV(T_SAFETY_UNDER_MV),过压仍 +100 mV: 带屏开机峰值 2.1 A 时实测 −115 mV 被误关。需求 SR-5 / SW-2 / SW-5 / 验收表同步。 板 B 上板:power_cycle → hr-boot → 手机开机,最低 3778 mV / 2.12 A 不再跳闸。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
421 行
16 KiB
C
421 行
16 KiB
C
/* WiFi / MQTT 链路的 ESP 胶水(A14 + C1)。**只进 IDF 目标**;topic 与规矩在 mlink.h,
|
||
配置与「密码永不外泄」在 netcfg.h。
|
||
|
||
---- 谁在哪个任务里跑 ------------------------------------------------------
|
||
| 事 | 任务 |
|
||
|---|---|
|
||
| WiFi / IP 事件(掉线、拿到地址) | 系统事件循环 |
|
||
| MQTT 事件(连上、收到命令) | esp-mqtt 自己的任务,优先级压到与 `t_link` 相同(§10.3:链路最低) |
|
||
| **MQTT 客户端的创建 / 销毁、重配置、遥测节拍** | `t_link`(`mlink_esp_poll()`)—— 生命周期只在一个任务里管 |
|
||
| 回应 / 遥测的发布 | 执行器任务只把一份拷贝塞进 `s_outbox`(不等);`t_link` 取出来 `esp_mqtt_client_enqueue()` |
|
||
|
||
⚠️ **执行器任务一律不碰 esp-mqtt 的任何 API**,连 `esp_mqtt_client_enqueue()` 也不行:
|
||
它要拿客户端的 `api_lock`,而 esp-mqtt 任务在连接 / 写超时 / 分发事件时一直持着这把锁。
|
||
0.1.1-c3 之前执行器是「持 `s_mtx` → 等 `api_lock`」,esp-mqtt 任务分发 DISCONNECTED 时是
|
||
「持 `api_lock` → `on_mqtt` 里 `ST_EDIT` 等 `s_mtx`」—— 2026-09-28 热点一断就死锁:
|
||
USB 命令没人执行、WiFi 不重连、**按到一半的键松不开**,只有 t_safety / t_thermal 还活着。
|
||
现在的规矩:**持 `s_mtx` 时不调 esp-mqtt;执行器根本不调 esp-mqtt**。
|
||
|
||
---- 失联不动作(§10.2 / §10.8) -----------------------------------------
|
||
WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。
|
||
没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */
|
||
#include <string.h>
|
||
|
||
#include "cmd_queue.h"
|
||
#include "esp_event.h"
|
||
#include "esp_log.h"
|
||
#include "esp_mac.h"
|
||
#include "esp_netif.h"
|
||
#include "esp_timer.h"
|
||
#include "esp_wifi.h"
|
||
#include "freertos/FreeRTOS.h"
|
||
#include "freertos/queue.h"
|
||
#include "freertos/semphr.h"
|
||
#include "hal_time.h"
|
||
#include "mlink.h"
|
||
#include "mlink_esp.h"
|
||
#include "mqtt_client.h"
|
||
#include "netcfg.h"
|
||
#include "nvs.h"
|
||
#include "nvs_flash.h"
|
||
#include "phonerail.h"
|
||
#include "tasks.h"
|
||
#include "ulink.h"
|
||
|
||
static const char *TAG = "mlink";
|
||
|
||
#define NVS_NS "phonerail"
|
||
#define NVS_KEY "net"
|
||
#define NVS_VERSION 1 /* netcfg_t 布局一变就加一:旧版本的 blob 直接当没配置 */
|
||
|
||
/* evse-ca(home 服务器上那个 mosquitto 的自签 CA,公钥证书,不是机密)。
|
||
`main/CMakeLists.txt` 的 EMBED_TXTFILES 编进来,结尾带 0。只在 mqtts:// 时用。 */
|
||
extern const char evse_ca_pem_start[] asm("_binary_evse_ca_pem_start");
|
||
|
||
#define MQTT_KEEPALIVE_S 15 /* 掉线后 ≈1.5 × 15 s broker 发遗嘱;默认 120 s 要等 3 分钟 */
|
||
|
||
typedef struct {
|
||
uint8_t ver;
|
||
netcfg_t cfg;
|
||
} nvs_blob_t;
|
||
|
||
static SemaphoreHandle_t s_mtx; /* 护 s_st 与 s_mqtt 句柄 */
|
||
static net_status_t s_st;
|
||
static esp_mqtt_client_handle_t s_mqtt;
|
||
static netcfg_t s_cur; /* 当前生效的配置(只在 t_link 里读写) */
|
||
static volatile bool s_dirty; /* 配置变了,等 t_link 来重配 */
|
||
static esp_timer_handle_t s_retry;
|
||
static uint32_t s_attempt;
|
||
static mlink_tele_t s_tele;
|
||
static volatile bool s_tele_inflight;
|
||
static bool s_up;
|
||
|
||
/* 发件队列(见文件头):执行器 / esp-mqtt 任务往里拷,t_link 取出来交给 esp-mqtt。
|
||
深度 4 × 1.5 KB:遥测 1 s 一条、回应偶尔一条,t_link 至少每 100 ms 来取一次;
|
||
满了说明 t_link 被 esp-mqtt 堵住了 —— 丢掉这一条,遥测记 skipped,回应靠上位机重传。 */
|
||
typedef struct {
|
||
uint8_t topic; /* mlink_topic_t */
|
||
uint8_t qos;
|
||
uint16_t len;
|
||
char data[CMD_OUT_MAX];
|
||
} out_msg_t;
|
||
|
||
#define OUTBOX_DEPTH 4
|
||
static QueueHandle_t s_outbox;
|
||
static SemaphoreHandle_t s_out_mtx; /* 只护 enqueue() 的暂存缓冲 */
|
||
|
||
/* ---- 状态:改完立刻推给 netcfg,命令处理器随时读得到一份完整的 ------------ */
|
||
|
||
/* 可变参数:语句里带逗号(snprintf 的实参)也能整个传进来。 */
|
||
#define ST_EDIT(...) \
|
||
do { \
|
||
xSemaphoreTake(s_mtx, portMAX_DELAY); \
|
||
__VA_ARGS__; \
|
||
net_status_set(&s_st); \
|
||
xSemaphoreGive(s_mtx); \
|
||
} while (0)
|
||
|
||
/* ---- NVS ----------------------------------------------------------------- */
|
||
|
||
static hal_err_t nvs_save(void *ctx, const netcfg_t *cfg)
|
||
{
|
||
(void)ctx;
|
||
/* ⚠️ 在执行器任务里被调(配置命令),flash 写可能要几十毫秒。配置是上架时一次性的事,
|
||
这点停顿可以接受;换成异步写就得处理「回了 ok 但没存下」。 */
|
||
nvs_handle_t h;
|
||
if (nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) {
|
||
return HAL_ERR_BUS;
|
||
}
|
||
const nvs_blob_t b = { .ver = NVS_VERSION, .cfg = *cfg };
|
||
esp_err_t e = nvs_set_blob(h, NVS_KEY, &b, sizeof(b));
|
||
if (e == ESP_OK) {
|
||
e = nvs_commit(h);
|
||
}
|
||
nvs_close(h);
|
||
return e == ESP_OK ? HAL_OK : HAL_ERR_BUS;
|
||
}
|
||
|
||
static bool nvs_load(netcfg_t *out)
|
||
{
|
||
nvs_handle_t h;
|
||
if (nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) {
|
||
return false;
|
||
}
|
||
nvs_blob_t b;
|
||
size_t n = sizeof(b);
|
||
const bool ok = nvs_get_blob(h, NVS_KEY, &b, &n) == ESP_OK && n == sizeof(b) &&
|
||
b.ver == NVS_VERSION;
|
||
nvs_close(h);
|
||
if (ok) {
|
||
*out = b.cfg;
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
static void on_cfg_changed(void *ctx)
|
||
{
|
||
(void)ctx;
|
||
s_dirty = true; /* 执行器任务里只置标志;断开重连在 t_link 里做(esp_mqtt_client_stop 会阻塞) */
|
||
}
|
||
|
||
/* ---- 发布:任何任务都只进 s_outbox,不等、不碰 esp-mqtt ------------------- */
|
||
|
||
static bool enqueue(mlink_topic_t t, const char *data, size_t len, int qos)
|
||
{
|
||
/* 1.5 KB 不放栈上。执行器和 esp-mqtt 任务(link_error)都会进来 ⇒ s_out_mtx 护着。
|
||
这把锁持有期间只有 memcpy 和一次**不等**的入队,不会再去等任何别的东西。 */
|
||
static out_msg_t m;
|
||
if (s_outbox == NULL || len > sizeof(m.data)) {
|
||
return false;
|
||
}
|
||
/* 没连上就不攒(上线后 on_mqtt 会先补一条遥测)。读一个枚举,不拿 s_mtx。 */
|
||
if (s_st.mqtt != NET_UP) {
|
||
return false;
|
||
}
|
||
if (xSemaphoreTake(s_out_mtx, pdMS_TO_TICKS(10)) != pdTRUE) {
|
||
return false;
|
||
}
|
||
m.topic = (uint8_t)t;
|
||
m.qos = (uint8_t)qos;
|
||
m.len = (uint16_t)len;
|
||
memcpy(m.data, data, len);
|
||
const bool ok = xQueueSend(s_outbox, &m, 0) == pdTRUE;
|
||
xSemaphoreGive(s_out_mtx);
|
||
return ok;
|
||
}
|
||
|
||
static void reply_mqtt(void *ctx, const char *line, size_t len)
|
||
{
|
||
(void)ctx;
|
||
(void)enqueue(MLINK_T_RSP, line, len, 1);
|
||
}
|
||
|
||
static void reply_tele(void *ctx, const char *line, size_t len)
|
||
{
|
||
(void)ctx;
|
||
const bool ok = enqueue(MLINK_T_TELE, line, len, 0);
|
||
ST_EDIT(if (ok) { s_st.tele_sent++; } else { s_st.tele_skipped++; });
|
||
s_tele_inflight = false;
|
||
}
|
||
|
||
/* t_link 里调:把发件队列交给 esp-mqtt。s_mqtt 只在 t_link 里改,这里读它不用锁;
|
||
esp_mqtt_client_enqueue 可能等 api_lock 等到 esp-mqtt 的网络超时 —— 堵的是 t_link,
|
||
而 t_link 此刻不持任何我们的锁,esp-mqtt 的事件回调照样拿得到 s_mtx。 */
|
||
static void outbox_drain(void)
|
||
{
|
||
static out_msg_t m;
|
||
while (s_outbox != NULL && xQueueReceive(s_outbox, &m, 0) == pdTRUE) {
|
||
if (s_mqtt != NULL) {
|
||
(void)esp_mqtt_client_enqueue(s_mqtt, mlink_topic((mlink_topic_t)m.topic), m.data,
|
||
(int)m.len, m.qos, 0, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
static void link_error(const char *payload, const char *err)
|
||
{
|
||
char out[96];
|
||
const size_t n = ulink_link_error(payload, err, out, sizeof(out));
|
||
if (n > 0) {
|
||
(void)enqueue(MLINK_T_RSP, out, n, 1);
|
||
}
|
||
}
|
||
|
||
/* ---- MQTT 事件(esp-mqtt 任务) ------------------------------------------- */
|
||
|
||
static void on_mqtt(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||
{
|
||
(void)arg;
|
||
(void)base;
|
||
esp_mqtt_event_handle_t ev = data;
|
||
switch ((esp_mqtt_event_id_t)id) {
|
||
case MQTT_EVENT_CONNECTED: {
|
||
esp_mqtt_client_subscribe_single(ev->client, mlink_topic(MLINK_T_CMD), 1);
|
||
char buf[96];
|
||
const size_t n = mlink_state_online(phonerail_version(), s_st.ip, buf, sizeof(buf));
|
||
esp_mqtt_client_enqueue(ev->client, mlink_topic(MLINK_T_STATE), buf, (int)n, 1, 1, true);
|
||
ST_EDIT(s_st.mqtt = NET_UP);
|
||
memset(&s_tele, 0, sizeof(s_tele)); /* 一连上先报一条遥测 */
|
||
ESP_LOGI(TAG, "MQTT 已连上,订阅 %s", mlink_topic(MLINK_T_CMD));
|
||
break;
|
||
}
|
||
case MQTT_EVENT_DISCONNECTED:
|
||
ST_EDIT(if (s_st.mqtt == NET_UP) { s_st.mqtt_drops++; } s_st.mqtt = NET_CONNECTING);
|
||
break;
|
||
case MQTT_EVENT_DATA: {
|
||
const char *cmd_t = mlink_topic(MLINK_T_CMD);
|
||
if (ev->topic_len != (int)strlen(cmd_t) || memcmp(ev->topic, cmd_t, (size_t)ev->topic_len)) {
|
||
break; /* 切片的后续片没有 topic —— 首片已经按 too_long 回过了 */
|
||
}
|
||
char line[CMD_LINE_MAX];
|
||
const char *err = mlink_inbound(ev->data, ev->data_len, ev->total_data_len, line, sizeof(line));
|
||
if (err != NULL) {
|
||
link_error(NULL, err);
|
||
} else if (!cmd_queue_submit(CMD_LINK_WIFI, line, reply_mqtt, NULL)) {
|
||
link_error(line, "queue_full");
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void mqtt_start(void)
|
||
{
|
||
static char client_id[24];
|
||
snprintf(client_id, sizeof(client_id), "phonerail-%s", mlink_id());
|
||
const esp_mqtt_client_config_t mc = {
|
||
.broker.address.uri = s_cur.uri,
|
||
.broker.verification.certificate =
|
||
(strncmp(s_cur.uri, "mqtts://", 8) == 0) ? evse_ca_pem_start : NULL,
|
||
.credentials.client_id = client_id,
|
||
.credentials.username = s_cur.user[0] ? s_cur.user : NULL,
|
||
.credentials.authentication.password = s_cur.mpass[0] ? s_cur.mpass : NULL,
|
||
.session.last_will = { .topic = mlink_topic(MLINK_T_STATE), .msg = MLINK_STATE_OFFLINE,
|
||
.qos = 1, .retain = 1 },
|
||
.session.keepalive = MQTT_KEEPALIVE_S,
|
||
.task.priority = task_desc(TASK_LINK)->prio,
|
||
.task.stack_size = 6144,
|
||
};
|
||
esp_mqtt_client_handle_t c = esp_mqtt_client_init(&mc);
|
||
if (c == NULL) {
|
||
ESP_LOGE(TAG, "MQTT 客户端建不起来");
|
||
return;
|
||
}
|
||
esp_mqtt_client_register_event(c, ESP_EVENT_ANY_ID, on_mqtt, NULL);
|
||
ST_EDIT(s_mqtt = c; s_st.mqtt = NET_CONNECTING);
|
||
esp_mqtt_client_start(c);
|
||
}
|
||
|
||
static void mqtt_stop(void)
|
||
{
|
||
esp_mqtt_client_handle_t c;
|
||
ST_EDIT(c = s_mqtt; s_mqtt = NULL; s_st.mqtt = NET_OFF);
|
||
if (c != NULL) {
|
||
esp_mqtt_client_stop(c); /* 会阻塞到它的任务退出 —— 所以只在 t_link 里调 */
|
||
esp_mqtt_client_destroy(c);
|
||
}
|
||
}
|
||
|
||
/* ---- WiFi 事件(系统事件循环) ------------------------------------------- */
|
||
|
||
static void retry_cb(void *arg)
|
||
{
|
||
(void)arg;
|
||
if (s_st.wifi != NET_UP && s_cur.ssid[0] != '\0') {
|
||
esp_wifi_connect();
|
||
}
|
||
}
|
||
|
||
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||
{
|
||
(void)arg;
|
||
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
|
||
const wifi_event_sta_disconnected_t *d = data;
|
||
ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后重试", (unsigned)d->reason,
|
||
(unsigned)mlink_backoff_ms(s_attempt));
|
||
ST_EDIT(s_st.wifi_reason = d->reason;if (s_st.wifi == NET_UP) { s_st.wifi_drops++; } s_st.wifi =
|
||
s_cur.ssid[0] ? NET_CONNECTING : NET_OFF;
|
||
s_st.ip[0] = '\0'; s_st.rssi = 0);
|
||
if (s_cur.ssid[0] != '\0') {
|
||
const uint32_t ms = mlink_backoff_ms(s_attempt++);
|
||
esp_timer_stop(s_retry);
|
||
esp_timer_start_once(s_retry, (uint64_t)ms * 1000u);
|
||
}
|
||
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
|
||
const ip_event_got_ip_t *e = data;
|
||
s_attempt = 0;
|
||
ST_EDIT(s_st.wifi = NET_UP;
|
||
snprintf(s_st.ip, sizeof(s_st.ip), IPSTR, IP2STR(&e->ip_info.ip)));
|
||
ESP_LOGI(TAG, "WiFi 已连上,IP %s", s_st.ip);
|
||
}
|
||
}
|
||
|
||
/* 按 s_cur 重新连 WiFi(t_link 里调)。 */
|
||
static void wifi_apply(void)
|
||
{
|
||
esp_timer_stop(s_retry);
|
||
esp_wifi_disconnect();
|
||
s_attempt = 0;
|
||
if (s_cur.ssid[0] == '\0') {
|
||
ST_EDIT(s_st.wifi = NET_OFF);
|
||
return;
|
||
}
|
||
wifi_config_t wc = { 0 };
|
||
memcpy(wc.sta.ssid, s_cur.ssid, strlen(s_cur.ssid));
|
||
memcpy(wc.sta.password, s_cur.pass, strlen(s_cur.pass));
|
||
wc.sta.threshold.authmode = s_cur.pass[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||
esp_wifi_set_config(WIFI_IF_STA, &wc);
|
||
ST_EDIT(s_st.wifi = NET_CONNECTING);
|
||
esp_wifi_connect();
|
||
ESP_LOGI(TAG, "连 WiFi「%s」", s_cur.ssid); /* 只打 SSID,不打口令 */
|
||
}
|
||
|
||
/* ---- 对外 ---------------------------------------------------------------- */
|
||
|
||
hal_err_t mlink_esp_init(void)
|
||
{
|
||
s_mtx = xSemaphoreCreateMutex();
|
||
s_out_mtx = xSemaphoreCreateMutex();
|
||
s_outbox = xQueueCreate(OUTBOX_DEPTH, sizeof(out_msg_t));
|
||
esp_err_t e = nvs_flash_init();
|
||
if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||
nvs_flash_erase(); /* NVS 分区格式不认:清掉重来(里面只有本模块的配置与 WiFi 校准) */
|
||
e = nvs_flash_init();
|
||
}
|
||
if (s_mtx == NULL || s_out_mtx == NULL || s_outbox == NULL || e != ESP_OK) {
|
||
ESP_LOGE(TAG, "NVS 起不来:%s —— WiFi 链路不可用", esp_err_to_name(e));
|
||
netcfg_init(NULL, NULL, NULL, NULL);
|
||
return HAL_ERR_STATE;
|
||
}
|
||
netcfg_t loaded;
|
||
const bool have = nvs_load(&loaded);
|
||
netcfg_init(have ? &loaded : NULL, nvs_save, on_cfg_changed, NULL);
|
||
netcfg_get(&s_cur);
|
||
|
||
uint8_t mac[6];
|
||
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
||
mlink_init(mac);
|
||
ST_EDIT(snprintf(s_st.id, sizeof(s_st.id), "%s", mlink_id()));
|
||
|
||
const esp_timer_create_args_t ta = { .callback = retry_cb, .name = "wifi_retry" };
|
||
if (esp_netif_init() != ESP_OK || esp_event_loop_create_default() != ESP_OK ||
|
||
esp_netif_create_default_wifi_sta() == NULL || esp_timer_create(&ta, &s_retry) != ESP_OK) {
|
||
ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用");
|
||
return HAL_ERR_STATE;
|
||
}
|
||
const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT();
|
||
if (esp_wifi_init(&wi) != ESP_OK) {
|
||
ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用");
|
||
return HAL_ERR_STATE;
|
||
}
|
||
esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, on_wifi, NULL);
|
||
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi, NULL);
|
||
esp_wifi_set_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */
|
||
esp_wifi_set_mode(WIFI_MODE_STA);
|
||
esp_wifi_start();
|
||
s_up = true;
|
||
ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD));
|
||
if (!have) {
|
||
ESP_LOGW(TAG, "还没配网:经 USB 发 wifi / mqtt 两条命令(tools/prlink.py)");
|
||
}
|
||
wifi_apply();
|
||
return HAL_OK;
|
||
}
|
||
|
||
void mlink_esp_poll(void)
|
||
{
|
||
if (!s_up) {
|
||
return;
|
||
}
|
||
if (s_dirty) {
|
||
s_dirty = false;
|
||
mqtt_stop();
|
||
netcfg_get(&s_cur);
|
||
wifi_apply();
|
||
}
|
||
/* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。
|
||
之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */
|
||
if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') {
|
||
mqtt_start();
|
||
}
|
||
outbox_drain();
|
||
if (s_st.mqtt != NET_UP || !mlink_tele_due(&s_tele, hal_time_now_ms(), s_cur.tele_ms)) {
|
||
return;
|
||
}
|
||
wifi_ap_record_t ap;
|
||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
|
||
ST_EDIT(s_st.rssi = ap.rssi);
|
||
}
|
||
if (s_tele_inflight) {
|
||
ST_EDIT(s_st.tele_skipped++); /* 上一条还在队列里:网络或执行器堵了,不再往里塞 */
|
||
return;
|
||
}
|
||
s_tele_inflight = true;
|
||
if (!cmd_queue_submit(CMD_LINK_WIFI, "{\"cmd\":\"status\"}", reply_tele, NULL)) {
|
||
s_tele_inflight = false;
|
||
ST_EDIT(s_st.tele_skipped++);
|
||
}
|
||
}
|