Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,44 @@ ctest --test-dir build-host-tests --output-on-failure
```bash
idf.py flash
```

## Reefwatch camera view (adampog fork)

Shows frames from [reefwatch](https://github.com/adampog/reefwatch) (ESP32-CAM + Coral object detection)
full screen on the robot, over any app and in AI agent mode.

- **Detections** show for 8 s with boxes and labels; LEDs go amber and, if configured, the head turns toward that camera.
- **Live view**: tap a detection to keep watching that camera, tap again to stop. In AI agent mode you can ask
for it by voice (`self.cameras.show_live` / `self.cameras.stop_live` MCP tools). Switches off after 5 min.

Set a token (and optionally head angles) in `sdkconfig.defaults.local`:

```
CONFIG_STACKCHAN_REEFWATCH_TOKEN="change-me"
CONFIG_STACKCHAN_REEFWATCH_CAMERA_YAW="front=-45,garden=45"
```

and point reefwatch at the robot with `REEFWATCH_STACKCHAN_URL=http://stackchan.local` (or its IP) and the
same `REEFWATCH_STACKCHAN_TOKEN`. The robot logs its address on boot (`listening on http://...`).
Protocol: `POST /frame` (JPEG ≤ 320×240, headers `X-Reefwatch-Token`, `X-Camera`, `X-Kind`, `X-Labels`)
and `GET /live` → `{"camera": "front" | null, "fps": 5}`. Code: `main/hal/hal_reefwatch.cpp`,
`main/apps/common/reefwatch_view/`.

## LAN MCP server (adampog fork)

The robot's own MCP tools (`main/hal/hal_mcp.cpp`) are also served to agents on the LAN, such as
[ESP-Claw](https://github.com/adampog/ESP-Claw) running on another ESP32-S3: JSON-RPC over
`POST http://stackchan.local/mcp_server` (`initialize`, `ping`, `tools/list` with cursor paging,
`tools/call`), advertised over mDNS as `_mcp._tcp` with TXT `endpoint=mcp_server`, which is what
ESP-Claw's `mcp_client` discovers. Tools: head angles, LED color, reminders, live camera view, and
`self.robot.set_expression` / `self.robot.show_message` for agents that can't drive the voice
pipeline's emotions. The same tool objects are registered with xiaozhi, so the voice agent sees them too.

```bash
curl -s http://stackchan.local/mcp_server -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s http://stackchan.local/mcp_server -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"self.robot.set_expression","arguments":{"expression":"happy"}}}'
```

There is no authentication: anything on the LAN can move the head. Turn it off with
`CONFIG_STACKCHAN_LAN_MCP_ENABLE=n`; port, hostname and endpoint are in the "LAN Services" Kconfig menu.
Code: `main/hal/hal_lan_server.cpp`, `main/hal/hal_lan_mcp.cpp`.
13 changes: 12 additions & 1 deletion firmware/dependencies.lock
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,16 @@ dependencies:
registry_url: https://components.espressif.com/
type: service
version: 3.0.3
espressif/mdns:
component_hash: b679eafd0acae2066e2645bd91d073e33ca5be515e2545cd3c4e55a3e3bce3cb
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.13.1
espressif/usb_host_uvc:
component_hash: b0cb9a3a2eaa09dc6e5cc3e47e04d0a73e47c2d3dcfb3afce959224a1fe5fb5e
dependencies:
Expand Down Expand Up @@ -901,6 +911,7 @@ direct_dependencies:
- espressif/iot_usbh_rndis
- espressif/knob
- espressif/led_strip
- espressif/mdns
- espressif2022/image_player
- idf
- lvgl/lvgl
Expand All @@ -910,6 +921,6 @@ direct_dependencies:
- waveshare/esp_lcd_sh8601
- waveshare/esp_lcd_touch_cst9217
- wvirgil123/sscma_client
manifest_hash: 350e17991f1a3226438480b027525b7cde67c34ed4ec6c5847cdfcb22e18dc4a
manifest_hash: 0acfddd452c73d46c5426d1c4a41c483ac5f876c18cbc69ca5b7fc4da0233cf8
target: esp32s3
version: 2.0.0
1 change: 1 addition & 0 deletions firmware/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ idf_component_register(SRCS ${SOURCES}
esp_pm
esp_psram
esp_netif
esp_http_server
esp_driver_gpio
esp_driver_uart
esp_driver_spi
Expand Down
73 changes: 73 additions & 0 deletions firmware/main/Kconfig.projbuild
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,79 @@ config STACKCHAN_SERVER_URL

endmenu

menu "LAN Services"

config STACKCHAN_LAN_SERVER_PORT
int "HTTP port"
default 80
help
One small HTTP server on the local network hosts the services below.

config STACKCHAN_LAN_HOSTNAME
string "mDNS hostname"
default "stackchan"
help
The robot answers as <hostname>.local on the LAN.

config STACKCHAN_LAN_MCP_ENABLE
bool "MCP server for LAN agents (e.g. ESP-Claw)"
default y
help
Exposes the robot's own MCP tools (head, LEDs, reminders, camera view)
as JSON-RPC over HTTP POST, and advertises them via mDNS as _mcp._tcp
so ESP-Claw's mcp_client can discover them. There is no
authentication: anything on the LAN can move the head.

config STACKCHAN_LAN_MCP_ENDPOINT
string "MCP endpoint path"
default "mcp_server"
depends on STACKCHAN_LAN_MCP_ENABLE
help
Served at http://<robot>/<endpoint>. "mcp_server" is ESP-Claw's default.

endmenu

menu "Reefwatch Camera View"

config STACKCHAN_REEFWATCH_ENABLE
bool "Show reefwatch camera frames on screen"
default y
help
Accepts JPEG frames from the reefwatch detector
(github.com/adampog/reefwatch) on the LAN server and shows them
full screen. POST /frame takes the JPEG, GET /live tells reefwatch
which camera to stream live.

config STACKCHAN_REEFWATCH_TOKEN
string "Shared token (X-Reefwatch-Token header)"
default ""
depends on STACKCHAN_REEFWATCH_ENABLE
help
Must match REEFWATCH_STACKCHAN_TOKEN on the reefwatch side. Empty
accepts any request on the LAN. Set it in sdkconfig.defaults.local.

config STACKCHAN_REEFWATCH_SHOW_MS
int "How long a detection stays on screen (ms)"
default 8000
depends on STACKCHAN_REEFWATCH_ENABLE

config STACKCHAN_REEFWATCH_LIVE_FPS
int "Live view frame rate requested from reefwatch"
default 5
range 1 15
depends on STACKCHAN_REEFWATCH_ENABLE

config STACKCHAN_REEFWATCH_CAMERA_YAW
string "Head yaw per camera, in degrees"
default ""
depends on STACKCHAN_REEFWATCH_ENABLE
help
Turns the head toward a camera when it sees something, e.g.
"front=-45,garden=45" (negative is the robot's left). Empty
keeps the head still.

endmenu

menu "Xiaozhi Assistant"

config OTA_URL
Expand Down
174 changes: 174 additions & 0 deletions firmware/main/apps/common/reefwatch_view/reefwatch_view.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* SPDX-License-Identifier: MIT
*/
#include "reefwatch_view.h"
#include <hal/hal.h>
#include <stackchan/stackchan.h>
#include <mooncake_log.h>
#include <sdkconfig.h>
#include <lvgl.h>
#include <map>
#include <memory>
#include <sstream>
#include <string>

#if CONFIG_STACKCHAN_REEFWATCH_ENABLE

static const std::string_view _tag = "reefwatch-view";

// Live frames keep the view open; if they stop arriving, close after this long
static constexpr uint32_t _live_idle_ms = 3000;

static lv_obj_t* _panel = nullptr;
static lv_obj_t* _image = nullptr;
static lv_obj_t* _caption = nullptr;
static lv_timer_t* _timer = nullptr;
static std::shared_ptr<LvglImage> _frame; // keeps the pixels alive while LVGL draws them
static std::string _camera;
static bool _detection_on_screen = false; // blocks live frames until it times out or is tapped
static bool _reacted = false; // LEDs changed, put them back on hide
static bool _head_turned = false;
static std::string _reacted_camera;
static std::map<std::string, int> _camera_yaw;

// "front=-45,garden=45" -> {front: -45, garden: 45}
static void parse_camera_yaw()
{
std::istringstream list(CONFIG_STACKCHAN_REEFWATCH_CAMERA_YAW);
std::string item;
while (std::getline(list, item, ',')) {
auto eq = item.find('=');
if (eq != std::string::npos) {
_camera_yaw[item.substr(0, eq)] = std::atoi(item.c_str() + eq + 1);
}
}
}

static void hide()
{
lv_obj_add_flag(_panel, LV_OBJ_FLAG_HIDDEN);
lv_timer_pause(_timer);
lv_image_set_src(_image, nullptr);
_frame.reset();

_detection_on_screen = false;
if (_reacted) {
_reacted = false;
GetStackChan().leftNeonLight().setColor(0, 0, 0);
GetStackChan().rightNeonLight().setColor(0, 0, 0);
}
if (_head_turned) {
_head_turned = false;
GetStackChan().motion().yawServo().moveWithSpeed(0, 300);
}
}

static void on_timer(lv_timer_t* timer)
{
hide();
}

static void on_click(lv_event_t* e)
{
// Tap a detection to keep watching that camera, tap the live view to stop it
if (GetHAL().getReefwatchLiveCamera().empty() && !_camera.empty()) {
GetHAL().setReefwatchLiveCamera(_camera);
_detection_on_screen = false;
lv_label_set_text_fmt(_caption, "%s - starting live view...", _camera.c_str());
lv_timer_set_period(_timer, _live_idle_ms + 2000); // reefwatch polls every 2 s
lv_timer_reset(_timer);
} else {
GetHAL().setReefwatchLiveCamera("");
hide();
}
}

static void react_to_detection(const std::string& camera)
{
// Amber LEDs, within the 0-168 range hal_mcp treats as safe
GetStackChan().leftNeonLight().setColor(168, 90, 0);
GetStackChan().rightNeonLight().setColor(168, 90, 0);

auto yaw = _camera_yaw.find(camera);
if (yaw != _camera_yaw.end()) {
GetStackChan().motion().yawServo().moveWithSpeed(yaw->second * 10, 300);
_head_turned = true;
}
_reacted = true;
_reacted_camera = camera;
}

static void show(const ReefwatchFrame_t& frame)
{
// A live frame never replaces a detection that is still on screen
if (frame.isLive && _detection_on_screen) {
return;
}

_frame = frame.image;
_camera = frame.camera;
lv_image_set_src(_image, _frame->image_dsc());

if (frame.isLive) {
lv_label_set_text_fmt(_caption, "%s - LIVE", frame.camera.c_str());
lv_timer_set_period(_timer, _live_idle_ms);
} else {
lv_label_set_text_fmt(_caption, "%s - %s", frame.camera.c_str(), frame.labels.c_str());
lv_timer_set_period(_timer, CONFIG_STACKCHAN_REEFWATCH_SHOW_MS);
if (!_reacted || frame.camera != _reacted_camera) {
react_to_detection(frame.camera);
}
_detection_on_screen = true;
}

lv_obj_remove_flag(_panel, LV_OBJ_FLAG_HIDDEN);
lv_timer_reset(_timer);
lv_timer_resume(_timer);
}

void view::reefwatch_view_init()
{
parse_camera_yaw();

LvglLockGuard lock;

_panel = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(_panel);
lv_obj_set_size(_panel, 320, 240);
lv_obj_set_style_bg_color(_panel, lv_color_black(), 0);
lv_obj_set_style_bg_opa(_panel, LV_OPA_COVER, 0);
lv_obj_remove_flag(_panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(_panel, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(_panel, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_event_cb(_panel, on_click, LV_EVENT_CLICKED, nullptr);

_image = lv_image_create(_panel);
lv_obj_center(_image);

_caption = lv_label_create(_panel);
lv_obj_set_width(_caption, 320);
lv_obj_align(_caption, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_bg_color(_caption, lv_color_black(), 0);
lv_obj_set_style_bg_opa(_caption, LV_OPA_60, 0);
lv_obj_set_style_text_color(_caption, lv_color_white(), 0);
lv_obj_set_style_pad_all(_caption, 4, 0);
lv_label_set_long_mode(_caption, LV_LABEL_LONG_DOT);

_timer = lv_timer_create(on_timer, CONFIG_STACKCHAN_REEFWATCH_SHOW_MS, nullptr);
lv_timer_pause(_timer);

GetHAL().onReefwatchFrame.connect([](const ReefwatchFrame_t& frame) {
LvglLockGuard lock;
show(frame);
});

mclog::tagInfo(_tag, "ready");
}

#else

void view::reefwatch_view_init()
{
}

#endif
15 changes: 15 additions & 0 deletions firmware/main/apps/common/reefwatch_view/reefwatch_view.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* SPDX-License-Identifier: MIT
*/
#pragma once

namespace view {

/**
* @brief Full screen overlay for reefwatch camera frames, on LVGL's top layer so it shows over
* any app and in AI agent mode. Tap a detection to watch that camera live, tap again to stop.
*
*/
void reefwatch_view_init();

} // namespace view
16 changes: 16 additions & 0 deletions firmware/main/hal/hal.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ struct WsTextMessage_t {
std::string content;
};

struct ReefwatchFrame_t {
std::shared_ptr<LvglImage> image;
std::string camera;
std::string labels;
bool isLive = false;
};

/**
* @brief
*
Expand Down Expand Up @@ -253,6 +260,15 @@ class Hal {

void startWebSocketAvatarService(std::function<void(std::string_view)> onStartLog);

/* ------------------------------- LAN server ------------------------------- */
void startLanServer(); // reefwatch frames + MCP for LAN agents, once Wi-Fi is up

/* -------------------------------- Reefwatch ------------------------------- */
uitk::Signal<const ReefwatchFrame_t&> onReefwatchFrame;

void setReefwatchLiveCamera(std::string_view camera); // empty to stop the live view
std::string getReefwatchLiveCamera();

/* ----------------------------------- IMU ---------------------------------- */
uitk::Signal<ImuMotionEvent> onImuMotionEvent;

Expand Down
Loading
Loading