Fix Arduino compilation error: add forward declaration for getQueryParam #151
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: HTML Render and Screenshot | |
| on: | |
| push: | |
| branches: [main, master] | |
| paths: | |
| - 'TankAlarm-092025-Server-Hologram/*.ino' | |
| - 'TankAlarm-112025-Server-BluesOpta/*.ino' | |
| - 'TankAlarm-112025-Viewer-BluesOpta/*.ino' | |
| - '.github/workflows/html-render.yml' | |
| pull_request: | |
| branches: [main, master] | |
| paths: | |
| - 'TankAlarm-092025-Server-Hologram/*.ino' | |
| - 'TankAlarm-112025-Server-BluesOpta/*.ino' | |
| - 'TankAlarm-112025-Viewer-BluesOpta/*.ino' | |
| - '.github/workflows/html-render.yml' | |
| workflow_dispatch: | |
| jobs: | |
| render-html: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.head_ref || github.ref_name }} | |
| fetch-depth: 1 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Install Playwright | |
| run: | | |
| npm install playwright | |
| npx playwright install chromium | |
| - name: Extract and render HTML for 092025 Server | |
| run: | | |
| mkdir -p /tmp/html-092025 | |
| python3 << 'EOF' | |
| import re | |
| import os | |
| ino_file = 'TankAlarm-092025-Server-Hologram/TankAlarm-092025-Server-Hologram.ino' | |
| output_dir = '/tmp/html-092025' | |
| with open(ino_file, 'r', encoding='utf-8', errors='ignore') as f: | |
| content = f.read() | |
| # Find functions that generate HTML pages | |
| pages = { | |
| 'dashboard': ('sendWebPage', 'Dashboard'), | |
| 'email-management': ('sendEmailManagementPage', 'Email Management'), | |
| 'tank-management': ('sendTankManagementPage', 'Tank Management'), | |
| 'calibration': ('sendCalibrationPage', 'Calibration'), | |
| '404': ('send404Page', '404 Page') | |
| } | |
| for page_name, (func_name, title) in pages.items(): | |
| # Find the function more carefully | |
| func_pattern = rf'void {func_name}\([^)]*\)\s*\{{' | |
| match = re.search(func_pattern, content) | |
| if match: | |
| start_pos = match.end() | |
| # Find the matching closing brace | |
| brace_count = 1 | |
| pos = start_pos | |
| while pos < len(content) and brace_count > 0: | |
| if content[pos] == '{': | |
| brace_count += 1 | |
| elif content[pos] == '}': | |
| brace_count -= 1 | |
| pos += 1 | |
| func_body = content[start_pos:pos-1] | |
| # Extract HTML from client.println statements | |
| html_lines = [] | |
| for line in func_body.split('\n'): | |
| line = line.strip() | |
| # Match client.println("...") or client.print("...") | |
| println_match = re.search(r'client\.print(?:ln)?\s*\(\s*[fF]?\s*\(\s*"([^"]*)"\s*\)\s*\)', line) | |
| if not println_match: | |
| println_match = re.search(r'client\.print(?:ln)?\s*\(\s*"([^"]*)"\s*\)', line) | |
| if println_match: | |
| html_content = println_match.group(1) | |
| # Unescape the string | |
| html_content = html_content.replace(r'\"', '"') | |
| html_content = html_content.replace(r"\'", "'") | |
| html_content = html_content.replace(r'\n', '\n') | |
| html_lines.append(html_content) | |
| # Handle String concatenation with variables | |
| elif 'client.print' in line: | |
| # Check for meta refresh | |
| if 'webPageRefreshSeconds' in line: | |
| html_lines.append("<meta http-equiv='refresh' content='30'>") | |
| # For other dynamic content, extract what we can | |
| elif '"' in line: | |
| parts = re.findall(r'"([^"]*)"', line) | |
| for part in parts: | |
| html_lines.append(part) | |
| # Join HTML lines | |
| html_content = '\n'.join(html_lines) | |
| # Add sample data for dynamic content | |
| if page_name == 'dashboard': | |
| # Add sample tank data grouped by sites | |
| sample_data = ''' | |
| <h2>North Facility</h2> | |
| <div class="tank-container"> | |
| <div class="tank-card"> | |
| <div class="tank-header">Tank #1</div> | |
| <div class="tank-level">Level: 85.3%</div> | |
| <div class="tank-change positive">24hr Change: +2.1%</div> | |
| <div class="status status-normal">Status: Normal</div> | |
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:55:56 AM</div> | |
| </div> | |
| <div class="tank-card"> | |
| <div class="tank-header">Tank #2</div> | |
| <div class="tank-level">Level: 52.1%</div> | |
| <div class="tank-change negative">24hr Change: -3.2%</div> | |
| <div class="status status-normal">Status: Normal</div> | |
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:55:56 AM</div> | |
| </div> | |
| </div> | |
| <h2>South Facility</h2> | |
| <div class="tank-container"> | |
| <div class="tank-card"> | |
| <div class="tank-header">Tank #1</div> | |
| <div class="tank-level">Level: 22.5%</div> | |
| <div class="tank-change negative">24hr Change: -8.5%</div> | |
| <div class="status status-alarm">Status: LOW</div> | |
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:54:56 AM</div> | |
| </div> | |
| </div> | |
| <h2>East Facility</h2> | |
| <div class="tank-container"> | |
| <div class="tank-card"> | |
| <div class="tank-header">Tank #1</div> | |
| <div class="tank-level">Level: 95.8%</div> | |
| <div class="tank-change positive">24hr Change: +1.2%</div> | |
| <div class="status status-normal">Status: Normal</div> | |
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:56:26 AM</div> | |
| </div> | |
| </div> | |
| <h2>West Facility</h2> | |
| <div class="tank-container"> | |
| <div class="tank-card"> | |
| <div class="tank-header">Tank #1</div> | |
| <div class="tank-level">Level: 15.2%</div> | |
| <div class="tank-change negative">24hr Change: -12.8%</div> | |
| <div class="status status-alarm">Status: CRITICAL</div> | |
| <div style="font-size: 12px; color: #666; margin-top: 10px;">Updated: 11/22/2025, 4:57:26 AM</div> | |
| </div> | |
| </div> | |
| <div class="footer">Last updated: 2025-11-22 18:00:00 UTC</div> | |
| ''' | |
| # Replace the "No tank reports received yet" message with sample data | |
| html_content = html_content.replace( | |
| "<p style='text-align: center;'>No tank reports received yet.</p>", | |
| sample_data | |
| ) | |
| # Make sure we have closing tags | |
| if '</html>' not in html_content: | |
| if '</body>' not in html_content: | |
| html_content += '\n</body>' | |
| html_content += '\n</html>' | |
| # Write to file | |
| output_file = os.path.join(output_dir, f'{page_name}.html') | |
| with open(output_file, 'w', encoding='utf-8') as out: | |
| out.write(html_content) | |
| print(f'Extracted {page_name}: {len(html_lines)} lines') | |
| EOF | |
| - name: Extract and render HTML for 112025 Server | |
| run: | | |
| mkdir -p /tmp/html-112025 | |
| python3 << 'EOF' | |
| import re | |
| import json | |
| import time | |
| ino_file = 'TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino' | |
| output_dir = '/tmp/html-112025' | |
| with open(ino_file, 'r', encoding='utf-8', errors='ignore') as f: | |
| content = f.read() | |
| # Find the DASHBOARD_HTML constant | |
| html_pattern = r'static const char DASHBOARD_HTML\[\] PROGMEM = R"HTML\((.*?)\)HTML"' | |
| match = re.search(html_pattern, content, re.DOTALL) | |
| if match: | |
| html_content = match.group(1) | |
| # Create sample data for the server dashboard | |
| current_time = int(time.time()) | |
| sample_data = { | |
| "serverUid": "dev:server001", | |
| "server": { | |
| "name": "Demo Tank Alarm Server", | |
| "clientFleet": "demo-tankalarm-clients", | |
| "webRefreshSeconds": 60, | |
| "pinConfigured": True | |
| }, | |
| "nextDailyEmailEpoch": current_time + 43200, # 12 hours from now | |
| "lastSyncEpoch": current_time - 180, # 3 minutes ago | |
| "clients": [ | |
| { | |
| "uid": "dev:client001", | |
| "site": "North Facility", | |
| "lastUpdate": current_time - 180, | |
| "tanks": [ | |
| { | |
| "tank": 1, | |
| "label": "Primary", | |
| "levelInches": 78.5, | |
| "percent": 85.3, | |
| "alarm": False, | |
| "lastUpdate": current_time - 180 | |
| }, | |
| { | |
| "tank": 2, | |
| "label": "Secondary", | |
| "levelInches": 45.2, | |
| "percent": 52.1, | |
| "alarm": False, | |
| "lastUpdate": current_time - 180 | |
| } | |
| ] | |
| }, | |
| { | |
| "uid": "dev:client002", | |
| "site": "South Facility", | |
| "lastUpdate": current_time - 240, | |
| "tanks": [ | |
| { | |
| "tank": 1, | |
| "label": "Storage", | |
| "levelInches": 18.3, | |
| "percent": 22.5, | |
| "alarm": True, | |
| "alarmType": "LOW", | |
| "lastUpdate": current_time - 240 | |
| } | |
| ] | |
| }, | |
| { | |
| "uid": "dev:client003", | |
| "site": "East Facility", | |
| "lastUpdate": current_time - 150, | |
| "tanks": [ | |
| { | |
| "tank": 1, | |
| "label": "Main", | |
| "levelInches": 92.1, | |
| "percent": 95.8, | |
| "alarm": False, | |
| "lastUpdate": current_time - 150 | |
| } | |
| ] | |
| }, | |
| { | |
| "uid": "dev:client004", | |
| "site": "West Facility", | |
| "lastUpdate": current_time - 90, | |
| "tanks": [ | |
| { | |
| "tank": 1, | |
| "label": "Backup", | |
| "levelInches": 12.7, | |
| "percent": 15.2, | |
| "alarm": True, | |
| "alarmType": "CRITICAL", | |
| "lastUpdate": current_time - 90 | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| # Inject the sample data | |
| sample_data_json = json.dumps(sample_data) | |
| # Replace the refreshData() call with data injection | |
| html_content = html_content.replace( | |
| 'refreshData();', | |
| f'applyServerData({sample_data_json}, null);' | |
| ) | |
| output_file = output_dir + '/dashboard.html' | |
| with open(output_file, 'w', encoding='utf-8') as out: | |
| out.write(html_content) | |
| print(f'Extracted dashboard HTML: {len(html_content)} bytes') | |
| print(f'Injected sample data with {len(sample_data["clients"])} clients') | |
| # Find the CONFIG_GENERATOR_HTML constant | |
| config_pattern = r'static const char CONFIG_GENERATOR_HTML\[\] PROGMEM = R"HTML\((.*?)\)HTML"' | |
| config_match = re.search(config_pattern, content, re.DOTALL) | |
| if config_match: | |
| config_html = config_match.group(1) | |
| config_file = output_dir + '/config-generator.html' | |
| with open(config_file, 'w', encoding='utf-8') as out: | |
| out.write(config_html) | |
| print(f'Extracted config generator HTML: {len(config_html)} bytes') | |
| EOF | |
| - name: Extract and render HTML for 112025 Viewer | |
| run: | | |
| mkdir -p /tmp/html-112025-viewer | |
| python3 << 'EOF' | |
| import re | |
| import json | |
| import time | |
| ino_file = 'TankAlarm-112025-Viewer-BluesOpta/TankAlarm-112025-Viewer-BluesOpta.ino' | |
| output_dir = '/tmp/html-112025-viewer' | |
| with open(ino_file, 'r', encoding='utf-8', errors='ignore') as f: | |
| content = f.read() | |
| # Find the VIEWER_DASHBOARD_HTML constant - need to handle macro splits | |
| # The HTML is split into multiple R"HTML()HTML" sections with STR() macros in between | |
| start_pattern = r'static const char VIEWER_DASHBOARD_HTML\[\] PROGMEM = R"HTML\(' | |
| start_match = re.search(start_pattern, content) | |
| if start_match: | |
| # Find the end - look for the final )HTML"; that ends the declaration | |
| start_pos = start_match.end() | |
| # Find the position after the HTML definition ends | |
| end_pattern = r'\)HTML";' | |
| remaining = content[start_pos:] | |
| # Collect all parts, handling the macro splits | |
| html_parts = [] | |
| current_pos = 0 | |
| while True: | |
| # Find next )HTML" | |
| end_match = re.search(r'\)HTML"', remaining[current_pos:]) | |
| if not end_match: | |
| break | |
| # Extract the HTML part | |
| html_parts.append(remaining[current_pos:current_pos + end_match.start()]) | |
| # Check if this is the final terminator | |
| check_pos = current_pos + end_match.end() | |
| if check_pos < len(remaining) and remaining[check_pos:check_pos+1] == ';': | |
| # This is the final terminator | |
| break | |
| # Look for the next R"HTML( | |
| next_start = re.search(r'R"HTML\(', remaining[check_pos:]) | |
| if next_start: | |
| # Extract the macro value in between | |
| macro_section = remaining[check_pos:check_pos + next_start.start()] | |
| # Replace STR(WEB_REFRESH_SECONDS) with 30 | |
| if 'STR(WEB_REFRESH_SECONDS)' in macro_section: | |
| html_parts.append('30') | |
| current_pos = check_pos + next_start.end() | |
| else: | |
| break | |
| html_content = ''.join(html_parts) | |
| # Create sample data for the viewer | |
| current_time = int(time.time()) | |
| sample_data = { | |
| "viewerName": "Demo Tank Alarm Viewer", | |
| "viewerUid": "dev:viewer001", | |
| "sourceServerName": "Demo Server", | |
| "sourceServerUid": "dev:server001", | |
| "generatedEpoch": current_time - 300, # 5 minutes ago | |
| "lastFetchEpoch": current_time - 120, # 2 minutes ago | |
| "nextFetchEpoch": current_time + 21480, # ~6 hours from now | |
| "refreshSeconds": 21600, # 6 hours | |
| "baseHour": 6, | |
| "tanks": [ | |
| { | |
| "client": "dev:client001", | |
| "site": "North Facility", | |
| "label": "Primary", | |
| "tank": 1, | |
| "levelInches": 78.5, | |
| "percent": 85.3, | |
| "alarm": False, | |
| "lastUpdate": current_time - 180 | |
| }, | |
| { | |
| "client": "dev:client001", | |
| "site": "North Facility", | |
| "label": "Secondary", | |
| "tank": 2, | |
| "levelInches": 45.2, | |
| "percent": 52.1, | |
| "alarm": False, | |
| "lastUpdate": current_time - 180 | |
| }, | |
| { | |
| "client": "dev:client002", | |
| "site": "South Facility", | |
| "label": "Storage", | |
| "tank": 1, | |
| "levelInches": 18.3, | |
| "percent": 22.5, | |
| "alarm": True, | |
| "alarmType": "LOW", | |
| "lastUpdate": current_time - 240 | |
| }, | |
| { | |
| "client": "dev:client003", | |
| "site": "East Facility", | |
| "label": "Main", | |
| "tank": 1, | |
| "levelInches": 92.1, | |
| "percent": 95.8, | |
| "alarm": False, | |
| "lastUpdate": current_time - 150 | |
| }, | |
| { | |
| "client": "dev:client004", | |
| "site": "West Facility", | |
| "label": "Backup", | |
| "tank": 1, | |
| "levelInches": 12.7, | |
| "percent": 15.2, | |
| "alarm": True, | |
| "alarmType": "CRITICAL", | |
| "lastUpdate": current_time - 90 | |
| } | |
| ] | |
| } | |
| # Inject the sample data into the HTML by replacing the fetchTanks call | |
| # Find the fetchTanks() call at the end of the script and replace it with data injection | |
| sample_data_json = json.dumps(sample_data) | |
| # Replace the async fetchTanks call with synchronous data application | |
| html_content = html_content.replace( | |
| 'fetchTanks();', | |
| f'applyTankData({sample_data_json}, null);' | |
| ) | |
| # Also remove the setInterval call to prevent attempts to fetch from non-existent API | |
| html_content = re.sub( | |
| r'setInterval\(\(\) => fetchTanks\(state\.selected\), REFRESH_SECONDS \* 1000\);', | |
| '// Auto-refresh disabled for static preview', | |
| html_content | |
| ) | |
| output_file = output_dir + '/dashboard.html' | |
| with open(output_file, 'w', encoding='utf-8') as out: | |
| out.write(html_content) | |
| print(f'Extracted viewer dashboard HTML: {len(html_content)} bytes') | |
| print(f'Injected sample data with {len(sample_data["tanks"])} tanks') | |
| EOF | |
| - name: Take screenshots with Playwright | |
| run: | | |
| node << 'EOF' | |
| const playwright = require('playwright'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| async function takeScreenshots() { | |
| const browser = await playwright.chromium.launch(); | |
| const context = await browser.newContext({ | |
| viewport: { width: 1280, height: 720 } | |
| }); | |
| const page = await context.newPage(); | |
| // Screenshot 092025 pages | |
| const dir092025 = '/tmp/html-092025'; | |
| const output092025 = 'TankAlarm-092025-Server-Hologram/screenshots'; | |
| if (fs.existsSync(dir092025)) { | |
| fs.mkdirSync(output092025, { recursive: true }); | |
| const files = fs.readdirSync(dir092025); | |
| for (const file of files) { | |
| if (file.endsWith('.html')) { | |
| const htmlPath = path.join(dir092025, file); | |
| const screenshotName = file.replace('.html', '.png'); | |
| const screenshotPath = path.join(output092025, screenshotName); | |
| try { | |
| await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); | |
| await page.screenshot({ path: screenshotPath, fullPage: true }); | |
| console.log(`Screenshot saved: ${screenshotPath}`); | |
| } catch (error) { | |
| console.error(`Error capturing ${file}:`, error.message); | |
| } | |
| } | |
| } | |
| } | |
| // Screenshot 112025 pages | |
| const dir112025 = '/tmp/html-112025'; | |
| const output112025 = 'TankAlarm-112025-Server-BluesOpta/screenshots'; | |
| if (fs.existsSync(dir112025)) { | |
| fs.mkdirSync(output112025, { recursive: true }); | |
| const files = fs.readdirSync(dir112025); | |
| for (const file of files) { | |
| if (file.endsWith('.html')) { | |
| const htmlPath = path.join(dir112025, file); | |
| const screenshotName = file.replace('.html', '.png'); | |
| const screenshotPath = path.join(output112025, screenshotName); | |
| try { | |
| await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); | |
| await page.screenshot({ path: screenshotPath, fullPage: true }); | |
| console.log(`Screenshot saved: ${screenshotPath}`); | |
| } catch (error) { | |
| console.error(`Error capturing ${file}:`, error.message); | |
| } | |
| } | |
| } | |
| } | |
| // Screenshot 112025 Viewer pages | |
| const dir112025Viewer = '/tmp/html-112025-viewer'; | |
| const output112025Viewer = 'TankAlarm-112025-Viewer-BluesOpta/screenshots'; | |
| if (fs.existsSync(dir112025Viewer)) { | |
| fs.mkdirSync(output112025Viewer, { recursive: true }); | |
| const files = fs.readdirSync(dir112025Viewer); | |
| for (const file of files) { | |
| if (file.endsWith('.html')) { | |
| const htmlPath = path.join(dir112025Viewer, file); | |
| const screenshotName = file.replace('.html', '.png'); | |
| const screenshotPath = path.join(output112025Viewer, screenshotName); | |
| try { | |
| await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); | |
| await page.screenshot({ path: screenshotPath, fullPage: true }); | |
| console.log(`Screenshot saved: ${screenshotPath}`); | |
| } catch (error) { | |
| console.error(`Error capturing ${file}:`, error.message); | |
| } | |
| } | |
| } | |
| } | |
| await browser.close(); | |
| } | |
| takeScreenshots().catch(console.error); | |
| EOF | |
| - name: Generate WEBSITE_PREVIEW.md for 092025 | |
| run: | | |
| cat > TankAlarm-092025-Server-Hologram/WEBSITE_PREVIEW.md << 'EOF' | |
| # Website Preview - TankAlarm 092025 Server | |
| This document contains screenshots of the web interface served by the TankAlarm-092025-Server-Hologram. | |
| ## Dashboard | |
|  | |
| ## Email Management | |
|  | |
| ## Tank Management | |
|  | |
| ## Calibration | |
|  | |
| ## 404 Page | |
|  | |
| --- | |
| *Screenshots automatically generated by GitHub Actions* | |
| *Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* | |
| EOF | |
| - name: Generate WEBSITE_PREVIEW.md for 112025 | |
| run: | | |
| cat > TankAlarm-112025-Server-BluesOpta/WEBSITE_PREVIEW.md << 'EOF' | |
| # Website Preview - TankAlarm 112025 Server | |
| This document contains screenshots of the web interface served by the TankAlarm-112025-Server-BluesOpta. | |
| ## Dashboard | |
|  | |
| ## Config Generator | |
|  | |
| --- | |
| *Screenshots automatically generated by GitHub Actions* | |
| *Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* | |
| EOF | |
| - name: Generate WEBSITE_PREVIEW.md for 112025 Viewer | |
| run: | | |
| cat > TankAlarm-112025-Viewer-BluesOpta/WEBSITE_PREVIEW.md << 'EOF' | |
| # Website Preview - TankAlarm 112025 Viewer | |
| This document contains screenshots of the web interface served by the TankAlarm-112025-Viewer-BluesOpta. | |
| ## Dashboard | |
|  | |
| --- | |
| *Screenshots automatically generated by GitHub Actions* | |
| *Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")* | |
| EOF | |
| - name: Commit and push changes | |
| if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') | |
| run: | | |
| git config --local user.email "github-actions[bot]@users.noreply.github.com" | |
| git config --local user.name "github-actions[bot]" | |
| git add TankAlarm-092025-Server-Hologram/WEBSITE_PREVIEW.md TankAlarm-092025-Server-Hologram/screenshots/ | |
| git add TankAlarm-112025-Server-BluesOpta/WEBSITE_PREVIEW.md TankAlarm-112025-Server-BluesOpta/screenshots/ | |
| git add TankAlarm-112025-Viewer-BluesOpta/WEBSITE_PREVIEW.md TankAlarm-112025-Viewer-BluesOpta/screenshots/ | |
| git diff --staged --quiet || git commit -m "Update website preview screenshots [skip ci]" | |
| git push origin HEAD:${{ github.head_ref || github.ref_name }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |