diff --git a/firmware/inc/Communication/WebFrontend/WebFrontend.h b/firmware/inc/Communication/WebFrontend/WebFrontend.h index 18a64ef..40929d9 100644 --- a/firmware/inc/Communication/WebFrontend/WebFrontend.h +++ b/firmware/inc/Communication/WebFrontend/WebFrontend.h @@ -74,6 +74,26 @@ ******************************************************************************************************************************************************/ class WebFrontend { + public: + /* One file a clock hands out unchanged: the two pages, the manifest and the two icons. + Everything a backend needs in order to answer for it, and nothing about how - which is + why this is a table and not five handlers. It was five handlers, twice, and the ten + differed only in a blob, a content type and whether a header went out with it. + + The bytes live in the generated WebPage.h, which only WebFrontend.cpp includes: the + generated arrays have internal linkage, so a second includer would put a second copy + of both pages in flash. */ + struct AssetType { + const char* Path; + const uint8_t* Bytes; + size_t Size; + const char* ContentType; + /* Stored compressed and sent compressed, so the backend has to say so in a header + and the browser unpacks it. False for the manifest, which is 485 bytes, and for + the icons, a PNG being deflated already. */ + bool IsGzipped; + }; + /****************************************************************************************************************************************************** * P R I V A T E D A T A A N D F U N C T I O N S ******************************************************************************************************************************************************/ @@ -107,6 +127,17 @@ class WebFrontend } // methods + /* What a backend may size a static array of routes with, since the count itself is only + known in the source. A static_assert there holds the two together, so a file added to + the table without raising this fails the build rather than one registration. */ + static constexpr byte MaxAssets{5u}; + + /* The files a clock serves unchanged, for a backend to register a route per entry and + answer it from the table. Walked rather than named one by one, so a file added to + web/ reaches both backends by appearing here. */ + static byte getNumberOfAssets(); + static const AssetType& getAsset(byte Index); + /* The command catalog as JSON, so a page can build its own form from it, and the panel's shape and letters. Both take the body a backend's route handler opened, which is the only thing about them that is a backend's business. */ diff --git a/firmware/src/Communication/WebFrontend/WebFrontend.cpp b/firmware/src/Communication/WebFrontend/WebFrontend.cpp index 5d542a7..da4ceca 100644 --- a/firmware/src/Communication/WebFrontend/WebFrontend.cpp +++ b/firmware/src/Communication/WebFrontend/WebFrontend.cpp @@ -24,6 +24,10 @@ #include "DisplayCharacters.h" #include "MessageCatalog.h" #include "Pixels.h" +/* The build generates this from web/. Included here and nowhere else: its arrays have + internal linkage, so every further includer would be a second copy of both pages in + flash. */ +#include "WebPage.h" #include "WordclockSerial.h" #include @@ -130,10 +134,64 @@ class ChunkWriter } // namespace +/****************************************************************************************************************************************************** + * L O C A L D A T A +******************************************************************************************************************************************************/ +namespace { + +/* Every file a clock hands out unchanged, at the path it hands it out at. + * + * "/" is the panel and "/console" the console, which is the whole arrangement rather than a + * detail: nobody types a path, they type the clock's address and take what comes, so what + * comes is the page for the things somebody changes often, with a link to the other one in + * its header. + * + * The three that follow are neither page but the home screen icon - a manifest and two PNGs - + * which is what lets the console be installed with an icon to tap and a start without an + * address bar. + */ +constexpr WebFrontend::AssetType Assets[]{ + {"/", WebAppGzip, WebAppGzipSize, "text/html", true}, + {"/console", WebPageGzip, WebPageGzipSize, "text/html", true}, + {"/manifest.webmanifest", WebManifest, WebManifestSize, "application/manifest+json", false}, + {"/icon-192.png", WebIcon192, WebIcon192Size, "image/png", false}, + {"/icon-512.png", WebIcon512, WebIcon512Size, "image/png", false}, +}; + +/* A backend sizes its route array with MaxAssets, which it can see and this count it cannot. + Adding a file here without raising that fails the build, rather than quietly registering + the first five of six. */ +static_assert((sizeof(Assets) / sizeof(Assets[0])) <= WebFrontend::MaxAssets, + "WebFrontend::MaxAssets has to grow with the asset table"); + +} // namespace + /****************************************************************************************************************************************************** * P U B L I C F U N C T I O N S ******************************************************************************************************************************************************/ +/****************************************************************************************************************************************************** + getNumberOfAssets() / getAsset() +******************************************************************************************************************************************************/ +/*! \brief The files a clock serves unchanged, for a backend to register routes from + * + * \details A table rather than a handler per file, because that is what the ten + * handlers this replaces actually were: each one set a content type, set a + * Content-Encoding header or did not, and pushed a blob. The difference + * between them was data, and the sameness between the two backends was + * complete. +******************************************************************************************************************************************************/ +byte WebFrontend::getNumberOfAssets() +{ + return static_cast(sizeof(Assets) / sizeof(Assets[0])); +} + +const WebFrontend::AssetType& WebFrontend::getAsset(byte Index) +{ + return Assets[Index]; +} + + /****************************************************************************************************************************************************** writeCommands() ******************************************************************************************************************************************************/ diff --git a/platform/esp32/src/WebInterface.cpp b/platform/esp32/src/WebInterface.cpp index 7a08e47..478f870 100644 --- a/platform/esp32/src/WebInterface.cpp +++ b/platform/esp32/src/WebInterface.cpp @@ -26,7 +26,6 @@ #include "System.h" #include "WebFrontend.h" #include "WebInterface.h" -#include "WebPage.h" #include "WebTransport.h" #include "WordclockSerial.h" @@ -150,71 +149,30 @@ bool isRequestAuthorised(httpd_req_t* Request) /****************************************************************************************************************************************************** - sendPage() + handleAsset() ******************************************************************************************************************************************************/ -/*! \brief Serves one of the two pages straight out of flash - * \details Sent compressed, which is how it is stored: the pages were gzipped at build - * time, so this only pushes the bytes and the browser unpacks them. +/*! \brief Serves one of the files the clock hands out unchanged + * + * \details One handler for all five - the two pages, the manifest and the two icons - + * because what separates them is data and the data is WebFrontend's table. + * Which entry this request is for arrives in the server's own user context, + * which is what that field exists for. + * + * A gzipped asset is sent as it is stored and the browser unpacks it; that + * is the whole of what the header does here, and the table says which. + * + * \return ESP_OK ******************************************************************************************************************************************************/ -esp_err_t sendPage(httpd_req_t* Request, const uint8_t* Page, size_t Size) +esp_err_t handleAsset(httpd_req_t* Request) { if(!isRequestAuthorised(Request)) { return ESP_OK; } - httpd_resp_set_type(Request, "text/html"); - httpd_resp_set_hdr(Request, "Content-Encoding", "gzip"); - - return httpd_resp_send(Request, reinterpret_cast(Page), Size); -} + const WebFrontend::AssetType& Asset = *static_cast(Request->user_ctx); + httpd_resp_set_type(Request, Asset.ContentType); + if(Asset.IsGzipped) { httpd_resp_set_hdr(Request, "Content-Encoding", "gzip"); } -/****************************************************************************************************************************************************** - handleRoot() -******************************************************************************************************************************************************/ -/*! \brief Serves the page a clock hands out at "/" - * \details The one made for what somebody changes: a colour, a brightness, an - * animation. Nobody types a path - they type the clock's address and take - * what comes - so what comes is the page for the frequent things, and the - * console is one link away. -******************************************************************************************************************************************************/ -esp_err_t handleRoot(httpd_req_t* Request) -{ - return sendPage(Request, WebAppGzip, WebAppGzipSize); -} - - -/****************************************************************************************************************************************************** - handleConsole() -******************************************************************************************************************************************************/ -/*! \brief Serves the console at "/console" - * \details Everything the page at "/" does not cover, which is most of the command - * set: it draws a group per command out of the catalog, so a command added to - * the firmware appears there without a page being touched. That is why it - * moved rather than being replaced. -******************************************************************************************************************************************************/ -esp_err_t handleConsole(httpd_req_t* Request) -{ - return sendPage(Request, WebPageGzip, WebPageGzipSize); -} - - -/****************************************************************************************************************************************************** - handleManifest() -******************************************************************************************************************************************************/ -/*! \brief Serves the web app manifest, which is what makes the console installable - * \details Nothing on the clock reads it - a browser does, to put the console on a - * home screen with an icon and start it without an address bar. Sent as it - * is, unlike the page: it is 485 bytes, and compressing it would save - * fewer than 250 of them for a header on the wire and an inflate in - * anything that wants to read it - curl and this backend's own test - * included. -******************************************************************************************************************************************************/ -esp_err_t handleManifest(httpd_req_t* Request) -{ - if(!isRequestAuthorised(Request)) { return ESP_OK; } - - httpd_resp_set_type(Request, "application/manifest+json"); - - return httpd_resp_send(Request, reinterpret_cast(WebManifest), WebManifestSize); + return httpd_resp_send(Request, reinterpret_cast(Asset.Bytes), Asset.Size); } @@ -324,32 +282,6 @@ esp_err_t handleUpdate(httpd_req_t* Request) } -/****************************************************************************************************************************************************** - handleIcon192() / handleIcon512() -******************************************************************************************************************************************************/ -/*! \brief Serves the home screen icons - * \details Two sizes because that is what a manifest is asked for: the small one is - * the icon itself and what iOS takes, the large one is what Android draws - * the splash screen from. Sent as they are - a PNG is already deflated, so - * gzipping one would add a header and save nothing. -******************************************************************************************************************************************************/ -esp_err_t handleIcon192(httpd_req_t* Request) -{ - if(!isRequestAuthorised(Request)) { return ESP_OK; } - - httpd_resp_set_type(Request, "image/png"); - - return httpd_resp_send(Request, reinterpret_cast(WebIcon192), WebIcon192Size); -} - -esp_err_t handleIcon512(httpd_req_t* Request) -{ - if(!isRequestAuthorised(Request)) { return ESP_OK; } - - httpd_resp_set_type(Request, "image/png"); - - return httpd_resp_send(Request, reinterpret_cast(WebIcon512), WebIcon512Size); -} /****************************************************************************************************************************************************** @@ -491,23 +423,28 @@ StdReturnType WebInterface::begin() return E_NOT_OK; } - static const httpd_uri_t RootUri{"/", HTTP_GET, handleRoot, nullptr, false, false, nullptr}; - static const httpd_uri_t ConsoleUri{"/console", HTTP_GET, handleConsole, nullptr, false, false, nullptr}; + /* The files served unchanged, one route per entry of WebFrontend's table. The array is + static because httpd keeps the pointer it is registered with rather than a copy, and + sized from the table so that a file added to web/ needs no edit here - only + max_uri_handlers above has to still be large enough, which is why that number is said + out loud rather than left at its default. */ + static httpd_uri_t AssetUris[WebFrontend::MaxAssets]{}; + + for(byte Index = 0u; Index < WebFrontend::getNumberOfAssets(); Index++) { + const WebFrontend::AssetType& Asset = WebFrontend::getAsset(Index); + + AssetUris[Index] = httpd_uri_t{Asset.Path, HTTP_GET, handleAsset, const_cast(&Asset), + false, false, nullptr}; + httpd_register_uri_handler(HttpServer, &AssetUris[Index]); + } + static const httpd_uri_t CommandsUri{"/commands", HTTP_GET, handleCommands, nullptr, false, false, nullptr}; static const httpd_uri_t DisplayUri{"/display", HTTP_GET, handleDisplay, nullptr, false, false, nullptr}; - static const httpd_uri_t ManifestUri{"/manifest.webmanifest", HTTP_GET, handleManifest, nullptr, false, false, nullptr}; - static const httpd_uri_t Icon192Uri{"/icon-192.png", HTTP_GET, handleIcon192, nullptr, false, false, nullptr}; - static const httpd_uri_t Icon512Uri{"/icon-512.png", HTTP_GET, handleIcon512, nullptr, false, false, nullptr}; static const httpd_uri_t UpdateUri{"/update", HTTP_POST, handleUpdate, nullptr, false, false, nullptr}; static const httpd_uri_t SocketUri{"/ws", HTTP_GET, handleSocket, nullptr, true, false, nullptr}; - httpd_register_uri_handler(HttpServer, &RootUri); - httpd_register_uri_handler(HttpServer, &ConsoleUri); httpd_register_uri_handler(HttpServer, &CommandsUri); httpd_register_uri_handler(HttpServer, &DisplayUri); - httpd_register_uri_handler(HttpServer, &ManifestUri); - httpd_register_uri_handler(HttpServer, &Icon192Uri); - httpd_register_uri_handler(HttpServer, &Icon512Uri); httpd_register_uri_handler(HttpServer, &UpdateUri); httpd_register_uri_handler(HttpServer, &SocketUri); diff --git a/platform/esp32/test/cases/web_test.cpp b/platform/esp32/test/cases/web_test.cpp index 0b28073..234cfee 100644 --- a/platform/esp32/test/cases/web_test.cpp +++ b/platform/esp32/test/cases/web_test.cpp @@ -17,15 +17,34 @@ #include -static httpd_handler_t SocketHandler = nullptr; -static httpd_handler_t RootHandler = nullptr; -static httpd_handler_t CommandsHandler = nullptr; -static httpd_handler_t DisplayHandler = nullptr; -static httpd_handler_t ManifestHandler = nullptr; -static httpd_handler_t Icon192Handler = nullptr; -static httpd_handler_t Icon512Handler = nullptr; -static httpd_handler_t UpdateHandler = nullptr; -static httpd_handler_t ConsoleHandler = nullptr; +/* A registered route as the server keeps it: the handler and the context handed back with + it. The context matters since the five files served unchanged share one handler and tell + each other apart by it - so a route is called through here rather than as a bare function + pointer, or every asset would answer as whichever was registered last. */ +struct RouteType { + httpd_handler_t Handler = nullptr; + void* Context = nullptr; + + esp_err_t operator()(httpd_req_t* Request) const { + Request->user_ctx = Context; + return Handler(Request); + } + + /* So a case can still ask whether a route was registered at all, which is what the + nullptr comparisons below are for. */ + bool operator!=(std::nullptr_t) const { return Handler != nullptr; } + bool operator==(std::nullptr_t) const { return Handler == nullptr; } +}; + +static RouteType SocketHandler; +static RouteType RootHandler; +static RouteType CommandsHandler; +static RouteType DisplayHandler; +static RouteType ManifestHandler; +static RouteType Icon192Handler; +static RouteType Icon512Handler; +static RouteType UpdateHandler; +static RouteType ConsoleHandler; static std::string SentType; static std::string SentStatus; /* Every header a handler set, by name - the gate's WWW-Authenticate is read out of here. */ @@ -45,15 +64,17 @@ esp_err_t httpd_start(httpd_handle_t* h, const httpd_config_t*) { *h = (httpd_ha esp_err_t httpd_register_uri_handler(httpd_handle_t, const httpd_uri_t* u) { - if(strcmp(u->uri, "/ws") == 0) { SocketHandler = u->handler; } - else if(strcmp(u->uri, "/commands") == 0) { CommandsHandler = u->handler; } - else if(strcmp(u->uri, "/display") == 0) { DisplayHandler = u->handler; } - else if(strcmp(u->uri, "/manifest.webmanifest") == 0) { ManifestHandler = u->handler; } - else if(strcmp(u->uri, "/icon-192.png") == 0) { Icon192Handler = u->handler; } - else if(strcmp(u->uri, "/icon-512.png") == 0) { Icon512Handler = u->handler; } - else if(strcmp(u->uri, "/update") == 0) { UpdateHandler = u->handler; } - else if(strcmp(u->uri, "/console") == 0) { ConsoleHandler = u->handler; } - else { RootHandler = u->handler; } + const RouteType Route{u->handler, u->user_ctx}; + + if(strcmp(u->uri, "/ws") == 0) { SocketHandler = Route; } + else if(strcmp(u->uri, "/commands") == 0) { CommandsHandler = Route; } + else if(strcmp(u->uri, "/display") == 0) { DisplayHandler = Route; } + else if(strcmp(u->uri, "/manifest.webmanifest") == 0) { ManifestHandler = Route; } + else if(strcmp(u->uri, "/icon-192.png") == 0) { Icon192Handler = Route; } + else if(strcmp(u->uri, "/icon-512.png") == 0) { Icon512Handler = Route; } + else if(strcmp(u->uri, "/update") == 0) { UpdateHandler = Route; } + else if(strcmp(u->uri, "/console") == 0) { ConsoleHandler = Route; } + else { RootHandler = Route; } return ESP_OK; } diff --git a/platform/rp2350/src/WebInterface.cpp b/platform/rp2350/src/WebInterface.cpp index 46433fe..f21e9bf 100644 --- a/platform/rp2350/src/WebInterface.cpp +++ b/platform/rp2350/src/WebInterface.cpp @@ -29,7 +29,6 @@ #include "System.h" #include "WebFrontend.h" #include "WebInterface.h" -#include "WebPage.h" #include "WebTransport.h" #include "WordclockSerial.h" @@ -229,72 +228,29 @@ void commitImage(size_t Announced) /****************************************************************************************************************************************************** - sendPage() + handleAsset() ******************************************************************************************************************************************************/ -/*! \brief Serves one of the two pages straight out of flash - * \details Sent compressed, which is how it is stored: the pages were gzipped at build - * time, so this only pushes the bytes and the browser unpacks them. +/*! \brief Serves one of the files the clock hands out unchanged + * + * \details One handler for all five - the two pages, the manifest and the two icons - + * because what separates them is data and the data is WebFrontend's table. + * Where the IDF's server carries a user context per route, this library takes + * a std::function, so the entry is captured instead. + * + * A gzipped asset is sent as it is stored and the browser unpacks it; that + * is the whole of what the header does here, and the table says which. ******************************************************************************************************************************************************/ -void sendPage(AsyncWebServerRequest* Request, const uint8_t* Page, size_t Size) +void handleAsset(AsyncWebServerRequest* Request, const WebFrontend::AssetType& Asset) { if(!isRequestAuthorised(Request)) { return; } - AsyncWebServerResponse* Response = Request->beginResponse(200, "text/html", Page, Size); + AsyncWebServerResponse* Response = Request->beginResponse(200, Asset.ContentType, Asset.Bytes, Asset.Size); - Response->addHeader("Content-Encoding", "gzip"); + if(Asset.IsGzipped) { Response->addHeader("Content-Encoding", "gzip"); } Request->send(Response); } -/****************************************************************************************************************************************************** - handleRoot() -******************************************************************************************************************************************************/ -/*! \brief Serves the page a clock hands out at "/" - * \details The one made for what somebody changes: a colour, a brightness, an - * animation. Nobody types a path - they type the clock's address and take what - * comes - so what comes is the page for the frequent things, and the console is - * one link away at "/console". -******************************************************************************************************************************************************/ -void handleRoot(AsyncWebServerRequest* Request) -{ - sendPage(Request, WebAppGzip, WebAppGzipSize); -} - - -/****************************************************************************************************************************************************** - handleConsole() -******************************************************************************************************************************************************/ -/*! \brief Serves the console at "/console" - * \details Everything the page at "/" does not cover, which is most of the command set: - * it draws a group per command out of the catalog, so a command added to the - * firmware appears there without a page being touched. That is why it moved - * rather than being replaced. -******************************************************************************************************************************************************/ -void handleConsole(AsyncWebServerRequest* Request) -{ - sendPage(Request, WebPageGzip, WebPageGzipSize); -} - - -/****************************************************************************************************************************************************** - handleManifest() -******************************************************************************************************************************************************/ -/*! \brief Serves the web app manifest, which is what makes the console installable - * \details Nothing on the clock reads it - a browser does, to put the console on a - * home screen with an icon and start it without an address bar. Sent as it - * is, unlike the page: it is 485 bytes, and compressing it would save - * fewer than 250 of them for a header on the wire and an inflate in - * anything that wants to read it - curl and this backend's own test - * included. -******************************************************************************************************************************************************/ -void handleManifest(AsyncWebServerRequest* Request) -{ - if(!isRequestAuthorised(Request)) { return; } - - Request->send(Request->beginResponse(200, "application/manifest+json", WebManifest, WebManifestSize)); -} - - /****************************************************************************************************************************************************** sendUpdateResult() ******************************************************************************************************************************************************/ @@ -395,30 +351,6 @@ void handleUpdate(AsyncWebServerRequest* Request) } -/****************************************************************************************************************************************************** - handleIcon192() / handleIcon512() -******************************************************************************************************************************************************/ -/*! \brief Serves the home screen icons - * \details Two sizes because that is what a manifest is asked for: the small one is - * the icon itself and what iOS takes, the large one is what Android draws - * the splash screen from. Sent as they are - a PNG is already deflated, so - * gzipping one would add a header and save nothing. -******************************************************************************************************************************************************/ -void handleIcon192(AsyncWebServerRequest* Request) -{ - if(!isRequestAuthorised(Request)) { return; } - - Request->send(Request->beginResponse(200, "image/png", WebIcon192, WebIcon192Size)); -} - -void handleIcon512(AsyncWebServerRequest* Request) -{ - if(!isRequestAuthorised(Request)) { return; } - - Request->send(Request->beginResponse(200, "image/png", WebIcon512, WebIcon512Size)); -} - - /****************************************************************************************************************************************************** handleCommands() / handleDisplay() ******************************************************************************************************************************************************/ @@ -509,13 +441,18 @@ StdReturnType WebInterface::begin() Socket.onEvent(onSocketEvent); HttpServer.addHandler(&Socket); - HttpServer.on("/", HTTP_GET, handleRoot); - HttpServer.on("/console", HTTP_GET, handleConsole); + /* The files served unchanged, one route per entry of WebFrontend's table, so a file added + to web/ needs no edit here. The entry is captured by reference and outlives the lambda: + the table has static storage duration. */ + for(byte Index = 0u; Index < WebFrontend::getNumberOfAssets(); Index++) { + const WebFrontend::AssetType& Asset = WebFrontend::getAsset(Index); + + HttpServer.on(Asset.Path, HTTP_GET, + [&Asset](AsyncWebServerRequest* Request) { handleAsset(Request, Asset); }); + } + HttpServer.on("/commands", HTTP_GET, handleCommands); HttpServer.on("/display", HTTP_GET, handleDisplay); - HttpServer.on("/manifest.webmanifest", HTTP_GET, handleManifest); - HttpServer.on("/icon-192.png", HTTP_GET, handleIcon192); - HttpServer.on("/icon-512.png", HTTP_GET, handleIcon512); /* Four arguments, and the fourth is where the image arrives: the library hands a POST body to that callback in chunks and calls the request handler afterwards. The third, the upload handler, is for multipart forms and stays empty - the panel sends the file