Skip to content

Commit

Permalink
Merge pull request #260 from somakeit/241-can-we-find-a-way-to-apply-…
Browse files Browse the repository at this point in the history
…new-firmware-over-the-air

241 can we find a way to apply new firmware over the air
  • Loading branch information
sam57719 authored Nov 27, 2024
2 parents edcd32a + 5c64272 commit 3f8d924
Show file tree
Hide file tree
Showing 11 changed files with 519 additions and 16 deletions.
20 changes: 19 additions & 1 deletion smibhid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ Press the space_open or space_closed buttons to call the smib server endpoint ap
- UI Logger captures timestamps of button presses and uploads to SMIB for logging and review of usage patterns
- Space open relay pin optionally sets a GPIO to high or low when the space is open
- Config file checker against config template - useful for upgrades missing config of new features
- Web server for admin functions - at present only provides API for version and MAC address (Check info log messages or DHCP server for IP and default port is 80)
- Over the air firmware updates - Web based management and display output on status
- Web server for admin functions (Check info log messages or DHCP server for IP and default port is 80)
- Home page with list of available functions
- API page that details API endpoints available and their usage
- Update page for performing over the air firmware updates and remote reset to apply them

## Circuit diagram
### Pico W Connections
Expand Down Expand Up @@ -112,6 +116,20 @@ If you add a new feature that has configuration options, ensure you set the cons
The admin web interface is hosted by a customised version of [tinyweb](https://github.com/belyalov/tinyweb) server which is a flask like implementation of a asyncio web server in MicroPython.
The website configuration and API definition is built out from the website.py module and all HTML/CSS/JS etc lives in the www subfolder.

### OTA firmware updates
- Load the admin web page and navigate to /update
- Add files to update
- Enter a URL to download the raw python file that will be moved into the lib folder overwriting any existing files with that name (Best approach is reference the raw file version on a github branch)
- Press "Add"
- Repeat above as needed until all files are staged
- Select a file and press "Remove" to remove a URL if not needed or to correct an error and re-add the URL
- When all files are staged ready to update, press "Restart" to reboot SMIBHID
- Display will show update status and result before restarting into normal mode with the new firmware

If any files are staged (.updating file exists in updates folder on the device) SMIBHID will reboot into update mode, download, copy over, then clear out the staging directory and restart again.

If errors are encountered such as no wifi on the update process, the staging file is deleted and SMIBHID will reboot back into normal mode.

### UI State diagram
The space state UI state machine is described in this diagram:

Expand Down
2 changes: 1 addition & 1 deletion smibhid/http/webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,4 +689,4 @@ def shutdown(self):
"""Gracefully shutdown Web Server"""
asyncio.cancel(self._server_coro)
for hid, coro in self.conns.items():
asyncio.cancel(coro)
asyncio.cancel(coro)
50 changes: 47 additions & 3 deletions smibhid/http/website.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from lib.module_config import ModuleConfig
from json import dumps
import uasyncio
from lib.updater import Updater

class WebApp:

Expand All @@ -19,10 +20,13 @@ def __init__(self, module_config: ModuleConfig, hid: object) -> None:
self.hid = hid
self.wifi = module_config.get_wifi()
self.display = module_config.get_display()
self.updater = Updater()
self.port = 80
self.running = False
self.create_style_css()
self.create_update_js()
self.create_homepage()
self.create_update()
self.create_api()

def startup(self):
Expand All @@ -40,32 +44,72 @@ def create_style_css(self):
@self.app.route('/css/style.css')
async def index(request, response):
await response.send_file('/http/www/css/style.css', content_type='text/css')

def create_update_js(self):
@self.app.route('/js/update.js')
async def index(request, response):
await response.send_file('/http/www/js/update.js', content_type='application/javascript')

def create_homepage(self) -> None:
@self.app.route('/')
async def index(request, response):
await response.send_file('/http/www/index.html')

def create_update(self) -> None:
@self.app.route('/update')
async def index(request, response):
await response.send_file('/http/www/update.html')

def create_api(self) -> None:
@self.app.route('/api')
async def api(request, response):
await response.send_file('/http/www/api.html')

self.app.add_resource(WLANMAC, '/api/wlan/mac', wifi = self.wifi, logger = self.log)
self.app.add_resource(Version, '/api/version', hid = self.hid, logger = self.log)
self.app.add_resource(FirmwareFiles, '/api/firmware_files', updater = self.updater, logger = self.log)
self.app.add_resource(Reset, '/api/reset', updater = self.updater, logger = self.log)

class WLANMAC():

def get(self, data, wifi, logger: uLogger):
def get(self, data, wifi, logger: uLogger) -> str:
logger.info("API request - wlan/mac")
html = dumps(wifi.get_mac())
logger.info(f"Return value: {html}")
return html

class Version():

def get(self, data, hid, logger: uLogger):
def get(self, data, hid, logger: uLogger) -> str:
logger.info("API request - version")
html = dumps(hid.version)
logger.info(f"Return value: {html}")
return html
return html

class FirmwareFiles():

def get(self, data, updater: Updater, logger: uLogger) -> str:
logger.info("API request - GET Firmware files")
html = dumps(updater.process_update_file())
logger.info(f"Return value: {html}")
return html

def post(self, data, updater: Updater, logger: uLogger) -> str:
logger.info("API request - POST Firmware files")
logger.info(f"Data: {data}")
if data["action"] == "add":
logger.info("Adding update - data: {data}")
html = updater.stage_update_url(data["url"])
elif data["action"] == "remove":
logger.info("Removing update - data: {data}")
html = updater.unstage_update_url(data["url"])
else:
html = f"Invalid request: {data["action"]}"
return dumps(html)

class Reset():

def post(self, data, updater: Updater, logger: uLogger) -> None:
logger.info("API request - reset")
updater.reset()
return
9 changes: 9 additions & 0 deletions smibhid/http/www/api.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SMIBHID - API</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
Expand All @@ -9,6 +10,14 @@ <h2>Endpoints</h2>
<ul>
<li>MAC address (GET): <a href="/api/wlan/mac">/api/wlan/mac</a></li>
<li>Firmware version (GET): <a href="/api/version">/api/version</a></li>
<li>Firmware files (GET,POST): <a href="/api/firmware_files">/api/firmware_files - List, add or remove URLs staged for download and patch on reset</a>
<ul>
<li>List files: GET</li>
<li>Add file: POST: Submit Value = "Add", URL = Str: id = "url" </li>
<li>Remove file: POST: Submit Value = "Remove", Str: URL </li>
</ul>
</li>
<li>Reset (POST): <a href="/api/reset">/api/reset - reset SMIBHID</a></li>
</ul>

<p />
Expand Down
4 changes: 4 additions & 0 deletions smibhid/http/www/index.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SMIBHID - Admin dashboard</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
Expand All @@ -10,6 +11,9 @@ <h1>SMIBHID - Admin dashboard</h1>

<h2>API</h2>
<p>SMIBHID provides an API for the admin functions. The API is documented <a href="/api">here</a>.

<h2>Update</h2>
<p>SMIBHID firmware can be updated over the air. The update page is <a href="/update">here</a>.

</body>
</html>
123 changes: 123 additions & 0 deletions smibhid/http/www/js/update.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const version = '0.0.28';

document.getElementById('add_file_form').addEventListener('submit', function(event) {
console.log('File staging update form submitted.');
event.preventDefault();

var request_body = JSON.stringify({"action": "add", "url": document.getElementById('url').value});

fetch('/api/firmware_files', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: request_body
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok ' + response.statusText);
}
return response.json();
})
.then(data => {
console.log('Request data returned:', data);
if (data === true) {
var result_message = "Firmware staging list updated successfully";
}

else {
var result_message = "Error updating firmware staging list. API return:", data;
}

document.getElementById('result').innerText = result_message;

fetchURLs();
})
.catch(error => {
console.error('Error encountered updating firmware staging list:', error);
document.getElementById('result').innerText = "Error updating firmware staging list: " + error.message;
});

});

document.getElementById('remove_file_form').addEventListener('submit', function(event) {
console.log('File staging update form submitted.');
event.preventDefault();

var selectedUrl = document.querySelector('input[name="url"]:checked').value;

var request_body = JSON.stringify({"action": "remove", "url": selectedUrl});

fetch('/api/firmware_files', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: request_body
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok ' + response.statusText);
}
return response.json();
})
.then(data => {
console.log('Request data returned:', data);
if (data === true) {
var result_message = "Firmware staging list updated successfully";
}

else {
var result_message = "Error updating firmware staging list. API return:", data;
}

document.getElementById('result').innerText = result_message;

fetchURLs();
})
.catch(error => {
console.error('Error encountered updating firmware staging list:', error);
document.getElementById('result').innerText = "Error updating firmware staging list: " + error.message;
});

});


document.addEventListener("DOMContentLoaded", function() {
function fetchURLs() {
fetch('api/firmware_files')
.then(response => response.json())
.then(data => {
console.log(data);
const urlList = document.getElementById('url-list');
console.log(urlList);
urlList.innerHTML = '';
if (data && data.length > 0) {
console.log('URLs returned and processing');
data.forEach((url, index) => {
if (url != "") {
console.log('Adding URL:', url);
const listItem = document.createElement('li');
const radioInput = document.createElement('input');
radioInput.type = 'radio';
radioInput.name = 'url';
radioInput.value = url;
radioInput.id = `${index + 1}`;

const label = document.createElement('label');
label.htmlFor = `${index + 1}`;
label.textContent = url;

listItem.appendChild(radioInput);
listItem.appendChild(label);
urlList.appendChild(listItem);
}
});
}
})
.catch(error => console.error('Error fetching URLs:', error));
}

window.fetchURLs = fetchURLs;
fetchURLs();
});
40 changes: 40 additions & 0 deletions smibhid/http/www/update.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SMIBHID - Update</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<h1>SMIBHID - Firmware update</h1>
<p>Firmware can be uploaded to SMIBHID over the air here and restarted to apply.
</p>

<h2>Update files</h2>

<h3>Delete files pending update</h3>
<button onclick="fetchURLs()">Refresh URLs</button>
<form action="/api/firmware_files" method="post" enctype="application/x-www-form-urlencoded" id="remove_file_form">
<ul id="url-list">
</ul>
<input type="submit" name="action" value="Remove" id="Remove"/>
</form>

<h3>Provide the URL for a new firmware file here:</h3>
<form action="" id="add_file_form">
<label for="url">Enter file URL:</label>
<input type="text" id="url" />
<input type="submit" value="Add" />
</form>

<span id="result"></span>

<h3>Restart SMIBHID</h3>
<form action="/api/reset" method="post">
<input type="submit" name="action" value="Restart" id="Restart"/>
</form>

<p>Return to <a href="/">home</a></p>

</body>
<script type="module" src="js/update.js?v=0.0.28"></script>
</html>
17 changes: 16 additions & 1 deletion smibhid/lib/LCD1602.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ def clear(self) -> None:
self._command(LCD_CLEARDISPLAY)
sleep(0.002)

def print_update_startup(self) -> None:
"""Render update startup information on screen."""
self.print_on_line(0, "S.M.I.B.H.I.D.")
self.print_on_line(1, "Updating...")

def print_download_progress(self, current: int, total: int) -> None:
"""Render update file count information on screen."""
self.print_on_line(0, "Downloading:")
self.print_on_line(1, f"{current}/{total}")

def print_update_status(self, status: str) -> None:
"""Render update status information on screen."""
self.print_on_line(0, "Updating...")
self.print_on_line(1, status)

def print_startup(self, version: str) -> None:
"""Render startup information on screen."""
self.print_on_line(0, "S.M.I.B.H.I.D.")
Expand Down Expand Up @@ -142,7 +157,7 @@ def update_status(self, status: dict) -> None:

self.print_on_line(state_line, f"State: {status["state"]}")

if self.error_loop_task == None or self.error_loop_task.done():
if self.error_loop_task is None or self.error_loop_task.done():
self.error_loop_task = create_task(self.async_error_printing_loop())

async def async_error_printing_loop(self) -> None:
Expand Down
Loading

0 comments on commit 3f8d924

Please sign in to comment.