Compare commits

...

5 Commits

Author SHA1 Message Date
Adolfo Reyna dcfd4e9bcc Add simulator audio support 2026-09-23 19:13:39 -04:00
Adolfo Reyna 982bba70b2 Add simulator MCP screen drawing tools 2026-09-22 16:51:50 -04:00
Adolfo Reyna 9630707b41 Enable MCP tools in POSIX simulator 2026-09-22 16:44:06 -04:00
Adolfo Reyna fd82efe08f Add native macOS simulator app packaging 2026-09-22 16:21:05 -04:00
Ken Van Hoeylandt 72089f74f3 Refactor SDL/FreeRTOS implementation to support macOS simulator properly (#653)
- Run SDL from main() and place FreeRTOS in separate thread. This fixes macOS support.
- Updated GitHub Actions to publish macOS simulator build for testing, updated amd64 to x86_64 for consistent naming.
2026-09-21 19:52:01 -04:00
46 changed files with 2070 additions and 400 deletions
@@ -99,12 +99,10 @@ logs. HTTP 200 or a package simply appearing in `/api/apps` is insufficient.
## Host simulator (buildsim) + web viewer
The POSIX simulator runs the real firmware (LVGL, services, web server) on
macOS/Linux with an SDL backend. macOS has no visible window (upstream
`.github/workflows/build-simulator.yml`: "macOS simulator currently fails due
to main thread requirement for rendering" — AppKit menu init must happen on
the process main thread, but FreeRTOS-POSIX parks main in `sigwait` and runs
everything on pthreads). The supported loop is **headless + web viewer**:
screenshots render server-side regardless of any window.
macOS/Linux with an SDL backend. On current firmware, `Main.cpp` runs SDL's
event loop on the process main thread while FreeRTOS runs on a separate thread.
This is required by AppKit and supports a native macOS window as well as the
web viewer. `SDL_VIDEODRIVER=dummy` remains useful for headless automation.
Code locations:
@@ -133,11 +131,14 @@ env -u ESP_IDF_VERSION -u IDF_PATH cmake --build buildsim --target TactilityKern
app-module crypt-module gps-module http-module lvgl-module lvgl-window-manager-module service-module
env -u ESP_IDF_VERSION -u IDF_PATH python3 Buildscripts/release-sdk-posix.py /tmp/sim-sdk
# release + headless run (MUST run from the firmware root: release-simulator.sh
# uses relative version.txt / Data paths)
# release (MUST run from the firmware root: release-simulator.sh uses relative
# version.txt / Data paths)
sh Buildscripts/release-simulator.sh buildsim /tmp/simrun
(cd /tmp/simrun && SIM_DISPLAY_W=480 SIM_DISPLAY_H=320 SDL_VIDEODRIVER=dummy \
nohup ./Tactility > /tmp/sim_web.log 2>&1 &)
# Native macOS window + web API. Run this from the release directory because
# data/ and system/ are relative to the process working directory.
(cd /tmp/simrun && SIM_DISPLAY_W=480 SIM_DISPLAY_H=320 \
nohup ./Tactility > /tmp/sim_gui.log 2>&1 &)
# For CI/headless operation, set SDL_VIDEODRIVER=dummy instead.
curl -s --max-time 5 http://127.0.0.1/api/sysinfo | head -c 120
```
@@ -146,7 +147,8 @@ matching on-device screenshots). ES3C35P panel is 320x480 portrait in DTS but
presents 480x320 landscape; ES3C28P is 320x240. The chosen geometry is logged
as `Simulator Sim display WxH`. `SDL_VIDEODRIVER=dummy` is expected to log one
`SdlDisplay Failed to create SDL window: Couldn't find matching render
driver` line — LVGL still renders and screenshots work.
driver` line — LVGL still renders and screenshots work. A native macOS launch
must not emit that line.
### Web viewer, touch, screenshots
@@ -187,6 +189,14 @@ URLs would 404 at the edge (no `/api` mount there).
- **Rebuild ≠ redeploy.** `cmake --build buildsim` updates `buildsim/` only.
Re-run `release-simulator.sh`, restart the process, then retest. A stale
`/tmp/simrun/Tactility` serves old handlers with new logs nowhere to be found.
- **One simulator owns port 80.** Do not launch a second instance while another
simulator is listening: it will initialize LVGL but fail `bind/listen`, so its
app API and viewer target the other process. Identify the listener with
`lsof -nP -iTCP:80 -sTCP:LISTEN`, stop only the intended simulator, then
release/restart it before installing or running POSIX apps.
- **Input is cross-thread on macOS.** SDL event pumping occurs on the real main
thread; LVGL and web touch injection run elsewhere. Keep all shared pointer,
key-queue, and touch-override state under `sdl_input.cpp`'s mutex.
- **C array `sizeof` decay.** A helper like
`f(HttpServerRequest*, char uri[256])` sees `sizeof(uri) == 8`, truncating
`get_uri` output to 7 chars (`/api/sy`, `/sim/ap` 404s). Pass the size
+5 -9
View File
@@ -4,11 +4,8 @@ inputs:
os_name:
description: A descriptive name for the operating system (e.g. linux, windows)
required: true
platform_name:
description: A descriptive name for the target platform (e.g. amd64, aarch64, etc.)
required: true
publish:
description: A boolean that enables publishing of artifacts
architecture:
description: A descriptive name for the target architecture (e.g. x86_64, aarch64, etc.)
required: true
runs:
@@ -48,11 +45,10 @@ runs:
run: cmake --build buildsim --target Tactility
- name: 'Release'
shell: bash
run: Buildscripts/release-simulator.sh buildsim release/Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
run: Buildscripts/release-simulator.sh buildsim release/Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
if: ${{ inputs.publish == 'true' }}
with:
name: Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
path: release/Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
name: Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
path: release/Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
retention-days: 30
+2 -5
View File
@@ -17,8 +17,7 @@ jobs:
uses: ./.github/actions/build-simulator
with:
os_name: linux
platform_name: amd64
publish: true
architecture: x86_64
macOS:
runs-on: macos-latest
steps:
@@ -29,6 +28,4 @@ jobs:
uses: ./.github/actions/build-simulator
with:
os_name: macos
platform_name: aarch64
# macOS simulator currently fails due to main thread requirement for rendering
publish: false
architecture: aarch64
+3 -1
View File
@@ -3,6 +3,8 @@
build*/
!.github/actions/build*/
!Buildscripts/
!Buildscripts/release-simulator-macos-app.sh
cmake*/
CMakeCache.txt
*.cbp
@@ -27,4 +29,4 @@ sdkconfig.board.*.dev
.caveman.json
.ai/mcp
__pycache__
__pycache__
@@ -0,0 +1,63 @@
#!/bin/sh
# Usage: release-simulator-macos-app.sh [builddir] [Tactility.app]
# Example: release-simulator-macos-app.sh buildsim release/Tactility.app
set -eu
build_path=$1
bundle_path=$2
if [ -e "$bundle_path" ]; then
echo "Refusing to overwrite existing bundle: $bundle_path" >&2
exit 1
fi
contents_path="$bundle_path/Contents"
resources_path="$contents_path/Resources"
macos_path="$contents_path/MacOS"
mkdir -p "$resources_path" "$macos_path"
cp "$build_path/Tactility/Tactility" "$resources_path/Tactility-bin"
cp version.txt "$resources_path/"
cp -R Data/data "$resources_path/"
cp -R Data/system "$resources_path/"
cat > "$contents_path/Info.plist" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Tactility</string>
<key>CFBundleIdentifier</key>
<string>org.tactilityproject.simulator</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Tactility</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.8.0-dev</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSMicrophoneUsageDescription</key>
<string>Tactility uses the microphone when a simulator app records audio.</string>
</dict>
</plist>
EOF
cat > "$macos_path/Tactility" <<'EOF'
#!/bin/sh
set -eu
resources_path="$(CDPATH= cd -- "$(dirname -- "$0")/../Resources" && pwd)"
cd "$resources_path"
exec "$resources_path/Tactility-bin" "$@"
EOF
chmod +x "$macos_path/Tactility" "$resources_path/Tactility-bin"
+11 -3
View File
@@ -62,9 +62,13 @@ if (DEFINED ENV{ESP_IDF_VERSION})
# to every target; PanicHandler.cpp branches internally per architecture.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_panic_handler" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=read" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=write" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=close" APPEND)
# Wraps newlib's reentrant syscall stubs, not the plain read()/write()/close() newlib itself
# implements as thin wrappers around them. newlib's own stdio (fflush()'s buffer-flush path in particular)
# calls these _r stubs directly, bypassing the plain names entirely.
# See Modules/app-module/source/stdio_wrap.cpp's own comment for the exact call chain.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_read_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_write_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_close_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_button_create" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_dropdown_create" APPEND)
@@ -106,6 +110,9 @@ endif ()
# Defined as regular project for PC and component for ESP
if (NOT DEFINED ENV{ESP_IDF_VERSION})
if (APPLE)
enable_language(OBJC OBJCXX)
endif ()
add_subdirectory(Tactility)
add_subdirectory(TactilityFreeRtos)
add_subdirectory(TactilityKernel)
@@ -133,6 +140,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/lvgl-window-manager-module)
add_subdirectory(Drivers/gps-generic-module)
add_subdirectory(Drivers/gps-meshtastic-module)
add_subdirectory(Drivers/audio-stream-module)
# FreeRTOS
set(FREERTOS_CONFIG_FILE_DIRECTORY ${PROJECT_SOURCE_DIR}/Devices/simulator/Source CACHE STRING "")
+9
View File
@@ -21,4 +21,13 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
target_link_libraries(simulator PRIVATE ${SDL2_LIBRARIES})
if (APPLE)
target_sources(simulator PRIVATE Source/drivers/sdl_audio_permission.mm)
target_link_libraries(simulator PRIVATE "-framework AVFoundation" "-framework Foundation")
# The developer executable can also request recording outside an .app bundle.
target_link_options(simulator INTERFACE
"LINKER:-sectcreate,__TEXT,__info_plist,${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
set_property(TARGET Tactility APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
endif ()
endif()
+2 -2
View File
@@ -10,13 +10,13 @@ constexpr auto* TAG = "FreeRTOS";
namespace simulator {
MainFunction mainFunction = nullptr;
static MainFunction mainFunction = nullptr;
void setMain(MainFunction newMainFunction) {
mainFunction = newMainFunction;
}
static void freertosMainTask(void* parameter) {
static void freertosMainTask(void*) {
LOG_I(TAG, "starting app_main()");
assert(simulator::mainFunction);
mainFunction();
+22 -2
View File
@@ -1,6 +1,11 @@
#pragma once
#include "Main.h"
#include "drivers/sdl_bridge.h"
#include <csignal>
#include <pthread.h>
#include <thread>
namespace simulator {
/** Set the function pointer of the real app_main() */
@@ -14,8 +19,23 @@ void app_main(); // ESP-IDF's main function, implemented in the application
}
int main() {
// Actual main function that passes on app_main() (to be executed in a FreeRTOS task) and bootstraps FreeRTOS
// The FreeRTOS POSIX port arms a process-wide SIGALRM timer for its tick and expects every one
// of its task pthreads to have all signals but SIGINT blocked.
// (see prvSetupSignalsAndSchedulerPolicy() in FreeRTOS-Kernel's Posix port.c)
// A signal-generated SIGALRM can land on any thread in the process that doesn't block it.
// This thread stays a plain OS thread (running the SDL loop below, never a FreeRTOS task),
// so without this it's eligible to catch a tick SIGALRM and freeze inside the scheduler's handler.
// Block the same set here, before anything else, so it never can.
sigset_t all_signals_except_sigint;
sigfillset(&all_signals_except_sigint);
sigdelset(&all_signals_except_sigint, SIGINT);
pthread_sigmask(SIG_SETMASK, &all_signals_except_sigint, nullptr);
// FreeRTOS and app_main() run on a separate thread: macOS requires SDL/Cocoa window creation,
// event pumping and rendering to happen on the real OS main thread, which sdl_bridge_run_main_loop()
// below takes over. freertosMain() never returns, so this thread is detached rather than joined.
simulator::setMain(app_main);
simulator::freertosMain();
std::thread(simulator::freertosMain).detach();
sdl_bridge_run_main_loop();
return 0;
}
@@ -0,0 +1,325 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_audio.h"
#include "sdl_audio_buffer.h"
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/freertos/task.h>
#include <tactility/log.h>
#include <tactility/module.h>
#include <SDL2/SDL.h>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <new>
namespace {
constexpr auto* TAG = "SdlAudio";
constexpr uint32_t SAMPLE_RATE = 48000;
struct AudioData {
SdlAudioBuffer buffer;
SemaphoreHandle_t mutex = nullptr; // task-side only; never used by the SDL callback
SDL_AudioDeviceID id = 0;
AudioCodecDirection direction;
std::atomic<float> volume { 100.0f };
std::atomic<bool> muted { false };
std::atomic<uint32_t> callbacks { 0 };
};
AudioData* get_data(Device* device) {
return static_cast<AudioData*>(device_get_driver_data(device));
}
class Lock {
SemaphoreHandle_t mutex;
public:
explicit Lock(AudioData* data) : mutex(data->mutex) { xSemaphoreTake(mutex, portMAX_DELAY); }
~Lock() { xSemaphoreGive(mutex); }
};
const char* device_name(const AudioData* data) {
const char* name = std::getenv(data->direction == AUDIO_CODEC_DIR_INPUT ? "SIM_AUDIO_INPUT" : "SIM_AUDIO_OUTPUT");
return name != nullptr && name[0] != '\0' ? name : nullptr;
}
bool available(const AudioData* data) {
const char* name = device_name(data);
if (name != nullptr && std::strcmp(name, "none") == 0) return false;
const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
const int count = SDL_GetNumAudioDevices(capture);
if (name == nullptr) return count > 0;
for (int i = 0; i < count; ++i) {
const char* candidate = SDL_GetAudioDeviceName(i, capture);
if (candidate != nullptr && std::strcmp(candidate, name) == 0) return true;
}
return false;
}
void apply_volume(AudioData* data, void* bytes, size_t size) {
const float gain = data->muted.load() ? 0.0f : data->volume.load() / 100.0f;
auto* output = static_cast<uint8_t*>(bytes);
for (size_t i = 0; i < size; i += sizeof(int16_t)) {
int16_t sample;
std::memcpy(&sample, output + i, sizeof(sample));
sample = static_cast<int16_t>(sample * gain);
std::memcpy(output + i, &sample, sizeof(sample));
}
}
void audio_callback(void* context, Uint8* stream, int length) {
auto* data = static_cast<AudioData*>(context);
const size_t count = static_cast<size_t>(length) / sizeof(int16_t);
if (data->direction == AUDIO_CODEC_DIR_INPUT) {
// Drop incoming frames when the bounded capture buffer is full. Muted capture
// must not leave real microphone samples queued for a later unmute.
if (data->muted.load()) std::memset(stream, 0, length);
data->buffer.write(stream, count);
} else {
const size_t copied = data->buffer.read(stream, count) * sizeof(int16_t);
std::memset(stream + copied, 0, length - copied); // silence on underrun
apply_volume(data, stream, copied);
}
data->callbacks.fetch_add(1, std::memory_order_relaxed);
}
error_t start(Device* device) {
const auto* config = static_cast<const SdlAudioConfig*>(device->config);
if (config == nullptr || (config->direction != AUDIO_CODEC_DIR_INPUT && config->direction != AUDIO_CODEC_DIR_OUTPUT)) {
return ERROR_INVALID_ARGUMENT;
}
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
LOG_E(TAG, "Cannot initialize audio: %s", SDL_GetError());
return ERROR_RESOURCE;
}
auto* data = new (std::nothrow) AudioData;
if (data == nullptr) {
SDL_QuitSubSystem(SDL_INIT_AUDIO);
return ERROR_OUT_OF_MEMORY;
}
data->direction = config->direction;
data->mutex = xSemaphoreCreateMutex();
if (data->mutex == nullptr) {
delete data;
SDL_QuitSubSystem(SDL_INIT_AUDIO);
return ERROR_OUT_OF_MEMORY;
}
device_set_driver_data(device, data);
const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
for (int i = 0; i < SDL_GetNumAudioDevices(capture); ++i) {
LOG_I(TAG, "%s device: %s", capture ? "Input" : "Output", SDL_GetAudioDeviceName(i, capture));
}
LOG_I(TAG, "%s: %s (%s)", device->name, device_name(data) != nullptr ? device_name(data) : "system default",
available(data) ? "available" : "unavailable");
return ERROR_NONE;
}
error_t open(Device* device, const AudioCodecStreamConfig* config) {
auto* data = get_data(device);
if (config == nullptr) return ERROR_INVALID_ARGUMENT;
if (config->direction != data->direction) return ERROR_NOT_SUPPORTED;
// The shared stream module performs rate/channel conversion on S16 PCM.
if (config->bits_per_sample != 16) return ERROR_NOT_SUPPORTED;
const uint8_t channels = data->direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
if (config->sample_rate != SAMPLE_RATE || config->channels != channels) return ERROR_INVALID_ARGUMENT;
Lock lock(data);
if (data->id != 0) return ERROR_INVALID_STATE;
if (!available(data)) {
LOG_W(TAG, "No selected %s device available", data->direction == AUDIO_CODEC_DIR_INPUT ? "input" : "output");
return ERROR_NOT_SUPPORTED;
}
#ifdef __APPLE__
// Only ask when recording is requested, and only with real macOS audio (dummy
// and disk backends are also useful for automated tests).
if (data->direction == AUDIO_CODEC_DIR_INPUT && std::strcmp(SDL_GetCurrentAudioDriver(), "coreaudio") == 0) {
error_t permission;
while ((permission = sdl_audio_microphone_permission()) == ERROR_RESOURCE_BUSY) vTaskDelay(1);
if (permission != ERROR_NONE) {
LOG_W(TAG, "Microphone permission denied; enable access in macOS Privacy & Security > Microphone");
return permission;
}
}
#endif
SDL_AudioSpec wanted {};
wanted.freq = SAMPLE_RATE;
wanted.format = AUDIO_S16SYS;
wanted.channels = channels;
wanted.samples = 512;
wanted.callback = audio_callback;
wanted.userdata = data;
data->buffer.reset();
data->callbacks.store(0);
// No allowed changes: SDL converts between our fixed PCM format and the host
// device's format when necessary. No hardware-specific format leaks to apps.
data->id = SDL_OpenAudioDevice(device_name(data), data->direction == AUDIO_CODEC_DIR_INPUT, &wanted, nullptr, 0);
if (data->id == 0) {
LOG_E(TAG, "Cannot open %s: %s", device->name, SDL_GetError());
return ERROR_RESOURCE;
}
SDL_PauseAudioDevice(data->id, 0);
return ERROR_NONE;
}
error_t close(Device* device) {
auto* data = get_data(device);
Lock lock(data);
if (data->id != 0) {
// Preserve the end of short sounds. Drain is bounded even if the device has
// disappeared; microphone close never waits for the capture buffer to empty.
const TickType_t start = xTaskGetTickCount();
while (data->direction == AUDIO_CODEC_DIR_OUTPUT && !data->buffer.empty()
&& xTaskGetTickCount() - start < pdMS_TO_TICKS(250)
&& SDL_GetAudioDeviceStatus(data->id) == SDL_AUDIO_PLAYING) {
vTaskDelay(1);
}
SDL_CloseAudioDevice(data->id);
data->id = 0;
data->buffer.reset();
}
return ERROR_NONE;
}
error_t stop(Device* device) {
auto* data = get_data(device);
close(device);
vSemaphoreDelete(data->mutex);
delete data;
device_set_driver_data(device, nullptr);
SDL_QuitSubSystem(SDL_INIT_AUDIO);
return ERROR_NONE;
}
error_t transfer(Device* device, void* destination, const void* source, size_t size, size_t* transferred, TickType_t timeout, bool capture) {
if (transferred != nullptr) *transferred = 0;
auto* data = get_data(device);
if ((data->direction == AUDIO_CODEC_DIR_INPUT) != capture) return ERROR_NOT_SUPPORTED;
const size_t frame_size = sizeof(int16_t) * (capture ? 1 : 2);
if (size % frame_size != 0 || (size != 0 && (capture ? destination == nullptr : source == nullptr))) return ERROR_INVALID_ARGUMENT;
Lock lock(data);
if (data->id == 0) return ERROR_INVALID_STATE;
const TickType_t start = xTaskGetTickCount();
TickType_t last_callback = start;
uint32_t callbacks = data->callbacks.load();
size_t done = 0;
error_t result = ERROR_NONE;
while (done < size) {
if (SDL_GetAudioDeviceStatus(data->id) != SDL_AUDIO_PLAYING) {
result = ERROR_RESOURCE;
break;
}
const size_t count = (size - done) / sizeof(int16_t);
const size_t copied = capture
? data->buffer.read(static_cast<uint8_t*>(destination) + done, count)
: data->buffer.write(static_cast<const uint8_t*>(source) + done, count);
done += copied * sizeof(int16_t);
if (done == size) break;
const TickType_t now = xTaskGetTickCount();
if (timeout != portMAX_DELAY && now - start >= timeout) {
result = ERROR_TIMEOUT;
break;
}
const uint32_t current_callbacks = data->callbacks.load();
if (current_callbacks != callbacks) {
callbacks = current_callbacks;
last_callback = now;
} else if (now - last_callback >= pdMS_TO_TICKS(2000)) {
// A stopped backend must not strand an infinite-timeout caller or a
// concurrent Settings disable waiting for that caller to finish.
result = ERROR_RESOURCE;
break;
}
vTaskDelay(1);
}
if (capture) apply_volume(data, destination, done);
if (transferred != nullptr) *transferred = done;
return result;
}
error_t read(Device* device, void* destination, size_t size, size_t* count, TickType_t timeout) {
return transfer(device, destination, nullptr, size, count, timeout, true);
}
error_t write(Device* device, const void* source, size_t size, size_t* count, TickType_t timeout) {
return transfer(device, nullptr, source, size, count, timeout, false);
}
error_t set_volume(Device* device, AudioCodecDirection direction, float volume) {
auto* data = get_data(device);
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
if (!std::isfinite(volume) || volume < 0.0f || volume > 100.0f) return ERROR_INVALID_ARGUMENT;
data->volume.store(volume);
return ERROR_NONE;
}
error_t get_volume(Device* device, AudioCodecDirection direction, float* volume) {
auto* data = get_data(device);
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
*volume = data->volume.load();
return ERROR_NONE;
}
error_t set_mute(Device* device, AudioCodecDirection direction, bool muted) {
auto* data = get_data(device);
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
data->muted.store(muted);
return ERROR_NONE;
}
error_t get_mute(Device* device, AudioCodecDirection direction, bool* muted) {
auto* data = get_data(device);
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
*muted = data->muted.load();
return ERROR_NONE;
}
error_t get_rate(Device* device, AudioCodecDirection direction, uint32_t* rate) {
if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
*rate = SAMPLE_RATE;
return ERROR_NONE;
}
error_t get_channels(Device* device, AudioCodecDirection direction, uint8_t* channels) {
if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
*channels = direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
return ERROR_NONE;
}
error_t get_capabilities(Device* device, AudioCodecDirection* direction) {
auto* data = get_data(device);
if (!available(data)) return ERROR_NOT_SUPPORTED;
*direction = data->direction;
return ERROR_NONE;
}
const AudioCodecApi api = {
.open = open,
.close = close,
.read = read,
.write = write,
.set_volume = set_volume,
.get_volume = get_volume,
.set_mute = set_mute,
.get_mute = get_mute,
.get_native_sample_rate = get_rate,
.get_native_channels = get_channels,
.get_capabilities = get_capabilities,
.get_input_gain_multiplier = nullptr,
};
} // namespace
extern "C" Module simulator_module;
Driver sdl_audio_driver = {
.name = "sdl-audio",
.compatible = (const char*[]) { "tactility,sdl-audio", nullptr },
.start_device = start,
.stop_device = stop,
.api = &api,
.device_type = &AUDIO_CODEC_TYPE,
.owner = &simulator_module,
.internal = nullptr,
};
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/drivers/audio_codec.h>
struct SdlAudioConfig {
AudioCodecDirection direction;
};
#ifdef __APPLE__
// Nonblocking: ERROR_RESOURCE_BUSY means that the user has not answered the prompt yet.
error_t sdl_audio_microphone_permission();
#endif
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
#include <cstring>
// Single producer / single consumer. The SDL callback is a native thread: it must never
// allocate, block, log, or call FreeRTOS. Unsigned counters also work across wraparound.
class SdlAudioBuffer {
static constexpr uint32_t CAPACITY = 16384; // samples; ~171 ms of 48 kHz stereo
std::array<int16_t, CAPACITY> samples {};
std::atomic<uint32_t> read_position { 0 };
std::atomic<uint32_t> write_position { 0 };
public:
size_t write(const void* source, size_t count) {
const uint32_t write = write_position.load(std::memory_order_relaxed);
const uint32_t read = read_position.load(std::memory_order_acquire);
count = std::min(count, static_cast<size_t>(CAPACITY - (write - read)));
const size_t first = std::min(count, static_cast<size_t>(CAPACITY - write % CAPACITY));
const auto* bytes = static_cast<const uint8_t*>(source);
std::memcpy(samples.data() + write % CAPACITY, bytes, first * sizeof(int16_t));
std::memcpy(samples.data(), bytes + first * sizeof(int16_t), (count - first) * sizeof(int16_t));
write_position.store(write + count, std::memory_order_release);
return count;
}
size_t read(void* destination, size_t count) {
const uint32_t read = read_position.load(std::memory_order_relaxed);
const uint32_t write = write_position.load(std::memory_order_acquire);
count = std::min(count, static_cast<size_t>(write - read));
const size_t first = std::min(count, static_cast<size_t>(CAPACITY - read % CAPACITY));
auto* bytes = static_cast<uint8_t*>(destination);
std::memcpy(bytes, samples.data() + read % CAPACITY, first * sizeof(int16_t));
std::memcpy(bytes + first * sizeof(int16_t), samples.data(), (count - first) * sizeof(int16_t));
read_position.store(read + count, std::memory_order_release);
return count;
}
bool empty() const {
return read_position.load(std::memory_order_acquire) == write_position.load(std::memory_order_acquire);
}
// Only while the SDL device is closed and no caller is doing I/O.
void reset() {
read_position.store(0);
write_position.store(0);
}
};
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_audio.h"
#import <AVFoundation/AVFoundation.h>
#include <atomic>
error_t sdl_audio_microphone_permission() {
@autoreleasepool {
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]) {
case AVAuthorizationStatusAuthorized:
return ERROR_NONE;
case AVAuthorizationStatusDenied:
case AVAuthorizationStatusRestricted:
return ERROR_NOT_ALLOWED;
case AVAuthorizationStatusNotDetermined: {
static std::atomic<bool> requested { false };
if (!requested.exchange(true)) {
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
requested.store(false);
}];
}
return ERROR_RESOURCE_BUSY;
}
}
return ERROR_NOT_ALLOWED;
}
}
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_bridge.h"
#include "sdl_input.h"
#include <tactility/error.h>
#include <chrono>
#include <condition_variable>
#include <mutex>
namespace {
struct PresentJob {
Device* device;
void* internal;
int32_t x_start;
int32_t y_start;
int32_t x_end;
int32_t y_end;
const void* color_data;
error_t result;
};
std::mutex job_mutex;
std::condition_variable job_ready_cv;
std::condition_variable job_done_cv;
bool job_pending = false;
bool job_done = false;
PresentJob pending_job;
}
error_t sdl_bridge_present(Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
std::unique_lock<std::mutex> lock(job_mutex);
pending_job = { device, internal, x_start, y_start, x_end, y_end, color_data, ERROR_NONE };
job_pending = true;
job_done = false;
job_ready_cv.notify_one();
job_done_cv.wait(lock, [] { return job_done; });
return pending_job.result;
}
void sdl_bridge_run_main_loop() {
while (true) {
sdl_input_pump();
std::unique_lock<std::mutex> lock(job_mutex);
if (job_ready_cv.wait_for(lock, std::chrono::milliseconds(1), [] { return job_pending; })) {
PresentJob job = pending_job;
lock.unlock();
job.result = sdl_display_execute_draw_bitmap(job.device, job.internal, job.x_start, job.y_start, job.x_end, job.y_end, job.color_data);
lock.lock();
pending_job.result = job.result;
job_pending = false;
job_done = true;
lock.unlock();
job_done_cv.notify_one();
}
}
}
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/error.h>
#include <stdint.h>
struct Device;
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Runs forever, pumping SDL input and executing display present jobs submitted via
* sdl_bridge_present(). Must be called exactly once, from the real OS main thread: macOS requires
* SDL/Cocoa window creation, event pumping and rendering to happen there, but FreeRTOS tasks
* (including the lvgl task that owns display flush and indev polling) run on separate pthreads
* spawned by the FreeRTOS POSIX port, not on that thread.
*/
void sdl_bridge_run_main_loop(void);
/**
* @brief Hands a display flush off to the main thread and blocks until it has finished copying
* the pixel data out (see sdl_display_execute_draw_bitmap() in sdl_display.cpp). Called from the
* lvgl task. Must block: the caller's pixel buffer is single-buffered and gets reused as soon as
* this returns.
*/
error_t sdl_bridge_present(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
/**
* @brief Implemented in sdl_display.cpp: the actual SDL work behind a display flush (lazy window
* init on first call, SDL_UpdateTexture, present). Only ever called from sdl_bridge_run_main_loop()
* on the main thread.
*/
error_t sdl_display_execute_draw_bitmap(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
#ifdef __cplusplus
}
#endif
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_display.h"
#include "sdl_bridge.h"
#include <tactility/device.h>
#include <tactility/driver.h>
@@ -141,8 +142,10 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
return true;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
// Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for
// SDL/Cocoa window creation and rendering on macOS.
error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(internal_ptr);
if (internal->init_failed) {
return ERROR_RESOURCE;
@@ -166,6 +169,16 @@ static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t
return ERROR_NONE;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
if (internal->init_failed) {
return ERROR_RESOURCE;
}
return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data);
}
static enum DisplayColorFormat sdl_display_get_color_format(Device*) {
return DISPLAY_COLOR_FORMAT_RGB565;
}
+70 -46
View File
@@ -7,11 +7,16 @@
#include <SDL2/SDL.h>
#include <cstdlib>
#include <mutex>
namespace {
constexpr size_t KEY_QUEUE_CAPACITY = 32;
// Written by sdl_input_pump() on the real main thread, read by sdl_input_get_pointer_state()/
// sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task.
std::mutex state_mutex;
SdlPointerState pointer_state = { 0, 0, false };
} // namespace
@@ -26,6 +31,7 @@ uint32_t touch_override_until_tick = 0;
#define SIM_TOUCH_HOLD_MS 1500
extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed) {
std::lock_guard<std::mutex> lock(state_mutex);
touch_override.x = x;
touch_override.y = y;
touch_override.pressed = pressed;
@@ -36,6 +42,7 @@ extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed)
}
extern "C" void sdl_input_clear_touch_override(void) {
std::lock_guard<std::mutex> lock(state_mutex);
touch_override_active = false;
touch_override.pressed = false;
}
@@ -101,58 +108,73 @@ uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
} // namespace
void sdl_input_pump() {
if (!text_input_started) {
SDL_StartTextInput();
text_input_started = true;
// exit() must run with state_mutex unlocked: it never returns, so a lock_guard held across it
// would never release the mutex, hanging any other thread that later calls into this file's
// other functions (all of which lock state_mutex) while exit() tears the process down.
bool quit_requested = false;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (!text_input_started) {
SDL_StartTextInput();
text_input_started = true;
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.pressed = false;
}
break;
case SDL_KEYDOWN:
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
break;
case SDL_TEXTINPUT:
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
quit_requested = true;
break;
default:
break;
}
}
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.pressed = false;
}
break;
case SDL_KEYDOWN:
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
break;
case SDL_TEXTINPUT:
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
exit(0);
default:
break;
}
if (quit_requested) {
exit(0);
}
}
void sdl_input_get_pointer_state(SdlPointerState* out_state) {
std::lock_guard<std::mutex> lock(state_mutex);
if (touch_override_active) {
// Auto-release: viewer sends press only; LVGL needs press then release
// to register a click. Hold long enough for several indev polls.
@@ -168,6 +190,7 @@ void sdl_input_get_pointer_state(SdlPointerState* out_state) {
}
bool sdl_input_pop_key(uint32_t* out_key) {
std::lock_guard<std::mutex> lock(state_mutex);
if (key_queue_count == 0) {
return false;
}
@@ -178,5 +201,6 @@ bool sdl_input_pop_key(uint32_t* out_key) {
}
bool sdl_input_has_queued_key() {
std::lock_guard<std::mutex> lock(state_mutex);
return key_queue_count > 0;
}
+3 -3
View File
@@ -19,9 +19,9 @@ struct SdlPointerState {
/**
* @brief Drains all pending SDL events exactly once, updating the pointer state and key queue
* below. Safe to call from both the sdl-pointer and sdl-keyboard drivers' polling functions:
* SDL_PollEvent() drains a single global queue, so whichever driver is polled first on a given
* LVGL indev tick pumps events for both.
* below. Must be called only from the real OS main thread (sdl_bridge_run_main_loop()): SDL
* requires event pumping to happen there on macOS. The getters below are safe to call from a
* different thread (the lvgl task, via sdl-pointer/sdl-keyboard's polling functions).
*/
void sdl_input_pump(void);
@@ -16,8 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
// region KeyboardApi
static error_t sdl_keyboard_read_key(Device*, KeyboardKeyData* data) {
sdl_input_pump();
uint32_t key = 0;
if (sdl_input_pop_key(&key)) {
data->key = key;
@@ -16,7 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
// region PointerApi
static error_t sdl_pointer_read_data(Device*, TickType_t) {
sdl_input_pump();
return ERROR_NONE;
}
+9
View File
@@ -1,4 +1,5 @@
#include "drivers/sdl_display.h"
#include "drivers/sdl_audio.h"
#include <tactility/device.h>
#include <tactility/device_listener.h>
@@ -16,11 +17,13 @@ extern "C" {
extern Driver sdl_display_driver;
extern Driver sdl_pointer_driver;
extern Driver sdl_keyboard_driver;
extern Driver sdl_audio_driver;
static Driver* const simulator_drivers[] = {
&sdl_display_driver,
&sdl_pointer_driver,
&sdl_keyboard_driver,
&sdl_audio_driver,
nullptr
};
@@ -54,6 +57,10 @@ static SdlDisplayConfig sdl_display_config = { 480, 320 };
static Device sdl_display_device {};
static Device sdl_pointer_device {};
static Device sdl_keyboard_device {};
static Device sdl_speaker_device {};
static Device sdl_microphone_device {};
static const SdlAudioConfig sdl_speaker_config { AUDIO_CODEC_DIR_OUTPUT };
static const SdlAudioConfig sdl_microphone_config { AUDIO_CODEC_DIR_INPUT };
static bool construct_add_start(Device* device, Device* parent, const char* name, const void* config, const char* compatible) {
device->address = 0;
@@ -109,6 +116,8 @@ static void on_root_started(Device* device, DeviceEvent event, void* context) {
construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display");
construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer");
construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard");
construct_add_start(&sdl_speaker_device, device, "speaker0", &sdl_speaker_config, "tactility,sdl-audio");
construct_add_start(&sdl_microphone_device, device, "microphone0", &sdl_microphone_config, "tactility,sdl-audio");
}
extern "C" {
+1
View File
@@ -1,3 +1,4 @@
dependencies:
- Platforms/platform-posix
- Drivers/audio-stream-module
dts: simulator.dts
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>org.tactilityproject.simulator</string>
<key>CFBundleName</key>
<string>Tactility</string>
<key>NSMicrophoneUsageDescription</key>
<string>Tactility uses the microphone when a simulator app records audio.</string>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
add_executable(SimulatorAudioTests EXCLUDE_FROM_ALL
audio.cpp
../Source/drivers/sdl_audio.cpp
)
target_include_directories(SimulatorAudioTests PRIVATE ${DOCTESTINC} ../Source/drivers)
target_link_libraries(SimulatorAudioTests PRIVATE TactilityKernel platform-posix audio-stream-module SDL2-static)
if (APPLE)
target_sources(SimulatorAudioTests PRIVATE ../Source/drivers/sdl_audio_permission.mm)
target_link_libraries(SimulatorAudioTests PRIVATE "-framework AVFoundation" "-framework Foundation")
endif ()
add_test(NAME SimulatorAudioTests COMMAND SimulatorAudioTests)
set_tests_properties(SimulatorAudioTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=dummy;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
add_test(NAME SimulatorAudioPcmTests COMMAND SimulatorAudioTests --no-skip "--test-case=disk PCM*")
set_tests_properties(SimulatorAudioPcmTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=disk;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
+301
View File
@@ -0,0 +1,301 @@
// SPDX-License-Identifier: Apache-2.0
#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
#include "sdl_audio.h"
#include "sdl_audio_buffer.h"
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/freertos/task.h>
#include <tactility/kernel_init.h>
#include <SDL2/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdio>
#include <fstream>
#include <unistd.h>
#include <vector>
extern Driver sdl_audio_driver;
extern "C" {
extern Module platform_posix_module;
extern Module audio_stream_module;
extern Device audio_stream_device;
static Driver* const drivers[] = { &sdl_audio_driver, nullptr };
Module simulator_module = { .name = "simulator-audio-test", .drivers = drivers };
}
static const SdlAudioConfig output_config { AUDIO_CODEC_DIR_OUTPUT };
static const SdlAudioConfig input_config { AUDIO_CODEC_DIR_INPUT };
static Device speaker { .name = "speaker-test", .config = &output_config };
static Device microphone { .name = "microphone-test", .config = &input_config };
struct Stream {
AudioStreamHandle handle = nullptr;
~Stream() { if (handle != nullptr) audio_stream_close(handle); }
};
TEST_CASE("bounded PCM buffer preserves data across wrap and overflow") {
SdlAudioBuffer buffer;
std::vector<int16_t> input(20000);
for (size_t i = 0; i < input.size(); ++i) input[i] = static_cast<int16_t>(i);
std::vector<int16_t> output(20000, -1);
CHECK(buffer.read(output.data(), output.size()) == 0);
REQUIRE(buffer.write(input.data(), input.size()) == 16384);
CHECK(buffer.write(input.data(), 2) == 0);
REQUIRE(buffer.read(output.data(), 10000) == 10000);
CHECK(std::equal(output.begin(), output.begin() + 10000, input.begin()));
REQUIRE(buffer.write(input.data(), 10000) == 10000);
REQUIRE(buffer.read(output.data(), output.size()) == 16384);
CHECK(std::equal(output.begin(), output.begin() + 6384, input.begin() + 10000));
CHECK(std::equal(output.begin() + 6384, output.begin() + 16384, input.begin()));
CHECK(buffer.empty());
}
TEST_CASE("simulator streams support independent full duplex and common PCM rates") {
for (uint32_t rate : { 16000u, 44100u, 48000u }) {
CAPTURE(rate);
const AudioStreamConfig config { rate, 16, 1 };
Stream input;
Stream output;
REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
AudioStreamHandle duplicate = nullptr;
CHECK(audio_stream_open_output(&audio_stream_device, &config, &duplicate) == ERROR_INVALID_STATE);
std::vector<int16_t> samples(rate / 20, 1234);
size_t count = 0;
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
CHECK(count == samples.size() * 2);
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
CHECK(count == samples.size() * 2);
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
output.handle = nullptr;
// Closing the speaker must not stop the microphone.
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
}
}
TEST_CASE("simulator audio controls persist across opens and disabling closes output") {
auto* device = &audio_stream_device;
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25.0f) == ERROR_NONE);
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
const AudioStreamConfig config { 48000, 16, 2 };
Stream output;
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
float volume = 0;
bool muted = false;
REQUIRE(audio_codec_get_volume(&speaker, AUDIO_CODEC_DIR_OUTPUT, &volume) == ERROR_NONE);
REQUIRE(audio_codec_get_mute(&speaker, AUDIO_CODEC_DIR_OUTPUT, &muted) == ERROR_NONE);
CHECK(volume == 25.0f);
CHECK(muted);
REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
output.handle = nullptr; // set_enabled closes and owns destruction of the handle
CHECK(audio_stream_open_output(device, &config, &output.handle) == ERROR_NOT_ALLOWED);
REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
}
TEST_CASE("bounded playback reports partial progress on a nonblocking timeout") {
const AudioStreamConfig config { 48000, 16, 2 };
Stream output;
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
std::vector<int16_t> samples(48000 * 2, 0);
size_t count = 999;
CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
CHECK(count > 0);
CHECK(count < samples.size() * 2);
CHECK(count % 4 == 0);
}
TEST_CASE("converted streams report partial progress on timeout") {
const AudioStreamConfig config { 16000, 16, 1 };
Stream output;
Stream input;
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
std::vector<int16_t> samples(16000, 0);
size_t count = 999;
CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
CHECK(count > 0);
CHECK(count < samples.size() * 2);
CHECK(count % 2 == 0);
vTaskDelay(pdMS_TO_TICKS(30)); // allow dummy capture to produce some samples
count = 999;
CHECK(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
CHECK(count > 0);
CHECK(count < samples.size() * 2);
CHECK(count % 2 == 0);
}
TEST_CASE("missing selected microphone does not prevent speaker playback") {
REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "tactility-nonexistent-microphone", 1) == 0);
AudioCodecDirection capability;
CHECK(audio_codec_get_capabilities(&microphone, &capability) == ERROR_NOT_SUPPORTED);
const AudioCodecStreamConfig config { 48000, 16, 1, AUDIO_CODEC_DIR_INPUT };
CHECK(audio_codec_open(&microphone, &config) == ERROR_NOT_SUPPORTED);
CHECK(audio_codec_get_capabilities(&speaker, &capability) == ERROR_NONE);
CHECK(capability == AUDIO_CODEC_DIR_OUTPUT);
REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "", 1) == 0);
}
TEST_CASE("unsupported sample widths fail without breaking a later open") {
AudioStreamConfig config { 16000, 24, 1 };
Stream output;
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) != ERROR_NONE);
CHECK(output.handle == nullptr);
config.bits_per_sample = 16;
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
}
TEST_CASE("zero sample rate is rejected before conversion") {
const AudioStreamConfig config { 0, 16, 1 };
Stream output;
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_INVALID_ARGUMENT);
}
TEST_CASE("disabling while a codec is opening cancels the pending stream") {
const auto* original_api = static_cast<const AudioCodecApi*>(sdl_audio_driver.api);
static const AudioCodecApi* wrapped_api;
wrapped_api = original_api;
AudioCodecApi delayed_api = *original_api;
delayed_api.open = [](Device* device, const AudioCodecStreamConfig* config) {
vTaskDelay(pdMS_TO_TICKS(50)); // models waiting for microphone permission
return wrapped_api->open(device, config);
};
sdl_audio_driver.api = &delayed_api;
struct RestoreApi {
const AudioCodecApi* api;
~RestoreApi() { sdl_audio_driver.api = api; }
} restore { original_api };
REQUIRE(xTaskCreate([](void*) {
vTaskDelay(pdMS_TO_TICKS(5));
audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, false);
// Re-enabling does not resurrect the cancelled attempt.
audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, true);
vTaskDelete(nullptr);
}, "disable-audio", 8192, nullptr, 1, nullptr) == pdPASS);
const AudioStreamConfig config { 48000, 16, 2 };
Stream output;
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NOT_ALLOWED);
CHECK(output.handle == nullptr);
sdl_audio_driver.api = original_api;
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
}
TEST_CASE("hardware output smoke test" * doctest::skip()) {
// Explicit opt-in only: SDL_AUDIODRIVER=coreaudio ... --no-skip --test-case='hardware output smoke test'
const AudioStreamConfig config { 48000, 16, 2 };
Stream output;
REQUIRE(audio_stream_set_volume(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, 20) == ERROR_NONE);
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
std::vector<int16_t> samples(48000); // half a second, stereo, quiet 440 Hz tone
for (size_t frame = 0; frame < samples.size() / 2; ++frame) {
samples[frame * 2] = samples[frame * 2 + 1] = static_cast<int16_t>(3000 * std::sin(frame * 440.0 * 6.283185307 / 48000));
}
size_t count = 0;
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(2000)) == ERROR_NONE);
CHECK(count == samples.size() * 2);
}
TEST_CASE("disk PCM capture and playback apply gain and mute" * doctest::skip()) {
REQUIRE(std::strcmp(SDL_GetCurrentAudioDriver(), "disk") == 0);
auto* device = &audio_stream_device;
const AudioStreamConfig config { 48000, 16, 1 };
Stream input;
Stream output;
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_INPUT, 50) == ERROR_NONE);
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25) == ERROR_NONE);
REQUIRE(audio_stream_open_input(device, &config, &input.handle) == ERROR_NONE);
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
std::vector<int16_t> samples(960, -1);
size_t count = 0;
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
CHECK(count == samples.size() * 2);
// Fixture contains 10000; microphone gain is 50%.
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 5000; }));
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
output.handle = nullptr;
{
std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
REQUIRE(file.good());
size_t nonzero = 0;
int16_t sample;
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
CHECK((sample == 0 || sample == 1250)); // 25% output volume
if (sample != 0) nonzero++;
}
CHECK(nonzero == samples.size() * 2); // mono was duplicated to stereo
}
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_INPUT, true) == ERROR_NONE);
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
std::fill(samples.begin(), samples.end(), 5000);
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
output.handle = nullptr;
std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
REQUIRE(file.good());
size_t total = 0;
int16_t sample;
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
CHECK(sample == 0);
total++;
}
CHECK(total >= samples.size() * 2);
}
struct TestContext { int argc; char** argv; int result = 1; };
static void run_tests(void* argument) {
auto* data = static_cast<TestContext*>(argument);
Module* modules[] = { &platform_posix_module, &simulator_module, &audio_stream_module, nullptr };
DtsDevice devices[] = { DTS_DEVICE_TERMINATOR };
if (kernel_init(modules, devices) == ERROR_NONE
&& device_construct_add_start(&speaker, "tactility,sdl-audio") == ERROR_NONE
&& device_construct_add_start(&microphone, "tactility,sdl-audio") == ERROR_NONE) {
doctest::Context context(data->argc, data->argv);
context.setOption("no-breaks", true);
data->result = context.run();
device_stop(&microphone);
device_stop(&speaker);
}
vTaskEndScheduler();
vTaskDelete(nullptr);
}
int main(int argc, char** argv) {
if (std::getenv("SDL_AUDIODRIVER") == nullptr) SDL_setenv("SDL_AUDIODRIVER", "dummy", 1);
char input_path[] = "sim-audio-input-XXXXXX";
char output_path[] = "sim-audio-output-XXXXXX";
const bool disk = std::strcmp(std::getenv("SDL_AUDIODRIVER"), "disk") == 0;
if (disk) {
const int input_fd = mkstemp(input_path);
const int output_fd = mkstemp(output_path);
if (input_fd < 0 || output_fd < 0) return 1;
FILE* file = fdopen(input_fd, "wb");
if (file == nullptr) return 1;
const std::vector<int16_t> fixture(48000, 10000);
const size_t written = std::fwrite(fixture.data(), sizeof(int16_t), fixture.size(), file);
std::fclose(file);
::close(output_fd);
if (written != fixture.size()) return 1;
SDL_setenv("SDL_DISKAUDIOFILEIN", input_path, 1);
SDL_setenv("SDL_DISKAUDIOFILE", output_path, 1);
}
TestContext data { argc, argv };
if (xTaskCreate(run_tests, "audio-test", 32768, &data, 1, nullptr) != pdPASS) return 1;
vTaskStartScheduler();
if (disk) {
std::remove(input_path);
std::remove(output_path);
}
return data.result;
}
+93
View File
@@ -0,0 +1,93 @@
# Simulator audio
The desktop simulator exposes an SDL speaker and microphone through Tactility's
standard `audio_stream_*` API. On macOS, SDL uses CoreAudio. Audio Settings controls
the simulator's input/output volume, mute, and enabled state; these controls do not
change macOS's system volume.
## Running on macOS
Build the simulator in the usual host build environment (with `python`, `lark`,
and `pyyaml` available, and without `ESP_IDF_VERSION`):
```sh
cmake -S . -B buildsim
cmake --build buildsim --target Tactility -j 8
```
Create a fresh application bundle (the script deliberately refuses to overwrite
an existing bundle):
```sh
sh Buildscripts/release-simulator-macos-app.sh buildsim release/Tactility-audio.app
open release/Tactility-audio.app
```
Alternatively, run `../buildsim/Tactility/Tactility` with `Data/` as the working
directory. Both the executable and the application bundle include a microphone
usage description. macOS asks for microphone access on the first actual recording
request, not at simulator startup. If denied, enable access in **System Settings
→ Privacy & Security → Microphone** and relaunch. For command-line launches, macOS
may attribute the permission to the terminal or launching application.
A Mac mini needs an external input device, such as a USB mic or headset. Without
an input device, speaker output still works and input is reported unavailable.
Connect the input before launch for predictable discovery/UI behavior.
## Selecting devices
By default, each stream opens the system's default device. Startup logs list SDL's
device names. Optional environment variables select an exact name:
```sh
SIM_AUDIO_OUTPUT="Mac mini Speakers" SIM_AUDIO_INPUT="USB Microphone" \
release/Tactility-audio.app/Contents/MacOS/Tactility
```
- `SIM_AUDIO_OUTPUT`: exact output name, or `none` to disable output.
- `SIM_AUDIO_INPUT`: exact input name, or `none` to disable input.
- Unset or empty values use the system default.
Selection is applied when opening a stream. An already-open stream does not
automatically switch when the system default changes; close/reopen it or relaunch.
A missing selected device causes an open failure rather than silently selecting a
different device. Permission/device errors are logged under `SdlAudio`.
## Formats and behavior
- Signed 16-bit PCM; the shared stream module converts app sample rates and channel
counts to 48 kHz mono capture / stereo playback. SDL handles host format conversion.
- One input and one output stream can be open simultaneously.
- Read/write from a worker task, never the LVGL thread.
- Audio callbacks use bounded lock-free buffers, with silence on playback underrun
and dropped incoming frames on capture overflow.
- Reads/writes report partial byte counts on timeout, including converted streams.
- Closing output drains its bounded buffer for up to 250 ms. Closing input does not
affect output, and vice versa.
- No audio mixing or acoustic echo cancellation is added by this backend. Use
headphones when testing simultaneous microphone capture and playback.
## Verification
```sh
cmake --build buildsim --target SimulatorAudioTests -j 8
ctest --test-dir buildsim/Tests -R SimulatorAudio --output-on-failure
```
These tests use SDL's dummy and disk backends without requiring microphone access.
They cover full duplex, 16/44.1/48 kHz app formats, bounded buffering, partial
timeouts, enable/disable, unavailable inputs, and sample-level input/output gain
and mute. They also cover disabling audio while a slow codec open is pending,
as can happen during a microphone permission prompt. Disk fixtures and output
files are temporary and removed after the run.
To explicitly play a quiet half-second 440 Hz tone through real Mac audio:
```sh
SDL_AUDIODRIVER=coreaudio SIM_AUDIO_OUTPUT= \
buildsim/Tests/simulator/SimulatorAudioTests \
--no-skip --test-case="hardware output smoke test"
```
Physical microphone capture, the first-use permission prompt, and live device
unplugging require a separate manual check with an input device attached.
@@ -113,6 +113,10 @@ struct AudioStreamHandleImpl : AudioStreamHandleData {
SemaphoreHandle_t drain_semaphore = nullptr;
};
// A slow codec open (notably the macOS microphone permission prompt) reserves a
// direction before a real handle exists. It must never be passed to close_stream().
AudioStreamHandleImpl* const OPENING_STREAM = reinterpret_cast<AudioStreamHandleImpl*>(1);
struct AudioStreamData {
Device* input_codec = nullptr;
Device* output_codec = nullptr;
@@ -130,6 +134,8 @@ struct AudioStreamData {
bool output_muted = false;
AudioStreamHandleImpl* open_input = nullptr;
AudioStreamHandleImpl* open_output = nullptr;
bool input_open_cancelled = false;
bool output_open_cancelled = false;
// Guards open_input/open_output and the closing/busy_count fields of any handle reachable
// through them, so close (possibly forced by set_enabled) can't race with read/write.
SemaphoreHandle_t mutex = nullptr;
@@ -261,7 +267,7 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
return ERROR_INVALID_ARGUMENT;
}
if (config->channels == 0) {
if (config->channels == 0 || config->sample_rate == 0) {
return ERROR_INVALID_ARGUMENT;
}
@@ -291,7 +297,9 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
// Reserve the slot with a placeholder so concurrent opens can't race past the check
// above while we do the (potentially slow) codec open below outside the lock.
auto* reservation = reinterpret_cast<AudioStreamHandleImpl*>(1);
auto* reservation = OPENING_STREAM;
bool* open_cancelled = is_input ? &data->input_open_cancelled : &data->output_open_cancelled;
*open_cancelled = false;
*slot = reservation;
xSemaphoreGive(data->mutex);
@@ -361,6 +369,18 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
}
xSemaphoreTake(data->mutex, portMAX_DELAY);
if (*open_cancelled) {
// Keep the reservation until the codec is closed, so re-enabling cannot
// open a second stream while this cancelled open is still cleaning up.
xSemaphoreGive(data->mutex);
vSemaphoreDelete(handle->drain_semaphore);
delete handle;
audio_codec_close(codec);
xSemaphoreTake(data->mutex, portMAX_DELAY);
*slot = nullptr;
xSemaphoreGive(data->mutex);
return ERROR_NOT_ALLOWED;
}
*slot = handle;
xSemaphoreGive(data->mutex);
@@ -377,6 +397,7 @@ error_t open_output(Device* device, const struct AudioStreamConfig* config, Audi
}
error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_size, size_t* bytes_read, TickType_t timeout) {
if (bytes_read != nullptr) *bytes_read = 0;
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
if (handle->direction != AUDIO_CODEC_DIR_INPUT || handle->bytes_per_frame == 0) {
return ERROR_INVALID_STATE;
@@ -419,7 +440,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
size_t codec_bytes_read = 0;
result = audio_codec_read(data->input_codec, handle->codec_buffer.data(), codec_bytes_needed, &codec_bytes_read, timeout);
if (result == ERROR_NONE) {
if (codec_bytes_read > 0) {
size_t codec_frames_read = codec_bytes_read / handle->codec_bytes_per_frame;
const int16_t* rate_input = reinterpret_cast<const int16_t*>(handle->codec_buffer.data());
uint8_t rate_input_channels = handle->codec_channels;
@@ -448,7 +469,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
}
}
if (result == ERROR_NONE && handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
if (handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
auto* samples = reinterpret_cast<int16_t*>(out_data);
size_t sample_count = *bytes_read / sizeof(int16_t);
for (size_t i = 0; i < sample_count; i++) {
@@ -462,6 +483,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
}
error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t data_size, size_t* bytes_written, TickType_t timeout) {
if (bytes_written != nullptr) *bytes_written = 0;
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
if (handle->direction != AUDIO_CODEC_DIR_OUTPUT || handle->bytes_per_frame == 0) {
return ERROR_INVALID_STATE;
@@ -532,9 +554,13 @@ error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t
size_t codec_bytes_to_write = codec_frames * handle->codec_bytes_per_frame;
size_t codec_bytes_written = 0;
result = audio_codec_write(data->output_codec, handle->codec_buffer.data(), codec_bytes_to_write, &codec_bytes_written, timeout);
if (result == ERROR_NONE && bytes_written != nullptr) {
// The caller provided `data_size` worth of input; we consumed all of it (resampled/converted).
*bytes_written = data_size;
if (bytes_written != nullptr && codec_frames > 0) {
// A bounded host/hardware queue can accept only part of a converted write,
// including when returning ERROR_TIMEOUT. Report progress in app-side whole
// frames rather than leaving the count untouched or claiming the entire write.
size_t written_frames = codec_bytes_written / handle->codec_bytes_per_frame;
if (written_frames > codec_frames) written_frames = codec_frames;
*bytes_written = (in_frames * written_frames / codec_frames) * handle->bytes_per_frame;
}
}
@@ -686,13 +712,17 @@ error_t set_enabled(Device* device, AudioCodecDirection direction, bool enabled)
data->output_enabled = enabled;
}
// Capture and clear the slot under the lock so we hand close_stream() a pointer that
// can't simultaneously be torn down by a racing close from the owning app (close_stream
// re-checks `*slot == handle` and no-ops if it's already been cleared/replaced).
// A pending open has no handle to close yet. Let its owning task clean up when
// the codec returns, even if the user re-enables the direction in the meantime.
AudioStreamHandleImpl* to_close = nullptr;
if (!enabled) {
AudioStreamHandleImpl** slot = is_input ? &data->open_input : &data->open_output;
to_close = *slot;
if (*slot == OPENING_STREAM) {
if (is_input) data->input_open_cancelled = true;
else data->output_open_cancelled = true;
} else {
to_close = *slot;
}
}
xSemaphoreGive(data->mutex);
+20 -6
View File
@@ -10,15 +10,29 @@ tactility_add_module(app-module
INCLUDE_DIRS include/
REQUIRES TactilityKernel service-module minitar
PRIV_REQUIRES TactilityKernelCpp
WHOLE_ARCHIVE
)
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close()
# rather than calling ::read/::write/::close() directly, on every platform whose build wraps
# those symbols (ESP-IDF always; POSIX except macOS, whose linker doesn't support --wrap) -
# see Tactility/CMakeLists.txt and the top-level CMakeLists.txt for where that's applied.
if (NOT APPLE)
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close() rather
# than calling ::read/::write/::close() directly, on every platform where those are wrapped: via
# -Wl,--wrap= on ESP-IDF (see the top-level CMakeLists.txt) and non-Apple POSIX (below), or via
# source/stdio_wrap.cpp's dyld interpose on Apple (whose linker lacks --wrap).
tactility_get_module_name(app-module MODULE_NAME)
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
# Routes every read()/write()/close() and printf-family call to source/stdio_wrap.cpp's __wrap_*
# functions; see that file for what they do and why. ESP-IDF wraps the same symbols itself (see
# the top-level CMakeLists.txt); Apple gets the same routing via dyld interpose instead, self-
# registered in that file. PUBLIC so every consumer of this module (Tactility, and any test target
# that links app-module directly) gets the flags too.
if (NOT ESP_PLATFORM AND NOT APPLE)
tactility_get_module_name(app-module MODULE_NAME)
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
target_link_options(${MODULE_NAME} PUBLIC
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# install.cpp resolves an installed binary's fixed bin/<platform>/<binary>.so path itself, so it
+33 -20
View File
@@ -8,13 +8,42 @@
#include <cerrno>
#if defined(TT_APP_IO_WRAPS_STDIO)
#ifdef ESP_PLATFORM
// Newlib's stdio (fflush()'s buffer-flush path, in particular) calls the reentrant _read_r/
// _write_r/_close_r syscall stubs directly. The plain read()/write()/close() newlib provides are
// just thin wrappers around them (esp-idf's components/newlib/src/syscalls.c: `write(fd, dst,
// size) { return _write_r(__getreent(), fd, dst, size); }`). Wrapping the _r stubs catches both;
// wrapping the plain names would only catch direct write()-style callers.
#include <reent.h>
extern "C" {
ssize_t __real__read_r(struct _reent* r, int fd, void* buffer, size_t size);
ssize_t __real__write_r(struct _reent* r, int fd, const void* buffer, size_t size);
int __real__close_r(struct _reent* r, int fd);
}
namespace {
ssize_t real_read(int fd, void* buffer, size_t size) { return __real__read_r(__getreent(), fd, buffer, size); }
ssize_t real_write(int fd, const void* buffer, size_t size) { return __real__write_r(__getreent(), fd, buffer, size); }
int real_close(int fd) { return __real__close_r(__getreent(), fd); }
} // namespace
#else
extern "C" {
ssize_t __real_read(int fd, void* buffer, size_t size);
ssize_t __real_write(int fd, const void* buffer, size_t size);
int __real_close(int fd);
}
namespace {
ssize_t real_read(int fd, void* buffer, size_t size) { return __real_read(fd, buffer, size); }
ssize_t real_write(int fd, const void* buffer, size_t size) { return __real_write(fd, buffer, size); }
int real_close(int fd) { return __real_close(fd); }
} // namespace
#endif
#else
#include <unistd.h>
namespace {
ssize_t real_read(int fd, void* buffer, size_t size) { return ::read(fd, buffer, size); }
ssize_t real_write(int fd, const void* buffer, size_t size) { return ::write(fd, buffer, size); }
int real_close(int fd) { return ::close(fd); }
} // namespace
#endif
namespace {
@@ -56,11 +85,7 @@ ssize_t app_io_read(int fd, void* buffer, size_t size) {
errno = EBADF;
return -1;
}
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_read(fd, buffer, size);
#else
return ::read(fd, buffer, size);
#endif
return real_read(fd, buffer, size);
}
ssize_t app_io_write(int fd, const void* buffer, size_t size) {
@@ -78,22 +103,14 @@ ssize_t app_io_write(int fd, const void* buffer, size_t size) {
// output is currently being intercepted. Without this, a log line emitted while any app
// instance has its stdout captured would vanish from the console entirely instead of
// just also being visible to the capturing parent.
#if defined(TT_APP_IO_WRAPS_STDIO)
__real_write(fd, buffer, size);
#else
::write(fd, buffer, size);
#endif
real_write(fd, buffer, size);
return result;
}
if (table != nullptr && app_fd_table_is_app_owned(table, fd)) {
errno = EBADF;
return -1;
}
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_write(fd, buffer, size);
#else
return ::write(fd, buffer, size);
#endif
return real_write(fd, buffer, size);
}
int app_io_close(int fd) {
@@ -108,11 +125,7 @@ int app_io_close(int fd) {
return -1;
}
}
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_close(fd);
#else
return ::close(fd);
#endif
return real_close(fd);
}
} // extern "C"
+327
View File
@@ -0,0 +1,327 @@
// SPDX-License-Identifier: Apache-2.0
// Paired with -Wl,--wrap= on POSIX (this module's own CMakeLists.txt) and ESP32 (top-level
// CMakeLists.txt); self-registered via dyld interpose below on Apple, whose linker doesn't
// support --wrap.
#include <app/io.h>
#include <sys/types.h>
#ifdef ESP_PLATFORM
// Newlib's own stdio (fflush()'s buffer-flush path, in particular) calls the reentrant
// _read_r/_write_r/_close_r syscall stubs directly, not the plain read()/write()/close() newlib
// itself provides as thin wrappers around them (see e.g. esp-idf's components/newlib/src/
// syscalls.c: `write(fd, dst, size) { return _write_r(__getreent(), fd, dst, size); }`)
// Wrapping the plain names only catches direct write()-style callers, not newlib's own internal
// stdio calls, so the _r stubs are wrapped here instead.
#include <reent.h>
extern "C" {
ssize_t __wrap__read_r(struct _reent* r, int fd, void* buffer, size_t size) {
(void)r;
return app_io_read(fd, buffer, size);
}
ssize_t __wrap__write_r(struct _reent* r, int fd, const void* buffer, size_t size) {
(void)r;
return app_io_write(fd, buffer, size);
}
int __wrap__close_r(struct _reent* r, int fd) {
(void)r;
return app_io_close(fd);
}
}
#else
extern "C" {
ssize_t __wrap_read(int fd, void* buffer, size_t size) {
return app_io_read(fd, buffer, size);
}
ssize_t __wrap_write(int fd, const void* buffer, size_t size) {
return app_io_write(fd, buffer, size);
}
int __wrap_close(int fd) {
return app_io_close(fd);
}
}
#endif // ESP_PLATFORM
#ifdef __APPLE__
// --wrap also synthesizes __real_read/write/close automatically; dyld interpose doesn't, so
// io.cpp's TT_APP_IO_WRAPS_STDIO fallback needs them defined here. dlsym(RTLD_NEXT, ...) is the
// standard way to reach the true libSystem implementation despite the interpose below: a direct
// call to read/write/close from this file would just recurse into __wrap_read/write/close, since
// interpose rewrites every reference to those symbols in the process, this file included.
#include <dlfcn.h>
#include <unistd.h>
extern "C" {
ssize_t __real_read(int fd, void* buffer, size_t size) {
static auto real = reinterpret_cast<ssize_t (*)(int, void*, size_t)>(dlsym(RTLD_NEXT, "read"));
return real(fd, buffer, size);
}
ssize_t __real_write(int fd, const void* buffer, size_t size) {
static auto real = reinterpret_cast<ssize_t (*)(int, const void*, size_t)>(dlsym(RTLD_NEXT, "write"));
return real(fd, buffer, size);
}
int __real_close(int fd) {
static auto real = reinterpret_cast<int (*)(int)>(dlsym(RTLD_NEXT, "close"));
return real(fd);
}
}
// <mach-o/dyld-interposing.h> isn't a public SDK header (it ships with dyld's own source, not
// Xcode/Command Line Tools), so this reimplements its DYLD_INTERPOSE macro locally; reused below
// for the printf-family interposes too.
#define TT_DYLD_INTERPOSE(replacement, replacee) \
__attribute__((used)) static struct { const void* replacement; const void* replacee; } \
tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \
(const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \
};
TT_DYLD_INTERPOSE(__wrap_read, read)
TT_DYLD_INTERPOSE(__wrap_write, write)
TT_DYLD_INTERPOSE(__wrap_close, close)
#endif // __APPLE__
// region glibc stdio wraps
//
// libc's printf/fprintf/etc are compiled into the C library and call an internal, non-exported
// write() alias, which --wrap=write/the read/write/close interpose above can't reach: only calls
// WE make to the public symbol. These wraps instead redirect calls WE make to printf/fprintf/etc,
// the same trick as read/write/close above. Newlib (ESP-IDF) doesn't have this gap: its stdio does
// call the wrappable syscall stubs, so this block is POSIX-only.
//
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
// route every such call through this file's stdin/stdout check, a correctness risk for unrelated
// code that isn't worth taking here. putc/getc are excluded too since libc defines them as
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
#if !defined(ESP_PLATFORM)
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <memory>
#include <unistd.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args);
int __real_fputs(const char* s, FILE* stream);
int __real_fputc(int c, FILE* stream);
int __real_fgetc(FILE* stream);
char* __real_fgets(char* buffer, int size, FILE* stream);
}
#ifdef __APPLE__
// --wrap synthesizes these automatically elsewhere; on Apple they're defined here via
// dlsym(RTLD_NEXT, ...) instead. See the read/write/close __real_* block above for why.
#include <dlfcn.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args) {
static auto real = reinterpret_cast<int (*)(FILE*, const char*, va_list)>(dlsym(RTLD_NEXT, "vfprintf"));
return real(stream, format, args);
}
int __real_fputs(const char* s, FILE* stream) {
static auto real = reinterpret_cast<int (*)(const char*, FILE*)>(dlsym(RTLD_NEXT, "fputs"));
return real(s, stream);
}
int __real_fputc(int c, FILE* stream) {
static auto real = reinterpret_cast<int (*)(int, FILE*)>(dlsym(RTLD_NEXT, "fputc"));
return real(c, stream);
}
int __real_fgetc(FILE* stream) {
static auto real = reinterpret_cast<int (*)(FILE*)>(dlsym(RTLD_NEXT, "fgetc"));
return real(stream);
}
char* __real_fgets(char* buffer, int size, FILE* stream) {
static auto real = reinterpret_cast<char* (*)(char*, int, FILE*)>(dlsym(RTLD_NEXT, "fgets"));
return real(buffer, size, stream);
}
}
#endif // __APPLE__
namespace {
void writeAllToStdout(const void* data, size_t size) {
const auto* bytes = static_cast<const char*>(data);
size_t remaining = size;
while (remaining > 0) {
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
if (written <= 0) {
break;
}
bytes += written;
remaining -= static_cast<size_t>(written);
}
}
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
int formatToStdout(const char* format, va_list args) {
char stackBuffer[256];
va_list argsForStack;
va_copy(argsForStack, args);
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
va_end(argsForStack);
if (needed < 0) {
return needed;
}
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
return needed;
}
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
va_list argsForHeap;
va_copy(argsForHeap, args);
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
va_end(argsForHeap);
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
return needed;
}
int readOneFromStdin(char& out) {
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
}
} // namespace
extern "C" {
int __wrap_vprintf(const char* format, va_list args) {
return formatToStdout(format, args);
}
int __wrap_printf(const char* format, ...) {
va_list args;
va_start(args, format);
int result = formatToStdout(format, args);
va_end(args);
return result;
}
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
if (stream == stdout) {
return formatToStdout(format, args);
}
return __real_vfprintf(stream, format, args);
}
int __wrap_fprintf(FILE* stream, const char* format, ...) {
va_list args;
va_start(args, format);
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
va_end(args);
return result;
}
int __wrap_puts(const char* s) {
writeAllToStdout(s, strlen(s));
writeAllToStdout("\n", 1);
return 0;
}
int __wrap_fputs(const char* s, FILE* stream) {
if (stream == stdout) {
writeAllToStdout(s, strlen(s));
return 0;
}
return __real_fputs(s, stream);
}
int __wrap_putchar(int c) {
auto ch = static_cast<char>(c);
writeAllToStdout(&ch, 1);
return c;
}
int __wrap_fputc(int c, FILE* stream) {
if (stream == stdout) {
return __wrap_putchar(c);
}
return __real_fputc(c, stream);
}
int __wrap_getchar() {
char c;
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
}
int __wrap_fgetc(FILE* stream) {
if (stream == stdin) {
return __wrap_getchar();
}
return __real_fgetc(stream);
}
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
if (stream != stdin) {
return __real_fgets(buffer, size, stream);
}
if (size <= 0) {
return nullptr;
}
int i = 0;
for (; i < size - 1; ++i) {
char c;
if (readOneFromStdin(c) != 1) {
break;
}
buffer[i] = c;
if (c == '\n') {
++i;
break;
}
}
if (i == 0) {
return nullptr;
}
buffer[i] = '\0';
return buffer;
}
}
#ifdef __APPLE__
TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf)
TT_DYLD_INTERPOSE(__wrap_printf, printf)
TT_DYLD_INTERPOSE(__wrap_vfprintf, vfprintf)
TT_DYLD_INTERPOSE(__wrap_fprintf, fprintf)
TT_DYLD_INTERPOSE(__wrap_puts, puts)
TT_DYLD_INTERPOSE(__wrap_fputs, fputs)
TT_DYLD_INTERPOSE(__wrap_putchar, putchar)
TT_DYLD_INTERPOSE(__wrap_fputc, fputc)
TT_DYLD_INTERPOSE(__wrap_getchar, getchar)
TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc)
TT_DYLD_INTERPOSE(__wrap_fgets, fgets)
#endif // __APPLE__
#endif // !ESP_PLATFORM
// endregion
+2 -20
View File
@@ -6,30 +6,12 @@ enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(AppModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
if (NOT APPLE)
# Provides __wrap_read/write/close for the -Wl,--wrap= below (see Tactility/CMakeLists.txt
# for the canonical pairing of this file with those flags).
target_sources(AppModuleTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../../../Tactility/Source/AppStdioWrap.cpp)
endif ()
target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC} ${CMAKE_CURRENT_LIST_DIR}/../private)
add_test(NAME AppModuleTests COMMAND AppModuleTests)
# Matches app-module's own TT_APP_IO_WRAPS_STDIO (see Modules/app-module/CMakeLists.txt):
# io.cpp calls __real_read/write/close() on non-Apple POSIX, so any final link of it needs
# these wraps too, or those symbols go unresolved. The printf-family/getc-family flags are
# needed for the same reason: AppStdioWrap.cpp compiles those __wrap_* functions unconditionally
# on this platform (see its own #if guard), and they reference __real_vfprintf/fputs/fputc/
# fgetc/fgets - those only exist once the matching --wrap flag is passed.
if (NOT APPLE)
target_link_options(AppModuleTests PRIVATE
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# No --wrap flags or stdio_wrap.cpp source needed here: linking app-module below already brings
# both along (see its own CMakeLists.txt).
target_link_libraries(AppModuleTests PUBLIC
TactilityKernel
@@ -15,6 +15,7 @@
#include <tactility/delay.h>
#include <tactility/freertos/task.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
@@ -110,6 +111,11 @@ int32_t stream_writer_app_main(int, char*[]) {
return 7;
}
int32_t printf_stream_writer_app_main(int, char*[]) {
printf("loc");
return 7;
}
// Writes argv[0] to its own stdout, for the app_execute_with_streams() argv-delivery test.
int32_t argv_echo_app_main(int argc, char* argv[]) {
if (argc < 1) {
@@ -321,3 +327,53 @@ TEST_CASE("app_execute_for_result_with_streams delivers both the stream data and
app_manager_stop(parent_id);
app_manager_remove("test.app.execute.parent_streams");
}
TEST_CASE("app_execute_for_result_with_streams pipes a child's plain printf() calls too") {
ensure_memory_loader_registered();
AppManifest parent_manifest { "test.app.execute.printf_parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(location_app_main) } };
REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE);
uint32_t parent_id = 0;
REQUIRE_EQ(app_start("test.app.execute.printf_parent", 0, nullptr, &parent_id), ERROR_NONE);
CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000));
TaskEventGroup parent_event_group {};
task_event_group_construct(&parent_event_group);
AppEventSubscription parent_sub {};
REQUIRE_EQ(app_event_subscribe_with_app_id(&parent_sub, &parent_event_group, parent_id), ERROR_NONE);
uint8_t storage[64];
AppStream child_stdout {};
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &parent_event_group };
AppLocation location { APP_LOCATION_MEMORY, reinterpret_cast<void*>(printf_stream_writer_app_main) };
uint32_t child_id = 0;
REQUIRE_EQ(app_execute_for_result_with_streams(location, AppStackConfig {}, 0, nullptr, &binding, 1, parent_id, &child_id), ERROR_NONE);
std::vector<uint8_t> received;
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
uint8_t chunk[16];
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
if (n == 0) {
break; // EOF
}
received.insert(received.end(), chunk, chunk + n);
}
REQUIRE_EQ(received.size(), 3u);
CHECK_EQ(std::memcmp(received.data(), "loc", 3), 0);
REQUIRE_EQ(task_event_group_wait(&parent_event_group, parent_sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
AppEvent event {};
REQUIRE_EQ(app_event_poll(&parent_sub, &event), ERROR_NONE);
CHECK_EQ(event.type, APP_EVENT_RESULT);
CHECK_EQ(event.result.launch_id, child_id);
CHECK_EQ(event.result.result, 7);
app_stream_unsubscribe(&child_stdout);
app_event_unsubscribe(&parent_sub);
task_event_group_destruct(&parent_event_group);
app_manager_stop(child_id);
app_manager_stop(parent_id);
app_manager_remove("test.app.execute.printf_parent");
}
@@ -16,6 +16,7 @@
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <vector>
@@ -66,6 +67,11 @@ int32_t stdout_writer_app_main(int, char*[]) {
return 0;
}
int32_t stdout_printf_app_main(int, char*[]) {
printf("hello");
return 0;
}
std::atomic<bool> g_blocked_writer_saw_error { false };
std::atomic<bool> g_blocked_writer_done { false };
@@ -180,6 +186,41 @@ TEST_CASE("app_start_with_streams pipes a child's app_io_write() calls into a pa
app_manager_remove("test.io.writer");
}
TEST_CASE("app_start_with_streams pipes a child's plain printf() calls into a parent-owned AppStream") {
ensure_memory_loader_registered();
AppManifest manifest { "test.io.printf_writer", "PrintfWriter", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(stdout_printf_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
uint8_t storage[64];
AppStream child_stdout {};
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &event_group };
AppInstanceId child_id = 0;
REQUIRE_EQ(app_start_with_streams("test.io.printf_writer", &binding, 1, &child_id), ERROR_NONE);
std::vector<uint8_t> received;
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
uint8_t chunk[16];
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
if (n == 0) {
break; // EOF
}
received.insert(received.end(), chunk, chunk + n);
}
REQUIRE_EQ(received.size(), 5u);
CHECK_EQ(std::memcmp(received.data(), "hello", 5), 0);
REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000));
app_stream_unsubscribe(&child_stdout);
task_event_group_destruct(&event_group);
app_manager_remove("test.io.printf_writer");
}
TEST_CASE("a write blocked on a full stream wakes with an error once the consumer closes it") {
ensure_memory_loader_registered();
g_blocked_writer_saw_error.store(false, std::memory_order_relaxed);
+9
View File
@@ -6,3 +6,12 @@ add_library(platform-posix OBJECT)
target_sources(platform-posix PRIVATE ${SOURCES})
#target_include_directories(platform-posix PUBLIC include/)
target_link_libraries(platform-posix PUBLIC TactilityKernel)
# Routes every pthread_attr_setstack() call to __wrap_pthread_attr_setstack() in source/pthread_stack_wrap.c,
# which no-ops it. see that file for why.
# --wrap is a GNU ld option; Apple's linker doesn't support it, so that file instead self-registers a dyld interpose
# for this symbol on Apple, needing no link flag. PUBLIC so every consumer of this OBJECT library
# (Tactility, and every test target that links platform-posix directly) gets it applied too.
if (NOT APPLE)
target_link_options(platform-posix PUBLIC "-Wl,--wrap=pthread_attr_setstack")
endif ()
@@ -2,20 +2,15 @@
#include <stddef.h>
/**
* Linked in with -Wl,--wrap=pthread_attr_setstack (see Tactility/CMakeLists.txt).
* FreeRTOS's POSIX port hands every task a stack carved from its own heap (pvPortMalloc) via this call.
* pthread_attr_setstack requires page alignment that pvPortMalloc doesn't guarantee.
* When it succeeds anyway, the task's real pthread stack ends up living inside that small FreeRTOS heap region,
* where a thread doing heavier stack work (e.g. Mesa GL shader compilation) can silently corrupt adjacent heap_4 objects.
* No-op'ing the call instead leaves every task's pthread_attr_t at its pthread_attr_init() default,
* so pthread_create() gives it a real, properly sized stack.
*
* FreeRTOS's POSIX port (Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/port.c)
* hands every task a stack carved out of its own heap (pvPortMalloc) via this call.
* pthread_attr_setstack requires page alignment, which pvPortMalloc doesn't guarantee;
* when it happens to succeed anyway (allocator alignment can vary run to run), the
* task's real pthread stack ends up living inside that small FreeRTOS heap region -
* fine for typical embedded task code, but a desktop GL driver doing on-the-fly shader
* compilation on that thread (e.g. Mesa on first frame present) can overflow it and
* silently corrupt adjacent heap_4 objects.
*
* Wrapping the call out entirely (rather than patching the vendored port.c) leaves every
* task's pthread_attr_t at its pthread_attr_init() default, so pthread_create() always
* gives it a real, properly allocated default-size stack instead.
* Linked via -Wl,--wrap=pthread_attr_setstack (this module's own CMakeLists.txt) on non-Apple
* platforms; reused as a dyld interpose target below on Apple platforms, whose linker lacks --wrap.
*/
int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t stacksize) {
(void)attr;
@@ -23,3 +18,17 @@ int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t s
(void)stacksize;
return 0;
}
#ifdef __APPLE__
// <mach-o/dyld-interposing.h> isn't a public SDK header (it ships with dyld's own source, not
// Xcode/Command Line Tools), so this reimplements its DYLD_INTERPOSE macro locally.
#define TT_DYLD_INTERPOSE(replacement, replacee) \
__attribute__((used)) static struct { const void* replacement; const void* replacee; } \
tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \
(const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \
};
TT_DYLD_INTERPOSE(__wrap_pthread_attr_setstack, pthread_attr_setstack)
#endif
+3 -25
View File
@@ -185,31 +185,9 @@ else ()
# Exports Tactility's own symbols (-rdynamic) so a dlopen()ed app-posix-module app can resolve
# calls back into it - the OS-native equivalent of app-esp32-module's custom symbol resolver.
set_target_properties(Tactility PROPERTIES ENABLE_EXPORTS ON)
# Routes every pthread_attr_setstack() call (FreeRTOS's POSIX port hands each task a stack
# carved out of its own heap through this) to __wrap_pthread_attr_setstack() in
# Platforms/platform-posix/source/pthread_stack_wrap.c, which no-ops it - see that file for why.
# --wrap is a GNU ld option; Apple's linker doesn't support it.
if (NOT APPLE)
target_link_options(Tactility PRIVATE "-Wl,--wrap=pthread_attr_setstack")
# Routes every read()/write()/close() call to Tactility/Source/AppStdioWrap.cpp's
# __wrap_read/write/close(), which forward into app_io_read/write/close() - the fd-table
# dispatch that lets an app's own stdio (e.g. a fileselection dialog's printf'd result
# path) reach an AppStream a parent bound via app_manager_start_with_streams(). Mirrors
# what ESP-IDF's build already does for the ESP32 target (see top-level CMakeLists.txt).
target_link_options(Tactility PRIVATE "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close")
# glibc's printf/fprintf/etc don't call the public write() symbol internally (they're
# already compiled into libc.so, out of --wrap's reach), so the read/write/close wrap
# above can't see them. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the
# wrappable syscall stubs - so this block is POSIX-only. Wrapping these symbols instead
# redirects OUR OWN calls to them (the only ones --wrap can rewrite) through
# AppStdioWrap.cpp's __wrap_* functions, which check the target stream (stdout/stdin) and
# fall back to the real libc function for any other FILE*.
target_link_options(Tactility PRIVATE
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# pthread_attr_setstack and read/write/close/printf-family aren't wrapped here: linking
# platform-posix and app-module already brings those wraps along (see their own
# CMakeLists.txt files).
endif ()
#
@@ -83,8 +83,8 @@ private:
static error_t handleApiScreenshot(struct HttpServerRequest* request, void* user_ctx);
static error_t handleApiSimTouch(struct HttpServerRequest* request, void* user_ctx);
static error_t handleSimViewer(struct HttpServerRequest* request, void* user_ctx);
#ifdef ESP_PLATFORM
static error_t handleApiMcp(struct HttpServerRequest* request, void* user_ctx);
#ifdef ESP_PLATFORM
static error_t handleApiScreenRaw(struct HttpServerRequest* request, void* user_ctx);
#endif
-200
View File
@@ -1,200 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Paired with -Wl,--wrap=read/write/close - see Tactility/CMakeLists.txt (POSIX) and the
// top-level CMakeLists.txt (ESP32) for where that's applied. On a platform where it isn't
// (currently: macOS, whose linker doesn't support --wrap), these are simply never called - real
// read()/write()/close() calls go straight through unredirected.
#include <app/io.h>
#include <sys/types.h>
extern "C" {
ssize_t __wrap_read(int fd, void* buffer, size_t size) {
return app_io_read(fd, buffer, size);
}
ssize_t __wrap_write(int fd, const void* buffer, size_t size) {
return app_io_write(fd, buffer, size);
}
int __wrap_close(int fd) {
return app_io_close(fd);
}
}
// region glibc stdio wraps
//
// glibc's printf/fprintf/etc are compiled into libc.so and call an internal, non-exported write()
// alias - --wrap=write (above) can't reach that call, only calls WE make to the public symbol.
// These wraps instead redirect calls WE make to printf/fprintf/etc, the same trick as read/write/
// close above. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the wrappable syscall
// stubs - so Tactility/CMakeLists.txt only applies the matching -Wl,--wrap= flags on POSIX.
//
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
// route every such call through this file's stdin/stdout check - a correctness risk for unrelated
// code that isn't worth taking here. putc/getc are excluded too since glibc defines them as
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
#if !defined(ESP_PLATFORM) && !defined(__APPLE__)
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <memory>
#include <unistd.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args);
int __real_fputs(const char* s, FILE* stream);
int __real_fputc(int c, FILE* stream);
int __real_fgetc(FILE* stream);
char* __real_fgets(char* buffer, int size, FILE* stream);
}
namespace {
void writeAllToStdout(const void* data, size_t size) {
const auto* bytes = static_cast<const char*>(data);
size_t remaining = size;
while (remaining > 0) {
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
if (written <= 0) {
break;
}
bytes += written;
remaining -= static_cast<size_t>(written);
}
}
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
int formatToStdout(const char* format, va_list args) {
char stackBuffer[256];
va_list argsForStack;
va_copy(argsForStack, args);
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
va_end(argsForStack);
if (needed < 0) {
return needed;
}
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
return needed;
}
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
va_list argsForHeap;
va_copy(argsForHeap, args);
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
va_end(argsForHeap);
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
return needed;
}
int readOneFromStdin(char& out) {
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
}
} // namespace
extern "C" {
int __wrap_vprintf(const char* format, va_list args) {
return formatToStdout(format, args);
}
int __wrap_printf(const char* format, ...) {
va_list args;
va_start(args, format);
int result = formatToStdout(format, args);
va_end(args);
return result;
}
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
if (stream == stdout) {
return formatToStdout(format, args);
}
return __real_vfprintf(stream, format, args);
}
int __wrap_fprintf(FILE* stream, const char* format, ...) {
va_list args;
va_start(args, format);
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
va_end(args);
return result;
}
int __wrap_puts(const char* s) {
writeAllToStdout(s, strlen(s));
writeAllToStdout("\n", 1);
return 0;
}
int __wrap_fputs(const char* s, FILE* stream) {
if (stream == stdout) {
writeAllToStdout(s, strlen(s));
return 0;
}
return __real_fputs(s, stream);
}
int __wrap_putchar(int c) {
auto ch = static_cast<char>(c);
writeAllToStdout(&ch, 1);
return c;
}
int __wrap_fputc(int c, FILE* stream) {
if (stream == stdout) {
return __wrap_putchar(c);
}
return __real_fputc(c, stream);
}
int __wrap_getchar() {
char c;
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
}
int __wrap_fgetc(FILE* stream) {
if (stream == stdin) {
return __wrap_getchar();
}
return __real_fgetc(stream);
}
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
if (stream != stdin) {
return __real_fgets(buffer, size, stream);
}
if (size <= 0) {
return nullptr;
}
int i = 0;
for (; i < size - 1; ++i) {
char c;
if (readOneFromStdin(c) != 1) {
break;
}
buffer[i] = c;
if (c == '\n') {
++i;
break;
}
}
if (i == 0) {
return nullptr;
}
buffer[i] = '\0';
return buffer;
}
}
#endif // !ESP_PLATFORM && !__APPLE__
// endregion
+5 -1
View File
@@ -194,6 +194,8 @@ namespace app {
#if CONFIG_TT_TDECK_WORKAROUND == 1
namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now
#endif
#else
namespace mcpsettings { extern const ::AppManifest manifest; }
#endif
namespace trackballsettings { extern const ::AppManifest manifest; } // T-Deck only for now
@@ -252,12 +254,14 @@ static void registerInternalApps() {
app_manager_add(&app::development::manifest);
app_manager_add(&app::webserversettings::manifest);
#ifdef ESP_PLATFORM
app_manager_add(&app::mcpsettings::manifest);
app_manager_add(&app::apwebserver::manifest);
app_manager_add(&app::crashdiagnostics::manifest);
app_manager_add(&app::mcpsettings::manifest);
#if defined(CONFIG_TT_TDECK_WORKAROUND)
app_manager_add(&app::keyboardsettings::manifest);
#endif
#else
app_manager_add(&app::mcpsettings::manifest);
#endif
if (device_exists_of_type(&TRACKBALL_TYPE)) {
@@ -85,7 +85,7 @@ int32_t appMain(int argc, char* argv[]) {
if (ctx.resultCode == 0) {
// The parent captures this via an AppStream bound to our stdout (see startWithMode()) -
// see AppStdioWrap.cpp for how printf() itself gets routed there on POSIX.
// see Modules/app-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
LOG_I(TAG, "Result: %s", ctx.resultPath.c_str());
printf("%s", ctx.resultPath.c_str());
}
@@ -138,7 +138,7 @@ int32_t appMain(int argc, char* argv[]) {
if (ctx.resultCode == 0) {
// The caller captures this via an AppStream bound to our stdout (see start()); see
// AppStdioWrap.cpp for how printf() itself gets routed there on POSIX.
// Modules/app-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
printf("%s", ctx.resultText.c_str());
}
return ctx.resultCode;
@@ -0,0 +1,66 @@
#ifndef ESP_PLATFORM
#include <app/event.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <lvgl/lvgl.h>
#include <lvgl_window_manager/window_manager.h>
namespace tt::app::mcpsettings {
struct Context { AppInstanceId instanceId; };
void createMcpInfo(lv_obj_t* parent, void* userData) {
auto* context = static_cast<Context*>(userData);
lv_obj_t* title = lv_label_create(parent);
lv_label_set_text(title, "MCP Settings");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18);
lv_obj_t* info = lv_label_create(parent);
lv_label_set_text(info,
"MCP is available while the simulator web server is running.\n\n"
"Endpoint: POST /api/mcp\n"
"JSON-RPC methods: tools/list, tools/call\n\n"
"Simulator tools: get_capabilities, clear_screen, draw_text, list_apps, run_app\n\n"
"Open the simulator dashboard/API at http://127.0.0.1/api/sysinfo.");
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info, LV_PCT(90));
lv_obj_align(info, LV_ALIGN_CENTER, 0, 8);
(void)context;
}
int32_t appMain(int, char**) {
Context context{app_scheduler_current_app_id()};
TaskEventGroup group{};
task_event_group_construct(&group);
AppEventSubscription subscription{};
if (app_event_subscribe(&subscription, &group) != ERROR_NONE) {
task_event_group_destruct(&group);
return -1;
}
auto window = window_manager_create(context.instanceId, createMcpInfo, &context);
bool close = false;
while (!close) {
task_event_group_wait_any(&group, nullptr, portMAX_DELAY);
AppEvent event{};
while (app_event_poll(&subscription, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_CLOSE) { close = true; break; }
}
}
window_manager_remove(window);
app_event_unsubscribe(&subscription);
task_event_group_destruct(&group);
return 0;
}
extern const AppManifest manifest = {
.id = "McpSettings",
.name = "MCP Settings",
.category = APP_CATEGORY_SETTINGS,
.location = {APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain)},
.flags = 0,
.stack = {.depth = 8192, .desired_memory_capability = 0}
};
}
#endif
@@ -0,0 +1,258 @@
#ifndef ESP_PLATFORM
#include <Tactility/service/webserver/WebServerService.h>
#include <Tactility/lvgl/Lvgl.h>
#include <lvgl/lvgl.h>
#include <app/manager.h>
#include <app/start.h>
#include <cJSON.h>
#include <http/server.h>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
namespace tt::service::webserver {
namespace {
constexpr size_t JSON_BODY_LIMIT = 64 * 1024;
lv_obj_t* gCanvas = nullptr;
bool gCanvasBlack = false;
lv_obj_t* getCanvas() {
if (gCanvas != nullptr) return gCanvas;
lv_display_t* display = lv_display_get_default();
if (display == nullptr) return nullptr;
lv_obj_t* layer = lv_display_get_layer_top(display);
gCanvas = lv_obj_create(layer);
lv_obj_set_pos(gCanvas, 0, 0);
lv_obj_set_size(gCanvas, lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display));
lv_obj_set_style_pad_all(gCanvas, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(gCanvas, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(gCanvas, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_clear_flag(gCanvas, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(gCanvas, LV_OBJ_FLAG_CLICKABLE);
return gCanvas;
}
bool clearCanvas(int color) {
lv_obj_t* canvas = getCanvas();
if (canvas == nullptr) return false;
lv_obj_clean(canvas);
gCanvasBlack = color == 1;
lv_obj_set_style_bg_color(canvas, gCanvasBlack ? lv_color_black() : lv_color_white(), LV_PART_MAIN);
lv_obj_set_style_bg_opa(canvas, LV_OPA_COVER, LV_PART_MAIN);
return true;
}
bool drawCanvasText(const char* text, int x, int y) {
lv_obj_t* canvas = getCanvas();
if (canvas == nullptr) return false;
lv_obj_t* label = lv_label_create(canvas);
lv_label_set_text(label, text);
lv_obj_set_pos(label, x, y);
lv_obj_set_style_text_color(label, gCanvasBlack ? lv_color_white() : lv_color_black(), LV_PART_MAIN);
return true;
}
cJSON* makeError(const cJSON* id, int code, const char* message) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "jsonrpc", "2.0");
cJSON* error = cJSON_AddObjectToObject(response, "error");
cJSON_AddNumberToObject(error, "code", code);
cJSON_AddStringToObject(error, "message", message);
if (id != nullptr) cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true));
else cJSON_AddNullToObject(response, "id");
return response;
}
cJSON* makeTextResult(const cJSON* id, const char* text, bool isError = false) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "jsonrpc", "2.0");
cJSON* result = cJSON_AddObjectToObject(response, "result");
cJSON* content = cJSON_AddArrayToObject(result, "content");
cJSON* item = cJSON_CreateObject();
cJSON_AddStringToObject(item, "type", "text");
cJSON_AddStringToObject(item, "text", text);
cJSON_AddItemToArray(content, item);
if (isError) cJSON_AddBoolToObject(result, "isError", true);
if (id != nullptr) cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true));
else cJSON_AddNullToObject(response, "id");
return response;
}
cJSON* makeToolList(const cJSON* id) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "jsonrpc", "2.0");
cJSON* result = cJSON_AddObjectToObject(response, "result");
cJSON* tools = cJSON_AddArrayToObject(result, "tools");
cJSON* capabilities = cJSON_CreateObject();
cJSON_AddStringToObject(capabilities, "name", "get_capabilities");
cJSON_AddStringToObject(capabilities, "description", "Get the simulator platform and display capability information.");
cJSON_AddItemToObject(capabilities, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{}}"));
cJSON_AddItemToArray(tools, capabilities);
cJSON* listApps = cJSON_CreateObject();
cJSON_AddStringToObject(listApps, "name", "list_apps");
cJSON_AddStringToObject(listApps, "description", "List apps available in the simulator.");
cJSON_AddItemToObject(listApps, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{}}"));
cJSON_AddItemToArray(tools, listApps);
cJSON* runApp = cJSON_CreateObject();
cJSON_AddStringToObject(runApp, "name", "run_app");
cJSON_AddStringToObject(runApp, "description", "Launch an installed simulator app by its app ID.");
cJSON_AddItemToObject(runApp, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{\"app_id\":{\"type\":\"string\"}},\"required\":[\"app_id\"]}"));
cJSON_AddItemToArray(tools, runApp);
cJSON* clearScreen = cJSON_CreateObject();
cJSON_AddStringToObject(clearScreen, "name", "clear_screen");
cJSON_AddStringToObject(clearScreen, "description", "Clear the MCP drawing canvas to white (0) or black (1).");
cJSON_AddItemToObject(clearScreen, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{\"color\":{\"type\":\"integer\",\"enum\":[0,1]}},\"required\":[\"color\"]}"));
cJSON_AddItemToArray(tools, clearScreen);
cJSON* drawText = cJSON_CreateObject();
cJSON_AddStringToObject(drawText, "name", "draw_text");
cJSON_AddStringToObject(drawText, "description", "Draw text on the simulator MCP canvas at x,y coordinates.");
cJSON_AddItemToObject(drawText, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}},\"required\":[\"text\",\"x\",\"y\"]}"));
cJSON_AddItemToArray(tools, drawText);
if (id != nullptr) cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true));
else cJSON_AddNullToObject(response, "id");
return response;
}
std::string listAppsJson() {
std::vector<const AppManifest*> manifests;
app_manager_for_each_manifest([](const AppManifest* manifest, void* context) {
static_cast<std::vector<const AppManifest*>*>(context)->push_back(manifest);
}, &manifests);
cJSON* apps = cJSON_CreateArray();
for (const AppManifest* manifest : manifests) {
cJSON* app = cJSON_CreateObject();
cJSON_AddStringToObject(app, "id", manifest->id);
cJSON_AddStringToObject(app, "name", manifest->name);
cJSON_AddItemToArray(apps, app);
}
char* text = cJSON_PrintUnformatted(apps);
std::string output = text == nullptr ? "[]" : text;
cJSON_free(text);
cJSON_Delete(apps);
return output;
}
cJSON* handleCall(const cJSON* id, const cJSON* params) {
const cJSON* name = cJSON_GetObjectItemCaseSensitive(params, "name");
const cJSON* args = cJSON_GetObjectItemCaseSensitive(params, "arguments");
if (!cJSON_IsString(name) || (args != nullptr && !cJSON_IsObject(args))) {
return makeError(id, -32602, "Invalid params");
}
if (strcmp(name->valuestring, "clear_screen") == 0) {
const cJSON* color = cJSON_GetObjectItemCaseSensitive(args, "color");
if (!cJSON_IsNumber(color) || (color->valueint != 0 && color->valueint != 1)) {
return makeError(id, -32602, "color must be 0 (white) or 1 (black)");
}
lvgl_lock();
const bool cleared = clearCanvas(color->valueint);
lvgl_unlock();
return cleared ? makeTextResult(id, "ok") : makeTextResult(id, "no active display", true);
}
if (strcmp(name->valuestring, "draw_text") == 0) {
const cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
const cJSON* x = cJSON_GetObjectItemCaseSensitive(args, "x");
const cJSON* y = cJSON_GetObjectItemCaseSensitive(args, "y");
if (!cJSON_IsString(text) || text->valuestring == nullptr || strlen(text->valuestring) > 4096 ||
!cJSON_IsNumber(x) || !cJSON_IsNumber(y) || x->valueint < 0 || y->valueint < 0) {
return makeError(id, -32602, "text, non-negative x and y are required (text max 4096 chars)");
}
lvgl_lock();
lv_display_t* display = lv_display_get_default();
const bool inBounds = display != nullptr && x->valueint < lv_display_get_horizontal_resolution(display) &&
y->valueint < lv_display_get_vertical_resolution(display);
const bool drawn = inBounds && drawCanvasText(text->valuestring, x->valueint, y->valueint);
lvgl_unlock();
if (!inBounds) return makeError(id, -32602, "coordinates are outside the simulator display");
return drawn ? makeTextResult(id, "ok") : makeTextResult(id, "could not draw on display", true);
}
if (strcmp(name->valuestring, "get_capabilities") == 0) {
return makeTextResult(id, "{\"platform\":\"tactility-simulator\",\"mcp\":true,\"drawing\":true}");
}
if (strcmp(name->valuestring, "list_apps") == 0) {
const std::string apps = listAppsJson();
return makeTextResult(id, apps.c_str());
}
if (strcmp(name->valuestring, "run_app") == 0) {
const cJSON* appId = cJSON_GetObjectItemCaseSensitive(args, "app_id");
if (!cJSON_IsString(appId) || appId->valuestring == nullptr || appId->valuestring[0] == '\0') {
return makeError(id, -32602, "app_id is required");
}
AppManifest manifest{};
if (app_manager_find_manifest(appId->valuestring, &manifest) != ERROR_NONE) {
return makeTextResult(id, "app not found", true);
}
AppInstanceId instanceId = 0;
const error_t result = app_start(appId->valuestring, 0, nullptr, &instanceId);
if (result != ERROR_NONE) return makeTextResult(id, "app launch failed", true);
return makeTextResult(id, "ok");
}
return makeError(id, -32602, "Unknown simulator MCP tool");
}
cJSON* handleRpc(const char* body) {
cJSON* request = cJSON_Parse(body);
if (request == nullptr) return makeError(nullptr, -32700, "Parse error");
const cJSON* id = cJSON_GetObjectItemCaseSensitive(request, "id");
const cJSON* version = cJSON_GetObjectItemCaseSensitive(request, "jsonrpc");
const cJSON* method = cJSON_GetObjectItemCaseSensitive(request, "method");
const cJSON* params = cJSON_GetObjectItemCaseSensitive(request, "params");
cJSON* response = nullptr;
if (!cJSON_IsObject(request) || !cJSON_IsString(version) || strcmp(version->valuestring, "2.0") != 0 || !cJSON_IsString(method)) {
response = makeError(id, -32600, "Invalid Request");
} else if (strcmp(method->valuestring, "tools/list") == 0) {
response = makeToolList(id);
} else if (strcmp(method->valuestring, "tools/call") == 0) {
response = handleCall(id, params);
} else {
response = makeError(id, -32601, "Method not found");
}
cJSON_Delete(request);
return response;
}
}
error_t WebServerService::handleApiMcp(HttpServerRequest* request, void*) {
const uint64_t length = http_server_request_get_content_length(request);
if (length == 0 || length > JSON_BODY_LIMIT) {
http_server_request_send_error(request, 400, "Invalid Content-Length");
return ERROR_INVALID_ARGUMENT;
}
std::string body(static_cast<size_t>(length), '\0');
size_t received = 0;
while (received < body.size()) {
const int count = http_server_request_receive(request, body.data() + received, body.size() - received);
if (count <= 0) {
http_server_request_send_error(request, 400, "Incomplete request body");
return ERROR_UNDEFINED;
}
received += static_cast<size_t>(count);
}
cJSON* response = handleRpc(body.c_str());
char* text = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
if (text == nullptr) {
http_server_request_send_error(request, 500, "Serialization failed");
return ERROR_RESOURCE;
}
http_server_request_set_content_type(request, "application/json");
http_server_request_send_string(request, text);
cJSON_free(text);
return ERROR_NONE;
}
}
#endif // ESP_PLATFORM
@@ -507,14 +507,14 @@ bool WebServerService::startServer() {
.callback = handleAdminPost,
.user_ctx = ctx
},
#ifdef ESP_PLATFORM
// MCP is LAN-local and is enabled whenever the web server is enabled.
// MCP is available on physical devices and the POSIX simulator.
{
.uri = "/api/mcp",
.method = HTTP_METHOD_POST,
.callback = handleApiMcp,
.user_ctx = ctx
},
#ifdef ESP_PLATFORM
{
.uri = "/api/screen/raw",
.method = HTTP_METHOD_POST,
+3 -12
View File
@@ -19,18 +19,9 @@ target_include_directories(TactilityTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../P
add_test(NAME TactilityTests COMMAND TactilityTests)
# Matches Tactility/CMakeLists.txt's own set of --wrap flags: this binary also compiles
# AppStdioWrap.cpp and links app-module (whose io.cpp calls __real_read/write/close() on
# non-Apple POSIX - see TT_APP_IO_WRAPS_STDIO in Modules/app-module/CMakeLists.txt), so it
# needs the same wraps applied or those go unresolved.
if (NOT APPLE)
target_link_options(TactilityTests PRIVATE
"-Wl,--wrap=pthread_attr_setstack" "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# No --wrap flags declared here: linking platform-posix and app-module below already brings all
# of pthread_attr_setstack/read/write/close/the printf family along (see their own CMakeLists.txt
# files).
target_link_libraries(TactilityTests PRIVATE
TactilityKernel
+2
View File
@@ -11,6 +11,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Devices/simulator/tests ${CMAKE_CURRENT_BINARY_DIR}/simulator)
add_custom_target(build-tests)
add_dependencies(build-tests ServiceModuleTests)
@@ -21,3 +22,4 @@ add_dependencies(build-tests CryptModuleTests)
add_dependencies(build-tests AppModuleTests)
add_dependencies(build-tests AppPosixModuleTests)
add_dependencies(build-tests HttpModuleTests)
add_dependencies(build-tests SimulatorAudioTests)