chore: initial commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"src/direction/domain_direction_service.c"
|
||||
"src/direction/domain_direction_catalog.c"
|
||||
"src/direction/domain_angle_math.c"
|
||||
"src/calibration/domain_calibration_store.c"
|
||||
"src/calibration/domain_calibration_service.c"
|
||||
"src/fortune/domain_fortune_store.c"
|
||||
"src/fortune/domain_user_config_server.c"
|
||||
"src/runtime/domain_runtime_adapters.c"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
PRIV_INCLUDE_DIRS
|
||||
"internal"
|
||||
PRIV_REQUIRES
|
||||
platform
|
||||
log
|
||||
json
|
||||
EMBED_TXTFILES
|
||||
"assets/user_config.html"
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>TQ Cat 用户信息配置</title>
|
||||
<style>
|
||||
:root{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#28231e;background:#f5f0e8}
|
||||
body{margin:0;padding:24px 14px}main{max-width:720px;margin:auto;background:#fffdf9;border:1px solid #ded4c6;border-radius:18px;padding:26px;box-shadow:0 12px 36px #49351d14}
|
||||
h1,h2{margin-top:0}.hint,#status{color:#6b6156}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
label{display:grid;gap:6px;font-size:14px;color:#62584d}input{box-sizing:border-box;width:100%;padding:10px 12px;border:1px solid #cec2b2;border-radius:9px;font:inherit}
|
||||
fieldset{margin:20px 0;padding:18px;border:1px solid #ded4c6;border-radius:12px}legend{padding:0 8px;font-weight:700}
|
||||
button{padding:11px 18px;border:0;border-radius:9px;background:#955033;color:#fff;font:inherit;font-weight:700;cursor:pointer}button:disabled{opacity:.55;cursor:wait}
|
||||
#result{display:grid;grid-template-columns:auto 1fr;gap:8px 18px;margin:0}dt{color:#74695e}dd{margin:0;font-weight:650}
|
||||
.ok{color:#28733c}.error{color:#a12f2f}@media(max-width:560px){main{padding:19px}.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>TQ Cat 用户信息配置</h1>
|
||||
<p class="hint">当前页面由设备提供。提交后设备会保存资料并获取今日喜色与财位。</p>
|
||||
<form id="profile">
|
||||
<fieldset>
|
||||
<legend>生辰信息</legend>
|
||||
<div class="grid">
|
||||
<label>出生年份<input name="birthYear" type="number" min="1900" max="2100" required></label>
|
||||
<label>出生月份<input name="birthMonth" type="number" min="1" max="12" required></label>
|
||||
<label>出生日期<input name="birthDay" type="number" min="1" max="31" required></label>
|
||||
<label>出生小时<input name="birthHour" type="number" min="0" max="23" required></label>
|
||||
<label>出生分钟<input name="birthMinute" type="number" min="0" max="59" required></label>
|
||||
<label>出生地点<input name="birthPlace" maxlength="63" placeholder="广东省广州市" required></label>
|
||||
<label>当前居住地(可选)<input name="currentResidence" maxlength="63" placeholder="广东省深圳市"></label>
|
||||
<label>MBTI(可选)<input name="mbti" maxlength="7" placeholder="INFJ"></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button id="submit" type="submit">保存并刷新今日结果</button>
|
||||
<p id="status" role="status">正在读取设备资料...</p>
|
||||
</form>
|
||||
<fieldset>
|
||||
<legend>今日结果</legend>
|
||||
<dl id="result">
|
||||
<dt>生辰八字</dt><dd id="bazi">-</dd>
|
||||
<dt>五行属性</dt><dd id="element">-</dd>
|
||||
<dt>今日喜色</dt><dd id="color">-</dd>
|
||||
<dt>今日财位</dt><dd id="direction">-</dd>
|
||||
<dt>日期</dt><dd id="date">-</dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
const form=document.querySelector("#profile"),statusNode=document.querySelector("#status"),submit=document.querySelector("#submit");
|
||||
function status(text,kind=""){statusNode.textContent=text;statusNode.className=kind}
|
||||
function showResult(data){for(const [id,key] of [["bazi","bazi"],["element","element"],["color","luckyColor"],["direction","wealthDirection"],["date","targetDate"]])document.querySelector("#"+id).textContent=data[key]||"-"}
|
||||
async function jsonFetch(url,options){const response=await fetch(url,{cache:"no-store",...options});const data=await response.json();if(!response.ok)throw new Error(data.error||("HTTP "+response.status));return data}
|
||||
async function loadProfile(){try{const data=await jsonFetch("/api/profile");if(data.configured)for(const name of ["birthYear","birthMonth","birthDay","birthHour","birthMinute","birthPlace","currentResidence","mbti"])form.elements[name].value=data[name]??"";status(data.configured?"已读取设备中的用户资料":"尚未配置用户资料",data.configured?"ok":"")}catch(error){status("读取资料失败:"+error.message,"error")}}
|
||||
async function loadFortune(){try{const data=await jsonFetch("/api/fortune");if(data.status==="ready"){showResult(data);status("今日结果已更新","ok")}else if(data.status==="pending")status("资料已保存,设备正在获取今日结果...");}catch(error){status("读取今日结果失败:"+error.message,"error")}}
|
||||
form.addEventListener("submit",async event=>{event.preventDefault();submit.disabled=true;const values=new FormData(form),payload={};for(const name of ["birthYear","birthMonth","birthDay","birthHour","birthMinute"])payload[name]=Number(values.get(name));for(const name of ["birthPlace","currentResidence","mbti"])payload[name]=String(values.get(name)||"").trim();try{await jsonFetch("/api/profile",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});showResult({});status("资料已保存,设备正在获取今日结果...","ok");await loadFortune()}catch(error){status("保存失败:"+error.message,"error")}finally{submit.disabled=false}});
|
||||
loadProfile().then(loadFortune);setInterval(loadFortune,2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
float initial_angle;
|
||||
int rotate_speed_us;
|
||||
} domain_direction_axis_config_t;
|
||||
|
||||
typedef struct {
|
||||
bool enabled;
|
||||
float calibration_angle;
|
||||
float step_angle_deg;
|
||||
float max_sweep_deg;
|
||||
bool search_clockwise;
|
||||
int settle_delay_ms;
|
||||
} domain_direction_calibration_t;
|
||||
|
||||
typedef enum {
|
||||
DOMAIN_WIFI_PROMPT_WIFI_SUCCESS = 0,
|
||||
DOMAIN_WIFI_PROMPT_AFTER_WIFI_SUCCESS,
|
||||
DOMAIN_WIFI_PROMPT_WIFI_CONNECTING,
|
||||
DOMAIN_WIFI_PROMPT_WIFI_FAILED,
|
||||
DOMAIN_WIFI_PROMPT_WIFI_CONFIG,
|
||||
} domain_wifi_prompt_id_t;
|
||||
|
||||
typedef struct {
|
||||
void (*set_keep_awake)(bool keep_awake);
|
||||
esp_err_t (*play_prompt)(domain_wifi_prompt_id_t prompt);
|
||||
esp_err_t (*play_prompt_file)(const char *path);
|
||||
} domain_wifi_behavior_hooks_t;
|
||||
|
||||
typedef enum {
|
||||
DOMAIN_MULTIMODAL_TRANSPORT_EVENT_CONNECTED = 0,
|
||||
DOMAIN_MULTIMODAL_TRANSPORT_EVENT_DISCONNECTED,
|
||||
DOMAIN_MULTIMODAL_TRANSPORT_EVENT_ERROR,
|
||||
DOMAIN_MULTIMODAL_TRANSPORT_EVENT_TEXT_MESSAGE,
|
||||
DOMAIN_MULTIMODAL_TRANSPORT_EVENT_BINARY_MESSAGE,
|
||||
} domain_multimodal_transport_event_type_t;
|
||||
|
||||
typedef struct {
|
||||
domain_multimodal_transport_event_type_t type;
|
||||
const uint8_t *data;
|
||||
size_t data_len;
|
||||
} domain_multimodal_transport_event_t;
|
||||
|
||||
typedef void (*domain_multimodal_transport_event_callback_t)(
|
||||
const domain_multimodal_transport_event_t *event,
|
||||
void *user_data);
|
||||
|
||||
typedef esp_err_t (*domain_multimodal_audio_uplink_cb_t)(
|
||||
const uint8_t *data,
|
||||
size_t len,
|
||||
void *user_data);
|
||||
|
||||
typedef enum {
|
||||
DOMAIN_MULTIMODAL_DOWNSTREAM_PLAYBACK_STARTED = 0,
|
||||
DOMAIN_MULTIMODAL_DOWNSTREAM_PLAYBACK_COMPLETED,
|
||||
DOMAIN_MULTIMODAL_DOWNSTREAM_PLAYBACK_STOPPED,
|
||||
} domain_multimodal_downstream_playback_event_type_t;
|
||||
|
||||
typedef struct {
|
||||
domain_multimodal_downstream_playback_event_type_t type;
|
||||
} domain_multimodal_downstream_playback_event_t;
|
||||
|
||||
typedef void (*domain_multimodal_downstream_playback_callback_t)(
|
||||
const domain_multimodal_downstream_playback_event_t *event,
|
||||
void *user_data);
|
||||
|
||||
typedef enum {
|
||||
DOMAIN_EXPRESSION_IDLE = 0,
|
||||
DOMAIN_EXPRESSION_SERVER_ERROR,
|
||||
DOMAIN_EXPRESSION_SLOT_REELS,
|
||||
DOMAIN_EXPRESSION_WIFI_DISCONNECTED,
|
||||
DOMAIN_EXPRESSION_WEALTH_SPIN,
|
||||
DOMAIN_EXPRESSION_WEALTH_DIRECTION,
|
||||
} domain_expression_id_t;
|
||||
|
||||
typedef enum {
|
||||
DOMAIN_EXPRESSION_TRIGGER_UNSPECIFIED = 0,
|
||||
DOMAIN_EXPRESSION_TRIGGER_SERVER_TASK_FAILED,
|
||||
DOMAIN_EXPRESSION_TRIGGER_SERVER_OUTPUT_ERROR,
|
||||
DOMAIN_EXPRESSION_TRIGGER_SLOT_SWITCH,
|
||||
DOMAIN_EXPRESSION_TRIGGER_WIFI_DISCONNECTED,
|
||||
DOMAIN_EXPRESSION_TRIGGER_WIFI_RECONNECTED,
|
||||
DOMAIN_EXPRESSION_TRIGGER_MODEL_DIRECTION,
|
||||
DOMAIN_EXPRESSION_TRIGGER_DAILY_FORTUNE,
|
||||
} domain_expression_trigger_t;
|
||||
|
||||
typedef struct {
|
||||
domain_expression_id_t id;
|
||||
domain_expression_trigger_t trigger;
|
||||
size_t direction_index; /* Only used by DOMAIN_EXPRESSION_WEALTH_DIRECTION */
|
||||
} domain_expression_request_t;
|
||||
|
||||
esp_err_t domain_init(const domain_direction_axis_config_t *config,
|
||||
const domain_direction_calibration_t *calibration);
|
||||
const domain_direction_axis_config_t *domain_direction_default_axis_config(void);
|
||||
const domain_direction_calibration_t *domain_direction_default_calibration(void);
|
||||
bool domain_is_initialized(void);
|
||||
size_t domain_get_direction_count(void);
|
||||
bool domain_get_direction_view(size_t index,
|
||||
const char **out_name_cn,
|
||||
const char **out_name_en,
|
||||
float *out_angle);
|
||||
bool domain_find_direction_by_name(const char *input, size_t *out_index);
|
||||
bool domain_get_current_direction_index(size_t *out_index);
|
||||
float domain_get_current_angle(void);
|
||||
void domain_set_current_angle(float angle);
|
||||
int domain_get_rotate_speed_us(void);
|
||||
/**
|
||||
* @brief Rotate to an absolute direction angle.
|
||||
*
|
||||
* `timeout_ms <= 0` uses the default domain timeout. The timeout is the total
|
||||
* caller-visible budget, including state-lock acquisition and motor movement.
|
||||
*/
|
||||
esp_err_t domain_rotate_to_angle_with_timeout(float target_angle, int timeout_ms);
|
||||
esp_err_t domain_apply_rotation_delta(float delta);
|
||||
void domain_set_calibration(const domain_direction_calibration_t *config);
|
||||
bool domain_get_calibration(domain_direction_calibration_t *out_config);
|
||||
/**
|
||||
* @brief Run direction calibration.
|
||||
*
|
||||
* `timeout_ms <= 0` uses the default calibration timeout. The timeout is the
|
||||
* total caller-visible budget, including state-lock acquisition, movement
|
||||
* steps, switch settle delays, and release sweep.
|
||||
*/
|
||||
esp_err_t domain_calibrate_with_timeout(const domain_direction_calibration_t *config, int timeout_ms);
|
||||
|
||||
esp_err_t domain_runtime_multimodal_transport_start(
|
||||
const char *bearer_token,
|
||||
const char *workspace_id,
|
||||
domain_multimodal_transport_event_callback_t cb,
|
||||
void *user_data);
|
||||
/**
|
||||
* @brief Stop the multimodal transport with bounded teardown.
|
||||
*
|
||||
* `timeout_ms < 0` is invalid, zero requests asynchronous stop, and positive
|
||||
* values wait up to the bound before lower layers apply deterministic fallback.
|
||||
*/
|
||||
esp_err_t domain_runtime_multimodal_transport_stop(int timeout_ms);
|
||||
bool domain_runtime_multimodal_transport_is_connected(void);
|
||||
esp_err_t domain_runtime_multimodal_transport_send_text(const char *label, const char *text);
|
||||
esp_err_t domain_runtime_multimodal_transport_send_text_realtime(const char *label, const char *text);
|
||||
esp_err_t domain_runtime_multimodal_transport_send_binary_realtime(const uint8_t *data, size_t len);
|
||||
|
||||
esp_err_t domain_runtime_multimodal_audio_init(void);
|
||||
void domain_runtime_multimodal_audio_deinit(void);
|
||||
esp_err_t domain_runtime_multimodal_audio_start_uplink(
|
||||
domain_multimodal_audio_uplink_cb_t cb,
|
||||
void *user_data);
|
||||
|
||||
/**
|
||||
* @brief Stop multimodal uplink capture/stream with bounded teardown.
|
||||
*
|
||||
* `timeout_ms < 0` is invalid, zero requests asynchronous stop, and positive
|
||||
* values wait up to the bound before lower layers apply deterministic fallback.
|
||||
*/
|
||||
esp_err_t domain_runtime_multimodal_audio_stop_uplink(int timeout_ms);
|
||||
bool domain_runtime_multimodal_audio_is_uplink_running(void);
|
||||
bool domain_runtime_multimodal_audio_is_initialized(void);
|
||||
esp_err_t domain_runtime_multimodal_audio_play_downstream_opus(const uint8_t *data, size_t len);
|
||||
esp_err_t domain_runtime_multimodal_audio_begin_downstream_response(void);
|
||||
esp_err_t domain_runtime_multimodal_audio_finish_downstream_response(void);
|
||||
esp_err_t domain_runtime_multimodal_audio_stop_downstream_playback(void);
|
||||
esp_err_t domain_runtime_multimodal_audio_register_downstream_playback_callback(
|
||||
domain_multimodal_downstream_playback_callback_t cb,
|
||||
void *user_data);
|
||||
esp_err_t domain_runtime_multimodal_ptt_button_init(void);
|
||||
bool domain_runtime_multimodal_ptt_button_is_pressed(void);
|
||||
esp_err_t domain_runtime_slot_feature_switch_init(void);
|
||||
bool domain_runtime_slot_feature_switch_is_active(void);
|
||||
|
||||
esp_err_t domain_runtime_prepare_storage(void);
|
||||
void domain_runtime_read_device_identity(char *device_id,
|
||||
size_t device_id_len,
|
||||
char *serial_number,
|
||||
size_t serial_number_len);
|
||||
|
||||
esp_err_t domain_runtime_write_device_identity(const char *device_id, const char *serial_number);
|
||||
|
||||
typedef enum {
|
||||
DOMAIN_RUNTIME_AUTH_RECORD_KEY_MATERIAL = 0,
|
||||
DOMAIN_RUNTIME_AUTH_RECORD_STATE,
|
||||
} domain_runtime_auth_record_t;
|
||||
|
||||
/**
|
||||
* @brief Domain-owned device-auth persistence port, backed by platform blobs.
|
||||
*
|
||||
* The domain facade owns only the auth record slots, storage key mapping, and
|
||||
* legacy migration boundary. Callers own the serialized record schema and pass
|
||||
* opaque bytes. `load_record` returns `ESP_ERR_NOT_FOUND` when no record exists
|
||||
* yet; `save_record` commits atomically.
|
||||
*/
|
||||
esp_err_t domain_runtime_auth_store_load_record(domain_runtime_auth_record_t record,
|
||||
void *out,
|
||||
size_t out_len,
|
||||
size_t *out_len_actual);
|
||||
esp_err_t domain_runtime_auth_store_save_record(domain_runtime_auth_record_t record,
|
||||
const void *data,
|
||||
size_t len);
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Daily fortune: user birth profile + server-computed daily result. */
|
||||
/* Owned by the domain layer; see docs/adr/0002-daily-fortune-contracts.md. */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
#define DOMAIN_BIRTH_PLACE_MAX_LEN 64
|
||||
#define DOMAIN_BIRTH_RESIDENCE_MAX_LEN 64
|
||||
#define DOMAIN_BIRTH_MBTI_MAX_LEN 8
|
||||
#define DOMAIN_BIRTH_TZ_MAX_LEN 32
|
||||
|
||||
typedef struct {
|
||||
int year;
|
||||
int month;
|
||||
int day;
|
||||
int hour;
|
||||
int minute;
|
||||
char birth_place[DOMAIN_BIRTH_PLACE_MAX_LEN];
|
||||
char current_residence[DOMAIN_BIRTH_RESIDENCE_MAX_LEN];
|
||||
char mbti[DOMAIN_BIRTH_MBTI_MAX_LEN];
|
||||
char timezone[DOMAIN_BIRTH_TZ_MAX_LEN];
|
||||
bool valid;
|
||||
} domain_birth_profile_t;
|
||||
|
||||
#define DOMAIN_FORTUNE_BAZI_MAX_LEN 64
|
||||
#define DOMAIN_FORTUNE_ELEMENT_MAX_LEN 8
|
||||
#define DOMAIN_FORTUNE_DIRECTION_MAX_LEN 16
|
||||
#define DOMAIN_FORTUNE_DATE_MAX_LEN 12
|
||||
|
||||
typedef struct {
|
||||
char bazi[DOMAIN_FORTUNE_BAZI_MAX_LEN];
|
||||
char element[DOMAIN_FORTUNE_ELEMENT_MAX_LEN];
|
||||
char lucky_color[DOMAIN_FORTUNE_ELEMENT_MAX_LEN];
|
||||
char wealth_direction[DOMAIN_FORTUNE_DIRECTION_MAX_LEN];
|
||||
char target_date[DOMAIN_FORTUNE_DATE_MAX_LEN];
|
||||
size_t wealth_direction_index;
|
||||
bool valid;
|
||||
} domain_fortune_result_t;
|
||||
|
||||
typedef void (*domain_birth_profile_observer_t)(const domain_birth_profile_t *profile, void *user_data);
|
||||
|
||||
/**
|
||||
* @brief Persist / query the user birth profile (NVS-backed via the platform store).
|
||||
*
|
||||
* `set` requires a valid profile, persists it, then notifies the registered
|
||||
* observer with a snapshot (invoked outside any lock). `get` returns false when
|
||||
* no valid profile has been stored. Storage is the single source of truth; no
|
||||
* higher-layer mirror should be kept.
|
||||
*/
|
||||
esp_err_t domain_birth_profile_store_set(const domain_birth_profile_t *profile);
|
||||
bool domain_birth_profile_store_get(domain_birth_profile_t *out_profile);
|
||||
|
||||
/**
|
||||
* @brief Persist / query the most recent daily fortune result.
|
||||
*/
|
||||
esp_err_t domain_fortune_result_store_set(const domain_fortune_result_t *result);
|
||||
bool domain_fortune_result_store_get(domain_fortune_result_t *out_result);
|
||||
esp_err_t domain_fortune_result_store_clear(void);
|
||||
|
||||
/**
|
||||
* @brief Register the single observer notified when a new birth profile is
|
||||
* stored (e.g. submitted over the LAN configuration page). Pass NULL to clear it. The
|
||||
* callback must be lightweight (it runs in the caller's context).
|
||||
*/
|
||||
void domain_runtime_register_birth_profile_observer(domain_birth_profile_observer_t cb, void *user_data);
|
||||
|
||||
/**
|
||||
* @brief Wall-clock time synchronization, bridged to the platform SNTP
|
||||
* primitive. `start` is idempotent and sets the process time zone
|
||||
* (`posix_tz`, e.g. "CST-8"); `is_synced` reports whether the clock has
|
||||
* been set. Call `start` after the network is up.
|
||||
*/
|
||||
esp_err_t domain_runtime_time_sync_start(const char *sntp_server, const char *posix_tz);
|
||||
bool domain_runtime_time_is_synced(void);
|
||||
|
||||
#define DOMAIN_USER_CONFIG_HOSTNAME_MAX_LEN 32
|
||||
|
||||
typedef struct {
|
||||
uint16_t port;
|
||||
char hostname[DOMAIN_USER_CONFIG_HOSTNAME_MAX_LEN];
|
||||
} domain_user_config_server_config_t;
|
||||
|
||||
typedef struct {
|
||||
bool running;
|
||||
uint16_t port;
|
||||
char hostname[DOMAIN_USER_CONFIG_HOSTNAME_MAX_LEN];
|
||||
} domain_user_config_server_state_t;
|
||||
|
||||
/**
|
||||
* @brief Start the domain-owned user profile and fortune web application.
|
||||
*
|
||||
* The platform supplies only the bounded raw HTTP transport. This domain
|
||||
* service owns the routes, JSON semantics, validation, and embedded page.
|
||||
* Start is idempotent while the service is starting or running.
|
||||
*/
|
||||
esp_err_t domain_user_config_server_start(
|
||||
const domain_user_config_server_config_t *config);
|
||||
|
||||
/**
|
||||
* @brief Stop the user configuration server with bounded teardown.
|
||||
*
|
||||
* `timeout_ms < 0` is invalid, zero requests asynchronous stop, and positive
|
||||
* values wait up to the bound before the platform applies deterministic
|
||||
* fallback.
|
||||
*/
|
||||
esp_err_t domain_user_config_server_stop(int timeout_ms);
|
||||
esp_err_t domain_user_config_server_get_state(
|
||||
domain_user_config_server_state_t *out_state);
|
||||
|
||||
/**
|
||||
* @brief POST domain JSON data through the lower-layer HTTPS transport adapter.
|
||||
*
|
||||
* Optional `device_id` is sent as X-Device-Id and optional `bearer_token` is
|
||||
* sent as Authorization: Bearer <token>. The response body is written to the
|
||||
* caller-owned buffer and NUL-terminated on success. A completed HTTP exchange
|
||||
* returns ESP_OK regardless of status code; callers inspect `out_status`.
|
||||
*
|
||||
* `timeout_ms < 0` is invalid. `timeout_ms == 0` uses the platform HTTPS
|
||||
* default bounded timeout. On `ESP_ERR_TIMEOUT`, the lower-layer exchange is
|
||||
* aborted and cleaned up before this function returns; no retry is performed.
|
||||
*/
|
||||
esp_err_t domain_runtime_post_json(const char *url,
|
||||
const char *payload,
|
||||
const char *device_id,
|
||||
const char *bearer_token,
|
||||
int timeout_ms,
|
||||
int tx_buffer_size,
|
||||
int rx_buffer_size,
|
||||
char *response_body,
|
||||
size_t response_body_capacity,
|
||||
size_t *out_response_body_len,
|
||||
int *out_status);
|
||||
|
||||
/**
|
||||
* @brief Console (UART) line-transport primitives, bridged to the platform.
|
||||
*
|
||||
* `read_bytes` returns bytes read (0 on timeout, <0 on error). A zero timeout
|
||||
* polls once; negative timeouts are invalid and return <0. Used by the factory
|
||||
* provisioning CLI.
|
||||
*/
|
||||
esp_err_t domain_runtime_console_install(void);
|
||||
int domain_runtime_console_read_bytes(uint8_t *buf, size_t len, int timeout_ms);
|
||||
void domain_runtime_console_write_line(const char *line);
|
||||
|
||||
void domain_runtime_behavior_set_keep_awake(bool keep_awake);
|
||||
esp_err_t domain_runtime_behavior_play_prompt(domain_wifi_prompt_id_t prompt);
|
||||
esp_err_t domain_runtime_behavior_play_prompt_file(const char *path);
|
||||
void domain_runtime_wifi_portal_set_behavior_hooks(const domain_wifi_behavior_hooks_t *hooks);
|
||||
esp_err_t domain_runtime_wifi_portal_init_defaults(const char *seed_ssid, const char *seed_password);
|
||||
bool domain_runtime_wifi_portal_has_saved_credentials(void);
|
||||
bool domain_runtime_wifi_portal_sta_is_connected(void);
|
||||
esp_err_t domain_runtime_wifi_portal_start_config_ap(const char *ssid_prefix);
|
||||
bool domain_runtime_wifi_connect_error_keeps_credentials(esp_err_t err);
|
||||
|
||||
/**
|
||||
* @brief Connect STA using stored credentials with explicit timeout.
|
||||
*
|
||||
* Semantics:
|
||||
* - `timeout_ms <= 0`: use default timeout.
|
||||
* - Returns `ESP_OK` when STA is already connected (idempotent).
|
||||
* - Returns explicit failure on timeout/connection error.
|
||||
*/
|
||||
esp_err_t domain_runtime_wifi_portal_connect_sta(int timeout_ms);
|
||||
esp_err_t domain_runtime_wifi_portal_save_credentials(const char *ssid, const char *password);
|
||||
esp_err_t domain_runtime_wifi_portal_clear_saved_credentials(void);
|
||||
|
||||
esp_err_t domain_runtime_init_lcd_device(void);
|
||||
void domain_runtime_start_lcd_render_loop(void);
|
||||
/**
|
||||
* @brief Stop LCD render loop with bounded teardown.
|
||||
*
|
||||
* `timeout_ms < 0` is invalid, zero requests asynchronous stop, and positive
|
||||
* values wait up to the bound before lower layers apply deterministic fallback.
|
||||
*/
|
||||
esp_err_t domain_runtime_stop_lcd_render_loop(int timeout_ms);
|
||||
esp_err_t domain_runtime_request_expression(const domain_expression_request_t *request);
|
||||
|
||||
/**
|
||||
* @brief Preload assets for an expression without changing the active display.
|
||||
*
|
||||
* Intended for asset-backed expressions that are likely to play soon. The
|
||||
* request is copied and the LCD render task performs the preload
|
||||
* asynchronously. Unsupported or non-asset expressions return an explicit
|
||||
* error.
|
||||
*/
|
||||
esp_err_t domain_runtime_preheat_expression(const domain_expression_request_t *request);
|
||||
|
||||
/**
|
||||
* @brief Request an expression and block until its finite asset sequence
|
||||
* completes, or @p timeout_ms elapses.
|
||||
*
|
||||
* Intended for finite presentations such as the wealth spin intro. Returns the
|
||||
* underlying request error if queuing fails, ESP_ERR_TIMEOUT if playback does
|
||||
* not finish within the timeout (the caller may proceed anyway), or
|
||||
* ESP_ERR_INVALID_STATE if the LCD is not running. A looping expression
|
||||
* (repeat_count == 0) never completes and will time out.
|
||||
*/
|
||||
esp_err_t domain_runtime_request_expression_blocking(const domain_expression_request_t *request,
|
||||
uint32_t timeout_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "domain.h"
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef struct {
|
||||
const char *name_cn;
|
||||
const char *name_en;
|
||||
float angle;
|
||||
} domain_direction_entry_t;
|
||||
|
||||
size_t domain_direction_catalog_count(void);
|
||||
const domain_direction_entry_t *domain_direction_catalog_get(size_t index);
|
||||
bool domain_direction_catalog_find_by_name(const char *input, size_t *out_index);
|
||||
bool domain_direction_catalog_find_by_angle(float angle, size_t *out_index);
|
||||
|
||||
float domain_angle_normalize(float angle);
|
||||
float domain_angle_shortest_rotation(float current, float target);
|
||||
|
||||
void domain_calibration_store_set(const domain_direction_calibration_t *config);
|
||||
bool domain_calibration_store_get(domain_direction_calibration_t *out_config);
|
||||
|
||||
bool domain_direction_lock_state(int timeout_ms);
|
||||
void domain_direction_unlock_state(void);
|
||||
|
||||
typedef struct {
|
||||
int gpio_num;
|
||||
int active_level;
|
||||
bool pullup_enabled;
|
||||
bool pulldown_enabled;
|
||||
} domain_digital_input_config_t;
|
||||
|
||||
esp_err_t domain_runtime_platform_init(void);
|
||||
esp_err_t domain_runtime_stepper_rotate_angle_with_accel_timeout(float angle,
|
||||
int target_delay_us,
|
||||
int timeout_ms);
|
||||
void domain_runtime_stepper_power_off(void);
|
||||
esp_err_t domain_runtime_digital_input_configure(const domain_digital_input_config_t *config);
|
||||
bool domain_runtime_digital_input_is_active(const domain_digital_input_config_t *config);
|
||||
esp_err_t domain_runtime_calibration_switch_init(void);
|
||||
bool domain_runtime_calibration_switch_is_active(void);
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain.h"
|
||||
|
||||
#include "domain_internal.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "domain_cal";
|
||||
|
||||
#define DOMAIN_CALIBRATION_TIMEOUT_MS 20000
|
||||
#define DOMAIN_CALIBRATION_STEP_TIMEOUT_MS 1500
|
||||
#define DOMAIN_CALIBRATION_RELEASE_SWEEP_DEG 45.0f
|
||||
|
||||
static float clamp_positive_default(float value, float default_value)
|
||||
{
|
||||
if (value > 0.0f) {
|
||||
return value;
|
||||
}
|
||||
return default_value;
|
||||
}
|
||||
|
||||
static esp_err_t prepare_calibration_switch(const domain_direction_calibration_t *calibration)
|
||||
{
|
||||
if (calibration == NULL || !calibration->enabled) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
return domain_runtime_calibration_switch_init();
|
||||
}
|
||||
|
||||
static bool is_calibration_switch_active(const domain_direction_calibration_t *calibration)
|
||||
{
|
||||
if (calibration == NULL || !calibration->enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return domain_runtime_calibration_switch_is_active();
|
||||
}
|
||||
|
||||
static bool calibration_timeout_reached(TickType_t start_tick, TickType_t timeout_ticks)
|
||||
{
|
||||
return (xTaskGetTickCount() - start_tick) >= timeout_ticks;
|
||||
}
|
||||
|
||||
static int calibration_remaining_timeout_ms(TickType_t start_tick, TickType_t timeout_ticks)
|
||||
{
|
||||
TickType_t elapsed_ticks = xTaskGetTickCount() - start_tick;
|
||||
if (elapsed_ticks >= timeout_ticks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
TickType_t remaining_ticks = timeout_ticks - elapsed_ticks;
|
||||
int remaining_ms = (int)(remaining_ticks * portTICK_PERIOD_MS);
|
||||
return remaining_ms > 0 ? remaining_ms : 1;
|
||||
}
|
||||
|
||||
static int calibration_step_timeout_ms(TickType_t start_tick, TickType_t timeout_ticks)
|
||||
{
|
||||
int remaining_ms = calibration_remaining_timeout_ms(start_tick, timeout_ticks);
|
||||
if (remaining_ms <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return remaining_ms < DOMAIN_CALIBRATION_STEP_TIMEOUT_MS ?
|
||||
remaining_ms :
|
||||
DOMAIN_CALIBRATION_STEP_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
static bool calibration_settle(const domain_direction_calibration_t *calibration,
|
||||
TickType_t start_tick,
|
||||
TickType_t timeout_ticks)
|
||||
{
|
||||
if (calibration != NULL && calibration->settle_delay_ms > 0) {
|
||||
int remaining_ms = calibration_remaining_timeout_ms(start_tick, timeout_ticks);
|
||||
if (remaining_ms <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int delay_ms = calibration->settle_delay_ms < remaining_ms ?
|
||||
calibration->settle_delay_ms :
|
||||
remaining_ms;
|
||||
TickType_t ticks = pdMS_TO_TICKS(delay_ms);
|
||||
vTaskDelay(ticks > 0 ? ticks : 1);
|
||||
}
|
||||
return !calibration_timeout_reached(start_tick, timeout_ticks);
|
||||
}
|
||||
|
||||
static esp_err_t calibration_rotate_and_track(float delta, int rotate_speed_us, int timeout_ms)
|
||||
{
|
||||
if (timeout_ms <= 0) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t err = domain_runtime_stepper_rotate_angle_with_accel_timeout(
|
||||
delta,
|
||||
rotate_speed_us,
|
||||
timeout_ms);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
return domain_apply_rotation_delta(delta);
|
||||
}
|
||||
|
||||
static esp_err_t calibration_release_active_switch(const domain_direction_calibration_t *calibration,
|
||||
float step_angle,
|
||||
float search_direction,
|
||||
int rotate_speed_us,
|
||||
TickType_t start_tick,
|
||||
TickType_t timeout_ticks)
|
||||
{
|
||||
float release_sweep_limit = clamp_positive_default(DOMAIN_CALIBRATION_RELEASE_SWEEP_DEG,
|
||||
step_angle);
|
||||
float released_sweep = 0.0f;
|
||||
float release_delta = -search_direction * step_angle;
|
||||
|
||||
while (released_sweep < release_sweep_limit) {
|
||||
if (!is_calibration_switch_active(calibration)) {
|
||||
ESP_LOGI(TAG, "Calibration switch released after %.1f deg", released_sweep);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (calibration_timeout_reached(start_tick, timeout_ticks)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
int step_timeout_ms = calibration_step_timeout_ms(start_tick, timeout_ticks);
|
||||
esp_err_t err = calibration_rotate_and_track(release_delta, rotate_speed_us, step_timeout_ms);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
released_sweep += step_angle;
|
||||
if (!calibration_settle(calibration, start_tick, timeout_ticks)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_calibration_switch_active(calibration)) {
|
||||
ESP_LOGW(TAG,
|
||||
"Calibration switch stayed active after %.1f deg release sweep",
|
||||
released_sweep);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Calibration switch released after %.1f deg", released_sweep);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t domain_calibrate_with_timeout(const domain_direction_calibration_t *config, int timeout_ms)
|
||||
{
|
||||
int effective_timeout_ms = timeout_ms > 0 ? timeout_ms : DOMAIN_CALIBRATION_TIMEOUT_MS;
|
||||
TickType_t timeout_ticks = pdMS_TO_TICKS(effective_timeout_ms);
|
||||
if (timeout_ticks == 0) {
|
||||
timeout_ticks = 1;
|
||||
}
|
||||
TickType_t start_tick = xTaskGetTickCount();
|
||||
|
||||
if (!domain_direction_lock_state(effective_timeout_ms)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
if (config != NULL) {
|
||||
domain_set_calibration(config);
|
||||
}
|
||||
|
||||
domain_direction_calibration_t calibration = {0};
|
||||
if (!domain_get_calibration(&calibration)) {
|
||||
ESP_LOGW(TAG, "Calibration not configured");
|
||||
domain_direction_unlock_state();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (!calibration.enabled) {
|
||||
ESP_LOGW(TAG, "Calibration disabled");
|
||||
domain_direction_unlock_state();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = prepare_calibration_switch(&calibration);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Prepare calibration switch failed");
|
||||
domain_direction_unlock_state();
|
||||
return err;
|
||||
}
|
||||
|
||||
float step_angle = clamp_positive_default(calibration.step_angle_deg, 5.0f);
|
||||
float max_sweep = clamp_positive_default(calibration.max_sweep_deg, 360.0f);
|
||||
float total_sweep = 0.0f;
|
||||
float direction = calibration.search_clockwise ? 1.0f : -1.0f;
|
||||
int rotate_speed_us = domain_get_rotate_speed_us();
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"Calibration start step=%.1f sweep=%.1f search=%s",
|
||||
step_angle,
|
||||
max_sweep,
|
||||
calibration.search_clockwise ? "clockwise" : "counterclockwise");
|
||||
|
||||
if (is_calibration_switch_active(&calibration)) {
|
||||
ESP_LOGI(TAG, "Calibration switch active at start, releasing before homing");
|
||||
err = calibration_release_active_switch(&calibration,
|
||||
step_angle,
|
||||
direction,
|
||||
rotate_speed_us,
|
||||
start_tick,
|
||||
timeout_ticks);
|
||||
if (err != ESP_OK) {
|
||||
domain_runtime_stepper_power_off();
|
||||
domain_direction_unlock_state();
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
while (total_sweep < max_sweep) {
|
||||
if (calibration_timeout_reached(start_tick, timeout_ticks)) {
|
||||
domain_runtime_stepper_power_off();
|
||||
domain_direction_unlock_state();
|
||||
ESP_LOGW(TAG, "Calibration timed out after %.1f deg search", total_sweep);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
float delta = direction * step_angle;
|
||||
int step_timeout_ms = calibration_step_timeout_ms(start_tick, timeout_ticks);
|
||||
err = calibration_rotate_and_track(delta, rotate_speed_us, step_timeout_ms);
|
||||
if (err != ESP_OK) {
|
||||
domain_runtime_stepper_power_off();
|
||||
domain_direction_unlock_state();
|
||||
return err;
|
||||
}
|
||||
|
||||
total_sweep += step_angle;
|
||||
if (!calibration_settle(&calibration, start_tick, timeout_ticks)) {
|
||||
domain_runtime_stepper_power_off();
|
||||
domain_direction_unlock_state();
|
||||
ESP_LOGW(TAG, "Calibration timed out during settle after %.1f deg search", total_sweep);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
if (is_calibration_switch_active(&calibration)) {
|
||||
domain_set_current_angle(calibration.calibration_angle);
|
||||
domain_runtime_stepper_power_off();
|
||||
domain_direction_unlock_state();
|
||||
ESP_LOGI(TAG, "Calibration switch detected after %.1f deg", total_sweep);
|
||||
return ESP_OK;
|
||||
}
|
||||
}
|
||||
|
||||
domain_runtime_stepper_power_off();
|
||||
domain_direction_unlock_state();
|
||||
ESP_LOGW(TAG, "Calibration sweep exhausted after %.1f deg", total_sweep);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain_internal.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
|
||||
static domain_direction_calibration_t s_calibration = {0};
|
||||
static bool s_calibration_configured = false;
|
||||
static portMUX_TYPE s_calibration_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void domain_calibration_store_set(const domain_direction_calibration_t *config)
|
||||
{
|
||||
portENTER_CRITICAL(&s_calibration_lock);
|
||||
if (config == NULL) {
|
||||
memset(&s_calibration, 0, sizeof(s_calibration));
|
||||
s_calibration_configured = false;
|
||||
portEXIT_CRITICAL(&s_calibration_lock);
|
||||
return;
|
||||
}
|
||||
|
||||
s_calibration = *config;
|
||||
s_calibration_configured = true;
|
||||
portEXIT_CRITICAL(&s_calibration_lock);
|
||||
}
|
||||
|
||||
bool domain_calibration_store_get(domain_direction_calibration_t *out_config)
|
||||
{
|
||||
portENTER_CRITICAL(&s_calibration_lock);
|
||||
if (!s_calibration_configured || out_config == NULL) {
|
||||
portEXIT_CRITICAL(&s_calibration_lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
*out_config = s_calibration;
|
||||
portEXIT_CRITICAL(&s_calibration_lock);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain_internal.h"
|
||||
|
||||
float domain_angle_normalize(float angle)
|
||||
{
|
||||
while (angle > 180.0f) {
|
||||
angle -= 360.0f;
|
||||
}
|
||||
while (angle <= -180.0f) {
|
||||
angle += 360.0f;
|
||||
}
|
||||
return angle;
|
||||
}
|
||||
|
||||
float domain_angle_shortest_rotation(float current, float target)
|
||||
{
|
||||
float diff = target - current;
|
||||
|
||||
while (diff > 180.0f) {
|
||||
diff -= 360.0f;
|
||||
}
|
||||
while (diff < -180.0f) {
|
||||
diff += 360.0f;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain_internal.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <string.h>
|
||||
|
||||
static const domain_direction_entry_t s_default_directions[] = {
|
||||
{"南", "S", 0.0f},
|
||||
{"西南", "SW", 45.0f},
|
||||
{"西", "W", 90.0f},
|
||||
{"西北", "NW", 135.0f},
|
||||
{"北", "N", 180.0f},
|
||||
{"东北", "NE", -135.0f},
|
||||
{"东", "E", -90.0f},
|
||||
{"东南", "SE", -45.0f},
|
||||
};
|
||||
|
||||
/*
|
||||
* Aliases for cardinal directions. The server fortune API returns the four
|
||||
* cardinals with a "正" prefix (正北/正南/正东/正西); map those to the catalog's
|
||||
* canonical names. The four diagonals (东北/西北/东南/西南) already match.
|
||||
*/
|
||||
static const struct {
|
||||
const char *alias;
|
||||
const char *canonical_cn;
|
||||
} s_direction_aliases[] = {
|
||||
{"正南", "南"},
|
||||
{"正西", "西"},
|
||||
{"正北", "北"},
|
||||
{"正东", "东"},
|
||||
};
|
||||
|
||||
static void trimmed_span(const char *input, const char **start_out, size_t *len_out)
|
||||
{
|
||||
const char *start = input;
|
||||
const char *end = NULL;
|
||||
|
||||
if (input == NULL) {
|
||||
*start_out = NULL;
|
||||
*len_out = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
while (*start != '\0' && isspace((unsigned char)*start)) {
|
||||
start++;
|
||||
}
|
||||
|
||||
end = start + strlen(start);
|
||||
while (end > start && isspace((unsigned char)*(end - 1))) {
|
||||
end--;
|
||||
}
|
||||
|
||||
*start_out = start;
|
||||
*len_out = (size_t)(end - start);
|
||||
}
|
||||
|
||||
static bool equals_trimmed(const char *input, const char *token, bool case_insensitive)
|
||||
{
|
||||
const char *start = NULL;
|
||||
size_t len = 0;
|
||||
size_t token_len = 0;
|
||||
|
||||
if (input == NULL || token == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
trimmed_span(input, &start, &len);
|
||||
token_len = strlen(token);
|
||||
if (len == 0 || len != token_len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char left = start[i];
|
||||
char right = token[i];
|
||||
|
||||
if (case_insensitive) {
|
||||
left = (char)tolower((unsigned char)left);
|
||||
right = (char)tolower((unsigned char)right);
|
||||
}
|
||||
|
||||
if (left != right) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t domain_direction_catalog_count(void)
|
||||
{
|
||||
return sizeof(s_default_directions) / sizeof(s_default_directions[0]);
|
||||
}
|
||||
|
||||
const domain_direction_entry_t *domain_direction_catalog_get(size_t index)
|
||||
{
|
||||
if (index >= domain_direction_catalog_count()) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return &s_default_directions[index];
|
||||
}
|
||||
|
||||
static bool matches_direction_alias(const char *input, const char *canonical_cn)
|
||||
{
|
||||
for (size_t i = 0; i < sizeof(s_direction_aliases) / sizeof(s_direction_aliases[0]); i++) {
|
||||
if (strcmp(s_direction_aliases[i].canonical_cn, canonical_cn) == 0 &&
|
||||
equals_trimmed(input, s_direction_aliases[i].alias, false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool domain_direction_catalog_find_by_name(const char *input, size_t *out_index)
|
||||
{
|
||||
for (size_t i = 0; i < domain_direction_catalog_count(); i++) {
|
||||
const domain_direction_entry_t *entry = domain_direction_catalog_get(i);
|
||||
if (entry == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (equals_trimmed(input, entry->name_cn, false) ||
|
||||
equals_trimmed(input, entry->name_en, true) ||
|
||||
matches_direction_alias(input, entry->name_cn)) {
|
||||
if (out_index != NULL) {
|
||||
*out_index = i;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool domain_direction_catalog_find_by_angle(float angle, size_t *out_index)
|
||||
{
|
||||
for (size_t i = 0; i < domain_direction_catalog_count(); i++) {
|
||||
const domain_direction_entry_t *entry = domain_direction_catalog_get(i);
|
||||
if (entry == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry->angle == angle) {
|
||||
if (out_index != NULL) {
|
||||
*out_index = i;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain.h"
|
||||
|
||||
#include "domain_internal.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "domain";
|
||||
|
||||
#define DOMAIN_DEFAULT_ROTATE_SPEED_US 1500
|
||||
#define DOMAIN_ROTATE_TIMEOUT_MS 8000
|
||||
#define DOMAIN_STATE_LOCK_TIMEOUT_MS 2000
|
||||
#define DOMAIN_DEFAULT_CALIBRATION_ENABLED 1
|
||||
#define DOMAIN_DEFAULT_CALIBRATION_ANGLE_DEG 180.0f
|
||||
#define DOMAIN_DEFAULT_CALIBRATION_STEP_ANGLE_DEG 5.0f
|
||||
#define DOMAIN_DEFAULT_CALIBRATION_MAX_SWEEP_DEG 360.0f
|
||||
#define DOMAIN_DEFAULT_CALIBRATION_SEARCH_CLOCKWISE 1
|
||||
#define DOMAIN_DEFAULT_CALIBRATION_SETTLE_DELAY_MS 10
|
||||
|
||||
static float s_current_angle = 0.0f;
|
||||
static int s_rotate_speed_us = DOMAIN_DEFAULT_ROTATE_SPEED_US;
|
||||
static bool s_initialized;
|
||||
static SemaphoreHandle_t s_state_mutex;
|
||||
static portMUX_TYPE s_state_mutex_init_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
#if ( configSUPPORT_STATIC_ALLOCATION == 1 )
|
||||
static StaticSemaphore_t s_state_mutex_buf;
|
||||
#endif
|
||||
|
||||
static const domain_direction_axis_config_t s_default_axis_config = {
|
||||
.initial_angle = 0.0f,
|
||||
.rotate_speed_us = 0
|
||||
};
|
||||
|
||||
static const domain_direction_calibration_t s_default_calibration = {
|
||||
.enabled = DOMAIN_DEFAULT_CALIBRATION_ENABLED,
|
||||
.calibration_angle = DOMAIN_DEFAULT_CALIBRATION_ANGLE_DEG,
|
||||
.step_angle_deg = DOMAIN_DEFAULT_CALIBRATION_STEP_ANGLE_DEG,
|
||||
.max_sweep_deg = DOMAIN_DEFAULT_CALIBRATION_MAX_SWEEP_DEG,
|
||||
.search_clockwise = DOMAIN_DEFAULT_CALIBRATION_SEARCH_CLOCKWISE,
|
||||
.settle_delay_ms = DOMAIN_DEFAULT_CALIBRATION_SETTLE_DELAY_MS
|
||||
};
|
||||
|
||||
static int remaining_timeout_ms(TickType_t start_tick, int timeout_ms)
|
||||
{
|
||||
int effective_timeout_ms = timeout_ms > 0 ? timeout_ms : DOMAIN_ROTATE_TIMEOUT_MS;
|
||||
TickType_t timeout_ticks = pdMS_TO_TICKS(effective_timeout_ms);
|
||||
if (timeout_ticks == 0) {
|
||||
timeout_ticks = 1;
|
||||
}
|
||||
|
||||
TickType_t elapsed_ticks = xTaskGetTickCount() - start_tick;
|
||||
if (elapsed_ticks >= timeout_ticks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
TickType_t remaining_ticks = timeout_ticks - elapsed_ticks;
|
||||
int remaining_ms = (int)(remaining_ticks * portTICK_PERIOD_MS);
|
||||
return remaining_ms > 0 ? remaining_ms : 1;
|
||||
}
|
||||
|
||||
static bool ensure_state_mutex(void)
|
||||
{
|
||||
if (s_state_mutex != NULL) {
|
||||
return true;
|
||||
}
|
||||
#if ( configSUPPORT_STATIC_ALLOCATION == 1 )
|
||||
portENTER_CRITICAL(&s_state_mutex_init_lock);
|
||||
if (s_state_mutex == NULL) {
|
||||
s_state_mutex = xSemaphoreCreateRecursiveMutexStatic(&s_state_mutex_buf);
|
||||
}
|
||||
portEXIT_CRITICAL(&s_state_mutex_init_lock);
|
||||
#else
|
||||
SemaphoreHandle_t created_mutex = xSemaphoreCreateRecursiveMutex();
|
||||
|
||||
portENTER_CRITICAL(&s_state_mutex_init_lock);
|
||||
if (s_state_mutex == NULL) {
|
||||
s_state_mutex = created_mutex;
|
||||
created_mutex = NULL;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_state_mutex_init_lock);
|
||||
|
||||
if (created_mutex != NULL) {
|
||||
vSemaphoreDelete(created_mutex);
|
||||
}
|
||||
#endif
|
||||
return (s_state_mutex != NULL);
|
||||
}
|
||||
|
||||
bool domain_direction_lock_state(int timeout_ms)
|
||||
{
|
||||
if (!ensure_state_mutex()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int effective_timeout_ms = timeout_ms > 0 ? timeout_ms : DOMAIN_STATE_LOCK_TIMEOUT_MS;
|
||||
TickType_t timeout_ticks = pdMS_TO_TICKS(effective_timeout_ms);
|
||||
if (timeout_ticks == 0) {
|
||||
timeout_ticks = 1;
|
||||
}
|
||||
return (xSemaphoreTakeRecursive(s_state_mutex, timeout_ticks) == pdTRUE);
|
||||
}
|
||||
|
||||
void domain_direction_unlock_state(void)
|
||||
{
|
||||
if (s_state_mutex != NULL) {
|
||||
(void)xSemaphoreGiveRecursive(s_state_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t domain_init(const domain_direction_axis_config_t *config,
|
||||
const domain_direction_calibration_t *calibration)
|
||||
{
|
||||
esp_err_t runtime_err = domain_runtime_platform_init();
|
||||
if (runtime_err != ESP_OK) {
|
||||
return runtime_err;
|
||||
}
|
||||
|
||||
if (!domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
if (s_initialized) {
|
||||
domain_direction_unlock_state();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
s_current_angle = 0.0f;
|
||||
s_rotate_speed_us = DOMAIN_DEFAULT_ROTATE_SPEED_US;
|
||||
|
||||
if (config != NULL) {
|
||||
s_current_angle = domain_angle_normalize(config->initial_angle);
|
||||
if (config->rotate_speed_us > 0) {
|
||||
s_rotate_speed_us = config->rotate_speed_us;
|
||||
}
|
||||
}
|
||||
|
||||
s_initialized = true;
|
||||
domain_direction_unlock_state();
|
||||
|
||||
domain_calibration_store_set(calibration);
|
||||
|
||||
ESP_LOGI(TAG, "Direction axis initialized (angle=%.1f, speed=%dus)",
|
||||
s_current_angle, s_rotate_speed_us);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
const domain_direction_axis_config_t *domain_direction_default_axis_config(void)
|
||||
{
|
||||
return &s_default_axis_config;
|
||||
}
|
||||
|
||||
const domain_direction_calibration_t *domain_direction_default_calibration(void)
|
||||
{
|
||||
return &s_default_calibration;
|
||||
}
|
||||
|
||||
bool domain_is_initialized(void)
|
||||
{
|
||||
bool initialized = false;
|
||||
if (domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
initialized = s_initialized;
|
||||
domain_direction_unlock_state();
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
|
||||
size_t domain_get_direction_count(void)
|
||||
{
|
||||
return domain_direction_catalog_count();
|
||||
}
|
||||
|
||||
bool domain_get_direction_view(size_t index,
|
||||
const char **out_name_cn,
|
||||
const char **out_name_en,
|
||||
float *out_angle)
|
||||
{
|
||||
const domain_direction_entry_t *entry = domain_direction_catalog_get(index);
|
||||
if (entry == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (out_name_cn != NULL) {
|
||||
*out_name_cn = entry->name_cn;
|
||||
}
|
||||
if (out_name_en != NULL) {
|
||||
*out_name_en = entry->name_en;
|
||||
}
|
||||
if (out_angle != NULL) {
|
||||
*out_angle = entry->angle;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool domain_find_direction_by_name(const char *input, size_t *out_index)
|
||||
{
|
||||
return domain_direction_catalog_find_by_name(input, out_index);
|
||||
}
|
||||
|
||||
bool domain_get_current_direction_index(size_t *out_index)
|
||||
{
|
||||
if (!domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
return false;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
domain_direction_unlock_state();
|
||||
return false;
|
||||
}
|
||||
float angle = s_current_angle;
|
||||
domain_direction_unlock_state();
|
||||
return domain_direction_catalog_find_by_angle(angle, out_index);
|
||||
}
|
||||
|
||||
float domain_get_current_angle(void)
|
||||
{
|
||||
if (!domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
return 0.0f;
|
||||
}
|
||||
float angle = s_current_angle;
|
||||
domain_direction_unlock_state();
|
||||
return angle;
|
||||
}
|
||||
|
||||
void domain_set_current_angle(float angle)
|
||||
{
|
||||
if (!domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
return;
|
||||
}
|
||||
s_current_angle = domain_angle_normalize(angle);
|
||||
domain_direction_unlock_state();
|
||||
}
|
||||
|
||||
int domain_get_rotate_speed_us(void)
|
||||
{
|
||||
if (!domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
return 0;
|
||||
}
|
||||
int speed = s_rotate_speed_us;
|
||||
domain_direction_unlock_state();
|
||||
return speed;
|
||||
}
|
||||
|
||||
esp_err_t domain_rotate_to_angle_with_timeout(float target_angle, int timeout_ms)
|
||||
{
|
||||
TickType_t start_tick = xTaskGetTickCount();
|
||||
int effective_timeout_ms = timeout_ms > 0 ? timeout_ms : DOMAIN_ROTATE_TIMEOUT_MS;
|
||||
|
||||
if (!domain_direction_lock_state(effective_timeout_ms)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
domain_direction_unlock_state();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
float normalized_target = domain_angle_normalize(target_angle);
|
||||
float rotation = domain_angle_shortest_rotation(s_current_angle, normalized_target);
|
||||
if (rotation == 0.0f) {
|
||||
domain_direction_unlock_state();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
int motor_timeout_ms = remaining_timeout_ms(start_tick, effective_timeout_ms);
|
||||
if (motor_timeout_ms <= 0) {
|
||||
domain_direction_unlock_state();
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t motor_err = domain_runtime_stepper_rotate_angle_with_accel_timeout(
|
||||
rotation,
|
||||
s_rotate_speed_us,
|
||||
motor_timeout_ms);
|
||||
if (motor_err != ESP_OK) {
|
||||
domain_direction_unlock_state();
|
||||
return motor_err;
|
||||
}
|
||||
|
||||
s_current_angle = domain_angle_normalize(s_current_angle + rotation);
|
||||
domain_direction_unlock_state();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t domain_apply_rotation_delta(float delta)
|
||||
{
|
||||
if (!domain_direction_lock_state(DOMAIN_STATE_LOCK_TIMEOUT_MS)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
domain_direction_unlock_state();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (delta == 0.0f) {
|
||||
domain_direction_unlock_state();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
s_current_angle = domain_angle_normalize(s_current_angle + delta);
|
||||
domain_direction_unlock_state();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void domain_set_calibration(const domain_direction_calibration_t *config)
|
||||
{
|
||||
domain_calibration_store_set(config);
|
||||
}
|
||||
|
||||
bool domain_get_calibration(domain_direction_calibration_t *out_config)
|
||||
{
|
||||
return domain_calibration_store_get(out_config);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
#include "platform.h"
|
||||
|
||||
static const char *TAG = "domain_fortune_store";
|
||||
|
||||
/* NVS namespace + keys (<=15 chars). The platform layer owns the raw storage;
|
||||
* the domain serializes its typed records as opaque struct blobs. */
|
||||
#define DOMAIN_FORTUNE_NVS_NAMESPACE "fortune"
|
||||
#define DOMAIN_FORTUNE_KEY_PROFILE "profile"
|
||||
#define DOMAIN_FORTUNE_KEY_RESULT "result"
|
||||
|
||||
static domain_birth_profile_observer_t s_birth_observer;
|
||||
static void *s_birth_observer_user_data;
|
||||
static domain_birth_profile_t s_birth_profile;
|
||||
static bool s_birth_profile_loaded;
|
||||
static bool s_birth_profile_valid;
|
||||
static portMUX_TYPE s_birth_profile_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
static domain_fortune_result_t s_fortune_result;
|
||||
static bool s_fortune_result_loaded;
|
||||
static bool s_fortune_result_valid;
|
||||
static portMUX_TYPE s_fortune_result_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
static bool domain_birth_profile_is_valid(const domain_birth_profile_t *profile)
|
||||
{
|
||||
return profile != NULL &&
|
||||
profile->valid &&
|
||||
profile->year > 0 &&
|
||||
profile->month >= 1 && profile->month <= 12 &&
|
||||
profile->day >= 1 && profile->day <= 31 &&
|
||||
profile->hour >= 0 && profile->hour <= 23 &&
|
||||
profile->minute >= 0 && profile->minute <= 59 &&
|
||||
profile->birth_place[0] != '\0';
|
||||
}
|
||||
|
||||
static bool domain_fortune_result_is_valid(const domain_fortune_result_t *result)
|
||||
{
|
||||
return result != NULL &&
|
||||
result->valid &&
|
||||
result->bazi[0] != '\0' &&
|
||||
result->element[0] != '\0' &&
|
||||
result->lucky_color[0] != '\0' &&
|
||||
result->wealth_direction[0] != '\0' &&
|
||||
result->target_date[0] != '\0' &&
|
||||
result->wealth_direction_index < domain_get_direction_count();
|
||||
}
|
||||
|
||||
void domain_runtime_register_birth_profile_observer(domain_birth_profile_observer_t cb, void *user_data)
|
||||
{
|
||||
portENTER_CRITICAL(&s_birth_profile_lock);
|
||||
s_birth_observer = cb;
|
||||
s_birth_observer_user_data = user_data;
|
||||
portEXIT_CRITICAL(&s_birth_profile_lock);
|
||||
}
|
||||
|
||||
esp_err_t domain_birth_profile_store_set(const domain_birth_profile_t *profile)
|
||||
{
|
||||
if (!domain_birth_profile_is_valid(profile)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
domain_birth_profile_t snapshot = *profile;
|
||||
snapshot.birth_place[sizeof(snapshot.birth_place) - 1U] = '\0';
|
||||
snapshot.current_residence[sizeof(snapshot.current_residence) - 1U] = '\0';
|
||||
snapshot.mbti[sizeof(snapshot.mbti) - 1U] = '\0';
|
||||
snapshot.timezone[sizeof(snapshot.timezone) - 1U] = '\0';
|
||||
|
||||
esp_err_t err = platform_storage_set_blob(DOMAIN_FORTUNE_NVS_NAMESPACE,
|
||||
DOMAIN_FORTUNE_KEY_PROFILE,
|
||||
&snapshot,
|
||||
sizeof(snapshot));
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
domain_birth_profile_observer_t cb = NULL;
|
||||
void *user_data = NULL;
|
||||
portENTER_CRITICAL(&s_birth_profile_lock);
|
||||
s_birth_profile = snapshot;
|
||||
s_birth_profile_loaded = true;
|
||||
s_birth_profile_valid = true;
|
||||
cb = s_birth_observer;
|
||||
user_data = s_birth_observer_user_data;
|
||||
portEXIT_CRITICAL(&s_birth_profile_lock);
|
||||
|
||||
if (cb != NULL) {
|
||||
cb(&snapshot, user_data);
|
||||
}
|
||||
ESP_LOGI(TAG, "Birth profile stored (%04d-%02d-%02d, %s)",
|
||||
snapshot.year, snapshot.month, snapshot.day, snapshot.birth_place);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool domain_birth_profile_store_get(domain_birth_profile_t *out_profile)
|
||||
{
|
||||
if (out_profile == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
portENTER_CRITICAL(&s_birth_profile_lock);
|
||||
bool loaded = s_birth_profile_loaded;
|
||||
bool valid = s_birth_profile_valid;
|
||||
if (loaded && valid) {
|
||||
*out_profile = s_birth_profile;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_birth_profile_lock);
|
||||
if (loaded) {
|
||||
return valid;
|
||||
}
|
||||
|
||||
domain_birth_profile_t persisted = {0};
|
||||
size_t actual = 0U;
|
||||
esp_err_t err = platform_storage_get_blob(DOMAIN_FORTUNE_NVS_NAMESPACE,
|
||||
DOMAIN_FORTUNE_KEY_PROFILE,
|
||||
&persisted,
|
||||
sizeof(persisted),
|
||||
&actual);
|
||||
bool persisted_valid = err == ESP_OK &&
|
||||
actual == sizeof(persisted) &&
|
||||
domain_birth_profile_is_valid(&persisted);
|
||||
|
||||
portENTER_CRITICAL(&s_birth_profile_lock);
|
||||
if (!s_birth_profile_loaded) {
|
||||
s_birth_profile_loaded = true;
|
||||
s_birth_profile_valid = persisted_valid;
|
||||
if (persisted_valid) {
|
||||
s_birth_profile = persisted;
|
||||
}
|
||||
}
|
||||
valid = s_birth_profile_valid;
|
||||
if (valid) {
|
||||
*out_profile = s_birth_profile;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_birth_profile_lock);
|
||||
return valid;
|
||||
}
|
||||
|
||||
esp_err_t domain_fortune_result_store_set(const domain_fortune_result_t *result)
|
||||
{
|
||||
if (!domain_fortune_result_is_valid(result)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
domain_fortune_result_t snapshot = *result;
|
||||
snapshot.bazi[sizeof(snapshot.bazi) - 1U] = '\0';
|
||||
snapshot.element[sizeof(snapshot.element) - 1U] = '\0';
|
||||
snapshot.lucky_color[sizeof(snapshot.lucky_color) - 1U] = '\0';
|
||||
snapshot.wealth_direction[sizeof(snapshot.wealth_direction) - 1U] = '\0';
|
||||
snapshot.target_date[sizeof(snapshot.target_date) - 1U] = '\0';
|
||||
|
||||
esp_err_t err = platform_storage_set_blob(DOMAIN_FORTUNE_NVS_NAMESPACE,
|
||||
DOMAIN_FORTUNE_KEY_RESULT,
|
||||
&snapshot,
|
||||
sizeof(snapshot));
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
portENTER_CRITICAL(&s_fortune_result_lock);
|
||||
s_fortune_result = snapshot;
|
||||
s_fortune_result_loaded = true;
|
||||
s_fortune_result_valid = true;
|
||||
portEXIT_CRITICAL(&s_fortune_result_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool domain_fortune_result_store_get(domain_fortune_result_t *out_result)
|
||||
{
|
||||
if (out_result == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
portENTER_CRITICAL(&s_fortune_result_lock);
|
||||
bool loaded = s_fortune_result_loaded;
|
||||
bool valid = s_fortune_result_valid;
|
||||
if (loaded && valid) {
|
||||
*out_result = s_fortune_result;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_fortune_result_lock);
|
||||
if (loaded) {
|
||||
return valid;
|
||||
}
|
||||
|
||||
domain_fortune_result_t persisted = {0};
|
||||
size_t actual = 0U;
|
||||
esp_err_t err = platform_storage_get_blob(DOMAIN_FORTUNE_NVS_NAMESPACE,
|
||||
DOMAIN_FORTUNE_KEY_RESULT,
|
||||
&persisted,
|
||||
sizeof(persisted),
|
||||
&actual);
|
||||
bool persisted_valid = err == ESP_OK &&
|
||||
actual == sizeof(persisted) &&
|
||||
domain_fortune_result_is_valid(&persisted);
|
||||
|
||||
portENTER_CRITICAL(&s_fortune_result_lock);
|
||||
if (!s_fortune_result_loaded) {
|
||||
s_fortune_result_loaded = true;
|
||||
s_fortune_result_valid = persisted_valid;
|
||||
if (persisted_valid) {
|
||||
s_fortune_result = persisted;
|
||||
}
|
||||
}
|
||||
valid = s_fortune_result_valid;
|
||||
if (valid) {
|
||||
*out_result = s_fortune_result;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_fortune_result_lock);
|
||||
return valid;
|
||||
}
|
||||
|
||||
esp_err_t domain_fortune_result_store_clear(void)
|
||||
{
|
||||
esp_err_t err = platform_storage_erase(DOMAIN_FORTUNE_NVS_NAMESPACE,
|
||||
DOMAIN_FORTUNE_KEY_RESULT);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
portENTER_CRITICAL(&s_fortune_result_lock);
|
||||
memset(&s_fortune_result, 0, sizeof(s_fortune_result));
|
||||
s_fortune_result_loaded = true;
|
||||
s_fortune_result_valid = false;
|
||||
portEXIT_CRITICAL(&s_fortune_result_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "domain.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include "platform.h"
|
||||
|
||||
static const char *TAG = "domain_user_web";
|
||||
static const char *kFortuneTimezone = "Asia/Shanghai";
|
||||
|
||||
extern const uint8_t user_config_html_start[]
|
||||
asm("_binary_user_config_html_start");
|
||||
extern const uint8_t user_config_html_end[]
|
||||
asm("_binary_user_config_html_end");
|
||||
|
||||
static bool is_leap_year(int year)
|
||||
{
|
||||
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
||||
}
|
||||
|
||||
static bool is_valid_date(int year, int month, int day)
|
||||
{
|
||||
static const uint8_t days_per_month[] = {
|
||||
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||||
};
|
||||
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int max_day = days_per_month[month - 1];
|
||||
if (month == 2 && is_leap_year(year)) {
|
||||
max_day = 29;
|
||||
}
|
||||
return day <= max_day;
|
||||
}
|
||||
|
||||
static void trim_ascii_whitespace(char *value)
|
||||
{
|
||||
if (value == NULL || value[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
char *start = value;
|
||||
while (*start != '\0' && isspace((unsigned char)*start)) {
|
||||
start++;
|
||||
}
|
||||
if (start != value) {
|
||||
memmove(value, start, strlen(start) + 1U);
|
||||
}
|
||||
|
||||
size_t len = strlen(value);
|
||||
while (len > 0U && isspace((unsigned char)value[len - 1U])) {
|
||||
value[--len] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static bool json_get_required_int(const cJSON *root, const char *key, int *out_value)
|
||||
{
|
||||
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||||
if (!cJSON_IsNumber(item)) {
|
||||
return false;
|
||||
}
|
||||
*out_value = item->valueint;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool json_copy_string(const cJSON *root,
|
||||
const char *key,
|
||||
char *out,
|
||||
size_t out_len,
|
||||
bool required)
|
||||
{
|
||||
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||||
if (item == NULL && !required) {
|
||||
out[0] = '\0';
|
||||
return true;
|
||||
}
|
||||
if (!cJSON_IsString(item) || item->valuestring == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t len = strlen(item->valuestring);
|
||||
if (len >= out_len) {
|
||||
return false;
|
||||
}
|
||||
memcpy(out, item->valuestring, len + 1U);
|
||||
trim_ascii_whitespace(out);
|
||||
return !required || out[0] != '\0';
|
||||
}
|
||||
|
||||
static esp_err_t response_copy(platform_lan_http_response_t *response,
|
||||
platform_lan_http_status_t status,
|
||||
platform_lan_http_content_type_t content_type,
|
||||
const uint8_t *body,
|
||||
size_t body_len)
|
||||
{
|
||||
if (body_len > response->body_capacity) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
if (body_len > 0U) {
|
||||
memcpy(response->body, body, body_len);
|
||||
}
|
||||
response->status = status;
|
||||
response->content_type = content_type;
|
||||
response->body_len = body_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t response_json_object(platform_lan_http_response_t *response,
|
||||
platform_lan_http_status_t status,
|
||||
cJSON *root)
|
||||
{
|
||||
if (response == NULL ||
|
||||
response->body == NULL ||
|
||||
response->body_capacity == 0U ||
|
||||
response->body_capacity > (size_t)INT_MAX) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
if (!cJSON_PrintPreallocated(root,
|
||||
(char *)response->body,
|
||||
(int)response->body_capacity,
|
||||
false)) {
|
||||
response->body_len = 0U;
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
response->status = status;
|
||||
response->content_type = PLATFORM_LAN_HTTP_CONTENT_JSON;
|
||||
response->body_len = strlen((const char *)response->body);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t response_error(platform_lan_http_response_t *response,
|
||||
platform_lan_http_status_t status,
|
||||
const char *message)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (root == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
cJSON_AddBoolToObject(root, "success", false);
|
||||
cJSON_AddStringToObject(root, "error", message);
|
||||
esp_err_t err = response_json_object(response, status, root);
|
||||
cJSON_Delete(root);
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t handle_index(platform_lan_http_response_t *response)
|
||||
{
|
||||
size_t html_len = (size_t)(user_config_html_end - user_config_html_start);
|
||||
return response_copy(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_OK,
|
||||
PLATFORM_LAN_HTTP_CONTENT_HTML,
|
||||
user_config_html_start,
|
||||
html_len);
|
||||
}
|
||||
|
||||
static esp_err_t handle_get_profile(platform_lan_http_response_t *response)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (root == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
domain_birth_profile_t profile = {0};
|
||||
bool configured = domain_birth_profile_store_get(&profile);
|
||||
cJSON_AddBoolToObject(root, "configured", configured);
|
||||
if (configured) {
|
||||
cJSON_AddNumberToObject(root, "birthYear", profile.year);
|
||||
cJSON_AddNumberToObject(root, "birthMonth", profile.month);
|
||||
cJSON_AddNumberToObject(root, "birthDay", profile.day);
|
||||
cJSON_AddNumberToObject(root, "birthHour", profile.hour);
|
||||
cJSON_AddNumberToObject(root, "birthMinute", profile.minute);
|
||||
cJSON_AddStringToObject(root, "birthPlace", profile.birth_place);
|
||||
cJSON_AddStringToObject(root, "currentResidence", profile.current_residence);
|
||||
cJSON_AddStringToObject(root, "mbti", profile.mbti);
|
||||
cJSON_AddStringToObject(root, "timezone", profile.timezone);
|
||||
}
|
||||
|
||||
esp_err_t err = response_json_object(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_OK,
|
||||
root);
|
||||
cJSON_Delete(root);
|
||||
return err;
|
||||
}
|
||||
|
||||
static bool parse_profile(const uint8_t *body,
|
||||
size_t body_len,
|
||||
domain_birth_profile_t *out_profile,
|
||||
const char **out_error)
|
||||
{
|
||||
if (body == NULL || body_len == 0U) {
|
||||
*out_error = "Request body is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_ParseWithLength((const char *)body, body_len);
|
||||
if (root == NULL) {
|
||||
*out_error = "Invalid JSON";
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(out_profile, 0, sizeof(*out_profile));
|
||||
bool valid = json_get_required_int(root, "birthYear", &out_profile->year) &&
|
||||
json_get_required_int(root, "birthMonth", &out_profile->month) &&
|
||||
json_get_required_int(root, "birthDay", &out_profile->day) &&
|
||||
json_get_required_int(root, "birthHour", &out_profile->hour) &&
|
||||
json_get_required_int(root, "birthMinute", &out_profile->minute) &&
|
||||
json_copy_string(root,
|
||||
"birthPlace",
|
||||
out_profile->birth_place,
|
||||
sizeof(out_profile->birth_place),
|
||||
true) &&
|
||||
json_copy_string(root,
|
||||
"currentResidence",
|
||||
out_profile->current_residence,
|
||||
sizeof(out_profile->current_residence),
|
||||
false) &&
|
||||
json_copy_string(root,
|
||||
"mbti",
|
||||
out_profile->mbti,
|
||||
sizeof(out_profile->mbti),
|
||||
false);
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (!valid) {
|
||||
*out_error = "Missing or oversized profile field";
|
||||
return false;
|
||||
}
|
||||
if (!is_valid_date(out_profile->year, out_profile->month, out_profile->day)) {
|
||||
*out_error = "Invalid birth date";
|
||||
return false;
|
||||
}
|
||||
if (out_profile->hour < 0 || out_profile->hour > 23 ||
|
||||
out_profile->minute < 0 || out_profile->minute > 59) {
|
||||
*out_error = "Invalid birth time";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (char *cursor = out_profile->mbti; *cursor != '\0'; ++cursor) {
|
||||
*cursor = (char)toupper((unsigned char)*cursor);
|
||||
}
|
||||
strlcpy(out_profile->timezone, kFortuneTimezone, sizeof(out_profile->timezone));
|
||||
out_profile->valid = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static esp_err_t handle_post_profile(const platform_lan_http_request_t *request,
|
||||
platform_lan_http_response_t *response)
|
||||
{
|
||||
domain_birth_profile_t profile = {0};
|
||||
const char *error_message = NULL;
|
||||
if (!parse_profile(request->body,
|
||||
request->body_len,
|
||||
&profile,
|
||||
&error_message)) {
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_BAD_REQUEST,
|
||||
error_message);
|
||||
}
|
||||
|
||||
esp_err_t err = domain_birth_profile_store_set(&profile);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Persist birth profile failed: %s", esp_err_to_name(err));
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_INTERNAL_ERROR,
|
||||
"Failed to persist profile");
|
||||
}
|
||||
|
||||
err = domain_fortune_result_store_clear();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Invalidate previous fortune failed: %s", esp_err_to_name(err));
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_INTERNAL_ERROR,
|
||||
"Profile saved but previous result could not be invalidated");
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (root == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
cJSON_AddBoolToObject(root, "success", true);
|
||||
cJSON_AddStringToObject(root, "status", "pending");
|
||||
err = response_json_object(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_ACCEPTED,
|
||||
root);
|
||||
cJSON_Delete(root);
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t handle_get_fortune(platform_lan_http_response_t *response)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (root == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
domain_birth_profile_t profile = {0};
|
||||
domain_fortune_result_t result = {0};
|
||||
if (!domain_birth_profile_store_get(&profile)) {
|
||||
cJSON_AddStringToObject(root, "status", "not_configured");
|
||||
} else if (!domain_fortune_result_store_get(&result)) {
|
||||
cJSON_AddStringToObject(root, "status", "pending");
|
||||
} else {
|
||||
cJSON_AddStringToObject(root, "status", "ready");
|
||||
cJSON_AddStringToObject(root, "bazi", result.bazi);
|
||||
cJSON_AddStringToObject(root, "element", result.element);
|
||||
cJSON_AddStringToObject(root, "luckyColor", result.lucky_color);
|
||||
cJSON_AddStringToObject(root, "wealthDirection", result.wealth_direction);
|
||||
cJSON_AddStringToObject(root, "targetDate", result.target_date);
|
||||
}
|
||||
|
||||
esp_err_t err = response_json_object(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_OK,
|
||||
root);
|
||||
cJSON_Delete(root);
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t user_config_http_handler(
|
||||
const platform_lan_http_request_t *request,
|
||||
platform_lan_http_response_t *response,
|
||||
void *user_data)
|
||||
{
|
||||
(void)user_data;
|
||||
|
||||
if (strcmp(request->path, "/") == 0) {
|
||||
if (request->method != PLATFORM_LAN_HTTP_METHOD_GET) {
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_METHOD_NOT_ALLOWED,
|
||||
"Method not allowed");
|
||||
}
|
||||
return handle_index(response);
|
||||
}
|
||||
if (strcmp(request->path, "/api/profile") == 0) {
|
||||
if (request->method == PLATFORM_LAN_HTTP_METHOD_GET) {
|
||||
return handle_get_profile(response);
|
||||
}
|
||||
if (request->method == PLATFORM_LAN_HTTP_METHOD_POST) {
|
||||
return handle_post_profile(request, response);
|
||||
}
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_METHOD_NOT_ALLOWED,
|
||||
"Method not allowed");
|
||||
}
|
||||
if (strcmp(request->path, "/api/fortune") == 0) {
|
||||
if (request->method != PLATFORM_LAN_HTTP_METHOD_GET) {
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_METHOD_NOT_ALLOWED,
|
||||
"Method not allowed");
|
||||
}
|
||||
return handle_get_fortune(response);
|
||||
}
|
||||
|
||||
return response_error(response,
|
||||
PLATFORM_LAN_HTTP_STATUS_NOT_FOUND,
|
||||
"Route not found");
|
||||
}
|
||||
|
||||
esp_err_t domain_user_config_server_start(
|
||||
const domain_user_config_server_config_t *config)
|
||||
{
|
||||
if (config == NULL ||
|
||||
config->port == 0U ||
|
||||
config->hostname[0] == '\0' ||
|
||||
strnlen(config->hostname, sizeof(config->hostname)) >= sizeof(config->hostname)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
platform_lan_http_server_config_t platform_config = {
|
||||
.port = config->port,
|
||||
.handler = user_config_http_handler,
|
||||
.user_data = NULL,
|
||||
};
|
||||
strlcpy(platform_config.hostname,
|
||||
config->hostname,
|
||||
sizeof(platform_config.hostname));
|
||||
return platform_lan_http_server_start(&platform_config);
|
||||
}
|
||||
|
||||
esp_err_t domain_user_config_server_stop(int timeout_ms)
|
||||
{
|
||||
return platform_lan_http_server_stop(timeout_ms);
|
||||
}
|
||||
|
||||
esp_err_t domain_user_config_server_get_state(
|
||||
domain_user_config_server_state_t *out_state)
|
||||
{
|
||||
if (out_state == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
platform_lan_http_server_state_t platform_state = {0};
|
||||
esp_err_t err = platform_lan_http_server_get_state(&platform_state);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
memset(out_state, 0, sizeof(*out_state));
|
||||
out_state->running = platform_state.running;
|
||||
out_state->port = platform_state.port;
|
||||
strlcpy(out_state->hostname,
|
||||
platform_state.hostname,
|
||||
sizeof(out_state->hostname));
|
||||
return ESP_OK;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user