From 1dd4560f50df877bcd594322d07d5bc2485d38d3 Mon Sep 17 00:00:00 2001 From: le0m Date: Mon, 15 Sep 2025 00:17:47 +0200 Subject: [PATCH 01/29] feat: client side handling of new "Map" mode --- README.md | 7 +- data/config.json | 2 +- web/index.html | 169 ++++++++++++++++++-- web/src/config.js | 64 ++++---- web/src/firmware.js | 32 ++-- web/src/image.js | 236 ++++++++++++++-------------- web/src/main.js | 2 + web/src/map.js | 368 ++++++++++++++++++++++++++++++++++++++++++++ web/src/style.css | 4 + web/src/system.js | 37 ++--- web/src/utils.js | 68 ++++++++ 11 files changed, 770 insertions(+), 219 deletions(-) create mode 100644 web/src/map.js diff --git a/README.md b/README.md index f3b60c9..31dad81 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,10 @@ Some LEDs have been harmed in the development process. Our thoughts go to all th # TODOs - add FPS and other Life config -- send current GIF - modeselector: prevent random wrong reads, use multiple reads in the span of some seconds -- a button to delete stored GIF and/or map image to free up space - music visualizer (needs mic) +- setting for map remote API pull time (15m, 30m, 1h) +- support PNG +- consolidate requests from web UI +- implement map zoom (need scaling on esp32) +- implement other position centering methods (center on point, use a margin to move the view before the position reaches the panel border, ...) diff --git a/data/config.json b/data/config.json index 69b8cde..de7a81b 100644 --- a/data/config.json +++ b/data/config.json @@ -1 +1 @@ -{"wifi":{"ssid":"","password":""},"panel":{"brightness": 63},"log":{"level":2}} +{"wifi":{"ssid":"","password":""},"panel":{"brightness":63},"log":{"level":2},"map":{"latitude":"","longitude":"","url":"","method":"GET","regex":"","body":"","headers":"","pointSize":2,"pointColor":63488,"trackColor":63488,"positions":[]}} diff --git a/web/index.html b/web/index.html index d922616..957599d 100644 --- a/web/index.html +++ b/web/index.html @@ -26,7 +26,145 @@

Image

+ + +
+

Confirm

+

Delete image/GIF stored on panel?

+ + + + +
+
+ + + + +
+

Map

+
+

Currently supports only equirectangular world maps

+ +
+
+ + +
+
+ +
+ +
+ +
+

Map preview

+ +
+ +
+

Panel preview

+ +
+ +
+
+ + + + +
+
+ +
+

Static location

+
+
+ + +
+
+
+ +
+

Remote API

+

ADVANCED: Periodically call a remote API to get position.

+

If all required fields are set, this has precedence over static location.

+

URL and method are required.

+

Regex is required, uses the Modified ECMAScript grammar.

+

Body is optional, must be a query string if the request is GET or stringified JSON if POST.

+

Headers are optional, must be a stringified JSON.

+
+
+ + + +
+
+ +
+ + +
+ + + +
+ + +
@@ -123,24 +261,23 @@

Debug

- -
+
+ + + -
- -
+
-
- +
diff --git a/web/src/config.js b/web/src/config.js index 5005774..95561b0 100644 --- a/web/src/config.js +++ b/web/src/config.js @@ -1,4 +1,4 @@ -import { resetMessage, setDisabled, showMessage } from './utils.js'; +import { resetMessage, setDisabled, showMessage, handleFetch } from './utils.js'; let hidePwd = true; const submitWifi = document.getElementById('submit-wifi'); @@ -21,19 +21,15 @@ const logSelect = document.getElementById('log-level'); export const config = (baseUrl) => { // Get current config - const url = new URL('config', baseUrl); - fetch(url, { + handleFetch(new URL('config', baseUrl), { method: 'GET', headers: { 'Accept': 'application/json' }, - }).then(async (res) => { - const data = await res.text(); - if (!res.ok || res.status != 200) { - console.error(`Error receiving configuration, status code ${res.status}: ${data}`); - + }).then(async (resp) => { + if (!resp) { return; } - const json = JSON.parse(data); + const json = await resp.json(); ssidInput.value = json?.wifi?.ssid; pwdInput.value = json?.wifi?.password; brightnessInput.value = json?.panel?.brightness || 127; @@ -64,36 +60,30 @@ export const config = (baseUrl) => { submitElem.addEventListener('click', async () => { setDisabled(submitElem, true); showMessage(feedbackElem, 'Saving...', 'is-primary'); - const config = { - wifi: { - ssid: ssidInput.value, - password: pwdInput.value, - }, - panel: { - brightness: Math.min(255, Math.max(0, brightnessInput.value)), - }, - log: { - level: parseInt(logSelect.value, 10) - } - }; - - try { - const url = new URL('config', baseUrl); - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }); - if (!response.ok || response.status != 204) { - throw new Error(`status code ${response.status}: ${await response.text()}`); - } - + const res = await handleFetch(new URL('config', baseUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + wifi: { + ssid: ssidInput.value, + password: pwdInput.value, + }, + panel: { + brightness: Math.min(255, Math.max(0, brightnessInput.value)), + }, + log: { + level: parseInt(logSelect.value, 10) + } + }), + }); + if (res) { showMessage(feedbackElem, 'Configuration saved', 'is-success'); - } catch (e) { - console.error('Error sending configuration:', e); - showMessage(feedbackElem, 'Error saving configuration', 'is-error'); - setDisabled(submitElem, false); + + return; } + + showMessage(feedbackElem, 'Error saving configuration', 'is-error'); + setDisabled(submitElem, false); }); }; handleSubmit(submitWifi, feedbackWifiMessage); diff --git a/web/src/firmware.js b/web/src/firmware.js index 151ebba..798901a 100644 --- a/web/src/firmware.js +++ b/web/src/firmware.js @@ -1,4 +1,4 @@ -import { resetMessage, setDisabled, showMessage } from './utils.js'; +import { resetMessage, setDisabled, showMessage, handleFetch } from './utils.js'; const submitUpdate = document.getElementById('submit-update'); setDisabled(submitUpdate, true); @@ -66,29 +66,19 @@ export const firmware = (baseUrl) => { return; } - let url = new URL('ota', baseUrl); + const url = new URL('ota', baseUrl); url.searchParams.set('mode', mode); url.searchParams.set('size', file.size); - try { - let response = await fetch(url); - if (!response.ok || response.status >= 300) { - throw new Error(`error initializing update: ${await response.text()}`); - } - - url = new URL('ota', baseUrl); - response = await fetch(url, { - method: 'POST', - body: file, - }); - if (!response.ok || response.status >= 300) { - throw new Error(`'error uploading file: ${await response.text()}'`); - } - + if ( + await handleFetch(url) + && await handleFetch(new URL('ota', baseUrl), { method: 'POST', body: file }) + ) { showMessage(feedbackMessage, 'Update applyed, rebooting', 'is-success'); - } catch (e) { - console.error('Error applying the update:', e); - setDisabled(submitUpdate, false); - showMessage(feedbackMessage, 'Error applying update', 'is-error'); + + return; } + + setDisabled(submitUpdate, false); + showMessage(feedbackMessage, 'Error applying update', 'is-error'); }); }; diff --git a/web/src/image.js b/web/src/image.js index 7de74d3..79c5ccc 100644 --- a/web/src/image.js +++ b/web/src/image.js @@ -1,6 +1,6 @@ -import { humanFileSize, resetMessage, setDisabled, showMessage } from './utils.js'; +import { humanFileSize, resetMessage, setDisabled, showMessage, handleFetch, readImage, canvas2jpeg } from './utils.js'; import { parseGIF, decompressFrames } from 'gifuct-js'; -import { quantize, applyPalette, prequantize, GIFEncoder, nearestColorIndexWithDistance } from 'gifenc'; +import { quantize, applyPalette, GIFEncoder } from 'gifenc'; /** * Original code taken from https://github.com/javl/image2cpp @@ -10,6 +10,8 @@ const submitFile = document.getElementById('submit-file'); setDisabled(submitFile, true); const feedbackMessage = document.getElementById('submit-file-message'); resetMessage(feedbackMessage); +const deleteMedia = document.getElementById('delete-media'); +const deleteMediaConfirm = document.getElementById('delete-media-confirm'); const fileInput = document.getElementById('file'); fileInput.value = null; const fileLabel = fileInput.parentElement.querySelector('span'); @@ -25,8 +27,6 @@ canvas.height = 64; /** @type {CanvasRenderingContext2D} */ const context = canvas.getContext('2d'); context.fillStyle = '#000000ff'; -/** @type {Uint8Array|null} */ -let result = null; // GIF stuff const tmpCanvas = new OffscreenCanvas(canvas.width, canvas.height); @@ -43,8 +43,42 @@ let offsetX, offsetY; let gifEncoder; /** @type {Uint8Array|undefined} */ let gifData; +/** @type {Uint8Array|undefined} */ +let imageData; let maxSize = 1024 * 1024; // 1 MiB, overridden if disk free size is lower +/** + * @param {File} file + */ +const loadImage = async (file) => { + const img = await readImage(file); + // Center and scale image to canvas size keeping ratio + const imgW = img.width; + const imgH = img.height; + const ratio = Math.min(canvas.width / imgW, canvas.height / imgH); + const dstW = (imgW * ratio) |0; + const dstH = (imgH * ratio) |0; + const dstX = dstW < canvas.width + ? ((canvas.width - dstW) / 2) |0 + : 0 + const dstY = dstH < canvas.height + ? ((canvas.height - dstH) / 2) |0 + : 0; + + // Draw preview + context.drawImage(img, 0, 0, imgW, imgH, dstX, dstY, dstW, dstH); + imageData = await canvas2jpeg(canvas).then((blob) => blob.bytes()); + if (imageData.byteLength > maxSize) { + console.error(`Not enough space for storing image (${humanFileSize(imageData.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`); + showMessage(feedbackMessage, `Resized image exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error'); + + return; + } + + setDisabled(submitFile, false); + console.log(`Image is ${humanFileSize(imageData.byteLength)}`); +}; + /** * @param {File} file */ @@ -64,7 +98,7 @@ const loadGif = async (file) => { ? ((canvas.height - scaledGifH) / 2) : 0; gifEncoder = new GIFEncoder({ auto: true }); - console.log(`Rendering GIF: ${frames.length} frames, ${(frames.reduce((tot, frm) => tot + frm.delay, 0) / 1000).toPrecision(2)}s, ${(file.size / 1024)|0} KiB`); + console.log(`Rendering GIF: ${frames.length} frames, ${(frames.reduce((tot, frm) => tot + frm.delay, 0) / 1000).toPrecision(2)}s, ${humanFileSize(file.size)}`); for (let i = 0; i < frames.length; i++) { const frameData = renderFrame(frames[i], i === 0); encodeFrame(frameData, frames[i].delay); @@ -73,7 +107,7 @@ const loadGif = async (file) => { gifData = finishScaledGif(); console.log(`Rendering took ${(Date.now() - start) / 1000}s, result file is ${humanFileSize(gifData.byteLength)}`); if (gifData.byteLength > maxSize) { - console.error(`GIF size is ${(gifData.byteLength / 1024)|0} KiB, max size allowed is ${humanFileSize(maxSize)}`); + console.error(`GIF size is ${humanFileSize(gifData.byteLength)}, max size allowed is ${humanFileSize(maxSize)}`); showMessage(feedbackMessage, `Scaled down GIF exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error'); return; @@ -185,48 +219,35 @@ export const image = (baseUrl) => { context.fillRect(0, 0, canvas.width, canvas.height); // Get current image - let url = new URL('drawer', baseUrl); - fetch(url).then(async (res) => { - if (!res.ok) { - console.error(`Error receiving current image, status code ${res.status}: ${await res.text()}`); - + handleFetch(new URL('drawer', baseUrl)).then(async (resp) => { + if (!resp) { return; } - - const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - const data = await res.bytes(); - let readIndex = 0; - // Add alpha values for ImageData - for (let i = 0; i < imageData.data.length; i++) { - if ((i + 1) % 4 === 0) { - imageData.data[i] = 255 - continue; - } - - imageData.data[i] = data[readIndex]; - readIndex++; + if (resp.status === 204) { + // no media stored + return; } - context.putImageData(imageData, 0, 0); + const contentType = resp.headers.get('Content-Type'); + if (contentType === 'image/jpeg') { + await loadImage(await resp.blob()); + } else if (contentType === 'image/gif') { + await loadGif(await resp.blob()); + } else { + console.error(`Unhandled content type for current image: ${contentType}`); + } }); // Get current free disk - url = new URL('status', baseUrl); - fetch(url).then(async (res) => { - if (!res.ok || res.status != 200) { - console.error(`Error getting current status, status code ${res.status}: ${await res.text()}`); - + handleFetch(new URL('status', baseUrl)).then(async (resp) => { + if (!resp) { return; } - try { - const { filesystem: { size, used } } = await res.json(); - const free = size - used; - if (free < maxSize) { - maxSize = (free * 0.9)|0; // leave some space - } - } catch (e) { - console.error(`Error fetching status`, e); + const { filesystem: { size, used } } = await resp.json(); + const free = size - used; + if (free < maxSize) { + maxSize = (free * 0.9)|0; // leave some space } }); @@ -235,8 +256,8 @@ export const image = (baseUrl) => { fileInput.value = null; fileLabel.innerText = initialFileLabelText; fileRemove.classList.add('is-hidden'); - result = null; resetGif(); + imageData = undefined; // Fill canvas with black, because the LED panel is black context.fillRect(0, 0, canvas.width, canvas.height); setDisabled(submitFile, true); @@ -246,19 +267,25 @@ export const image = (baseUrl) => { }); // File selected - fileInput.addEventListener('change', (e) => { + fileInput.addEventListener('change', async (e) => { + setDisabled(submitFile, true); if (e.target.files.length === 0) { fileLabel.innerText = initialFileLabelText; return; } + if (!['image/jpeg', 'image/gif'].includes(e.target.files[0].type)) { + console.error(`Selected media is not JPEG or GIF: ${e.target.files[0].type}`); + showMessage(feedbackMessage, 'Only JPEG and GIF files are currently supported', 'is-error'); + + return; + } // Fill canvas with black, because the LED panel is black context.fillRect(0, 0, canvas.width, canvas.height); - setDisabled(submitFile, true); resetMessage(feedbackMessage); resetGif(); - result = null; + imageData = undefined; canvas.classList.remove('is-hidden'); gifPreview.classList.add('is-hidden'); const file = e.target.files[0]; @@ -270,92 +297,61 @@ export const image = (baseUrl) => { return; } - // Convert image to array of 8bit values - const reader = new FileReader(); - reader.onload = (file) => { - const img = new Image(); - img.onload = () => { - // Center and scale image to canvas size keeping ratio - const imgW = img.width; - const imgH = img.height; - const ratio = Math.min(canvas.width / imgW, canvas.height / imgH); - const dstW = (imgW * ratio) |0; - const dstH = (imgH * ratio) |0; - const dstX = dstW < canvas.width - ? ((canvas.width - dstW) / 2) |0 - : 0 - const dstY = dstH < canvas.height - ? ((canvas.height - dstH) / 2) |0 - : 0; - - // Draw preview - context.drawImage(img, 0, 0, imgW, imgH, dstX, dstY, dstW, dstH); - // Prepare data - const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - // Remove alpha values from ImageData - result = new Uint8Array(canvas.width * canvas.height * 3); - let resultIndex = 0; - for (let i = 0; i < imageData.data.length; i++) { - if ((i + 1) % 4 === 0) { - continue; - } - - result[resultIndex] = imageData.data[i]; - resultIndex++; - } - - if (result.byteLength > maxSize) { - console.error(`Not enough space for storing image (${humanFileSize(result.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`); - showMessage(feedbackMessage, `Converted image exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error'); - - return; - } - - resetMessage(feedbackMessage); - setDisabled(submitFile, false); - console.log(`Image data is ${result.byteLength} bytes`/* , [...result].map(b => b.toString(16).padStart(2, '0')).join(', ') */); - }; - img.src = file.target.result; - }; - reader.readAsDataURL(file); + loadImage(file); }); // Send file bytes to server submitFile.addEventListener('click', async () => { setDisabled(submitFile, true); showMessage(feedbackMessage, 'Uploading...', 'is-primary'); - try { - let url = new URL('drawer', baseUrl); - let data, contentType, length; - if (result) { - console.log('uploading image'); - data = result; - contentType = 'application/octet-stream'; - length = result.byteLength; - } else if (gifData) { - console.log('uploading GIF'); - data = gifData; - contentType = 'image/gif'; - length = gifData.byteLength; - } else { - console.error('Neither image or gif selected'); - - return; - } - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': contentType, 'Content-Length': length }, - body: data, - }); - if (!response.ok || response.status != 204) { - throw new Error(`status code ${response.status}: ${await response.text()}`); - } + let data, contentType; + if (imageData) { + data = imageData; + contentType = 'image/jpeg'; + } else if (gifData) { + data = gifData; + contentType = 'image/gif'; + } else { + console.error('Neither image or gif selected'); + return; + } + + const res = await handleFetch(new URL('drawer', baseUrl), { + method: 'POST', + headers: { 'Content-Type': contentType, 'Content-Length': data.byteLength }, + body: data, + }); + if (res) { showMessage(feedbackMessage, 'File uploaded', 'is-success'); - } catch (e) { - console.error('Error uploading file:', e); - showMessage(feedbackMessage, 'Error uploading file', 'is-error'); + + return; + } + + showMessage(feedbackMessage, 'Error uploading file', 'is-error'); + setDisabled(submitFile, false); + }); + + // Delete image/gif + deleteMedia.addEventListener('click', () => deleteMediaConfirm.showModal()); + deleteMediaConfirm.addEventListener('close', async () => { + const confirmed = parseInt(deleteMediaConfirm.returnValue, 10); + if (!confirmed) { + return; + } + + setDisabled(deleteMedia, true); + showMessage(feedbackMessage, 'Deleting media...', 'is-primary'); + const res = await handleFetch(new URL('drawer', baseUrl), { method: 'DELETE' }); + setDisabled(deleteMedia, false); + if (!res) { + showMessage(feedbackMessage, 'Error deleting media', 'is-error'); + + return; + } + + showMessage(feedbackMessage, 'Media deleted', 'is-success'); + if (imageData?.byteLength || gifData?.byteLength) { setDisabled(submitFile, false); } }); diff --git a/web/src/main.js b/web/src/main.js index ec20455..be809a0 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -1,5 +1,6 @@ import './style.css'; import { image } from './image.js'; +import { map } from './map.js'; import { config } from './config.js'; import { firmware } from './firmware.js'; import { websocket } from './websocket.js'; @@ -8,6 +9,7 @@ import { system } from './system.js'; (() => { const baseUrl = new URL(window.location.origin); image(baseUrl); + map(baseUrl); config(baseUrl); firmware(baseUrl); websocket(baseUrl); diff --git a/web/src/map.js b/web/src/map.js new file mode 100644 index 0000000..abdbaa6 --- /dev/null +++ b/web/src/map.js @@ -0,0 +1,368 @@ +import { handleFetch, humanFileSize, resetMessage, setDisabled, showMessage, readImage, canvas2jpeg } from './utils.js'; + +const submitFile = document.getElementById('submit-map-file'); +setDisabled(submitFile, true); +const feedbackMessage = document.getElementById('submit-map-file-message'); +resetMessage(feedbackMessage); +const fileInput = document.getElementById('map-file'); +fileInput.value = null; +const fileLabel = fileInput.parentElement.querySelector('span'); +const initialFileLabelText = fileLabel.innerText; +const fileRemove = document.getElementById('remove-map-file'); +fileRemove.classList.add('is-hidden'); +const showGrid = document.getElementById('show-grid'); +showGrid.checked = false; +const zoomInput = document.getElementById('map-zoom'); +zoomInput.value = 1; +const pointSizeInput = document.getElementById('location-size'); +pointSizeInput.value = 2; +const locationColorInput = document.getElementById('location-color'); +locationColorInput.value = '#ff0000'; +const routeColorInput = document.getElementById('location-route-color'); +routeColorInput.value = '#27e7f5'; +const locationLatitude = document.getElementById('location-latitude'); +const locationLongitude = document.getElementById('location-longitude'); +const locationUrl = document.getElementById('location-url'); +const locationMethod = document.getElementById('location-method'); +const locationRegex = document.getElementById('location-regex'); +const locationBody = document.getElementById('location-body'); +const locationHeaders = document.getElementById('location-headers'); +const locationTest = document.getElementById('location-test'); +const locationTestMessage = document.getElementById('location-test-message'); +resetMessage(locationTestMessage); + +/** @type {HTMLCanvasElement} */ +const canvas = document.getElementById('map-preview'); +canvas.width = 64 * 36; +canvas.height = 64 * 18; +/** @type {CanvasRenderingContext2D} */ +const context = canvas.getContext('2d'); +context.fillStyle = '#000000ff'; +/** @type {HTMLCanvasElement} */ +const canvasPanel = document.getElementById('panel-preview'); +canvasPanel.width = 64; +canvasPanel.height = 64; +/** @type {CanvasRenderingContext2D} */ +const contextPanel = canvasPanel.getContext('2d'); +contextPanel.fillStyle = '#000000ff'; +let maxSize = 1024 * 1024; // 1 MiB, overridden if disk free size is lower +/** @type {File|undefined} */ +let currentFile = undefined; +/** @type {Uint8Array|undefined} */ +let imageData; +let positions = []; + +const drawGrid = (cellW, cellH) => { + // Draw latitude and longitude lines on preview + context.strokeStyle = '#ffffff'; + context.lineWidth = 1; + // Longitude (vertical) + for (let x = cellW; x < canvas.width; x += cellW) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, canvas.height); + context.stroke(); + } + // Latitude (horizontal) + for (let y = cellH; y < canvas.height; y += cellH) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(canvas.width, y); + context.stroke(); + } +}; + +const handleFile = async (file) => { + const img = await readImage(file); + canvas.width = canvasPanel.width * 36; + canvas.height = canvasPanel.height * 18; + // Scale to canvas size and show preview + context.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height); + imageData = await canvas2jpeg(canvas).then((blob) => blob.bytes()); + if (imageData.byteLength > maxSize) { + console.error(`Not enough space for storing map (${humanFileSize(imageData.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`); + showMessage(feedbackMessage, `Resized map exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error'); + + return; + } + + const cellW = canvasPanel.width * zoomInput.value; + const cellH = canvasPanel.height * zoomInput.value; + if (showGrid.checked) { + drawGrid(cellW, cellH); + } + + setDisabled(submitFile, false); + console.log(`Image is ${humanFileSize(imageData.byteLength)}`); + const [lat, lon] = (await getLatLon()) ?? []; + if (!lat || !lon) { + console.log('Skipping position draw'); + + return; + } + + const x = ((lon + 180) / 360) * canvas.width; + const y = ((90 - lat) / 180) * canvas.height; + const col = (x / cellW) |0; + const row = (y / cellH) |0; + const cellX = (cellW * col); + const cellY = (cellH * row); + // Draw panel preview + contextPanel.drawImage(canvas, cellX, cellY, cellW, cellH, 0, 0, canvasPanel.width, canvasPanel.height); + // Draw position + context.fillStyle = locationColorInput.value; + context.fillRect(x - 4, y - 4, 8, 8); + context.fillStyle = '#000000'; // restore + // Visualize a border around the cell on the map preview + context.strokeStyle = locationColorInput.value; + context.lineWidth = 4; + context.strokeRect(cellX, cellY, cellW, cellH); + context.strokeStyle = '#ffffff'; // restore + // Scale and draw position on panel preview + const pX = (x - cellX) * (canvasPanel.width / cellW); + const pY = (y - cellY) * (canvasPanel.height / cellH); + contextPanel.fillStyle = locationColorInput.value; + contextPanel.fillRect(pX - 2, pY - 2, 4, 4); + contextPanel.fillStyle = '#000000'; // restore + // TODO: use `routeColorInput` to draw `positions` +}; + +const getLatLon = async () => { + if (locationUrl.value && locationMethod.value && locationRegex.value) { + return getFromAPI(); + } + + if (locationLatitude.value.trim() && locationLongitude.value.trim()) { + return [parseFloat(locationLatitude.value.trim()), parseFloat(locationLongitude.value.trim())]; + } + + return undefined; +}; + +const getFromAPI = async () => { + if (!locationUrl.value || !locationMethod.value || !locationRegex.value) { + return undefined; + } + + const url = new URL(locationUrl.value); + const method = (locationMethod.value ?? 'GET').toUpperCase(); + let body = undefined; + if (locationBody.value) { + if (method === 'GET') { + url.search = locationBody.value; + } else { + body = locationBody.value; + } + } + + let headers = undefined; + if (locationHeaders.value) { + headers = JSON.parse(locationHeaders.value); + } + + showMessage(locationTestMessage, 'Sending request...', 'is-primary'); + const res = await handleFetch(url, { method, headers, body }); + if (!res) { + showMessage(locationTestMessage, 'Error in API request', 'is-error'); + + return undefined; + } + + const text = await res.text(); + const re = new RegExp(locationRegex.value); + const matches = re.exec(text); + if (!matches || matches.length === 0) { + showMessage(locationTestMessage, 'Regex did not match', 'is-error'); + + return undefined; + } + + showMessage(locationTestMessage, 'Regex matched', 'is-success'); + matches.shift(); + + return matches.map((m) => parseFloat(m.trim())); +}; + +export const map = (baseUrl) => { + // Fill canvas with black, because the LED panel is black + context.fillRect(0, 0, canvas.width, canvas.height); + + // Get current image + // TODO: consolidate API calls, this one causes heap allocation issues if run concurrently + // handleFetch(new URL('map', baseUrl), { headers: { 'Accept': 'image/jpeg' } }).then((resp) => { + // if (!resp) { + // return; + // } + + // resp.blob().then(handleFile); + // }); + + // Get current config + handleFetch(new URL('config', baseUrl), { + method: 'GET', + headers: { 'Accept': 'application/json' }, + }).then(async (resp) => { + if (!resp) { + return; + } + + const json = await resp.json(); + locationLatitude.value = json?.map?.latitude; + locationLongitude.value = json?.map?.longitude; + locationUrl.value = json?.map?.url; + locationMethod.value = (json?.map?.method ?? 'GET').toUpperCase(); + locationRegex.value = json?.map?.regex; + locationBody.value = json?.map?.body; + locationHeaders.value = json?.map?.headers; + positions = json?.map?.positions ?? []; + }); + + // Get current free disk + handleFetch(new URL('status', baseUrl)).then(async (resp) => { + if (!resp) { + return; + } + + const { filesystem: { size, used } } = await resp.json(); + const free = size - used; + if (free < maxSize) { + maxSize = (free * 0.9)|0; // leave some space + } + }); + + // Clear selected file and canvas + fileRemove.addEventListener('click', () => { + fileInput.value = null; + fileLabel.innerText = initialFileLabelText; + fileRemove.classList.add('is-hidden'); + currentFile = undefined; + imageData = undefined; + // Fill canvas with black, because the LED panel is black + context.fillRect(0, 0, canvas.width, canvas.height); + contextPanel.fillRect(0, 0, canvasPanel.width, canvasPanel.height) + setDisabled(submitFile, true); + resetMessage(feedbackMessage); + }); + + // Enable submit and redraw map + showGrid.addEventListener('change', () => { + if (!currentFile) { + return; + } + + handleFile(currentFile); + }); + [ + zoomInput, + pointSizeInput, + locationColorInput, + routeColorInput, + locationLatitude, + locationLongitude, + locationUrl, + locationMethod, + locationRegex, + locationBody + ].forEach((elem) => elem.addEventListener('change', () => { + setDisabled( + submitFile, + !(locationLatitude.value && locationLongitude.value) + && !(locationUrl.value && locationMethod.value && locationRegex.value) + ); + if (!currentFile) { + return; + } + + handleFile(currentFile); + })); + + // Test location API + locationTest.addEventListener('click', () => { + if (currentFile) { + handleFile(currentFile); + + return; + } + + getLatLon(); + }); + + // File selected + fileInput.addEventListener('change', (e) => { + setDisabled(submitFile, true); + if (e.target.files.length === 0) { + fileLabel.innerText = initialFileLabelText; + + return; + } + if (e.target.files[0].type !== 'image/jpeg') { + console.error(`Selected image is not JPEG: ${e.target.files[0].type}`); + + return; + } + + // Fill canvas with black, because the LED panel is black + context.fillRect(0, 0, canvas.width, canvas.height); + resetMessage(feedbackMessage); + currentFile = e.target.files[0]; + fileLabel.innerText = currentFile.name; + fileRemove.classList.remove('is-hidden'); + handleFile(currentFile); + }); + + // Send map image and config to server + submitFile.addEventListener('click', async () => { + setDisabled(submitFile, true); + if (currentFile) { + showMessage(feedbackMessage, 'Uploading image...', 'is-primary'); + const res = await handleFetch(new URL('map', baseUrl), { + method: 'POST', + headers: { + 'Content-Type': currentFile.type, + 'Content-Length': imageData.byteLength + }, + body: imageData + }); + if (!res) { + showMessage(feedbackMessage, 'Error uploading image', 'is-error'); + setDisabled(submitFile, false); + + return; + } + + showMessage(feedbackMessage, 'Image uploaded', 'is-success'); + } + + showMessage(feedbackMessage, 'Saving...', 'is-primary'); + const res = await handleFetch(new URL('config', baseUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + map: { + latitude: locationLatitude.value, + longitude: locationLongitude.value, + url: locationUrl.value, + method: locationMethod.value, + regex: locationRegex.value, + body: locationBody.value, + headers: locationHeaders.value, + pointSize: pointSizeInput.value, + pointColor: locationColorInput.value.length + ? parseInt(locationColorInput.value.replace('#', ''), 16) + : 63488, // red + trackColor: routeColorInput.value.length + ? parseInt(routeColorInput.value.replace('#', ''), 16) + : 63488, // red + } + }), + }); + if (!res) { + showMessage(feedbackMessage, 'Error saving configuration', 'is-error'); + setDisabled(submitFile, false); + + return; + } + + showMessage(feedbackMessage, 'Saved', 'is-success'); + }); +}; diff --git a/web/src/style.css b/web/src/style.css index d3ac0b8..fc34f69 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -52,6 +52,10 @@ img#gif-preview { height: 256px; } +canvas#map-preview { + max-width: 80vw; +} + /* taken from NES.CSS examples */ .message-list > .message.-left { align-self: flex-start; diff --git a/web/src/system.js b/web/src/system.js index 96fdf53..8c1bd70 100644 --- a/web/src/system.js +++ b/web/src/system.js @@ -1,4 +1,4 @@ -import { humanFileSize } from "./utils"; +import { handleFetch, humanFileSize } from "./utils"; /** * @typedef SystemStatus @@ -10,7 +10,6 @@ import { humanFileSize } from "./utils"; * @property {string} sdk */ -let pullTimer = undefined; const hwMsg = document.getElementById('hardware-message'); const swMsg = document.getElementById('software-message'); const heap = document.getElementById('heap-perc'); @@ -19,22 +18,7 @@ const fw = document.getElementById('firmware-perc'); const heapInfo = document.getElementById('heap-info'); const fsInfo = document.getElementById('fs-info'); const fwInfo = document.getElementById('fw-info'); - -/** - * Get current status. - * - * @param {string} baseUrl API URL - * @returns {Promise} - */ -const getStatus = async (baseUrl) => { - const url = new URL('status', baseUrl); - const res = await fetch(url) - if (!res.ok || res.status != 200) { - throw new Error(`Error getting current status, status code ${res.status}: ${await res.text()}`); - } - - return await res.json(); -}; +const refreshButton = document.getElementById('refresh-resources'); const setProgress = (elem, progress) => { elem.value = progress; @@ -48,8 +32,14 @@ const setProgress = (elem, progress) => { } }; -const loop = async (baseUrl) => { - const status = await getStatus(baseUrl); +const updateStatus = async (baseUrl) => { + const res = await handleFetch(new URL('status', baseUrl)); + if (!res) { + return; + } + + /** @type {SystemStatus} */ + const status = await res.json(); // Update hardware/software info hwMsg.innerText = `Running on a ${status.chip.model} (rev. ${status.chip.revision}) with ${status.chip.cores} CPU clocking at ${status.chip.clock} MHz`; @@ -67,5 +57,8 @@ const loop = async (baseUrl) => { fwInfo.innerText = `(${humanFileSize(status.firmware.used)} / ${humanFileSize(status.firmware.size)})`; }; -export const system = (baseUrl) => - loop(baseUrl).then(() => pullTimer = setTimeout(() => loop(baseUrl), 60_000)); +export const system = (baseUrl) => { + updateStatus(baseUrl); + + refreshButton.addEventListener('click', () => updateStatus(baseUrl)); +}; diff --git a/web/src/utils.js b/web/src/utils.js index f3500cc..ba26816 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -1,3 +1,9 @@ +/** + * Disable or enable an input. + * + * @param {HTMLInputElement} elem Element + * @param {boolean} disabled Whether to disable or enable + */ export const setDisabled = (elem, disabled = false) => { elem.disabled = disabled; if (disabled) { @@ -53,3 +59,65 @@ export const humanFileSize = (size) => { return `${Number((size / Math.pow(1024, i)).toFixed(2))} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`; }; + +/** + * Wait some time. + * + * @param {number} ms Milliseconds + * @returns {Promise} + */ +export const wait = (ms) => new Promise((res) => setTimeout(res, ms)); + +/** + * Wrapper for fetch() that handles errors by logging and returning undefined. + * + * @param {URL} url + * @param {RequestInit} options + * @returns {Promise} + */ +export const handleFetch = async (url, options = {}) => { + try { + const resp = await fetch(url, options); + if (!resp.ok) { + throw new Error(`Status code ${resp.status}`, { cause: { response: resp } }); + } + + return resp; + } catch (e) { + console.error(`Error fetching ${url.toString()}: ${e.message}`, e); + + return undefined; + } +}; + +/** + * Read a blob into an Image. + * + * @param {Blob} data + * @returns {Promise} + */ +export const readImage = (data) => new Promise((res, rej) => { + const objUrl = window.URL.createObjectURL(data); + const img = new Image(); + img.onerror = (e) => rej(e); + img.onload = () => res(img); + img.src = objUrl; +}); + +/** + * Convert the canvas to a JPEG image. + * + * @param {HTMLCanvasElement} canvas + * @returns {Promise} + */ +export const canvas2jpeg = (canvas) => new Promise((res, rej) => + canvas.toBlob((blob) => { + if (!blob) { + rej('Error generating scaled image'); + + return; + } + + res(blob); + }, 'image/jpeg') +); From 23533849eff34615c94c8d073b48ad2556e265d6 Mon Sep 17 00:00:00 2001 From: le0m Date: Mon, 15 Sep 2025 00:21:56 +0200 Subject: [PATCH 02/29] refactor: replace using an array of RGB bytes with a proper JPEG decoding library, to save on space and a bit of memory --- platformio.ini | 2 + src/filesystem.cpp | 1 - src/filesystem.h | 1 + src/gif.cpp | 6 +- src/image.cpp | 296 +++++++++++---------------------------------- src/image.h | 39 +++--- src/main.cpp | 10 +- src/main.h | 3 +- src/media.cpp | 257 +++++++++++++++++++++++++++++++++++++++ src/media.h | 38 ++++++ 10 files changed, 394 insertions(+), 259 deletions(-) create mode 100644 src/media.cpp create mode 100644 src/media.h diff --git a/platformio.ini b/platformio.ini index 3d48ab3..fdbce49 100644 --- a/platformio.ini +++ b/platformio.ini @@ -34,6 +34,8 @@ lib_deps = https://github.com/bblanchon/ArduinoJson#v7.3.0 ; Play GIFs https://github.com/bitbank2/AnimatedGIF.git#2.2.0 +; Decode JPEGs + https://github.com/bitbank2/JPEGDEC.git#1.8.3 build_flags = ; esp32-idf library debug level ; -DCORE_DEBUG_LEVEL=3 diff --git a/src/filesystem.cpp b/src/filesystem.cpp index cf34f9a..7fc81b4 100644 --- a/src/filesystem.cpp +++ b/src/filesystem.cpp @@ -42,7 +42,6 @@ bool readBytes(const char *path, uint8_t *buf, size_t size) { return false; } - File file = LittleFS.open(path); if (!file) { Log::instance()->error("Error opening file: %s\n", path); diff --git a/src/filesystem.h b/src/filesystem.h index 6548b7b..8effd80 100644 --- a/src/filesystem.h +++ b/src/filesystem.h @@ -7,6 +7,7 @@ #include #define MAX_GIF_SIZE 1048576 // 1 MiB +#define MAX_IMAGE_SIZE 1048576 // 1 MiB bool pathExists(const char*); bool writeBytes(const char*, uint8_t*, size_t); diff --git a/src/gif.cpp b/src/gif.cpp index 0e1640f..8189ec4 100644 --- a/src/gif.cpp +++ b/src/gif.cpp @@ -28,6 +28,7 @@ void GIF::close() { } playing = false; + delay(50); // wait for possible renderFrame() execution to finish gif.close(); frameDelay = 0; lastGifFrameRender = 0; @@ -45,7 +46,7 @@ void GIF::renderFrame(MatrixPanel_I2S_DMA *display) { break; case -1: - Log::instance()->error("Error decoding GIF frame\n"); + Log::instance()->error("Error decoding GIF frame: %d\n", gif.getLastError()); break; } }; @@ -91,12 +92,9 @@ int32_t GIF::readFile(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) { }; int32_t GIF::seekFile(GIFFILE *pFile, int32_t iPosition) { - int i = micros(); File *f = static_cast(pFile->fHandle); f->seek(iPosition); pFile->iPos = (int32_t)f->position(); - i = micros() - i; - // Log::instance()->trace("Seek time = %d us\n", i); return pFile->iPos; }; diff --git a/src/image.cpp b/src/image.cpp index cf8fd8e..d3044b2 100644 --- a/src/image.cpp +++ b/src/image.cpp @@ -1,260 +1,108 @@ #include "image.h" -Image::Image(uint8_t w, uint8_t h) { - width = w; - height = h; - size = width * height * sizeof(uint8_t) * 3; - image = (uint8_t*)malloc(size); - gif = new GIF(); -}; +Image::Image() {}; Image::~Image() { - free(image); - delete image; - gif->close(); -}; - -void Image::print() { - Log::instance()->trace("Image bytes:\n"); - for (uint8_t y = 0; y < height; y++) { - for (uint8_t x = 0; x < width; x++) { - Log::instance()->trace("%02X%02X%02X ", image[x + y * width], image[1 + (x + y * width)], image[2 + (x + y * width)]); - } - - Log::instance()->trace("\n"); - } + jpeg.close(); }; -bool Image::processImageChunk(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { - if (index == 0) { - hasMedia = MEDIA_TYPE_NONE; - Log::instance()->debug("Receiving image: %d bytes\n", total); - if (total > size) { - Log::instance()->error("Image is bigger than max size: %d bytes\n", size); - request->send(400, "text/plain", "image is too big"); - - return false; - } - if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) { - Log::instance()->error("Image size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes()); - request->send(500, "text/plain", "not enough free space"); - - return false; - } - - gif->close(); - if (!deleteFile(GIF_PATH)) { - Log::instance()->warning("Error deleting previous GIF\n"); - request->send(500, "text/plain", "error deleting previous GIF"); - - return false; - } - if (!deleteFile(IMAGE_PATH)) { - Log::instance()->warning("Error deleting previous image\n"); - request->send(500, "text/plain", "error deleting previous image"); - - return false; - } - - Log::instance()->debug("Previous image and GIF deleted or not present\n"); +bool Image::open(const char *path) { + if (isOpen) { + return true; } - if (index + len > size) { - Log::instance()->error("Data chunk exceeds image size boundaries: chunk start %d, end %d\n", index, index + len); - request->send(400, "text/plain", "chunk exceeds image size boundaries"); - return false; - } + filePath = path; + isOpen = true; - Log::instance()->trace("Received image chunk: %d-%d (%d bytes)\n", index, index + len, len); - memcpy(&image[index], data, len); - if (!writeBytes(IMAGE_PATH, data, len)) { - Log::instance()->error("Error writing image chunk\n"); - request->send(500, "text/plain", "error writing image"); + return jpeg.open(filePath, openFile, closeFile, readFile, seekFile, draw); +}; - return false; +void Image::close() { + if (!isOpen) { + return; } - return true; + isOpen = false; + delay(50); // wait for possible renderFrame() execution to finish + jpeg.close(); + lastRender = 0; }; -bool Image::processGIFChunk(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { - if (index == 0) { - hasMedia = MEDIA_TYPE_NONE; - Log::instance()->debug("Receiving GIF: %d bytes\n", total); - if (total > MAX_GIF_SIZE) { - Log::instance()->debug("GIF size %d exceeds maximum size %d\n", total, MAX_GIF_SIZE); - request->send(400, "text/plain", "GIF too big"); - - return false; - } - if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) { - Log::instance()->error("GIF size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes()); - request->send(500, "text/plain", "not enough free space"); - - return false; - } - - gif->close(); - if (!deleteFile(GIF_PATH)) { - Log::instance()->error("Error deleting previous GIF\n"); - request->send(500, "text/plain", "error deleting previous GIF"); - - return false; - } - if (!deleteFile(IMAGE_PATH)) { - Log::instance()->error("Error deleting previous image\n"); - request->send(500, "text/plain", "error deleting previous image"); - - return false; - } - - Log::instance()->debug("Previous image and GIF deleted or not present\n"); +void Image::renderFrame(MatrixPanel_I2S_DMA *display) { + // Re-render at most once a second + if (!isOpen || millis() - lastRender < 1000) { + return; } - Log::instance()->trace("Received GIF chunk: %d-%d (%d bytes)\n", index, index + len, len); - if (!writeBytes(GIF_PATH, data, len)) { - Log::instance()->error("Error writing GIF chunk\n"); - request->send(500, "text/plain", "error writing GIF"); - - return false; + // Re-open image to rewind file position, otherwise calling decode() more than once corrupts the displayed image + jpeg.open(filePath, openFile, closeFile, readFile, seekFile, draw); + lastRender = millis(); + jpeg.setUserPointer(static_cast(display)); + if (!jpeg.decode(0, 0, 0)) { + Log::instance()->error("Error decoding JPEG: %d\n", jpeg.getLastError()); } - return true; + jpeg.close(); }; -void Image::initServer(AsyncWebServer* server) { - server->on("/drawer", HTTP_GET, [&](AsyncWebServerRequest *request) { - if (hasMedia == MEDIA_TYPE_NONE) { - Log::instance()->info("No image currently stored\n"); - request->send(204); +void* Image::openFile(const char *path, int32_t *size) { + File *file = new File(); + *file = LittleFS.open(path); + if (file) { + *size = file->size(); - return; - } - if (hasMedia == MEDIA_TYPE_IMAGE) { - Log::instance()->info("Sending current image\n"); - AsyncResponseStream *response = request->beginResponseStream("application/octet-stream"); - response->addHeader("Content-Length", size); - response->write(image, size); - request->send(response); - - return; - } - if (hasMedia == MEDIA_TYPE_GIF) { - request->send(204); - - return; - } - }); - server->on("/drawer", HTTP_POST, - [&](AsyncWebServerRequest *request) { - Log::instance()->info("Media received\n"); - const AsyncWebHeader *contentType = request->getHeader("Content-Type"); - if (contentType == nullptr) { - Log::instance()->error("File upload has no content-type header"); - request->send(400, "text/plain", "no content-type header"); - - return; - } - if (contentType->value() == "application/octet-stream") { - drawNewImage = true; - hasMedia = MEDIA_TYPE_IMAGE; - Log::instance()->info("Image stored\n"); - } - if (contentType->value() == "image/gif") { - Log::instance()->info("GIF stored\n"); - hasMedia = MEDIA_TYPE_GIF; - } - - request->send(204); - }, - nullptr, - [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { - const AsyncWebHeader *contentType = request->getHeader("Content-Type"); - if (contentType == nullptr) { - Log::instance()->error("File upload has no content-type header"); - request->send(400, "text/plain", "no content-type header"); - - return; - } - if (contentType->value() == "application/octet-stream") { - processImageChunk(request, data, len, index, total); - - return; - } - if (contentType->value() == "image/gif") { - processGIFChunk(request, data, len, index, total); + return static_cast(file); + } - return; - } + delete file; - request->send(400, "text/plain", "unknown content type"); - } - ); + return NULL; }; -void Image::loadMedia() { - if (readBytes(IMAGE_PATH, image, width * height * 3)) { - Log::instance()->info("Loaded image from FLASH\n"); - hasMedia = MEDIA_TYPE_IMAGE; - drawNewImage = true; - - return; +void Image::closeFile(void *pHandle) { + File *f = static_cast(pHandle); + if (f != NULL) { + f->close(); + delete f; } - if (pathExists(GIF_PATH)) { - Log::instance()->info("Using GIF from FLASH\n"); - hasMedia = MEDIA_TYPE_GIF; - if (!gif->open(GIF_PATH)) { - Log::instance()->error("Error opening GIF from FLASH\n"); - hasMedia = MEDIA_TYPE_NONE; - } - - return; - } - - Log::instance()->info("No media stored in FLASH\n"); }; -void Image::render(MatrixPanel_I2S_DMA *display) { - if (hasMedia == MEDIA_TYPE_NONE) { - return; +int32_t Image::readFile(JPEGFILE *pFile, uint8_t *pBuf, int32_t iLen) { + int32_t iBytesRead; + iBytesRead = iLen; + File *f = static_cast(pFile->fHandle); + // Note: If you read a file all the way to the last byte, seek() stops working + if ((pFile->iSize - pFile->iPos) < iLen) { + iBytesRead = pFile->iSize - pFile->iPos - 1; // <-- ugly work-around + } + if (iBytesRead <= 0) { + return 0; } - // GIF handles timing internally - if (hasMedia == MEDIA_TYPE_GIF) { - // Call to open is here because trying to open in webserver endpoint (right after finished writing) causes a panic and reboot - if (!gif->open(GIF_PATH)) { - Log::instance()->error("Error opening GIF from FLASH\n"); - hasMedia = MEDIA_TYPE_NONE; - return; - } + iBytesRead = (int32_t)f->read(pBuf, iBytesRead); + pFile->iPos = f->position(); - gif->renderFrame(display); + return iBytesRead; +}; - return; - } - // Render image once a second - if (millis() - lastRender < 1000) { - return; - } - // Clear screen only if a new image was uploaded. - if (drawNewImage) { - display->clearScreen(); - drawNewImage = false; - } +int32_t Image::seekFile(JPEGFILE *pFile, int32_t iPosition) { + File *f = static_cast(pFile->fHandle); + f->seek(iPosition); + pFile->iPos = (int32_t)f->position(); - // Keep redrawing whether the images has changed or not so that it gets redrawn when the picture frame is rotated. - lastRender = millis(); - uint16_t index; - for (uint8_t x = 0; x < width; x++) { - for (uint8_t y = 0; y < height; y++) { - index = 3 * (x + y * width); - display->drawPixelRGB888( - x, - y, - image[index], - image[1 + index], - image[2 + index] - ); + return pFile->iPos; +}; + +int Image::draw(JPEGDRAW *pDraw) { + MatrixPanel_I2S_DMA *display = static_cast(pDraw->pUser); + uint16_t *s = pDraw->pPixels; + int lastX = pDraw->x + pDraw->iWidth; + int lastY = pDraw->y + pDraw->iHeight; + for (int y = pDraw->y; y < lastY; y++) { + for (int x = pDraw->x; x < lastX; x++) { + display->drawPixel(x, y, *s++); } } + + return 1; }; diff --git a/src/image.h b/src/image.h index 587a693..19db4f5 100644 --- a/src/image.h +++ b/src/image.h @@ -2,37 +2,28 @@ #define _IMAGE_H #include "log.h" -#include "renderer.h" -#include #include "filesystem.h" -#include "gif.h" +#include #include -#define IMAGE_PATH "/image.bin" -#define GIF_PATH "/media.gif" - -class Image : public Renderer { +class Image { private: - typedef enum { - MEDIA_TYPE_NONE = 0, - MEDIA_TYPE_IMAGE = 1, - MEDIA_TYPE_GIF = 2, - } media_type; - uint8_t *image; - bool drawNewImage = false; + JPEGDEC jpeg; ulong lastRender = 0; - GIF *gif; - media_type hasMedia = MEDIA_TYPE_NONE; - bool processImageChunk(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t); - bool processGIFChunk(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t); + bool isOpen = false; + const char* filePath; + static int draw(JPEGDRAW*); + static void* openFile(const char*, int32_t*); + static void closeFile(void*); + static int32_t readFile(JPEGFILE*, uint8_t*, int32_t); + static int32_t seekFile(JPEGFILE*, int32_t); public: - Image(uint8_t, uint8_t); - virtual ~Image(); - virtual void initServer(AsyncWebServer*); - void loadMedia(); - virtual void render(MatrixPanel_I2S_DMA*); - virtual void print(); + Image(); + ~Image(); + bool open(const char*); + void renderFrame(MatrixPanel_I2S_DMA*); + void close(); }; #endif diff --git a/src/main.cpp b/src/main.cpp index 6eb5ee0..73fbcbe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,7 +20,7 @@ OTA ota(1000); draw_mode drawMode = DRAW_MODE_NONE; WiFiController wifi; Life life(WIDTH, HEIGHT); -Image image(WIDTH, HEIGHT); +Media media(WIDTH, HEIGHT); QRCode qr(WIDTH, HEIGHT); AsyncWebServer server(80); ModeSelector selector; @@ -58,7 +58,7 @@ void updateConfig(JsonDocument newConfig) { } if (newConfig["log"]["level"].as() != conf.current["log"]["level"].as()) { Log::instance()->setLevel(newConfig["log"]["level"].as()); - Log::instance()->info("Log level changed: %s\n", Log::instance()->stringLevel()); + Log::instance()->info("Log level changed: %s\n", Log::instance()->stringLevel().c_str()); } }; @@ -103,8 +103,8 @@ void setup() { ota.initServer(&server); // Initialize modules - image.initServer(&server); - image.loadMedia(); + media.initServer(&server); + media.loadMedia(); life.setFPS(FPS); conf.initServer(&server); @@ -155,7 +155,7 @@ void loop() { break; case DRAW_MODE_IMAGE: - image.render(display); + media.render(display); break; case DRAW_MODE_QR: diff --git a/src/main.h b/src/main.h index 8cfc2e6..9b76453 100644 --- a/src/main.h +++ b/src/main.h @@ -7,7 +7,8 @@ #include "wifi.h" #include "led.h" #include "modeselector.h" -#include "image.h" +#include "media.h" +#include "map.h" #include "life.h" #include "qrcode.h" #include "ota.h" diff --git a/src/media.cpp b/src/media.cpp new file mode 100644 index 0000000..bccb81f --- /dev/null +++ b/src/media.cpp @@ -0,0 +1,257 @@ +#include "media.h" + +Media::Media(uint8_t w, uint8_t h) { + width = w; + height = h; +}; + +Media::~Media() { + gif.close(); + image.close(); +}; + +void Media::print() { + // TODO +}; + +bool Media::processImageChunk(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + if (index == 0) { + mediaType = MEDIA_TYPE_NONE; + Log::instance()->debug("Receiving image: %d bytes\n", total); + if (total > MAX_IMAGE_SIZE) { + Log::instance()->error("Image size %d exceeds maximum size %d\n", total, MAX_IMAGE_SIZE); + request->send(400, "text/plain", "image is too big"); + + return false; + } + if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) { + Log::instance()->error("Image size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes()); + request->send(500, "text/plain", "not enough free space"); + + return false; + } + + gif.close(); + image.close(); + if (!deleteFile(GIF_PATH)) { + Log::instance()->warning("Error deleting previous GIF\n"); + request->send(500, "text/plain", "error deleting previous GIF"); + + return false; + } + if (!deleteFile(IMAGE_PATH)) { + Log::instance()->warning("Error deleting previous image\n"); + request->send(500, "text/plain", "error deleting previous image"); + + return false; + } + + Log::instance()->debug("Previous image and GIF deleted or not present\n"); + } + + Log::instance()->trace("Received image chunk: %d-%d (%d bytes)\n", index, index + len, len); + if (!writeBytes(IMAGE_PATH, data, len)) { + Log::instance()->error("Error writing image chunk\n"); + request->send(500, "text/plain", "error writing image"); + + return false; + } + + return true; +}; + +bool Media::processGIFChunk(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + if (index == 0) { + mediaType = MEDIA_TYPE_NONE; + Log::instance()->debug("Receiving GIF: %d bytes\n", total); + if (total > MAX_GIF_SIZE) { + Log::instance()->debug("GIF size %d exceeds maximum size %d\n", total, MAX_GIF_SIZE); + request->send(400, "text/plain", "GIF too big"); + + return false; + } + if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) { + Log::instance()->error("GIF size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes()); + request->send(500, "text/plain", "not enough free space"); + + return false; + } + + gif.close(); + image.close(); + if (!deleteFile(GIF_PATH)) { + Log::instance()->error("Error deleting previous GIF\n"); + request->send(500, "text/plain", "error deleting previous GIF"); + + return false; + } + if (!deleteFile(IMAGE_PATH)) { + Log::instance()->error("Error deleting previous image\n"); + request->send(500, "text/plain", "error deleting previous image"); + + return false; + } + + Log::instance()->debug("Previous image and GIF deleted or not present\n"); + } + + Log::instance()->trace("Received GIF chunk: %d-%d (%d bytes)\n", index, index + len, len); + if (!writeBytes(GIF_PATH, data, len)) { + Log::instance()->error("Error writing GIF chunk\n"); + request->send(500, "text/plain", "error writing GIF"); + + return false; + } + + return true; +}; + +void Media::initServer(AsyncWebServer *server) { + server->on("/drawer", HTTP_GET, [&](AsyncWebServerRequest *request) { + if (mediaType == MEDIA_TYPE_NONE) { + Log::instance()->info("No media currently stored\n"); + request->send(204); + + return; + } + if (mediaType == MEDIA_TYPE_IMAGE && pathExists(IMAGE_PATH)) { + Log::instance()->info("Sending current image\n"); + request->send(LittleFS, IMAGE_PATH, "image/jpeg"); + + return; + } + if (mediaType == MEDIA_TYPE_GIF && pathExists(GIF_PATH)) { + Log::instance()->info("Sending current GIF\n"); + request->send(LittleFS, GIF_PATH, "image/gif"); + + return; + } + }); + server->on("/drawer", HTTP_DELETE, [&](AsyncWebServerRequest *request) { + mediaType = MEDIA_TYPE_NONE; + gif.close(); + image.close(); + Log::instance()->info("Deleting media\n"); + if (!deleteFile(GIF_PATH)) { + Log::instance()->error("Error deleting GIF\n"); + request->send(500, "text/plain", "error deleting GIF"); + + return; + } + if (!deleteFile(IMAGE_PATH)) { + Log::instance()->error("Error deleting image\n"); + request->send(500, "text/plain", "error deleting image"); + + return; + } + + Log::instance()->info("Media deleted\n"); + request->send(204); + }); + server->on("/drawer", HTTP_POST, + [&](AsyncWebServerRequest *request) { + Log::instance()->info("Media received\n"); + const AsyncWebHeader *contentType = request->getHeader("Content-Type"); + if (contentType == nullptr) { + Log::instance()->error("File upload has no content-type header"); + request->send(400, "text/plain", "no content-type header"); + + return; + } + if (contentType->value() == "image/jpeg") { + drawNewImage = true; + Log::instance()->info("Image stored\n"); + mediaType = MEDIA_TYPE_IMAGE; + } + if (contentType->value() == "image/gif") { + Log::instance()->info("GIF stored\n"); + mediaType = MEDIA_TYPE_GIF; + } + + request->send(204); + }, + nullptr, + [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + const AsyncWebHeader *contentType = request->getHeader("Content-Type"); + if (contentType->value() == "image/jpeg") { + processImageChunk(request, data, len, index, total); + + return; + } + if (contentType->value() == "image/gif") { + processGIFChunk(request, data, len, index, total); + + return; + } + + Log::instance()->error("Uploaded file has unhandled content-type: %s\n", contentType->value()); + request->send(400, "text/plain", "unhandled content type"); + } + ); +}; + +void Media::loadMedia() { + if (pathExists(IMAGE_PATH)) { + if (image.open(IMAGE_PATH)) { + Log::instance()->info("Loaded image from FLASH\n"); + mediaType = MEDIA_TYPE_IMAGE; + } else { + Log::instance()->error("Error opening image from FLASH\n"); + mediaType = MEDIA_TYPE_NONE; + } + + return; + } + if (pathExists(GIF_PATH)) { + if (gif.open(GIF_PATH)) { + Log::instance()->info("Loaded GIF from FLASH\n"); + mediaType = MEDIA_TYPE_GIF; + } else { + Log::instance()->error("Error opening GIF from FLASH\n"); + mediaType = MEDIA_TYPE_NONE; + } + + return; + } + + Log::instance()->info("No media stored in FLASH\n"); +}; + +void Media::render(MatrixPanel_I2S_DMA *display) { + if (mediaType == MEDIA_TYPE_NONE) { + display->clearScreen(); + + return; + } + if (mediaType == MEDIA_TYPE_IMAGE) { + // Call to open is here because trying to open in webserver endpoint (right after finished writing) causes a panic and reboot + if (!image.open(IMAGE_PATH)) { + Log::instance()->error("Error opening image from FLASH\n"); + mediaType = MEDIA_TYPE_NONE; + + return; + } + // Clear screen only if a new image was uploaded. + if (drawNewImage) { + display->clearScreen(); + drawNewImage = false; + } + + image.renderFrame(display); + + return; + } + if (mediaType == MEDIA_TYPE_GIF) { + // Call to open is here because trying to open in webserver endpoint (right after finished writing) causes a panic and reboot + if (!gif.open(GIF_PATH)) { + Log::instance()->error("Error opening GIF from FLASH\n"); + mediaType = MEDIA_TYPE_NONE; + + return; + } + + gif.renderFrame(display); + + return; + } +}; diff --git a/src/media.h b/src/media.h new file mode 100644 index 0000000..139ad9f --- /dev/null +++ b/src/media.h @@ -0,0 +1,38 @@ +#ifndef _MEDIA_H +#define _MEDIA_H + +#include "log.h" +#include "renderer.h" +#include +#include "filesystem.h" +#include "image.h" +#include "gif.h" +#include + +#define IMAGE_PATH "/image.jpeg" +#define GIF_PATH "/media.gif" + +class Media : public Renderer { + private: + typedef enum { + MEDIA_TYPE_NONE = 0, + MEDIA_TYPE_IMAGE = 1, + MEDIA_TYPE_GIF = 2, + } media_type; + bool drawNewImage = false; + GIF gif; + Image image; + media_type mediaType = MEDIA_TYPE_NONE; + bool processImageChunk(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t); + bool processGIFChunk(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t); + + public: + Media(uint8_t, uint8_t); + virtual ~Media(); + virtual void initServer(AsyncWebServer*); + void loadMedia(); + virtual void render(MatrixPanel_I2S_DMA*); + virtual void print(); +}; + +#endif From 22256d521ad1b9aefd229bc7c2c90538eaab5c02 Mon Sep 17 00:00:00 2001 From: le0m Date: Mon, 15 Sep 2025 00:23:30 +0200 Subject: [PATCH 03/29] fix: merging new config config --- src/config.cpp | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index 3d3bbf0..ff3ec94 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -22,35 +22,33 @@ bool Config::load(const char *path) { // Asynchronously update configuration void Config::taskRunner(void *p) { Config *t = (Config*) p; + // Merge new config with current for partial updates + JsonDocument merged = t->current; + Config::merge(merged.as(), t->newCfg.as()); if (t->configChangeHandler) { - t->configChangeHandler(t->newCfg); + t->configChangeHandler(merged); } - t->current.clear(); - Config::merge(t->current, t->newCfg); t->newCfg.clear(); + t->current.clear(); + t->current = merged; t->save(); - vTaskDelete(NULL); }; void Config::merge(JsonVariant dst, JsonVariantConst src) { - if (src.is()) { - for (JsonPairConst kvp : src.as()) { - if (dst[kvp.key()]) { - Config::merge(dst[kvp.key()], kvp.value()); - - return; - } - - dst[kvp.key()] = kvp.value(); - } + if (!src.is()) { + dst.set(src); return; } - if (!src.isNull() && strcmp(src.as(), "") != 0) { - dst.set(src); + for (JsonPairConst kvp : src.as()) { + if (dst[kvp.key()]) { + Config::merge(dst[kvp.key()], kvp.value()); + } else { + dst[kvp.key()] = kvp.value(); + } } }; @@ -74,6 +72,7 @@ void Config::initServer(AsyncWebServer *server) { JsonObject obj = json.as(); newCfg.set(obj); xTaskCreate(taskRunner, "Update config", 8192, this, 0, NULL); + newCfg.set(json); }); server->addHandler(handler); }; From 21ec0b7b14dd3f199a9944a682f65ff889f2386a Mon Sep 17 00:00:00 2001 From: le0m Date: Mon, 15 Sep 2025 00:25:21 +0200 Subject: [PATCH 04/29] feat: backend side handling of new "Map" mode --- src/config.cpp | 7 +- src/config.h | 2 +- src/main.cpp | 10 ++ src/map.cpp | 325 +++++++++++++++++++++++++++++++++++++++++++ src/map.h | 53 +++++++ src/modeselector.cpp | 2 +- src/modeselector.h | 2 +- 7 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 src/map.cpp create mode 100644 src/map.h diff --git a/src/config.cpp b/src/config.cpp index ff3ec94..b8c6598 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -19,8 +19,7 @@ bool Config::load(const char *path) { return !current.isNull(); }; -// Asynchronously update configuration -void Config::taskRunner(void *p) { +void Config::asyncUpdateConfig(void *p) { Config *t = (Config*) p; // Merge new config with current for partial updates JsonDocument merged = t->current; @@ -69,10 +68,8 @@ void Config::initServer(AsyncWebServer *server) { Log::instance()->info("Config JSON received\n"); request->send(204); - JsonObject obj = json.as(); - newCfg.set(obj); - xTaskCreate(taskRunner, "Update config", 8192, this, 0, NULL); newCfg.set(json); + xTaskCreate(asyncUpdateConfig, "Update config", 8192, this, 0, NULL); }); server->addHandler(handler); }; diff --git a/src/config.h b/src/config.h index a3837ff..0cf717f 100644 --- a/src/config.h +++ b/src/config.h @@ -14,7 +14,7 @@ class Config { JsonDocument newCfg; ConfigChangeHandler configChangeHandler; static void merge(JsonVariant, JsonVariantConst); - static void taskRunner(void*); + static void asyncUpdateConfig(void*); public: JsonDocument current; diff --git a/src/main.cpp b/src/main.cpp index 73fbcbe..91bc53a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,6 +21,7 @@ draw_mode drawMode = DRAW_MODE_NONE; WiFiController wifi; Life life(WIDTH, HEIGHT); Media media(WIDTH, HEIGHT); +Map mapp(WIDTH, HEIGHT); QRCode qr(WIDTH, HEIGHT); AsyncWebServer server(80); ModeSelector selector; @@ -60,6 +61,8 @@ void updateConfig(JsonDocument newConfig) { Log::instance()->setLevel(newConfig["log"]["level"].as()); Log::instance()->info("Log level changed: %s\n", Log::instance()->stringLevel().c_str()); } + + mapp.setConfig(newConfig["map"].as()); }; void setup() { @@ -105,6 +108,9 @@ void setup() { // Initialize modules media.initServer(&server); media.loadMedia(); + mapp.initServer(&server); + mapp.loadMedia(); + mapp.setConfig(conf.current["map"].as()); life.setFPS(FPS); conf.initServer(&server); @@ -158,6 +164,10 @@ void loop() { media.render(display); break; + case DRAW_MODE_MAP: + mapp.render(display); + break; + case DRAW_MODE_QR: qr.render(display); break; diff --git a/src/map.cpp b/src/map.cpp new file mode 100644 index 0000000..e1dd13a --- /dev/null +++ b/src/map.cpp @@ -0,0 +1,325 @@ +#include "map.h" + +Map::Map(uint8_t w, uint8_t h) { + width = w; + height = h; +}; + +Map::~Map() { + jpeg.close(); +}; + +void Map::print() {}; + +bool Map::openImage() { + if (isOpen) { + return true; + } + + isOpen = true; + + return jpeg.open(MAP_PATH, openFile, closeFile, readFile, seekFile, draw); +}; + +void Map::closeImage() { + if (!isOpen) { + return; + } + + isOpen = false; + delay(50); // wait for possible renderFrame() execution to finish + jpeg.close(); + lastRender = 0; +}; + +void Map::setConfig(JsonVariantConst c) { + config.clear(); + config.set(c); + xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + Log::instance()->info("Updated map configuration\n"); +}; + +void Map::initServer(AsyncWebServer *server) { + server->on("/map", HTTP_GET, [&](AsyncWebServerRequest *request) { + const AsyncWebHeader *accept = request->getHeader("Accept"); + if (accept->value() != "image/jpeg") { + Log::instance()->error("Map request has unhandled accept: %s\n", accept->value()); + request->send(400, "text/plain", "unhandled accept type"); + + return; + } + if (!pathExists(MAP_PATH)) { + Log::instance()->info("No map currently stored\n"); + request->send(204); + + return; + } + + Log::instance()->info("Sending current map\n"); + request->send(LittleFS, MAP_PATH, "image/jpeg"); + }); + server->on("/map", HTTP_DELETE, [&](AsyncWebServerRequest *request) { + closeImage(); + Log::instance()->info("Deleting map\n"); + if (!deleteFile(MAP_PATH)) { + Log::instance()->error("Error deleting map\n"); + request->send(500, "text/plain", "error deleting map"); + + return; + } + + Log::instance()->info("Map deleted\n"); + request->send(204); + }); + server->on("/map", HTTP_POST, + [&](AsyncWebServerRequest *request) { + Log::instance()->info("Map received\n"); + const AsyncWebHeader *contentType = request->getHeader("Content-Type"); + if (contentType->value() != "image/jpeg") { + Log::instance()->error("Uploaded map has unhandled content-type: %s\n", contentType->value()); + request->send(400, "text/plain", "unhandled content type"); + + return; + } + + openImage(); + xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + request->send(204); + }, + nullptr, + [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + const AsyncWebHeader *contentType = request->getHeader("Content-Type"); + if (contentType->value() != "image/jpeg") { + Log::instance()->error("Uploaded map has unhandled content-type: %s\n", contentType->value()); + request->send(400, "text/plain", "unhandled content type"); + + return; + } + if (index == 0) { + Log::instance()->debug("Receiving map: %d bytes\n", total); + if (total > MAX_IMAGE_SIZE) { + Log::instance()->error("Map size %d exceeds maximum size %d\n", total, MAX_IMAGE_SIZE); + request->send(400, "text/plain", "map is too big"); + + return; + } + if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) { + Log::instance()->error("Map size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes()); + request->send(500, "text/plain", "not enough free space"); + + return; + } + + closeImage(); + if (!deleteFile(MAP_PATH)) { + Log::instance()->warning("Error deleting previous map\n"); + request->send(500, "text/plain", "error deleting previous map"); + + return; + } + + Log::instance()->debug("Previous map deleted or not present\n"); + } + + Log::instance()->trace("Received map chunk: %d-%d (%d bytes)\n", index, index + len, len); + if (!writeBytes(MAP_PATH, data, len)) { + Log::instance()->error("Error writing map chunk\n"); + request->send(500, "text/plain", "error writing map"); + + return; + } + } + ); +}; + +void Map::loadMedia() { + if (!pathExists(MAP_PATH)) { + Log::instance()->info("No map stored in FLASH\n"); + + return; + } + if (!openImage()) { + Log::instance()->error("Error opening map from FLASH\n"); + + return; + } + + xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + Log::instance()->info("Loaded map from FLASH\n"); +}; + +std::array Map::getPositionFromAPI() { + std::array pos; + + // TODO + Log::instance()->info("Getting position from API (%s): %f, %f\n", config["url"].as(), pos[0], pos[1]); + + return pos; +}; + +std::array Map::getPosition() { + if ( + strcmp(config["url"].as(), "") != 0 + && strcmp(config["method"].as(), "") != 0 + && strcmp(config["regex"].as(), "") != 0 + ) { + return getPositionFromAPI(); + } + + std::array pos; + if ( + strcmp(config["latitude"].as(), "") != 0 + && strcmp(config["longitude"].as(), "") != 0 + ) { + pos[0] = strtod(config["latitude"].as(), NULL); + pos[1] = strtod(config["longitude"].as(), NULL); + Log::instance()->info("Getting position from configuration: %f, %f\n", pos[0], pos[1]); + } + + return pos; +}; + +void Map::asyncUpdateCrop(void *p) { + Map *m = (Map*) p; + if (m->isUpdating) { + vTaskDelete(NULL); + + return; + } + + Log::instance()->debug("Updating crop asynchronously\n"); + m->isUpdating = true; + m->updateCrop(); + m->lastCropUpdate = millis(); + m->isUpdating = false; + vTaskDelete(NULL); +}; + +void Map::updateCrop() { + if (!isOpen) { + return; + } + + Log::instance()->info("Updating map crop area and position\n"); + // Crop area + int imgW = jpeg.getWidth(); + int imgH = jpeg.getHeight(); + std::array position = getPosition(); + double x = ((position[1] + 180.0) / 360.0) * imgW; + double y = ((90.0 - position[0]) / 180.0) * imgH; + int col = static_cast(x / width); + int row = static_cast(y / height); + cropArea[0] = width * col; + cropArea[1] = height * row; + // Position + cropPosition[0] = x - cropArea[0]; + cropPosition[1] = y - cropArea[1]; + // Offset position to fit the point in the panel + cropPosition[0] -= std::max(0, (cropPosition[0] + config["pointSize"].as()) - width); + cropPosition[1] -= std::max(0, (cropPosition[1] + config["pointSize"].as()) - height); +}; + +void Map::render(MatrixPanel_I2S_DMA *display) { + if (!isOpen) { + display->clearScreen(); + + return; + } + // Re-render at most once a second and avoid running while updating position + if (isUpdating || millis() - lastRender < 1000) { + return; + } + // Update crop area (and position) once every 15 minutes + if (millis() - lastCropUpdate > 1000 * 60 * 15) { + xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + + return; + } + + // Re-open image to rewind file position, otherwise calling decode() more than once corrupts the displayed image + jpeg.open(MAP_PATH, openFile, closeFile, readFile, seekFile, draw); + lastRender = millis(); + drawUserData data = { + display, + cropPosition, + config["pointSize"].as(), + config["pointColor"].as(), + config["trackColor"].as() + }; + jpeg.setUserPointer(static_cast(&data)); + jpeg.setCropArea(cropArea[0], cropArea[1], width, height); + if (!jpeg.decode(0, 0, 0)) { + Log::instance()->error("Error decoding JPEG: %d\n", jpeg.getLastError()); + } + + jpeg.close(); +}; + +void* Map::openFile(const char *path, int32_t *size) { + File *file = new File(); + *file = LittleFS.open(path); + if (file) { + *size = file->size(); + + return static_cast(file); + } + + delete file; + + return NULL; +}; + +void Map::closeFile(void *pHandle) { + File *f = static_cast(pHandle); + if (f != NULL) { + f->close(); + delete f; + } +}; + +int32_t Map::readFile(JPEGFILE *pFile, uint8_t *pBuf, int32_t iLen) { + int32_t iBytesRead; + iBytesRead = iLen; + File *f = static_cast(pFile->fHandle); + // Note: If you read a file all the way to the last byte, seek() stops working + if ((pFile->iSize - pFile->iPos) < iLen) { + iBytesRead = pFile->iSize - pFile->iPos - 1; // <-- ugly work-around + } + if (iBytesRead <= 0) { + return 0; + } + + iBytesRead = (int32_t)f->read(pBuf, iBytesRead); + pFile->iPos = f->position(); + + return iBytesRead; +}; + +int32_t Map::seekFile(JPEGFILE *pFile, int32_t iPosition) { + File *f = static_cast(pFile->fHandle); + f->seek(iPosition); + pFile->iPos = (int32_t)f->position(); + + return pFile->iPos; +}; + +int Map::draw(JPEGDRAW *pDraw) { + drawUserData *data = static_cast(pDraw->pUser); + uint16_t *s = pDraw->pPixels; + int lastX = pDraw->x + pDraw->iWidth; + int lastY = pDraw->y + pDraw->iHeight; + for (int y = pDraw->y; y < lastY; y++) { + for (int x = pDraw->x; x < lastX; x++) { + data->display->drawPixel(x, y, *s++); + } + } + + // Draw position + for (int y = 0; y < data->size; y++) { + for (int x = 0; x < data->size; x++) { + data->display->drawPixel(data->position[0] + x, data->position[1] + y, data->color); + } + } + + return 1; +}; diff --git a/src/map.h b/src/map.h new file mode 100644 index 0000000..fd8d554 --- /dev/null +++ b/src/map.h @@ -0,0 +1,53 @@ +#ifndef _MAP_H +#define _MAP_H + +#include +#include "log.h" +#include "renderer.h" +#include "filesystem.h" +#include +#include +#include + +#define MAP_PATH "/map.jpeg" + +class Map : public Renderer { + private: + typedef struct drawUserData { + MatrixPanel_I2S_DMA *display; + uint16_t *position; + uint8_t size; + uint16_t color; + uint16_t track; + } drawUserData; + JPEGDEC jpeg; + ulong lastRender = 0; + ulong lastCropUpdate = 0; + uint16_t cropArea[2]; + uint16_t cropPosition[2]; + bool isUpdating = false; + bool isOpen = false; + JsonDocument config; + static void asyncUpdateCrop(void*); + bool openImage(); + void closeImage(); + static int draw(JPEGDRAW*); + static void* openFile(const char*, int32_t*); + static void closeFile(void*); + static int32_t readFile(JPEGFILE*, uint8_t*, int32_t); + static int32_t seekFile(JPEGFILE*, int32_t); + std::array getPositionFromAPI(); + std::array getPosition(); + void updateCrop(); + + public: + Map(uint8_t, uint8_t); + virtual ~Map(); + virtual void initServer(AsyncWebServer*); + void setConfig(JsonVariantConst); + void loadMedia(); + virtual void render(MatrixPanel_I2S_DMA*); + virtual void print(); +}; + +#endif diff --git a/src/modeselector.cpp b/src/modeselector.cpp index d6ee13f..b8adf36 100644 --- a/src/modeselector.cpp +++ b/src/modeselector.cpp @@ -63,7 +63,7 @@ draw_mode ModeSelector::getMode() { } else if (M_PI_4 < ypr[1] && ypr[1] <= M_3PI_4) { // right return DRAW_MODE_LIFE; } else if (-M_3PI_4 <= ypr[1] && ypr[1] < -M_PI_4) { // left - return DRAW_MODE_TBD; + return DRAW_MODE_MAP; } else { // down return DRAW_MODE_QR; } diff --git a/src/modeselector.h b/src/modeselector.h index afa9df0..75e66ce 100644 --- a/src/modeselector.h +++ b/src/modeselector.h @@ -15,7 +15,7 @@ typedef enum { DRAW_MODE_NONE = 0, DRAW_MODE_IMAGE = 1, DRAW_MODE_LIFE = 2, - DRAW_MODE_TBD = 3, // TODO: the other orientation, TBD on what to do + DRAW_MODE_MAP = 3, DRAW_MODE_QR = 4, } draw_mode; From 97192e60a15985d824b8aa68247be18381ed349e Mon Sep 17 00:00:00 2001 From: le0m Date: Tue, 16 Sep 2025 02:30:51 +0200 Subject: [PATCH 05/29] fix: changing point and route colors --- data/config.json | 2 +- web/index.html | 6 +++--- web/src/image.js | 4 ++-- web/src/map.js | 27 ++++++++++++++++++--------- web/src/style.css | 5 +++++ web/src/utils.js | 44 +++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/data/config.json b/data/config.json index de7a81b..efea729 100644 --- a/data/config.json +++ b/data/config.json @@ -1 +1 @@ -{"wifi":{"ssid":"","password":""},"panel":{"brightness":63},"log":{"level":2},"map":{"latitude":"","longitude":"","url":"","method":"GET","regex":"","body":"","headers":"","pointSize":2,"pointColor":63488,"trackColor":63488,"positions":[]}} +{"wifi":{"ssid":"","password":""},"panel":{"brightness":63},"log":{"level":2},"map":{"latitude":"","longitude":"","url":"","method":"GET","regex":"","body":"","headers":"","pointSize":2,"pointColor":63488,"trackColor":31,"positions":[]}} diff --git a/web/index.html b/web/index.html index 957599d..760878f 100644 --- a/web/index.html +++ b/web/index.html @@ -92,12 +92,12 @@

Map

diff --git a/web/src/image.js b/web/src/image.js index 79c5ccc..4ab3afa 100644 --- a/web/src/image.js +++ b/web/src/image.js @@ -1,4 +1,4 @@ -import { humanFileSize, resetMessage, setDisabled, showMessage, handleFetch, readImage, canvas2jpeg } from './utils.js'; +import { humanFileSize, resetMessage, setDisabled, showMessage, handleFetch, readImage, canvasToJpeg } from './utils.js'; import { parseGIF, decompressFrames } from 'gifuct-js'; import { quantize, applyPalette, GIFEncoder } from 'gifenc'; @@ -67,7 +67,7 @@ const loadImage = async (file) => { // Draw preview context.drawImage(img, 0, 0, imgW, imgH, dstX, dstY, dstW, dstH); - imageData = await canvas2jpeg(canvas).then((blob) => blob.bytes()); + imageData = await canvasToJpeg(canvas).then((blob) => blob.bytes()); if (imageData.byteLength > maxSize) { console.error(`Not enough space for storing image (${humanFileSize(imageData.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`); showMessage(feedbackMessage, `Resized image exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error'); diff --git a/web/src/map.js b/web/src/map.js index abdbaa6..5968213 100644 --- a/web/src/map.js +++ b/web/src/map.js @@ -1,4 +1,14 @@ -import { handleFetch, humanFileSize, resetMessage, setDisabled, showMessage, readImage, canvas2jpeg } from './utils.js'; +import { + handleFetch, + humanFileSize, + resetMessage, + setDisabled, + showMessage, + readImage, + canvasToJpeg, + hexToRgb, + rgbToHex +} from './utils.js'; const submitFile = document.getElementById('submit-map-file'); setDisabled(submitFile, true); @@ -19,7 +29,7 @@ pointSizeInput.value = 2; const locationColorInput = document.getElementById('location-color'); locationColorInput.value = '#ff0000'; const routeColorInput = document.getElementById('location-route-color'); -routeColorInput.value = '#27e7f5'; +routeColorInput.value = '#0000ff'; const locationLatitude = document.getElementById('location-latitude'); const locationLongitude = document.getElementById('location-longitude'); const locationUrl = document.getElementById('location-url'); @@ -78,7 +88,7 @@ const handleFile = async (file) => { canvas.height = canvasPanel.height * 18; // Scale to canvas size and show preview context.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height); - imageData = await canvas2jpeg(canvas).then((blob) => blob.bytes()); + imageData = await canvasToJpeg(canvas).then((blob) => blob.bytes()); if (imageData.byteLength > maxSize) { console.error(`Not enough space for storing map (${humanFileSize(imageData.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`); showMessage(feedbackMessage, `Resized map exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error'); @@ -214,6 +224,9 @@ export const map = (baseUrl) => { locationRegex.value = json?.map?.regex; locationBody.value = json?.map?.body; locationHeaders.value = json?.map?.headers; + pointSizeInput.value = json?.map?.pointSize; + locationColorInput.value = rgbToHex(json?.map?.pointColor ?? 63488); // red + routeColorInput.value = rgbToHex(json?.map?.trackColor ?? 31); // blue positions = json?.map?.positions ?? []; }); @@ -347,12 +360,8 @@ export const map = (baseUrl) => { body: locationBody.value, headers: locationHeaders.value, pointSize: pointSizeInput.value, - pointColor: locationColorInput.value.length - ? parseInt(locationColorInput.value.replace('#', ''), 16) - : 63488, // red - trackColor: routeColorInput.value.length - ? parseInt(routeColorInput.value.replace('#', ''), 16) - : 63488, // red + pointColor: hexToRgb(locationColorInput.value || '#ff0000'), + trackColor: hexToRgb(routeColorInput.value || '#0000ff'), } }), }); diff --git a/web/src/style.css b/web/src/style.css index fc34f69..88349f4 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -76,3 +76,8 @@ canvas#map-preview { .message-list > .message i { align-self: flex-end; } + +/* taken from NES.CSS code */ +.pointer { + cursor: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAzElEQVRYR+2X0Q6AIAhF5f8/2jYXZkwEjNSVvVUjDpcrGgT7FUkI2D9xRfQETwNIiWO85wfINfQUEyxBG2ArsLwC0jioGt5zFcwF4OYDPi/mBYKm4t0U8ATgRm3ThFoAqkhNgWkA0jJLvaOVSs7j3qMnSgXWBMiWPXe94QqMBMBc1VZIvaTu5u5pQewq0EqNZvIEMCmxAawK0DNkay9QmfFNAJUXfgGgUkLaE7j/h8fnASkxHTz0DGIBMCnBeeM7AArpUd3mz2x3C7wADglA8BcWMZhZAAAAAElFTkSuQmCC) 14 0,pointer; +} diff --git a/web/src/utils.js b/web/src/utils.js index ba26816..67df2ec 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -110,7 +110,7 @@ export const readImage = (data) => new Promise((res, rej) => { * @param {HTMLCanvasElement} canvas * @returns {Promise} */ -export const canvas2jpeg = (canvas) => new Promise((res, rej) => +export const canvasToJpeg = (canvas) => new Promise((res, rej) => canvas.toBlob((blob) => { if (!blob) { rej('Error generating scaled image'); @@ -121,3 +121,45 @@ export const canvas2jpeg = (canvas) => new Promise((res, rej) => res(blob); }, 'image/jpeg') ); + +/** + * Convert hex string to rgb565. + * + * @param {string} hex Hexadecimal color + * @returns {number} + */ +export const hexToRgb = (hex) => { + const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/.exec(hex); + if (rgb === null) { + console.error(`Hex to rgb regex failed: ${hex}`); + + return null; + } + + const r = parseInt(rgb[1], 16); + const g = parseInt(rgb[2], 16); + const b = parseInt(rgb[3], 16); + + return ((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3); +}; + +/** + * Convert rgb565 to hex string. + * + * @param {number} rgb RGB565 color + * @returns {string} + */ +export const rgbToHex = (rgb) => { + const rgb565 = [ + (rgb & 0xf800) >> 11, + (rgb & 0x07e0) >> 5, + rgb & 0x001f + ]; + const rgb888 = [ + Math.floor(rgb565[0] * 255 / 31 + 0.5), + Math.floor(rgb565[1] * 255 / 63 + 0.5), + Math.floor(rgb565[2] * 255 / 31 + 0.5) + ]; + + return `#${rgb888.map((b) => b.toString(16).padStart(2, '0')).join('')}`; +}; From 85070fcd29fa037d29b4398144ad612c4f273e86 Mon Sep 17 00:00:00 2001 From: le0m Date: Sat, 20 Sep 2025 02:46:26 +0200 Subject: [PATCH 06/29] feat: some responsive css --- web/src/style.css | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/web/src/style.css b/web/src/style.css index 88349f4..5946dc7 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -15,7 +15,7 @@ html * { display: flex; flex-direction: column; gap: 5vh; - padding: 3vw 5vh; + padding: 5vh 3vw; } section.nes-container.with-title > div > * { @@ -47,7 +47,8 @@ canvas { /* enlarge preview with CSS, actual size remains unchanged */ canvas#drawer-preview, -img#gif-preview { +img#gif-preview, +canvas#panel-preview { width: 256px; height: 256px; } @@ -81,3 +82,22 @@ canvas#map-preview { .pointer { cursor: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAzElEQVRYR+2X0Q6AIAhF5f8/2jYXZkwEjNSVvVUjDpcrGgT7FUkI2D9xRfQETwNIiWO85wfINfQUEyxBG2ArsLwC0jioGt5zFcwF4OYDPi/mBYKm4t0U8ATgRm3ThFoAqkhNgWkA0jJLvaOVSs7j3qMnSgXWBMiWPXe94QqMBMBc1VZIvaTu5u5pQewq0EqNZvIEMCmxAawK0DNkay9QmfFNAJUXfgGgUkLaE7j/h8fnASkxHTz0DGIBMCnBeeM7AArpUd3mz2x3C7wADglA8BcWMZhZAAAAAElFTkSuQmCC) 14 0,pointer; } + +@media screen and (width < 768px) { + html, + body { + font-size: 12px; + } + + .container { + gap: 3vh; + } + + /* enlarge preview with CSS, actual size remains unchanged */ + canvas#drawer-preview, + img#gif-preview, + canvas#panel-preview { + width: 512px; + height: 512px; + } +} From 79dc2600c2186f3652352837fbde58d89147bba9 Mon Sep 17 00:00:00 2001 From: le0m Date: Wed, 24 Sep 2025 22:46:48 +0200 Subject: [PATCH 07/29] fix: concurrency problem when updating map configuration --- src/map.cpp | 26 ++++++++++---------------- src/map.h | 2 ++ web/src/map.js | 4 ++-- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/map.cpp b/src/map.cpp index e1dd13a..c9da7dd 100644 --- a/src/map.cpp +++ b/src/map.cpp @@ -35,7 +35,7 @@ void Map::closeImage() { void Map::setConfig(JsonVariantConst c) { config.clear(); config.set(c); - xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + forceCropUpdate = true; Log::instance()->info("Updated map configuration\n"); }; @@ -83,7 +83,7 @@ void Map::initServer(AsyncWebServer *server) { } openImage(); - xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + forceCropUpdate = true; request->send(204); }, nullptr, @@ -144,7 +144,7 @@ void Map::loadMedia() { return; } - xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); + forceCropUpdate = true; Log::instance()->info("Loaded map from FLASH\n"); }; @@ -158,16 +158,15 @@ std::array Map::getPositionFromAPI() { }; std::array Map::getPosition() { + std::array pos; if ( strcmp(config["url"].as(), "") != 0 && strcmp(config["method"].as(), "") != 0 && strcmp(config["regex"].as(), "") != 0 ) { - return getPositionFromAPI(); - } - - std::array pos; - if ( + pos = getPositionFromAPI(); + Log::instance()->info("Getting position from API (%s): %f, %f\n", config["url"].as(), pos[0], pos[1]); + } else if ( strcmp(config["latitude"].as(), "") != 0 && strcmp(config["longitude"].as(), "") != 0 ) { @@ -181,16 +180,10 @@ std::array Map::getPosition() { void Map::asyncUpdateCrop(void *p) { Map *m = (Map*) p; - if (m->isUpdating) { - vTaskDelete(NULL); - - return; - } - Log::instance()->debug("Updating crop asynchronously\n"); - m->isUpdating = true; m->updateCrop(); m->lastCropUpdate = millis(); + m->forceCropUpdate = false; m->isUpdating = false; vTaskDelete(NULL); }; @@ -230,7 +223,8 @@ void Map::render(MatrixPanel_I2S_DMA *display) { return; } // Update crop area (and position) once every 15 minutes - if (millis() - lastCropUpdate > 1000 * 60 * 15) { + if (forceCropUpdate || millis() - lastCropUpdate > UPDATE_CROP_AREA) { + isUpdating = true; xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL); return; diff --git a/src/map.h b/src/map.h index fd8d554..f84605f 100644 --- a/src/map.h +++ b/src/map.h @@ -10,6 +10,7 @@ #include #define MAP_PATH "/map.jpeg" +#define UPDATE_CROP_AREA 1000 * 60 * 15 class Map : public Renderer { private: @@ -21,6 +22,7 @@ class Map : public Renderer { uint16_t track; } drawUserData; JPEGDEC jpeg; + bool forceCropUpdate = false; ulong lastRender = 0; ulong lastCropUpdate = 0; uint16_t cropArea[2]; diff --git a/web/src/map.js b/web/src/map.js index 5968213..a4e6612 100644 --- a/web/src/map.js +++ b/web/src/map.js @@ -187,8 +187,8 @@ const getFromAPI = async () => { return undefined; } - showMessage(locationTestMessage, 'Regex matched', 'is-success'); matches.shift(); + showMessage(locationTestMessage, `Regex matched: ${matches}`, 'is-success'); return matches.map((m) => parseFloat(m.trim())); }; @@ -355,7 +355,7 @@ export const map = (baseUrl) => { latitude: locationLatitude.value, longitude: locationLongitude.value, url: locationUrl.value, - method: locationMethod.value, + method: (locationMethod.value || 'GET').toUpperCase(), regex: locationRegex.value, body: locationBody.value, headers: locationHeaders.value, From c0dcc6ff97f08d7e7859a25f6650c93af77c7c35 Mon Sep 17 00:00:00 2001 From: le0m Date: Wed, 24 Sep 2025 22:47:03 +0200 Subject: [PATCH 08/29] chore: increase app partition size --- partitions.csv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/partitions.csv b/partitions.csv index 960469b..573ec02 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,7 +1,7 @@ # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, -app0, app, ota_0, 0x10000, 0x140000, -app1, app, ota_1, 0x150000,0x140000, -spiffs, data, spiffs, 0x290000,0x160000, +app0, app, ota_0, 0x10000, 0x170000, +app1, app, ota_1, 0x180000,0x170000, +spiffs, data, spiffs, 0x2F0000,0x100000, coredump, data, coredump,0x3F0000,0x10000, From 02279ad22ded7836746442cd1f356d56ee9315b0 Mon Sep 17 00:00:00 2001 From: le0m Date: Fri, 26 Sep 2025 20:18:24 +0200 Subject: [PATCH 09/29] feat: implemented fetching of position from remote API --- README.md | 18 ++++--- platformio.ini | 2 + src/map.cpp | 138 ++++++++++++++++++++++++++++++++++++++++++++++--- src/map.h | 4 ++ web/index.html | 2 +- 5 files changed, 151 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 31dad81..665acc9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ See [gallery](#gallery) for photos. - [FastLED](https://github.com/FastLED/FastLED), for color conversion functions and other utilities - the ESP32Async project and their [AsyncTCP](https://github.com/ESP32Async/AsyncTCP)/[ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) libraries - [ArduinoJSON](https://github.com/bblanchon/ArduinoJson), for JSON de/serialization -- [AnimatedGIF](https://github.com/bitbank2/AnimatedGIF), for decoding and drawing GIFs +- [AnimatedGIF](https://github.com/bitbank2/AnimatedGIF) and [JPEGDEC](https://github.com/bitbank2/JPEGDEC), for decoding GIFs and JPEGs - project Nayuki's [qr-code-generator](https://github.com/nayuki/QR-Code-generator), for generating QR codes (included in `lib/` directory) - [i2cdevlib](https://github.com/jrowberg/i2cdevlib), for reading the MPU6050 sensor (included in `lib/` directory) - [NES.css](https://github.com/nostalgic-css/NES.css), for the NES-styled theme @@ -40,7 +40,7 @@ This is why the `pathExists()` method exists. # Usage -On first boot the esp32 creates a WiFi network with name `esp32-led-matrix` and password `daftpunk` (hardcoded in `src/wifi.h`). Connect to it, turn the frame upside down and scan the QR code to visit the web UI, where the WiFi name and password can be configured (stored in plain JSON in the flash memory). The esp32 will then connect to the provided WiFi and show a different QR code to visit the web UI on that network. +On first boot the esp32 creates a WiFi network with name `esp32-led-matrix` and password `daftpunk` (hardcoded in `src/wifi.h`). Connect to it, turn the frame upside down and scan the QR code to visit the web UI, where the WiFi SSID and password can be configured (stored in plain JSON in the flash memory). The esp32 will then connect to the provided WiFi and show a different QR code to visit the web UI on that network. ## Display modes @@ -48,15 +48,17 @@ The LED panel has 4 display modes, depending on the frame orientation: - ↑ shows image or GIF uploaded through the web UI - → shows a [Conway's Game of Life](https://en.wikipedia.org/wiki/Conway's_Game_of_Life) game - ↓ shows the QR code -- ← shows nothing, TBD what to do +- ← shows a position on a map ## Web UI - WiFi settings - brightness (0-255) -- upload image or GIF, with preview of the scaled-down version; this file will be stored in the flash memory and loaded on boot, so the size is limited depending on the model of esp32 used (mine has 1.4MB and I'm reserving 0.4MB for the project's static data) +- upload image or GIF, with preview of the scaled-down version; this file will be stored in the flash memory and loaded on boot, so the size is limited depending on the model of esp32 used (mine has 1.4MB and I'm reserving some for the project's static data) +- map settings (upload map image, set position, use remote API) - upload firmware updates (OTA) - application logs (by default the logs are sent only if the web UI is loaded by at least 1 user, use the compile variable `LOG_QUEUE` to enable a queue of up to 50 messages that will be delivered once a user connects) +- resource usage and system info # Gallery @@ -123,5 +125,9 @@ Some LEDs have been harmed in the development process. Our thoughts go to all th - setting for map remote API pull time (15m, 30m, 1h) - support PNG - consolidate requests from web UI -- implement map zoom (need scaling on esp32) -- implement other position centering methods (center on point, use a margin to move the view before the position reaches the panel border, ...) +- reduce heap usage (close and open "modes" when rotating?) +- replace `WiFiClient` with `WiFiClientSecure` to support HTTPS (it currently does not have enough free heap for SSL stuff) +- map: + - zoom (need scaling on esp32) + - other position centering methods (center on point, use a margin to move the view before the position reaches the panel border, ...) + - store previous positions and show route diff --git a/platformio.ini b/platformio.ini index fdbce49..081b441 100644 --- a/platformio.ini +++ b/platformio.ini @@ -39,6 +39,8 @@ lib_deps = build_flags = ; esp32-idf library debug level ; -DCORE_DEBUG_LEVEL=3 +; WiFi client debug level +; -ARDUHAL_LOG_LEVEL=4 ; build with slimmed version of Adafruit GFX + FastLED ; -DUSE_GFX_LITE=1 ; build without AdafruitGFX; no drawing, text or fonts! diff --git a/src/map.cpp b/src/map.cpp index c9da7dd..e1749ea 100644 --- a/src/map.cpp +++ b/src/map.cpp @@ -148,17 +148,140 @@ void Map::loadMedia() { Log::instance()->info("Loaded map from FLASH\n"); }; +bool Map::setHeaders(HTTPClient &client, const char *headers) { + JsonDocument h; + DeserializationError err = deserializeJson(h, headers); + if (err) { + Log::instance()->error("Error deserializing headers: %s\n", err.c_str()); + + return false; + } + if (!h.is()) { + Log::instance()->error("Header is not a JSON object\n"); + + return false; + } + + for (JsonPairConst kvp : h.as()) { + String key(kvp.key().c_str()); + std::string value = kvp.value().as(); + if (key.equalsIgnoreCase("Authorization")) { + size_t i = value.find(' '); + if (i == std::string::npos) { + Log::instance()->error("Missing header authorization type or value\n"); + + return false; + } + + client.setAuthorizationType(value.substr(0, i).c_str()); + client.setAuthorization(value.substr(i).c_str()); + continue; + } + if (key.equalsIgnoreCase("User-Agent")) { + client.setUserAgent(value.c_str()); + continue; + } + if (key.equalsIgnoreCase("Connection")) { + client.setReuse(strcmp(value.c_str(), "close") != 0); + continue; + } + if (key.equalsIgnoreCase("Host")) { + continue; + } + + client.addHeader(key.c_str(), value.c_str()); + } + + return true; +}; + +std::array Map::matchRegex(String res, const char *r) { + std::array pos = { 0, 0 }; + std::regex regex(r); + std::smatch matches; + std::string str = std::string(res.c_str()); + if (!std::regex_search(str, matches, regex)) { + Log::instance()->error("Regex %s did not match position API response:\n%s\n\n", r, str.c_str()); + + return pos; + } + if (matches.size() != 3) { + Log::instance()->error("Regex %s found %d matches in position API response, need 2:\n%s\n\n", r, matches.size() - 1, str.c_str()); + + return pos; + } + + pos[0] = strtod(matches.str(1).c_str(), NULL); + pos[1] = strtod(matches.str(2).c_str(), NULL); + + return pos; +}; + std::array Map::getPositionFromAPI() { - std::array pos; + std::array pos = { 0, 0 }; + WiFiClient client; + HTTPClient http; + http.setReuse(false); + http.setConnectTimeout(10000); + http.setTimeout(60000); + String u = config["url"].as(); + String m = config["method"].as(); + const char *r = config["regex"].as(); + if (u.isEmpty()) { + Log::instance()->error("Position API URL is not set\n"); + + return pos; + } + if (m.isEmpty()) { + Log::instance()->error("Position API method is not set\n"); - // TODO - Log::instance()->info("Getting position from API (%s): %f, %f\n", config["url"].as(), pos[0], pos[1]); + return pos; + } + if (strcmp(r, "") == 0) { + Log::instance()->error("Position API regexp is not set\n"); + + return pos; + } + + // Apply query params + String b = config["body"].as(); + if (m.equalsIgnoreCase("GET") && !b.isEmpty()) { + if (!u.concat(b)) { + Log::instance()->error("Error adding query parameters\n"); + + return pos; + } + } + + http.begin(client, u); + // Apply headers + const char *hs = config["headers"].as(); + if (strcmp(hs, "") != 0 && !setHeaders(http, hs)) { + return pos; + } + + // Send request + int code; + if (m.equalsIgnoreCase("POST")) { + code = http.POST(b); + } else { + code = http.GET(); + } + if (code < 0) { + Log::instance()->error("Error in API request: %d %s\n", code, http.errorToString(code).c_str()); + + return pos; + } + + // Match regex + String res = http.getString(); + pos = matchRegex(res, r); return pos; }; std::array Map::getPosition() { - std::array pos; + std::array pos = { 0, 0 }; if ( strcmp(config["url"].as(), "") != 0 && strcmp(config["method"].as(), "") != 0 @@ -166,8 +289,11 @@ std::array Map::getPosition() { ) { pos = getPositionFromAPI(); Log::instance()->info("Getting position from API (%s): %f, %f\n", config["url"].as(), pos[0], pos[1]); - } else if ( - strcmp(config["latitude"].as(), "") != 0 + } + // default to static position, if available + if ( + pos[0] == 0 && pos[1] == 0 + && strcmp(config["latitude"].as(), "") != 0 && strcmp(config["longitude"].as(), "") != 0 ) { pos[0] = strtod(config["latitude"].as(), NULL); diff --git a/src/map.h b/src/map.h index f84605f..d141f3b 100644 --- a/src/map.h +++ b/src/map.h @@ -2,12 +2,14 @@ #define _MAP_H #include +#include #include "log.h" #include "renderer.h" #include "filesystem.h" #include #include #include +#include #define MAP_PATH "/map.jpeg" #define UPDATE_CROP_AREA 1000 * 60 * 15 @@ -38,6 +40,8 @@ class Map : public Renderer { static void closeFile(void*); static int32_t readFile(JPEGFILE*, uint8_t*, int32_t); static int32_t seekFile(JPEGFILE*, int32_t); + bool setHeaders(HTTPClient&, const char*); + std::array matchRegex(String, const char*); std::array getPositionFromAPI(); std::array getPosition(); void updateCrop(); diff --git a/web/index.html b/web/index.html index 760878f..751bbf0 100644 --- a/web/index.html +++ b/web/index.html @@ -263,7 +263,7 @@

Debug