@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
@@ -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"}};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user