Files
Rene KievitsandClaude Opus 5 d3e4cb6410 Restrict the brightness sink to one app, picked by name not typed
Confirmed the brightness command applies globally, not per-LED
(serverinfo showed exactly one adjustment object, "id": "default",
covering the whole string), so no LED-count configuration is needed
for this at all -- that question resolved itself once the mechanism
was actually inspected instead of assumed.

For "only react while Spotify is running": only one app can be in the
foreground on webOS at a time, so a "capture the current app" button
in this app's own UI can never work -- pressing it means this app is
foreground, not Spotify. The only workable UI is picking a target from
every *installed* app by name, regardless of what's currently running.

That needed a new native capability this service never had: calling
OUT to another Luna service, not just being called. Two additions:

  foreground_app.c   subscribes once, at startup, to
                      com.webos.applicationManager/getForegroundAppInfo
                      and keeps a thread-safe cache the audio thread can
                      read without a blocking Luna call
  service.c           new listApps method, bridging to
                      com.webos.applicationManager/listApps so the
                      frontend never has to call another service
                      directly -- same rule as everywhere else here

Until the subscription has delivered at least one reply, a restricted
sink treats the target app as inactive, not active -- reacting to
audio when the user explicitly restricted it to one app would be the
wrong failure mode. Verified end to end on the host: engine_smoke.c
opens the sink with a restriction set, confirms it reports itself
correctly inactive against the stub Luna bus (which always "fails" to
call out, exactly like a real host with no bus).

Needed real, linkable stub bodies for LSCall/LSCallOneReply/
LSCallCancel/LSMessageGetPayload/LSErrorInit/LSErrorFree
(test/stubs/luna-service2/lunaservice_stub.c) since foreground_app.c
is the first source file here that's actually linked into a host test
binary rather than only syntax-checked -- service.c/main.c's existing
stub declarations were never called, only compiled against. Confirmed
those really are the correct symbol names by cross-compiling clean
against the real webOS SDK's actual libluna-service2, not just the
stub.

Bumped to 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:46:09 +02:00

287 lines
8.6 KiB
C

// End-to-end check of the capture pipeline, minus webOS.
//
// Runs the engine with the `tone` backend feeding the TCP and HTTP sinks, then
// connects to both as a client and verifies that real audio comes out with the
// right framing. Everything here works identically on the TV; only the Luna
// layer is missing, so this exercises capture -> DSP -> fan-out -> socket.
//
// Build and run: test/run-tests.sh
#include "common/log.h"
#include "engine.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define HTTP_PORT 45812
#define TCP_PORT 45811
static int failures = 0;
static void check(bool ok, const char* what)
{
printf("%s %s\n", ok ? " ok " : " FAIL", what);
if (!ok)
failures++;
}
static void sleep_ms(int ms)
{
struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
static int connect_local(int port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
return -1;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
close(fd);
return -1;
}
struct timeval tv = { .tv_sec = 3, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
return fd;
}
// Reads exactly `len` bytes or fails.
static bool read_exact(int fd, void* dst, size_t len)
{
size_t got = 0;
while (got < len) {
ssize_t n = read(fd, (char*)dst + got, len - got);
if (n <= 0)
return false;
got += (size_t)n;
}
return true;
}
static uint32_t rd_u32le(const uint8_t* p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static uint16_t rd_u16le(const uint8_t* p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
// True if the buffer contains something other than digital silence.
static bool has_signal(const int16_t* pcm, size_t samples)
{
for (size_t i = 0; i < samples; i++) {
if (pcm[i] > 500 || pcm[i] < -500)
return true;
}
return false;
}
static void test_http(void)
{
printf("HTTP WAV sink\n");
int fd = connect_local(HTTP_PORT);
if (fd < 0) {
check(false, "connect to the HTTP sink");
return;
}
const char* req = "GET /audio.wav HTTP/1.1\r\nHost: tv\r\n\r\n";
check(write(fd, req, strlen(req)) == (ssize_t)strlen(req), "send the request");
// Headers end at the blank line; read a byte at a time so we do not eat
// into the WAV header that follows.
char headers[1024];
size_t hlen = 0;
bool complete = false;
while (hlen < sizeof(headers) - 1) {
if (!read_exact(fd, headers + hlen, 1))
break;
hlen++;
headers[hlen] = '\0';
if (hlen >= 4 && memcmp(headers + hlen - 4, "\r\n\r\n", 4) == 0) {
complete = true;
break;
}
}
check(complete, "receive complete HTTP headers");
check(strstr(headers, "200 OK") != NULL, "status line is 200 OK");
check(strstr(headers, "Content-Type: audio/wav") != NULL, "content type is audio/wav");
uint8_t wav[44];
if (!read_exact(fd, wav, sizeof(wav))) {
check(false, "receive the WAV header");
close(fd);
return;
}
check(memcmp(wav, "RIFF", 4) == 0, "RIFF magic");
check(memcmp(wav + 8, "WAVE", 4) == 0, "WAVE magic");
check(memcmp(wav + 12, "fmt ", 4) == 0, "fmt chunk");
check(rd_u32le(wav + 16) == 16, "fmt chunk length is 16");
check(rd_u16le(wav + 20) == 1, "format is PCM");
check(rd_u16le(wav + 22) == 2, "2 channels");
check(rd_u32le(wav + 24) == 48000, "48000 Hz");
check(rd_u32le(wav + 28) == 48000 * 4, "byte rate matches");
check(rd_u16le(wav + 32) == 4, "block align is 4");
check(rd_u16le(wav + 34) == 16, "16 bits per sample");
check(memcmp(wav + 36, "data", 4) == 0, "data chunk");
check(rd_u32le(wav + 4) == 0xFFFFFFFFu, "RIFF size is the unknown-length marker");
check(rd_u32le(wav + 40) == 0xFFFFFFFFu, "data size is the unknown-length marker");
int16_t pcm[4096];
bool got = read_exact(fd, pcm, sizeof(pcm));
check(got, "receive 16 KB of audio");
check(got && has_signal(pcm, sizeof(pcm) / sizeof(pcm[0])), "audio is not silence");
close(fd);
}
static void test_tcp(void)
{
printf("Raw PCM TCP sink\n");
int fd = connect_local(TCP_PORT);
if (fd < 0) {
check(false, "connect to the TCP sink");
return;
}
int16_t pcm[4096];
bool got = read_exact(fd, pcm, sizeof(pcm));
check(got, "receive 16 KB of audio");
check(got && has_signal(pcm, sizeof(pcm) / sizeof(pcm[0])), "audio is not silence");
close(fd);
}
static void test_status(engine_t* e)
{
printf("Status document\n");
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
engine_write_status(e, &w);
jw_obj_close(&w);
char* text = jw_take(&w);
check(text != NULL, "status serialises");
if (!text)
return;
json_value_t* v = json_parse(text);
check(v != NULL, "status is valid JSON");
if (v) {
check(strcmp(json_str(v, "state", ""), "running") == 0, "state is running");
const json_value_t* cap = json_get(v, "capture");
check(strcmp(json_str(cap, "backend", ""), "tone") == 0, "backend is the tone generator");
check(json_int(cap, "rate", 0) == 48000, "reports 48000 Hz");
check(json_int(cap, "frames", 0) > 0, "frames have been captured");
const json_value_t* levels = json_get(v, "levels");
check(json_num(levels, "peak", 0) > 0.05, "peak level is non-trivial");
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
const json_value_t* sinks = json_get(v, "sinks");
check(json_len(sinks) == 3, "three sinks reported");
for (size_t i = 0; i < json_len(sinks); i++) {
const json_value_t* s = json_at(sinks, i);
char label[64];
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
check(json_bool(s, "ok", false), label);
if (strcmp(json_str(s, "id", ""), "hyperhdrAdjust") == 0) {
// A host has no Luna bus (see lunaservice_stub.c), so the
// sink can never confirm the restricted app is foreground.
// The correct failure mode is inactive, not "assume yes".
check(strcmp(json_str(s, "restrictToApp", ""), "some.other.app") == 0,
"restriction target reported back");
check(json_bool(s, "restrictedAppActive", true) == false,
"restricted app correctly reported as not active (fail-closed)");
}
}
json_free(v);
}
free(text);
}
int main(void)
{
log_init(LOG_WARN); // keep the test output readable
char cfg_text[512];
snprintf(cfg_text, sizeof(cfg_text),
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
"\"sinks\":[\"tcp\",\"http\",\"hyperhdrAdjust\"],"
"\"tcp\":{\"port\":%d},"
"\"http\":{\"port\":%d},"
"\"hyperhdrAdjust\":{\"host\":\"127.0.0.1\",\"port\":19444,"
"\"restrictToApp\":\"some.other.app\"}}",
TCP_PORT, HTTP_PORT);
json_value_t* cfg = json_parse(cfg_text);
if (!cfg) {
fprintf(stderr, "test bug: config does not parse\n");
return 1;
}
engine_t* e = engine_create(NULL, NULL);
if (!e) {
fprintf(stderr, "cannot create the engine\n");
return 1;
}
char err[256] = { 0 };
printf("Engine\n");
if (!engine_start(e, cfg, err, sizeof(err))) {
printf(" FAIL start: %s\n", err);
return 1;
}
// Startup happens on the engine thread; wait for it to settle.
for (int i = 0; i < 100 && engine_state(e) == ENGINE_STARTING; i++)
sleep_ms(50);
check(engine_state(e) == ENGINE_RUNNING, "engine reaches the running state");
if (engine_state(e) != ENGINE_RUNNING) {
engine_destroy(e);
json_free(cfg);
return 1;
}
test_tcp();
test_http();
test_status(e);
engine_stop(e);
check(engine_state(e) == ENGINE_STOPPED, "engine stops cleanly");
engine_destroy(e);
json_free(cfg);
printf("\n%s\n", failures ? "FAILED" : "All engine checks passed.");
return failures ? 1 : 0;
}