Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+8
View File
@@ -0,0 +1,8 @@
.section .rodata.vamp_pico_artwork,"a",%progbits
.balign 4
.global vamp_pico_artwork_begin
.global vamp_pico_artwork_end
vamp_pico_artwork_begin:
.incbin "@VAMP_PICO_ARTWORK_BINARY@"
vamp_pico_artwork_end:
.section .note.GNU-stack,"",%progbits
+128 -155
View File
@@ -1,7 +1,5 @@
#include <algorithm>
#include <array>
#include <cinttypes>
#include <cstdio>
#include <cstdint>
#include <cstring>
@@ -10,7 +8,6 @@
#include <pico/stdlib.h>
#include <vamp_pico/display.hpp>
#include <vamp_style/font.hpp>
namespace {
@@ -73,53 +70,50 @@ auto set_window(std::int32_t x0, std::int32_t y0, std::int32_t x1, std::int32_t
command(0x2c);
}
auto same_color(vamp_style::color left, vamp_style::color right) -> std::bool_t {
return left.r == right.r && left.g == right.g && left.b == right.b && left.a == right.a;
extern "C" std::char_t const vamp_pico_artwork_begin[];
extern "C" std::char_t const vamp_pico_artwork_end[];
auto sprite_image(vamp_pico::sprite value) -> vamp_pico::coverage_image {
return vamp_pico::static_sprite({vamp_pico_artwork_begin,
static_cast<std::size_t>(vamp_pico_artwork_end - vamp_pico_artwork_begin)},
value);
}
template <std::size_t Count>
auto same_text(std::array<std::char_t, Count> const &cached, std::char_t const *text) -> std::bool_t {
auto const *value = text == nullptr ? "" : text;
return std::strncmp(cached.data(), value, Count) == 0;
auto channel(std::uint8_t fg, std::uint8_t bg, std::uint8_t alpha) -> std::uint8_t {
return static_cast<std::uint8_t>((fg * alpha + bg * (15U - alpha) + 7U) / 15U);
}
template <std::size_t Count>
auto copy_text(std::array<std::char_t, Count> &cached, std::char_t const *text) -> void {
auto const *value = text == nullptr ? "" : text;
auto count = std::min(Count - 1U, std::strlen(value));
std::memcpy(cached.data(), value, count);
cached[count] = '\0';
auto blend(vamp_style::color fg, std::uint8_t alpha) -> std::uint16_t {
auto bg = vamp_style::background;
return vamp_style::rgb565({channel(fg.r, bg.r, alpha), channel(fg.g, bg.g, alpha), channel(fg.b, bg.b, alpha), 255});
}
auto blend_channel(std::uint8_t foreground, std::uint8_t background, std::uint8_t alpha) -> std::uint8_t {
auto fg = static_cast<std::uint32_t>(foreground) * alpha;
auto bg = static_cast<std::uint32_t>(background) * (15U - alpha);
return static_cast<std::uint8_t>((fg + bg + 7U) / 15U);
}
auto blend(vamp_style::color foreground, vamp_style::color background, std::uint8_t alpha) -> vamp_style::color {
if (alpha >= 15U) {
return foreground;
}
return {
.r = blend_channel(foreground.r, background.r, alpha),
.g = blend_channel(foreground.g, background.g, alpha),
.b = blend_channel(foreground.b, background.b, alpha),
.a = 255,
};
}
auto draw_centered_text(vamp_pico::display &display, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t preferred_scale, std::int32_t max_width) -> void {
if (text == nullptr || text[0] == '\0') {
auto overlay(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::coverage_image image, std::size_t x, std::size_t top, vamp_style::color ink) -> void {
if (y < top || y - top >= image.height) {
return;
}
auto scale = preferred_scale;
while (scale > 1 && vamp_style::ui_text_width(text, scale) > max_width) {
--scale;
for (auto column = std::size_t{}; column < image.width && x + column < row.size(); ++column) {
row[x + column] = blend(ink, image.at(column, y - top));
}
auto width = vamp_style::ui_text_width(text, scale);
auto x = std::max<std::int32_t>(12, (vamp_pico::display_width - width) / 2);
display.draw_text(x, y, text, color, scale);
}
auto centered(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::sprite value, std::size_t top, vamp_style::color ink) -> void {
auto image = sprite_image(value);
overlay(row, y, image, (row.size() - image.width) / 2U, top, ink);
}
auto status_color(vamp_pico::link_status link) -> vamp_style::color {
switch (link) {
case vamp_pico::link_status::CONNECTING:
return vamp_style::connecting;
case vamp_pico::link_status::CONNECTED:
return vamp_style::connected;
case vamp_pico::link_status::ERROR:
return vamp_style::danger;
case vamp_pico::link_status::OFFLINE:
return vamp_style::retrying;
}
return vamp_style::retrying;
}
}
@@ -155,135 +149,114 @@ auto vamp_pico::display::init() -> void {
command(0x13);
command(0x29);
sleep_ms(20);
fill(vamp_pico::color_black);
remote_valid_ = false;
dirty(0U, display_height);
}
auto vamp_pico::display::fill(std::uint16_t color) -> void {
fill_rect(0, 0, vamp_pico::display_width, vamp_pico::display_height, color);
auto vamp_pico::display::dirty(std::size_t begin, std::size_t end) -> void {
std::fill(dirty_.begin() + begin, dirty_.begin() + end, true);
}
auto vamp_pico::display::fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void {
if (width <= 0 || height <= 0) {
return;
auto vamp_pico::display::show_remote(link_status link, remote_display_state const &state, std::bool_t busy) -> void {
if (link != link_ || busy != busy_) {
dirty(0U, 24U);
dirty(144U, 172U);
}
auto x0 = std::clamp(x, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1});
auto y0 = std::clamp(y, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1});
auto x1 = std::clamp(x + width - std::int32_t{1}, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1});
auto y1 = std::clamp(y + height - std::int32_t{1}, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1});
if (x1 < x0 || y1 < y0) {
return;
auto title_changed = title_.select(state.active ? std::string_view{state.title_image.data()} : std::string_view{});
if (active_ != state.active || title_changed) {
dirty(56U, 120U);
dirty(144U, 172U);
}
set_window(x0, y0, x1, y1);
auto hi = static_cast<std::uchar_t>((color >> 8) & 0xff);
auto lo = static_cast<std::uchar_t>(color & 0xff);
auto row = std::array<std::uchar_t, vamp_pico::display_width * 2>{};
for (auto i = std::size_t{}; i < row.size(); i += 2U) {
row[i] = hi;
row[i + 1U] = lo;
if (state.active && state.title_image[0] == 0 && !title_.failed()) {
reject_title("");
}
gpio_put(pin_lcd_dc, 1);
select();
for (auto yy = y0; yy <= y1; ++yy) {
spi_write_blocking(spi1, row.data(), static_cast<std::size_t>(x1 - x0 + 1) * 2U);
if (active_ != state.active || page_ != state.page || pages_ != state.pages) {
dirty(188U, 216U);
}
deselect();
auto part = std::string_view{state.part};
auto part_sprite = part == "Acoustic" ? sprite::ACOUSTIC : part == "Electric" ? sprite::ELECTRIC
: part == "Vocal" ? sprite::VOCAL
: sprite::COUNT;
if (part_ != part_sprite) {
dirty(144U, 172U);
}
part_ = part_sprite;
active_ = state.active;
page_ = state.page;
pages_ = state.pages;
link_ = link;
busy_ = busy;
}
auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale) -> void {
if (text == nullptr || scale <= 0) {
auto vamp_pico::display::accept_title(std::string_view identifier, std::string_view image) -> std::bool_t {
if (!title_.accept(identifier, image)) {
return false;
}
dirty(56U, 120U);
dirty(144U, 172U);
return true;
}
auto vamp_pico::display::title_ready() const -> std::bool_t {
return title_.ready();
}
auto vamp_pico::display::reject_title(std::string_view identifier) -> void {
if (title_.fail(identifier)) {
dirty(144U, 172U);
}
}
auto vamp_pico::display::poll(std::uint64_t now_ms) -> void {
if (now_ms - last_write_ms_ < 8U) {
return;
}
auto foreground = vamp_style::color{
.r = static_cast<std::uint8_t>(((color >> 11U) & 0x1fU) * 255U / 31U),
.g = static_cast<std::uint8_t>(((color >> 5U) & 0x3fU) * 255U / 63U),
.b = static_cast<std::uint8_t>((color & 0x1fU) * 255U / 31U),
.a = 255,
};
auto cursor = x;
auto previous = std::char_t{};
for (auto i = std::size_t{}; i < std::strlen(text); ++i) {
if (previous != std::char_t{}) {
cursor += vamp_style::ui_font_pair_adjust(previous, text[i]) * scale;
last_write_ms_ = now_ms;
auto written = std::size_t{};
for (auto scanned = std::size_t{}; scanned < dirty_.size() && written < 4U; ++scanned) {
auto y = next_row_;
next_row_ = (next_row_ + 1U) % dirty_.size();
if (!dirty_[y]) {
continue;
}
auto glyph = vamp_style::ui_font_glyph(text[i]);
for (auto row = std::int32_t{}; row < vamp_style::ui_font_height; ++row) {
auto col = std::int32_t{};
while (col < static_cast<std::int32_t>(glyph.width)) {
auto row_offset = static_cast<std::size_t>(row * vamp_style::ui_font_max_width);
while (col < static_cast<std::int32_t>(glyph.width) &&
glyph.alpha[row_offset + static_cast<std::size_t>(col)] == 0U) {
++col;
}
auto start = col;
auto alpha = start < static_cast<std::int32_t>(glyph.width)
? glyph.alpha[row_offset + static_cast<std::size_t>(start)]
: std::uint8_t{};
while (col < static_cast<std::int32_t>(glyph.width) &&
glyph.alpha[row_offset + static_cast<std::size_t>(col)] == alpha) {
++col;
}
if (col > start) {
auto run_color = vamp_style::rgb565(blend(foreground, vamp_style::background, alpha));
fill_rect(cursor + start * scale, y + row * scale, (col - start) * scale, scale, run_color);
}
static auto row = std::array<std::uint16_t, display_width>{};
row.fill(vamp_style::rgb565(y < 24U ? (busy_ ? vamp_style::danger : status_color(link_)) : vamp_style::background));
if (active_ && title_.ready()) {
overlay(row, y, title_.image(), 12U, 56U, vamp_style::ink);
} else if (!active_) {
centered(row, y, sprite::VAMP, 74U, vamp_style::ink);
}
auto label = busy_ ? sprite::BUSY : link_ == link_status::ERROR ? sprite::ERROR
: link_ == link_status::CONNECTING ? sprite::CONNECTING
: link_ == link_status::OFFLINE ? sprite::OFFLINE
: active_ && title_.failed() ? sprite::UNAVAILABLE
: active_ && !title_.ready() ? sprite::LOADING
: active_ && part_ != sprite::COUNT ? part_
: sprite::READY;
centered(row, y, label, 144U, vamp_style::muted);
if (active_ && pages_ != 0U && y >= 188U && y < 216U) {
auto digits = counter_text(page_, pages_);
auto size = std::strlen(digits.data());
auto label_image = sprite_image(sprite::PAGE);
auto width = label_image.width + 8U + static_cast<std::size_t>(size) * digit_width;
auto x = (row.size() - width) / 2U;
if (size != 0U) {
overlay(row, y, label_image, x, 188U, vamp_style::muted);
}
x += label_image.width + 8U;
for (auto index = std::size_t{}; index < size; ++index) {
auto value = digits[index] == '/' ? sprite::SLASH : static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + digits[index] - '0');
overlay(row, y, sprite_image(value), x, 188U, vamp_style::muted);
x += digit_width;
}
}
cursor += static_cast<std::int32_t>(glyph.advance) * scale;
if (cursor >= vamp_pico::display_width) {
return;
static auto bytes = std::array<std::uchar_t, display_width * 2>{};
for (auto x = std::size_t{}; x < row.size(); ++x) {
bytes[x * 2U] = static_cast<std::uchar_t>(row[x] >> 8U);
bytes[x * 2U + 1U] = static_cast<std::uchar_t>(row[x]);
}
previous = text[i];
set_window(0, static_cast<std::int32_t>(y), display_width - 1, static_cast<std::int32_t>(y));
data(bytes.data(), bytes.size());
dirty_[y] = false;
++written;
}
}
auto vamp_pico::display::show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void {
remote_valid_ = false;
fill(vamp_style::rgb565(vamp_style::background));
fill_rect(0, 0, vamp_pico::display_width, 28, accent);
draw_text(14, 48, "VAMP", vamp_style::rgb565(vamp_style::ink), 2);
if (line1 != nullptr) {
draw_text(92, 54, line1, vamp_style::rgb565(vamp_style::ink), 1);
}
if (line2 != nullptr) {
draw_text(14, 96, line2, vamp_style::rgb565(vamp_style::muted), 1);
}
}
auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active, std::bool_t busy) -> void {
auto const *title_text = title == nullptr ? "" : title;
auto const *part_text = part == nullptr ? "" : part;
auto status_changed = !remote_valid_ || !same_color(last_status_, status) || last_busy_ != busy;
auto content_changed = !remote_valid_ || last_active_ != active || last_busy_ != busy || !same_text(last_title_, title_text) ||
!same_text(last_part_, part_text) || last_page_ != page || last_pages_ != pages;
if (!remote_valid_) {
fill(vamp_style::rgb565(vamp_style::background));
}
if (status_changed) {
fill_rect(0, 0, vamp_pico::display_width, 24, vamp_style::rgb565(busy ? vamp_style::danger : status));
}
if (content_changed) {
fill_rect(0, 24, vamp_pico::display_width, vamp_pico::display_height - 24, vamp_style::rgb565(vamp_style::background));
if (active && title_text[0] != '\0') {
draw_centered_text(*this, 66, title_text, vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24);
draw_centered_text(*this, 118, busy ? "BUSY" : part_text, vamp_style::rgb565(busy ? vamp_style::ink : vamp_style::muted), 1, vamp_pico::display_width - 24);
if (pages != 0U) {
auto page_text = std::array<std::char_t, 32>{};
auto written = std::snprintf(page_text.data(), page_text.size(), "PAGE %" PRIu32 "/%" PRIu32, page, pages);
if (written > 0) {
draw_centered_text(*this, 168, page_text.data(), vamp_style::rgb565(vamp_style::muted), 1, vamp_pico::display_width - 24);
}
}
} else if (busy) {
draw_centered_text(*this, 118, "BUSY", vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24);
}
}
last_status_ = status;
last_active_ = active;
last_busy_ = busy;
copy_text(last_title_, title_text);
copy_text(last_part_, part_text);
last_page_ = page;
last_pages_ = pages;
remote_valid_ = true;
}
+86 -3
View File
@@ -1,5 +1,6 @@
#include <cstdio>
#include <cstdint>
#include <charconv>
#include <lwip/ip_addr.h>
#include <lwip/pbuf.h>
@@ -30,6 +31,65 @@ auto response_is_success(std::string_view response) -> std::bool_t {
response[12] == ' ' && response.find("\r\n\r\n") != std::string_view::npos;
}
auto header_equal(std::string_view name, std::string_view expected) -> std::bool_t {
if (name.size() != expected.size()) {
return false;
}
for (auto index = std::size_t{}; index < name.size(); ++index) {
auto character = name[index];
if (character >= 'A' && character <= 'Z') {
character += 'a' - 'A';
}
if (character != expected[index]) {
return false;
}
}
return true;
}
auto title_body_offset(std::string_view response) -> std::size_t {
if (response.substr(0U, 13U) != "HTTP/1.1 200 " && response.substr(0U, 13U) != "HTTP/1.0 200 ") {
return 0U;
}
auto marker = response.find("\r\n\r\n");
if (marker == std::string_view::npos || marker + 4U > 1024U) {
return 0U;
}
auto length_seen = false;
auto type_seen = false;
auto cursor = response.find("\r\n") + 2U;
while (cursor < marker) {
auto end = response.find("\r\n", cursor);
auto line = response.substr(cursor, end - cursor);
auto colon = line.find(':');
if (colon == std::string_view::npos) {
return 0U;
}
auto name = line.substr(0U, colon);
auto value = line.substr(colon + 1U);
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) {
value.remove_prefix(1U);
}
if (header_equal(name, "content-length")) {
auto length = std::size_t{};
auto parsed = std::from_chars(value.data(), value.data() + value.size(), length);
if (length_seen || parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || length != vamp_protocol::title_image_size) {
return 0U;
}
length_seen = true;
} else if (header_equal(name, "content-type")) {
if (type_seen || value != "application/octet-stream") {
return 0U;
}
type_seen = true;
} else if (header_equal(name, "transfer-encoding") || header_equal(name, "content-encoding")) {
return 0U;
}
cursor = end + 2U;
}
return length_seen && type_seen && vamp_protocol::valid_title_image(response.substr(marker + 4U)) ? marker + 4U : 0U;
}
auto clear_callbacks(tcp_pcb *pcb) -> void {
tcp_arg(pcb, nullptr);
tcp_recv(pcb, nullptr);
@@ -65,7 +125,8 @@ struct http_client_callbacks {
return finish(client, error == ERR_OK && response_is_success(response));
}
auto size = buffer->tot_len;
auto remaining = client.response_buffer_.size() - client.response_size_;
auto capacity = client.request_.kind == request_kind::TITLE ? client.response_buffer_.size() : 1024U;
auto remaining = capacity - client.response_size_;
auto valid = error == ERR_OK && size <= remaining;
if (valid) {
auto copied = pbuf_copy_partial(buffer, client.response_buffer_.data() + client.response_size_, size, 0);
@@ -113,17 +174,27 @@ auto vamp_pico::http_client::start(remote_request request, std::uint64_t now_ms)
status_ = http_status::IN_FLIGHT;
success_ = false;
response_size_ = 0U;
title_offset_ = 0U;
started_ms_ = now_ms;
timeout_ms_ = request.kind == request_kind::DISPLAY ? 1000U : 700U;
timeout_ms_ = request.kind == request_kind::TITLE ? 1500U : request.kind == request_kind::DISPLAY ? 1000U
: 700U;
auto const *path = vamp_protocol::remote_display_path;
if (request.kind == request_kind::COMMAND) {
path = vamp_protocol::page_command_path(request.command);
} else if (request.kind == request_kind::HEARTBEAT) {
path = vamp_protocol::remote_heartbeat_path;
} else if (request.kind == request_kind::TITLE) {
path = vamp_protocol::remote_title_path;
if (request.title_id.back() != 0 || !vamp_protocol::valid_title_id(request.title_id.data())) {
http_client_callbacks::finish(*this, false);
return http_start_result::STARTED;
}
}
auto written = std::int32_t{};
if (request.kind == request_kind::DISPLAY) {
written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "GET %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT);
} else if (request.kind == request_kind::TITLE) {
written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: 73\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":\"%s\"}", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT, request.title_id.data());
} else {
written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: 2\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT);
}
@@ -162,7 +233,10 @@ auto vamp_pico::http_client::take_result() -> std::optional<http_result> {
auto result = http_result{};
result.request = request_;
result.success = success_;
if (result.success && result.request.kind != request_kind::HEARTBEAT) {
if (result.success && result.request.kind == request_kind::TITLE) {
title_offset_ = title_body_offset({response_buffer_.data(), response_size_});
result.success = title_offset_ != 0U;
} else if (result.success && result.request.kind != request_kind::HEARTBEAT) {
auto text = std::string_view{response_buffer_.data(), response_size_};
auto marker = text.find("\r\n\r\n");
result.success = marker != std::string_view::npos && parse_remote_display_body(text.substr(marker + 4U), result.display);
@@ -176,4 +250,13 @@ auto vamp_pico::http_client::cancel() -> void {
http_client_callbacks::finish(*this, false);
status_ = http_status::IDLE;
response_size_ = 0U;
title_offset_ = 0U;
}
auto vamp_pico::http_client::title_image() const -> std::string_view {
return title_offset_ == 0U ? std::string_view{} : std::string_view{response_buffer_.data() + title_offset_, vamp_protocol::title_image_size};
}
auto vamp_pico::http_client::current_kind() const -> request_kind {
return request_.kind;
}
+26 -2
View File
@@ -1,4 +1,5 @@
#include <vamp_pico/input.hpp>
#include <algorithm>
auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command) -> enqueue_result {
@@ -7,6 +8,7 @@ auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command)
}
commands_[(head_ + size_) % commands_.size()] = command;
++size_;
title_pending_ = false;
return enqueue_result::QUEUED;
}
@@ -20,10 +22,15 @@ auto vamp_pico::request_scheduler::next(std::uint64_t now_ms) -> std::optional<r
request.command = commands_[head_];
head_ = (head_ + 1U) % commands_.size();
--size_;
} else if (title_pending_) {
request.kind = request_kind::TITLE;
request.title_id = title_id_;
title_pending_ = false;
} else if (completed_ && now_ms - completed_ms_ < 1000U) {
return std::nullopt;
}
in_flight_ = true;
active_kind_ = request.kind;
return request;
}
@@ -32,10 +39,27 @@ auto vamp_pico::request_scheduler::complete(std::uint64_t now_ms) -> void {
return;
}
in_flight_ = false;
completed_ = true;
completed_ms_ = now_ms;
if (active_kind_ != request_kind::TITLE) {
completed_ = true;
completed_ms_ = now_ms;
}
}
auto vamp_pico::request_scheduler::queued() const -> std::size_t {
return size_;
}
auto vamp_pico::request_scheduler::want_title(std::string_view identifier) -> void {
title_pending_ = size_ == 0U && vamp_protocol::valid_title_id(identifier);
title_id_.fill(0);
if (title_pending_) {
std::copy(identifier.begin(), identifier.end(), title_id_.begin());
}
}
auto vamp_pico::request_scheduler::cancel_title() -> void {
if (in_flight_ && active_kind_ == request_kind::TITLE) {
in_flight_ = false;
}
title_pending_ = false;
}
+75 -71
View File
@@ -8,96 +8,100 @@
#include <vamp_protocol/page_control.hpp>
namespace {
enum class link_status {
CONNECTING,
RETRYING,
CONNECTED,
};
auto color_for(link_status status) -> vamp_style::color {
switch (status) {
case link_status::CONNECTING:
return vamp_style::connecting;
case link_status::RETRYING:
return vamp_style::retrying;
case link_status::CONNECTED:
return vamp_style::connected;
}
return vamp_style::retrying;
}
auto render(vamp_pico::display &display, link_status status, vamp_pico::remote_display_state const &remote, std::bool_t busy = false) -> void {
display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active, busy);
}
auto connect_wifi(vamp_pico::display &display) -> std::bool_t {
auto empty = vamp_pico::remote_display_state{};
render(display, link_status::CONNECTING, empty);
if (cyw43_arch_init() != 0) {
display.show_boot("WIFI", "ERROR", vamp_style::rgb565(vamp_style::danger));
return false;
}
cyw43_arch_enable_sta_mode();
while (cyw43_arch_wifi_connect_timeout_ms(VAMP_PICO_WIFI_SSID, VAMP_PICO_WIFI_PASSPHRASE, CYW43_AUTH_WPA2_AES_PSK, 15000) != 0) {
render(display, link_status::RETRYING, empty);
sleep_ms(2000);
render(display, link_status::CONNECTING, empty);
}
render(display, link_status::RETRYING, empty);
return true;
}
}
auto main() -> int {
stdio_init_all();
auto display = vamp_pico::display{};
// Artwork and network buffers live outside the Pico's small main stack.
static auto display = vamp_pico::display{};
static auto client = vamp_pico::http_client{};
auto touch = vamp_pico::touch_controller{};
display.init();
touch.init();
if (!connect_wifi(display)) {
while (true) {
sleep_ms(1000);
}
}
auto remote = vamp_pico::remote_display_state{};
auto status = link_status::RETRYING;
auto status = vamp_pico::link_status::CONNECTING;
auto gesture = vamp_protocol::gesture_state{};
auto scheduler = vamp_pico::request_scheduler{};
// Keep the owned request buffers off the Pico's small main stack.
static auto client = vamp_pico::http_client{};
auto busy_until_ms = std::uint64_t{};
render(display, status, remote);
auto wifi_ready = cyw43_arch_init() == 0;
if (wifi_ready) {
cyw43_arch_enable_sta_mode();
}
auto wifi_up = false;
auto joining = false;
auto retry_at_ms = std::uint64_t{};
auto join_started_ms = std::uint64_t{};
while (true) {
auto point = vamp_pico::touch_point{};
auto touching = touch.read(point);
auto now_ms = time_us_64() / 1000U;
auto link = CYW43_LINK_DOWN;
if (wifi_ready) {
cyw43_arch_lwip_begin();
link = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
cyw43_arch_lwip_end();
}
if (link != CYW43_LINK_UP) {
if (wifi_up) {
client.cancel();
scheduler = vamp_pico::request_scheduler{};
retry_at_ms = now_ms + 2000U;
}
wifi_up = false;
if (joining && (link < 0 || now_ms - join_started_ms >= 15000U)) {
joining = false;
retry_at_ms = now_ms + 2000U;
}
if (wifi_ready && !joining && now_ms >= retry_at_ms) {
joining = cyw43_arch_wifi_connect_async(VAMP_PICO_WIFI_SSID, VAMP_PICO_WIFI_PASSPHRASE, CYW43_AUTH_WPA2_AES_PSK) == 0;
join_started_ms = now_ms;
retry_at_ms = now_ms + 2000U;
}
status = !wifi_ready ? vamp_pico::link_status::ERROR : joining ? vamp_pico::link_status::CONNECTING
: vamp_pico::link_status::OFFLINE;
} else {
wifi_up = true;
joining = false;
}
if (auto command = gesture.update(touching, now_ms); command) {
if (scheduler.enqueue(*command) == vamp_pico::enqueue_result::BUSY) {
if (!wifi_up || scheduler.enqueue(*command) == vamp_pico::enqueue_result::BUSY) {
busy_until_ms = now_ms + 1000U;
} else if (client.current_kind() == vamp_pico::request_kind::TITLE) {
// An image request is expendable; a command must not wait for its timeout.
client.cancel();
scheduler.cancel_title();
}
}
auto network = client.poll(now_ms);
if (network == vamp_pico::http_status::READY) {
auto result = client.take_result();
scheduler.complete(now_ms);
network = vamp_pico::http_status::IDLE;
if (result && result->success) {
remote = result->display;
status = link_status::CONNECTED;
} else {
status = link_status::RETRYING;
remote = vamp_pico::remote_display_state{};
if (wifi_up) {
auto network = client.poll(now_ms);
if (network == vamp_pico::http_status::READY) {
auto result = client.take_result();
scheduler.complete(now_ms);
network = vamp_pico::http_status::IDLE;
if (result && result->request.kind == vamp_pico::request_kind::TITLE) {
if (result->success && scheduler.queued() == 0U) {
display.accept_title(result->request.title_id.data(), client.title_image());
} else if (!result->success) {
display.reject_title(result->request.title_id.data());
}
} else if (result && result->success) {
remote = result->display;
status = vamp_pico::link_status::CONNECTED;
display.show_remote(status, remote);
if (remote.active && !display.title_ready()) {
scheduler.want_title(remote.title_image.data());
}
} else {
status = vamp_pico::link_status::OFFLINE;
}
}
if (network == vamp_pico::http_status::IDLE) {
if (auto request = scheduler.next(now_ms); request) {
client.start(*request, now_ms);
}
}
}
if (network == vamp_pico::http_status::IDLE) {
if (auto request = scheduler.next(now_ms); request) {
client.start(*request, now_ms);
}
}
render(display, status, remote, now_ms < busy_until_ms);
auto busy = now_ms < busy_until_ms || scheduler.queued() != 0U;
display.show_remote(status, remote, busy);
display.poll(now_ms);
sleep_ms(8);
}
}
+5
View File
@@ -57,6 +57,11 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
parsed.active = value == "1";
} else if (key == "title") {
copy_value(value, parsed.title, sizeof(parsed.title));
} else if (key == "title_image") {
if (!value.empty() && !vamp_protocol::valid_title_id(value)) {
return false;
}
copy_value(value, parsed.title_image.data(), parsed.title_image.size());
} else if (key == "part") {
copy_value(value, parsed.part, sizeof(parsed.part));
} else if (key == "mode") {
+8
View File
@@ -205,6 +205,10 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalog const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
for (auto const &part : catalog.performance.parts) {
auto title = vamp_zero::validated_title_path(config.runtime.assets_path, part.title_image);
if (!title) {
co_return std::unexpected(title.error());
}
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id};
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
if (!valid) {
@@ -213,6 +217,10 @@ auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config c
}
for (auto const &exercise : catalog.practice.exercises) {
for (auto const &practice_part : exercise.parts) {
auto title = vamp_zero::validated_title_path(config.runtime.assets_path, practice_part.title_image);
if (!title) {
co_return std::unexpected(title.error());
}
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = practice_part.id};
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
if (!valid) {
+12 -5
View File
@@ -88,8 +88,13 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t {
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
}
auto valid_title_image(noto::sqlite_value const &value) -> std::bool_t {
auto text = text_value(value);
return text.size() == 64U && std::all_of(text.begin(), text.end(), [](std::char_t c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); });
}
auto compatible_table(noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::bool_t> {
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages"};
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages", "title_image"};
if (table == "practice_exercises") {
columns.push_back("exercise_id");
}
@@ -159,7 +164,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
co_return std::unexpected(connected.error());
}
auto selected =
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages FROM "
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image FROM "
"songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
if (!selected) {
auto close = co_await connection.async_close();
@@ -180,7 +185,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
break;
}
auto const &row = **next;
if (row.size() != 8U || count_value(row[5]) == 0U || count_value(row[7]) == 0U) {
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
break;
}
@@ -198,6 +203,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
.source_path = std::move(*source),
.measure_count = count_value(row[5]),
.total_pages = count_value(row[7]),
.title_image = text_value(row[8]),
});
}
auto closed = co_await close_catalog_query(connection, *selected, std::move(status));
@@ -214,7 +220,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
if (!connected) {
co_return std::unexpected(connected.error());
}
auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id "
auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id, title_image "
"FROM practice_exercises ORDER BY exercise_id, id");
if (!selected) {
auto close = co_await connection.async_close();
@@ -235,7 +241,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
break;
}
auto const &row = **next;
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty()) {
if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty() || !valid_title_image(row[9])) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
break;
}
@@ -253,6 +259,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
.source_path = std::move(*source),
.measure_count = count_value(row[5]),
.total_pages = count_value(row[6]),
.title_image = text_value(row[9]),
};
exercises.push_back({
.id = text_value(row[8]),
+4
View File
@@ -435,6 +435,7 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::jso
auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::string {
auto title = std::string{};
auto title_image = std::string{};
auto part = std::string{};
auto page = std::size_t{};
auto pages = std::size_t{};
@@ -443,6 +444,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
if (auto const *selected = active_part(state); selected != nullptr) {
active = true;
title = selected->title;
title_image = selected->title_image;
part = selected->part;
page = state.page_index + 1U;
pages = selected->total_pages;
@@ -453,6 +455,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
active = true;
title = selected->title;
part = practice_part == nullptr ? std::string{} : practice_part->part;
title_image = practice_part == nullptr ? std::string{} : practice_part->title_image;
page = state.page_index + 1U;
pages = practice_part == nullptr ? 0U : practice_part->total_pages;
}
@@ -461,6 +464,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
output << "active=" << (active ? "1" : "0") << "\n";
output << "title=" << sanitize_line_value(title) << "\n";
output << "title_image=" << title_image << "\n";
output << "part=" << sanitize_line_value(part) << "\n";
output << "page=" << page << "\n";
output << "pages=" << pages << "\n";
+116 -1
View File
@@ -1,24 +1,111 @@
#include <vamp_zero/portal.hpp>
#include <chrono>
#include <array>
#include <expected>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <boost/asio/awaitable.hpp>
#include <boost/json/object.hpp>
#include <boost/json/value.hpp>
#include <openssl/sha.h>
#include <vamp_protocol/page_control.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/errors.hpp>
namespace {
auto read_asset(std::filesystem::path const &root, std::filesystem::path const &relative, std::size_t limit, std::string *resolved = nullptr) -> std::optional<std::string> {
auto error = std::error_code{};
auto canonical_root = std::filesystem::canonical(root, error);
if (error) {
return std::nullopt;
}
auto path = std::filesystem::canonical(canonical_root / relative, error);
auto contained = path.lexically_relative(canonical_root);
if (error || contained.empty() || *contained.begin() == ".." || !std::filesystem::is_regular_file(path, error) || error) {
return std::nullopt;
}
auto size = std::filesystem::file_size(path, error);
if (error || size == 0U || size > limit) {
return std::nullopt;
}
auto bytes = std::string(static_cast<std::size_t>(size), '\0');
auto input = std::ifstream{path, std::ios::binary};
if (!input.read(bytes.data(), static_cast<std::streamsize>(bytes.size())) || input.peek() != std::ifstream::traits_type::eof()) {
return std::nullopt;
}
if (resolved != nullptr) {
*resolved = path.string();
}
return bytes;
}
struct title_file {
std::string path;
std::string bytes;
};
auto read_title(std::filesystem::path const &root, std::string const &identifier) -> std::optional<title_file> {
if (!vamp_protocol::valid_title_id(identifier)) {
return std::nullopt;
}
auto resolved = std::string{};
auto bytes = read_asset(root, std::filesystem::path{"pico/titles"} / (identifier + ".mask"), vamp_protocol::title_image_size, &resolved);
if (!bytes || !vamp_protocol::valid_title_image(*bytes)) {
return std::nullopt;
}
auto error = std::error_code{};
auto directory = std::filesystem::canonical(root / "pico/titles", error);
if (error || std::filesystem::path{resolved}.parent_path() != directory) {
return std::nullopt;
}
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
SHA256(reinterpret_cast<std::uchar_t const *>(bytes->data()), bytes->size(), digest.data());
auto hash = std::string{};
constexpr auto digits = std::string_view{"0123456789abcdef"};
for (auto byte : digest) {
hash.push_back(digits[byte >> 4U]);
hash.push_back(digits[byte & 15U]);
}
if (hash != identifier) {
return std::nullopt;
}
return title_file{std::move(resolved), std::move(*bytes)};
}
class title_assets {
public:
title_assets(std::filesystem::path root, vamp_zero::catalog const &catalog)
: root_{std::move(root)} {
for (auto const &part : catalog.performance.parts) {
identifiers_.insert(part.title_image);
}
for (auto const &exercise : catalog.practice.exercises) {
for (auto const &part : exercise.parts) {
identifiers_.insert(part.title_image);
}
}
}
auto image(std::string const &identifier) const -> std::optional<title_file> {
return identifiers_.contains(identifier) ? read_title(root_, identifier) : std::nullopt;
}
private:
std::filesystem::path root_;
std::set<std::string> identifiers_{};
};
auto portal_html() -> std::string {
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title><style>@font-face{font-family:PetalumaScript;src:url('/assets/PetalumaScript.otf') format('opentype');font-display:swap}body,button,pre{font-family:PetalumaScript;font-size:22px}pre{white-space:pre-wrap;overflow-wrap:anywhere}button{font:inherit}</style></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
async function refresh(){const r=await fetch('/api/status');const j=await r.json();document.getElementById('status').textContent=JSON.stringify(j.payload||j,null,2);}
document.getElementById('prev').onclick=async()=>{await fetch('/api/remote/previous',{method:'POST'});refresh();};
document.getElementById('select').onclick=async()=>{await fetch('/api/remote/select',{method:'POST'});refresh();};
@@ -41,6 +128,14 @@ auto remote_response(vamp_zero::app_state const &state) -> ligo::service_respons
}
auto vamp_zero::validated_title_path(std::string const &assets_path, std::string const &identifier) -> std::expected<std::string, modulus::error> {
auto title = read_title(assets_path, identifier);
if (!title) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "invalid or missing generated Pico title; regenerate assets"));
}
return std::move(title->path);
}
vamp_zero::control_state::control_state(vamp_zero::config config, vamp_zero::app_state state)
: config_{std::move(config)}, state_{std::move(state)} {}
@@ -163,6 +258,26 @@ auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::a
auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router {
auto router = ligo::service_router{};
auto assets = state.config().runtime.assets_path;
auto artwork = std::make_shared<title_assets const>(assets, *state.snapshot().catalog);
router.add_response_route("GET", "/assets/PetalumaScript.otf", [assets](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto font = read_asset(assets, "fonts/petaluma/PetalumaScript.otf", 1024U * 1024U);
if (!font) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "PetalumaScript font unavailable"));
}
co_return ligo::text_response("font/otf", std::move(*font));
});
router.add_response_route("POST", vamp_protocol::remote_title_path, [artwork](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto const *id = payload.is_object() && payload.as_object().size() == 1U ? payload.as_object().if_contains("id") : nullptr;
if (id == nullptr || !id->is_string() || !vamp_protocol::valid_title_id(id->as_string())) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "invalid title identifier"));
}
auto bytes = artwork->image(std::string{id->as_string()});
if (!bytes) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "generated title unavailable"));
}
co_return ligo::text_response("application/octet-stream", std::move(bytes->bytes));
});
router.add_route("GET", "/api/health", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
co_return boost::json::object{{"product", "product-vamp"}, {"device", "zero"}, {"status", "ready"}};
});
-1
View File
@@ -15,7 +15,6 @@
#include <luma.hpp>
#include <vamp_style.hpp>
#include <vamp_style/font.hpp>
#include <vamp_zero/color.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp>