Skip to content

Add remote serial log support for client and server #150

Add remote serial log support for client and server

Add remote serial log support for client and server #150

Workflow file for this run

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
![Dashboard](screenshots/dashboard.png)
## Email Management
![Email Management](screenshots/email-management.png)
## Tank Management
![Tank Management](screenshots/tank-management.png)
## Calibration
![Calibration](screenshots/calibration.png)
## 404 Page
![404 Page](screenshots/404.png)
---
*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
![Dashboard](screenshots/dashboard.png)
## Config Generator
![Config Generator](screenshots/config-generator.png)
---
*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
![Dashboard](screenshots/dashboard.png)
---
*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 }}