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>
287 lines
8.6 KiB
C
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;
|
|
}
|