310 lines
13 KiB
C++
310 lines
13 KiB
C++
#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.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><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();};
|
|
document.getElementById('next').onclick=async()=>{await fetch('/api/remote/next',{method:'POST'});refresh();};
|
|
refresh();
|
|
</script></main></body></html>)html";
|
|
}
|
|
|
|
auto endpoint_port(std::string const &endpoint) -> std::string {
|
|
auto separator = endpoint.rfind(':');
|
|
if (separator == std::string::npos || separator + 1U >= endpoint.size()) {
|
|
return "8080";
|
|
}
|
|
return endpoint.substr(separator + 1U);
|
|
}
|
|
|
|
auto remote_response(vamp_zero::app_state const &state) -> ligo::service_response {
|
|
return ligo::text_response("text/plain; charset=utf-8", vamp_zero::remote_display_text(state));
|
|
}
|
|
|
|
}
|
|
|
|
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)} {}
|
|
|
|
auto vamp_zero::control_state::config() const -> vamp_zero::config {
|
|
auto lock = std::scoped_lock{mutex_};
|
|
return config_;
|
|
}
|
|
|
|
auto vamp_zero::control_state::pico_connected_locked() const -> std::bool_t {
|
|
return last_heartbeat_ != std::chrono::steady_clock::time_point{} && std::chrono::steady_clock::now() - last_heartbeat_ < std::chrono::seconds{3};
|
|
}
|
|
|
|
auto vamp_zero::control_state::snapshot_locked() const -> vamp_zero::app_state {
|
|
auto copy = state_;
|
|
copy.pico_connected = pico_connected_locked();
|
|
return copy;
|
|
}
|
|
|
|
auto vamp_zero::control_state::snapshot() const -> vamp_zero::app_state {
|
|
auto lock = std::scoped_lock{mutex_};
|
|
return snapshot_locked();
|
|
}
|
|
|
|
auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vamp_zero::app_state {
|
|
auto observer = std::function<void(vamp_zero::app_state)>{};
|
|
auto updated = vamp_zero::app_state{};
|
|
{
|
|
auto lock = std::scoped_lock{mutex_};
|
|
state_ = vamp_zero::apply_app_command(std::move(state_), command);
|
|
state_.pico_connected = pico_connected_locked();
|
|
updated = state_;
|
|
observer = observer_;
|
|
}
|
|
if (observer) {
|
|
observer(updated);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state {
|
|
auto observer = std::function<void(vamp_zero::app_state)>{};
|
|
auto updated = vamp_zero::app_state{};
|
|
{
|
|
auto lock = std::scoped_lock{mutex_};
|
|
last_heartbeat_ = std::chrono::steady_clock::now();
|
|
++state_.heartbeat_count;
|
|
auto notify = !state_.pico_connected;
|
|
state_.pico_connected = true;
|
|
updated = state_;
|
|
if (notify) {
|
|
observer = observer_;
|
|
}
|
|
}
|
|
if (observer) {
|
|
observer(updated);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
auto vamp_zero::control_state::refresh_connection() -> std::optional<vamp_zero::app_state> {
|
|
auto observer = std::function<void(vamp_zero::app_state)>{};
|
|
auto updated = vamp_zero::app_state{};
|
|
{
|
|
auto lock = std::scoped_lock{mutex_};
|
|
auto connected = pico_connected_locked();
|
|
if (connected == state_.pico_connected) {
|
|
return std::nullopt;
|
|
}
|
|
state_.pico_connected = connected;
|
|
updated = state_;
|
|
observer = observer_;
|
|
}
|
|
if (observer) {
|
|
observer(updated);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
auto vamp_zero::control_state::set_observer(std::function<void(vamp_zero::app_state)> observer) -> void {
|
|
auto lock = std::scoped_lock{mutex_};
|
|
observer_ = std::move(observer);
|
|
}
|
|
|
|
auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::string error, std::shared_ptr<vamp_zero::catalog const> catalog) -> void {
|
|
auto observer = std::function<void(vamp_zero::app_state)>{};
|
|
auto updated = vamp_zero::app_state{};
|
|
{
|
|
auto lock = std::scoped_lock{mutex_};
|
|
if (!state_.audio.playback_enabled || state_.score_session != session || (catalog && catalog != state_.catalog) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
|
return;
|
|
}
|
|
if (error.empty()) {
|
|
auto count = state_.select_count;
|
|
state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT);
|
|
state_.select_count = count;
|
|
state_.last_command = "playback-completed";
|
|
} else {
|
|
state_.audio.playback_error = std::move(error);
|
|
}
|
|
updated = snapshot_locked();
|
|
observer = observer_;
|
|
}
|
|
if (observer) {
|
|
observer(std::move(updated));
|
|
}
|
|
}
|
|
|
|
auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value {
|
|
auto control = boost::json::object{};
|
|
control["ap_ssid"] = config.control.ap_ssid;
|
|
control["zero_ip"] = config.control.zero_ip;
|
|
control["endpoint"] = "http://" + config.control.zero_ip + ":" + endpoint_port(config.control.endpoint.bind);
|
|
auto root = boost::json::object{};
|
|
root["device"] = "zero";
|
|
root["git_sha"] = VAMP_GIT_SHA;
|
|
root["control"] = std::move(control);
|
|
root["state"] = vamp_zero::app_status_json(state);
|
|
return root;
|
|
}
|
|
|
|
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"}};
|
|
});
|
|
router.add_response_route("GET", vamp_protocol::root_path, [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
co_return ligo::text_response("text/html; charset=utf-8", portal_html());
|
|
});
|
|
router.add_route("GET", vamp_protocol::status_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
|
co_return vamp_zero::portal_status_json(state.config(), state.snapshot());
|
|
});
|
|
router.add_response_route("GET", vamp_protocol::remote_display_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
auto updated = state.heartbeat();
|
|
co_return remote_response(updated);
|
|
});
|
|
router.add_response_route("POST", vamp_protocol::remote_heartbeat_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
auto updated = state.heartbeat();
|
|
co_return remote_response(updated);
|
|
});
|
|
router.add_response_route("POST", vamp_protocol::remote_next_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
static_cast<void>(state.heartbeat());
|
|
auto updated = state.apply(vamp_protocol::page_command::NEXT);
|
|
co_return remote_response(updated);
|
|
});
|
|
router.add_response_route("POST", vamp_protocol::remote_previous_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
static_cast<void>(state.heartbeat());
|
|
auto updated = state.apply(vamp_protocol::page_command::PREVIOUS);
|
|
co_return remote_response(updated);
|
|
});
|
|
router.add_response_route("POST", vamp_protocol::remote_select_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
static_cast<void>(state.heartbeat());
|
|
auto updated = state.apply(vamp_protocol::page_command::SELECT);
|
|
co_return remote_response(updated);
|
|
});
|
|
return router;
|
|
}
|