Add persistent script storage + hot-reload, verified live

lua_runtime now loads the running script from a new LittleFS-backed
drivers::storage (joltwallet/littlefs, the project's fifth external
managed component -- mounts the "storage" partition already reserved
in partitions.csv), falling back to the compiled-in default script
only on first boot. transport exposes GET/POST /api/script -- raw Lua
text, not JSON, since json_helpers.h can't round-trip scripts safely
-- relayed to lua_runtime via the same callback-registration pattern
already used for LedColorHandler, keeping transport free of a
dependency on lua_runtime. lua_runtime_reload() persists and hot-swaps
to a fresh Lua state immediately, no reboot.

Verified on real ESP32-C6 hardware: script loads via kvida-sdk, an
edited script hot-reloads within one tick, and the edit survives a
power cycle (proving LittleFS persistence, not just an in-RAM change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ronny Eia
2026-07-12 10:27:00 +02:00
parent eae6d3af5a
commit 967fecbf69
12 changed files with 329 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
idf_component_register(
SRCS "src/chip_temperature.cpp" "src/rgb_led.cpp"
SRCS "src/chip_temperature.cpp" "src/rgb_led.cpp" "src/storage.cpp"
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_tsens espressif__led_strip
PRIV_REQUIRES esp_driver_tsens espressif__led_strip joltwallet__littlefs
)

View File

@@ -4,5 +4,4 @@ Hardware abstraction: wraps ESP32-C6 peripherals (GPIO, ADC, I2C) behind a board
- `chip_temperature.h`/`.cpp` -- wraps ESP-IDF's built-in `temperature_sensor` driver (die temperature; there's no external ambient temperature sensor on the ESP32-C6-DevKitC-1).
- `rgb_led.h`/`.cpp` -- wraps the DevKitC-1's onboard WS2812 addressable RGB LED (GPIO8) via the `espressif/led_strip` managed component (`idf_component.yml`).
TODO: mounting the `storage` LittleFS partition (see `partitions.csv`) belongs here once a LittleFS component (e.g. `joltwallet/esp_littlefs` from the IDF Component Registry) is picked and pinned via `idf_component.yml`.
- `storage.h`/`.cpp` -- mounts the `storage` LittleFS partition (see `partitions.csv`) at `/storage` via `joltwallet/littlefs` (verified against the component's actual header, not guessed -- the real mount function is `esp_vfs_littlefs_register()`, not `esp_vfs_littlefs_mount()` despite what some docs summaries say), then exposes plain whole-file `storage_read_file()`/`storage_write_file()` helpers over the standard `fopen`/`fread`/`fwrite` VFS calls. Used by `lua_runtime` to persist the running script (`components/lua_runtime/README.md`).

View File

@@ -1,3 +1,4 @@
dependencies:
espressif/led_strip:
version: "*"
joltwallet/littlefs: "1.22.2"

View File

@@ -0,0 +1,21 @@
#pragma once
#include <cstddef>
namespace kvida::drivers {
// Mounts the "storage" partition (see partitions.csv) as a LittleFS
// filesystem at /storage. Safe to call multiple times (no-op if already
// mounted). Formats the partition automatically if it's blank or corrupt.
void storage_init();
// Reads a whole file into buf (cap includes room for the implicit '\0'
// this appends). Returns false if the filesystem isn't mounted, the file
// doesn't exist, or it doesn't fit in cap.
bool storage_read_file(const char *path, char *buf, size_t cap, size_t *out_len);
// Writes/overwrites a whole file. Returns false if the filesystem isn't
// mounted or the write fails.
bool storage_write_file(const char *path, const char *data, size_t len);
} // namespace kvida::drivers

View File

@@ -0,0 +1,79 @@
#include "storage.h"
#include <cstdio>
#include "esp_log.h"
#include "esp_littlefs.h"
namespace kvida::drivers {
namespace {
constexpr const char *TAG = "storage";
bool g_mounted = false;
} // namespace
void storage_init()
{
if (g_mounted) {
return;
}
esp_vfs_littlefs_conf_t conf = {};
conf.base_path = "/storage";
conf.partition_label = "storage";
conf.format_if_mount_failed = true;
conf.grow_on_mount = false;
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_vfs_littlefs_register failed: %s", esp_err_to_name(err));
return;
}
g_mounted = true;
}
bool storage_read_file(const char *path, char *buf, size_t cap, size_t *out_len)
{
if (!g_mounted || cap == 0) {
return false;
}
FILE *f = fopen(path, "r");
if (!f) {
return false;
}
size_t n = fread(buf, 1, cap - 1, f);
bool truncated = !feof(f);
fclose(f);
if (truncated) {
ESP_LOGE(TAG, "%s is larger than the %u-byte read buffer", path, static_cast<unsigned>(cap - 1));
return false;
}
buf[n] = '\0';
if (out_len) {
*out_len = n;
}
return true;
}
bool storage_write_file(const char *path, const char *data, size_t len)
{
if (!g_mounted) {
return false;
}
FILE *f = fopen(path, "w");
if (!f) {
ESP_LOGE(TAG, "fopen(%s, \"w\") failed", path);
return false;
}
size_t written = fwrite(data, 1, len, f);
fclose(f);
return written == len;
}
} // namespace kvida::drivers

View File

@@ -6,20 +6,21 @@ Built on `espressif/lua` (v5.5.0, pinned in `idf_component.yml`) -- Espressif's
## What's here
- `lua_runtime_init()` creates the Lua state, opens only `base`/`table`/`string`/`math` (deliberately *not* `luaL_openlibs()`, which would also expose `io`, `os`, `package`/`require`, `debug` -- see AGENTS.md's "Lua should never access ESP-IDF directly"), registers the `kvida` API table, then loads the default script (`src/default_script.h`, a compiled-in string) which just *defines* `on_tick()`.
- `lua_runtime_init()` mounts storage (`drivers::storage_init()`) and tries to read `/storage/script.lua`; if none is stored yet (first boot, or the read fails), it falls back to the compiled-in default script (`src/default_script.h`). Either way it then builds the Lua state: opens only `base`/`table`/`string`/`math` (deliberately *not* `luaL_openlibs()`, which would also expose `io`, `os`, `package`/`require`, `debug` -- see AGENTS.md's "Lua should never access ESP-IDF directly"), registers the `kvida` API table, then runs the script, which just *defines* `on_tick()`.
- `lua_runtime_tick()` calls `on_tick()` -- one "wake, execute Lua, publish changes, sleep" cycle. A Lua runtime error is logged, not fatal.
- `lua_runtime_reload(script, len)` persists a new script to storage and hot-reloads it into a fresh Lua state immediately -- no reboot. Wired to `transport::set_script_change_handler()` (POST /api/script) by `main.cpp`. `lua_runtime_current_script(buf, cap)` is the read side, wired to `transport::set_script_provider()` (GET /api/script).
- The `kvida` table exposed to scripts:
- `kvida.chip_temperature()` -- reads the ESP32-C6's internal die temperature via `profiles::ChipTemperatureSensor` (the same `Sensor` implementation `main.cpp` used directly before this component existed).
- `kvida.set_led(r, g, b)` -- sets the onboard WS2812 via `drivers::rgb_led_set_color()`.
- `kvida.publish(name, value)` -- calls `transport::publish_value()`, AGENTS.md's semantic publish API, built for the first time here.
- The default script reads chip temperature, maps it to a blue(cool)-to-red(hot) LED color (linear interpolation, 20-60C, clamped), and publishes the reading to Home Assistant. Verified end-to-end on real hardware.
## Known limitation, accepted for now
## Known limitations, accepted for now
The onboard LED is also controllable manually from Home Assistant (an MQTT light entity, see `components/transport/src/mqtt.cpp`). The two aren't reconciled: `on_tick()` overwrites a manually-chosen HA color every 30s. Accepted as a conflict between two "quick proof" demos rather than solved now -- a real answer (e.g. a Lua-settable "mode" toggle, or the MQTT light entity deferring to Lua) needs product input, not a guess.
- The onboard LED is also controllable manually from Home Assistant (an MQTT light entity, see `components/transport/src/mqtt.cpp`). The two aren't reconciled: `on_tick()` overwrites a manually-chosen HA color every 30s. Accepted as a conflict between two "quick proof" demos rather than solved now -- a real answer (e.g. a Lua-settable "mode" toggle, or the MQTT light entity deferring to Lua) needs product input, not a guess.
- `lua_runtime_reload()` doesn't roll back to the last-good script if the new one fails to compile/run -- it just leaves `on_tick` undefined until the next successful reload (same behavior `lua_runtime_init()` already has for a bad default script). Storage and `lua_runtime_current_script()` always reflect the most recently POSTed script either way, even a broken one, so a user editing from `kvida-sdk` can see (and fix) exactly what they submitted.
## Not yet done
- The script is compiled in, not loaded from storage -- there's no upload mechanism yet. Once `kvida-sdk` can push a script (and `drivers` mounts the `storage` LittleFS partition -- both still TODO), scripts should be loaded from there instead.
- No Blockly-to-Lua compiler exists yet (that's `kvida-sdk`'s job per AGENTS.md's "Level 2").
- No memory/CPU/runtime limits on the Lua state beyond the restricted library set -- a script with an infinite loop would hang `lua_runtime_tick()` (and, since it currently runs on the same timer callback, block other `esp_timer` callbacks too). Not a concern for a compiled-in, developer-authored script; becomes one once arbitrary user scripts can be uploaded.
- No Blockly-to-Lua compiler exists yet (that's `kvida-sdk`'s job per AGENTS.md's "Level 2") -- for now scripts are hand-written Lua, edited as raw text in `kvida-sdk`.
- No memory/CPU/runtime limits on the Lua state beyond the restricted library set -- a script with an infinite loop would hang `lua_runtime_tick()` (and, since it currently runs on the same timer callback, block other `esp_timer` callbacks too). Was a non-issue for a compiled-in, developer-authored script; now that scripts are user-editable and persisted, this is a real gap, just not one this pass solves.

View File

@@ -1,11 +1,14 @@
#pragma once
#include <cstddef>
namespace kvida {
// Creates the sandboxed Lua state and loads the default script (see
// src/default_script.h). The script only *defines* the on_tick()
// function -- call lua_runtime_tick() to actually run it. Call once at
// boot.
// Mounts storage and loads the script from there (see
// components/drivers/include/storage.h), falling back to the compiled-in
// default (src/default_script.h) if none is stored yet. The script only
// *defines* the on_tick() function -- call lua_runtime_tick() to actually
// run it. Call once at boot.
void lua_runtime_init();
// Calls the script's on_tick() global function -- one "wake, execute
@@ -14,4 +17,14 @@ void lua_runtime_init();
// down the device.
void lua_runtime_tick();
// Persists `script` to storage and hot-reloads it into a fresh Lua
// state, replacing whatever's currently running -- no reboot needed.
// Wired to transport::set_script_change_handler() by main.cpp.
void lua_runtime_reload(const char *script, size_t len);
// Copies the currently-running script into buf (capacity cap), returns
// the number of bytes written. Wired to transport::set_script_provider()
// by main.cpp.
size_t lua_runtime_current_script(char *buf, size_t cap);
} // namespace kvida

View File

@@ -1,10 +1,14 @@
#include "lua_runtime.h"
#include <algorithm>
#include <cstring>
#include "default_script.h"
#include "chip_temperature_sensor.h"
#include "mqtt.h"
#include "rgb_led.h"
#include "storage.h"
#include "esp_log.h"
@@ -19,10 +23,18 @@ namespace kvida {
namespace {
constexpr const char *TAG = "lua_runtime";
constexpr const char *kScriptPath = "/storage/script.lua";
lua_State *g_L = nullptr;
ChipTemperatureSensor g_chip_temp_sensor;
// Holds whatever script is currently loaded into g_L -- either read from
// storage at boot, the compiled-in default (no stored script yet, or the
// read failed), or the most recent successful lua_runtime_reload() body.
// This is what GET /api/script (via lua_runtime_current_script()) serves.
char g_script[8192];
size_t g_script_len = 0;
// Deliberately not luaL_openlibs(), which would also expose `io`, `os`,
// `package`/`require`, and `debug` -- AGENTS.md: "Lua should never
// access ESP-IDF directly. Lua interacts only through the Kvida API."
@@ -85,6 +97,50 @@ void register_kvida_api(lua_State *L)
lua_setglobal(L, "kvida");
}
// Copies into g_script/g_script_len, truncating (with a logged warning)
// if it doesn't fit -- the same fixed buffer backs both what gets
// persisted to storage and what GET /api/script serves back.
void copy_script(const char *script, size_t len)
{
size_t n = std::min(len, sizeof(g_script) - 1);
if (n < len) {
ESP_LOGW(TAG, "script truncated from %u to %u bytes to fit the buffer", static_cast<unsigned>(len),
static_cast<unsigned>(n));
}
memcpy(g_script, script, n);
g_script[n] = '\0';
g_script_len = n;
}
// (Re)creates the Lua state and runs `script` in it -- shared by both
// lua_runtime_init() and lua_runtime_reload(). Uses luaL_loadbuffer()
// (explicit length) rather than luaL_dostring() (relies on strlen())
// since callers -- e.g. the POST /api/script body -- don't guarantee
// NUL-termination.
bool load_script(const char *script, size_t len)
{
if (g_L) {
lua_close(g_L);
g_L = nullptr;
}
g_L = luaL_newstate();
if (!g_L) {
ESP_LOGE(TAG, "luaL_newstate failed");
return false;
}
open_safe_libs(g_L);
register_kvida_api(g_L);
if (luaL_loadbuffer(g_L, script, len, "script") != LUA_OK || lua_pcall(g_L, 0, 0, 0) != LUA_OK) {
ESP_LOGE(TAG, "Failed to load script: %s", lua_tostring(g_L, -1));
lua_pop(g_L, 1);
return false;
}
return true;
}
} // namespace
void lua_runtime_init()
@@ -93,20 +149,36 @@ void lua_runtime_init()
return;
}
g_L = luaL_newstate();
if (!g_L) {
ESP_LOGE(TAG, "luaL_newstate failed");
return;
}
open_safe_libs(g_L);
drivers::storage_init();
g_chip_temp_sensor.begin();
register_kvida_api(g_L);
if (luaL_dostring(g_L, default_script()) != LUA_OK) {
ESP_LOGE(TAG, "Failed to load default script: %s", lua_tostring(g_L, -1));
lua_pop(g_L, 1);
size_t stored_len = 0;
if (!drivers::storage_read_file(kScriptPath, g_script, sizeof(g_script), &stored_len)) {
copy_script(default_script(), strlen(default_script()));
} else {
g_script_len = stored_len;
}
load_script(g_script, g_script_len);
}
// No rollback to the last-good script if the new one fails to
// compile/run -- accepted simplification for now (same as
// lua_runtime_init()'s handling of a bad default script), see the
// README. g_script/storage always reflect the most recent POST either
// way, so the user can see (and fix) what they submitted.
void lua_runtime_reload(const char *script, size_t len)
{
copy_script(script, len);
drivers::storage_write_file(kScriptPath, g_script, g_script_len);
load_script(g_script, g_script_len);
}
size_t lua_runtime_current_script(char *buf, size_t cap)
{
size_t n = std::min(g_script_len, cap);
memcpy(buf, g_script, n);
return n;
}
void lua_runtime_tick()

View File

@@ -34,6 +34,7 @@ Both httpd servers (the commissioning AP's and the settings server's) expose a s
- `POST /api/wifi` (commissioning AP only) -- `{"ssid","password"}`, replaces the old form-urlencoded `/connect`.
- `GET /api/status` (settings server) -- `{"device_id","wifi_connected","mqtt_connected"}`.
- `GET /api/mqtt` / `POST /api/mqtt` (settings server) -- `{"host","port","username"[,"password"]}`; `GET` never returns the password.
- `GET /api/script` / `POST /api/script` (settings server) -- the one endpoint in this API that is **not** JSON: the body is the raw Lua source as `text/plain`, both directions. Deliberate, not an oversight -- `json_helpers.h` is explicitly a non-escaping minimal parser, and Lua scripts routinely contain `"`, `\`, and newlines it can't round-trip safely. `transport` doesn't store or interpret the script itself (would mean depending on `lua_runtime`, breaking this component's "no dependency on other Kvida components" rule) -- it just relays through two function-pointer callbacks main.cpp wires up, `set_script_change_handler`/`set_script_provider`, the same pattern already used for `LedColorHandler`. `POST` reads the body in a loop up to an 8KB cap (`413` beyond that) since, unlike the small fixed-shape JSON bodies elsewhere here, `httpd_req_recv()` isn't guaranteed to return a script-sized body in one call.
No JSON library ships with ESP-IDF v6.0.2 core (verified: no `cJSON`, no bundled `json` component). Given these payloads are tiny, flat, fixed-shape objects, `src/json_helpers.h` hand-rolls minimal `strstr`-based extraction rather than adding a 4th external registry dependency (already have `mdns`, `mqtt`, `led_strip`) -- explicitly not a general parser (no nesting, arrays, or escaping beyond what these specific request bodies need).

View File

@@ -1,5 +1,6 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace kvida {
@@ -29,4 +30,17 @@ void publish_value(const char *name, double value);
using LedColorHandler = void (*)(uint8_t red, uint8_t green, uint8_t blue);
void set_led_color_handler(LedColorHandler handler);
// Same no-dependency-on-other-components pattern as LedColorHandler:
// registered by main so a POST to /api/script can hand the new script to
// `lua_runtime` without transport depending on it. `script` is not
// NUL-terminated by the caller's guarantee -- handlers must use `len`.
using ScriptChangeHandler = void (*)(const char *script, size_t len);
void set_script_change_handler(ScriptChangeHandler handler);
// Registered by main so GET /api/script can pull the currently-running
// script from `lua_runtime`. Called with a buffer of size `cap`; returns
// the number of bytes written (0 if no provider is registered yet).
using ScriptProvider = size_t (*)(char *buf, size_t cap);
void set_script_provider(ScriptProvider provider);
} // namespace kvida

View File

@@ -38,6 +38,12 @@ esp_mqtt_client_handle_t g_client = nullptr;
esp_timer_handle_t g_retry_timer = nullptr;
httpd_handle_t g_settings_httpd = nullptr;
LedColorHandler g_led_handler = nullptr;
ScriptChangeHandler g_script_change_handler = nullptr;
ScriptProvider g_script_provider = nullptr;
// Matches lua_runtime's g_script buffer size -- a script that doesn't fit
// in either is rejected the same way.
constexpr size_t kMaxScriptSize = 8192;
char g_availability_topic[48];
char g_discovery_topic[80];
@@ -451,6 +457,64 @@ esp_err_t api_mqtt_post_handler(httpd_req_t *req)
return ESP_OK;
}
// GET /api/script -- the currently-running Lua script, raw text (not
// JSON: see the POST handler's comment for why).
esp_err_t api_script_get_handler(httpd_req_t *req)
{
add_cors_headers(req);
if (!g_script_provider) {
httpd_resp_set_status(req, "503 Service Unavailable");
httpd_resp_send(req, "", 0);
return ESP_OK;
}
static char buf[kMaxScriptSize];
size_t len = g_script_provider(buf, sizeof(buf));
httpd_resp_set_type(req, "text/plain");
httpd_resp_send(req, buf, len);
return ESP_OK;
}
// POST /api/script -- body is the raw Lua source, not JSON. Scripts
// routinely contain '"', '\', and newlines that json_helpers.h's
// non-escaping parser (see its own header comment) can't round-trip
// safely, so this endpoint skips JSON entirely. httpd_req_recv() isn't
// guaranteed to return the whole body in one call once it's more than a
// few hundred bytes (unlike the small fixed-shape JSON bodies elsewhere
// in this file), so this reads in a loop until content_len bytes are in.
esp_err_t api_script_post_handler(httpd_req_t *req)
{
add_cors_headers(req);
if (req->content_len == 0 || req->content_len >= kMaxScriptSize) {
httpd_resp_set_status(req, "413 Payload Too Large");
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "{\"error\":\"script too large\"}", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static char body[kMaxScriptSize];
size_t received = 0;
while (received < req->content_len) {
int ret = httpd_req_recv(req, body + received, req->content_len - received);
if (ret <= 0) {
httpd_resp_send_500(req);
return ESP_FAIL;
}
received += static_cast<size_t>(ret);
}
if (g_script_change_handler) {
g_script_change_handler(body, received);
}
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "{\"status\":\"reloaded\"}", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
esp_err_t api_options_handler(httpd_req_t *req)
{
return cors_preflight_handler(req);
@@ -486,7 +550,7 @@ void start_settings_server()
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
config.max_uri_handlers = 12;
config.max_uri_handlers = 15;
if (httpd_start(&g_settings_httpd, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start settings HTTP server");
return;
@@ -502,11 +566,20 @@ void start_settings_server()
.uri = "/api/mqtt", .method = HTTP_POST, .handler = api_mqtt_post_handler, .user_ctx = nullptr};
static const httpd_uri_t mqtt_options_uri = {
.uri = "/api/mqtt", .method = HTTP_OPTIONS, .handler = api_options_handler, .user_ctx = nullptr};
static const httpd_uri_t script_get_uri = {
.uri = "/api/script", .method = HTTP_GET, .handler = api_script_get_handler, .user_ctx = nullptr};
static const httpd_uri_t script_post_uri = {
.uri = "/api/script", .method = HTTP_POST, .handler = api_script_post_handler, .user_ctx = nullptr};
static const httpd_uri_t script_options_uri = {
.uri = "/api/script", .method = HTTP_OPTIONS, .handler = api_options_handler, .user_ctx = nullptr};
httpd_register_uri_handler(g_settings_httpd, &root_uri);
httpd_register_uri_handler(g_settings_httpd, &status_uri);
httpd_register_uri_handler(g_settings_httpd, &mqtt_get_uri);
httpd_register_uri_handler(g_settings_httpd, &mqtt_post_uri);
httpd_register_uri_handler(g_settings_httpd, &mqtt_options_uri);
httpd_register_uri_handler(g_settings_httpd, &script_get_uri);
httpd_register_uri_handler(g_settings_httpd, &script_post_uri);
httpd_register_uri_handler(g_settings_httpd, &script_options_uri);
}
} // namespace
@@ -590,4 +663,14 @@ void set_led_color_handler(LedColorHandler handler)
g_led_handler = handler;
}
void set_script_change_handler(ScriptChangeHandler handler)
{
g_script_change_handler = handler;
}
void set_script_provider(ScriptProvider provider)
{
g_script_provider = provider;
}
} // namespace kvida

View File

@@ -1,3 +1,5 @@
#include <cstddef>
#include "commissioning.h"
#include "esp_log.h"
#include "esp_timer.h"
@@ -15,19 +17,33 @@ void on_led_color(uint8_t red, uint8_t green, uint8_t blue)
kvida::drivers::rgb_led_set_color(red, green, blue);
}
void on_script_change(const char *script, size_t len)
{
kvida::lua_runtime_reload(script, len);
}
size_t on_script_provider(char *buf, size_t cap)
{
return kvida::lua_runtime_current_script(buf, cap);
}
} // namespace
extern "C" void app_main(void)
{
ESP_LOGI(TAG, "kvida-os starting");
// The default Lua script (components/lua_runtime/src/default_script.h)
// Runs whatever script is in storage (uploaded via kvida-sdk's
// editor), falling back to the compiled-in default
// (components/lua_runtime/src/default_script.h) on first boot --
// reads chip temperature, drives the LED, and publishes to Home
// Assistant -- see that component's README for what's real here vs.
// still a "quick proof" using only what's on the ESP32-C6-DevKitC-1.
// Assistant. See lua_runtime's README for what's real here vs. still
// a "quick proof" using only what's on the ESP32-C6-DevKitC-1.
kvida::drivers::rgb_led_init();
kvida::lua_runtime_init();
kvida::set_led_color_handler(&on_led_color);
kvida::set_script_change_handler(&on_script_change);
kvida::set_script_provider(&on_script_provider);
const esp_timer_create_args_t timer_args = {
.callback = [](void *) { kvida::lua_runtime_tick(); },