commit 9-7-25
This commit is contained in:
parent
12b5b25081
commit
084de5cd44
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": false,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -55,22 +55,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": false,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": false,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -56,22 +56,12 @@
|
|||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1
|
||||||
"min": 5.0,
|
|
||||||
"max": 100.0,
|
|
||||||
"step": 1.5,
|
|
||||||
"skip-count": 5,
|
|
||||||
"vision": true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"oled": {
|
"oled": {
|
||||||
|
|||||||
@ -58,17 +58,17 @@
|
|||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0,
|
||||||
"min": 5.0,
|
"min": 0.0,
|
||||||
"max": 100.0,
|
"max": 100.0,
|
||||||
"step": 1.5,
|
"step": 1.5,
|
||||||
"skip-count": 5,
|
"skip-count": 5,
|
||||||
"vision": true
|
"vision": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": false,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1,
|
||||||
"min": 5.0,
|
"min": 0.0,
|
||||||
"max": 100.0,
|
"max": 100.0,
|
||||||
"step": 1.5,
|
"step": 1.5,
|
||||||
"skip-count": 5,
|
"skip-count": 5,
|
||||||
@ -106,6 +106,7 @@
|
|||||||
"rgb-order": "rgb",
|
"rgb-order": "rgb",
|
||||||
"shift":-5,
|
"shift":-5,
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
|
"bright": 200,
|
||||||
"power-div": 0,
|
"power-div": 0,
|
||||||
"i2s-ch": 0,
|
"i2s-ch": 0,
|
||||||
"core": 1
|
"core": 1
|
||||||
@ -117,6 +118,7 @@
|
|||||||
"rgb-order": "rgb",
|
"rgb-order": "rgb",
|
||||||
"shift":-27,
|
"shift":-27,
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
|
"bright": 255,
|
||||||
"power-div": 0,
|
"power-div": 0,
|
||||||
"i2s-ch": 0,
|
"i2s-ch": 0,
|
||||||
"core": 1
|
"core": 1
|
||||||
|
|||||||
@ -58,17 +58,17 @@
|
|||||||
"en": true,
|
"en": true,
|
||||||
"relay-index": 0,
|
"relay-index": 0,
|
||||||
"button-index": 0,
|
"button-index": 0,
|
||||||
"min": 5.0,
|
"min": 0.0,
|
||||||
"max": 100.0,
|
"max": 100.0,
|
||||||
"step": 1.5,
|
"step": 1.5,
|
||||||
"skip-count": 5,
|
"skip-count": 5,
|
||||||
"vision": true
|
"vision": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"en": true,
|
"en": false,
|
||||||
"relay-index": 1,
|
"relay-index": 1,
|
||||||
"button-index": 1,
|
"button-index": 1,
|
||||||
"min": 5.0,
|
"min": 0.0,
|
||||||
"max": 100.0,
|
"max": 100.0,
|
||||||
"step": 1.5,
|
"step": 1.5,
|
||||||
"skip-count": 5,
|
"skip-count": 5,
|
||||||
|
|||||||
@ -5,242 +5,409 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ATA Firmware Update</title>
|
<title>ATA Firmware Update</title>
|
||||||
<style>
|
<style>
|
||||||
|
/* --- CSS Variables for Easy Theming --- */
|
||||||
|
:root {
|
||||||
|
--color-bg: #f8f9fa;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-text: #212529;
|
||||||
|
--color-text-muted: #6c757d;
|
||||||
|
--color-primary: #007bff;
|
||||||
|
--color-primary-hover: #0056b3;
|
||||||
|
--color-disabled: #e9ecef;
|
||||||
|
--color-disabled-text: #6c757d;
|
||||||
|
--color-border: #dee2e6;
|
||||||
|
--color-success: #28a745;
|
||||||
|
--color-warning: #ffc107;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
--transition-speed: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Base & Layout --- */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: var(--font-family);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f4f4f4;
|
background-color: var(--color-bg);
|
||||||
text-align: center;
|
color: var(--color-text);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Typography --- */
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 22px;
|
font-size: 24px;
|
||||||
margin-bottom: 5px;
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-container {
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all var(--transition-speed) ease;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar button:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar button.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* --- Status Indicators --- */
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-text-muted);
|
||||||
|
transition: background-color var(--transition-speed) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.is-success { background-color: var(--color-success); }
|
||||||
|
.status-indicator.is-warning { background-color: var(--color-warning); }
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form Elements & Buttons --- */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: border-color var(--transition-speed) ease, box-shadow var(--transition-speed) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio button styling */
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: left;
|
gap: 8px;
|
||||||
margin-bottom: 4px;
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicator-ble {
|
.password-toggle input {
|
||||||
width: 20px;
|
width: auto;
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: gray;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-wifi {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: gray;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-internet {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: gray;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-container {
|
.btn-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
margin-bottom: 10px;
|
margin: 20px 0;
|
||||||
}
|
|
||||||
|
|
||||||
/* Adds space above the WiFi Connect button */
|
|
||||||
.btn-container.wifi {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 130px;
|
min-width: 140px;
|
||||||
padding: 10px;
|
padding: 12px 20px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: var(--border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: #007bff;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
transition: background 0.3s ease;
|
transition: background-color var(--transition-speed) ease, transform var(--transition-speed) ease;
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
background-color: #ccc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
background-color: #0056b3;
|
background-color: var(--color-primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
button:disabled {
|
||||||
|
background-color: var(--color-disabled);
|
||||||
|
color: var(--color-disabled-text);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Log Area --- */
|
||||||
|
textarea#logArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 250px;
|
||||||
font-size: 14px;
|
font-family: "SF Mono", "Consolas", "Menlo", monospace;
|
||||||
padding: 10px;
|
font-size: 13px;
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 90%;
|
|
||||||
max-width: 300px;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 5px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body {
|
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.tab-bar { display:flex; gap:8px; justify-content:center; margin:12px 0 16px; flex-wrap:wrap; }
|
|
||||||
.tab-bar button { max-width:none; flex:0 0 auto; background:#6c757d; }
|
|
||||||
.tab-bar button.active { background:#007bff; }
|
|
||||||
.tab-panel { display:none; }
|
|
||||||
.tab-panel.active { display:block; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<main>
|
||||||
<h1>ATA Firmware Update</h1>
|
<h1>ATA Firmware Update</h1>
|
||||||
|
|
||||||
<!-- Tab Buttons -->
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn active" data-tab="tab-upgrade">Upgrade</button>
|
<button class="tab-btn active" data-tab="tab-upgrade">Upgrade</button>
|
||||||
<button class="tab-btn" data-tab="tab-wifi">WiFi Comm</button>
|
<button class="tab-btn" data-tab="tab-wifi">WiFi Settings</button>
|
||||||
|
<button class="tab-btn" data-tab="tab-info">Info</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-upgrade" class="tab-panel active">
|
<div id="tab-upgrade" class="tab-panel active">
|
||||||
<!-- Status Indicators -->
|
<div class="status-grid">
|
||||||
<div class="status-container">
|
<div class="status-indicator" id="indicator-ble"></div>
|
||||||
<span class="status-indicator-ble"></span>
|
<div class="status-label" id="status-ble-connection">BLE Status: ...</div>
|
||||||
<label id="status-ble-connection">BLE Status: ...</label>
|
<div class="status-indicator" id="indicator-wifi"></div>
|
||||||
|
<div class="status-label" id="status-wifi-client">WiFi Client: ...</div>
|
||||||
|
<div class="status-indicator" id="indicator-internet"></div>
|
||||||
|
<div class="status-label" id="status-internet">Internet: ...</div>
|
||||||
|
<span></span> <div class="status-label" id="status-current-version">Current Version: ...</div>
|
||||||
|
<span></span> <div class="status-label" id="status-new-version">New Version: ...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-container">
|
<div class="form-group">
|
||||||
<span class="status-indicator-wifi"></span>
|
<label for="input-DeviceName" id="ble-device-name-label">Device Name</label>
|
||||||
<label id="status-wifi-client">Wifi Client: ...</label>
|
<input type="text" id="input-DeviceName" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-container">
|
|
||||||
<span class="status-indicator-internet"></span>
|
|
||||||
<label id="status-internet">Internet: ...</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-container">
|
|
||||||
<label id="status-current-version">Curr Version: ...</label>
|
|
||||||
</div>
|
|
||||||
<div class="status-container">
|
|
||||||
<label id="status-new-version">New Version: ...</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<label id="ble-device-name">Device Name:</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<input type="text" id="input-DeviceName" placeholder="..." style="width: 100%; max-width: 220px;" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buttons -->
|
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<button id="bleConnectBtn" onclick="connectToBle()">Connect</button>
|
<button id="bleConnectBtn" onclick="connectToBle()">Connect</button>
|
||||||
<button id="checkStatusBtn" onclick="checkStatus()" disabled>Check Status</button>
|
<button id="checkStatusBtn" onclick="checkStatus()" disabled>Check Status</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log Area -->
|
|
||||||
<textarea id="logArea" readonly></textarea>
|
<textarea id="logArea" readonly></textarea>
|
||||||
|
|
||||||
|
<!-- Update Mode Selection -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Update Mode:</label>
|
||||||
|
<div style="display: flex; gap: 15px; margin-top: 8px; flex-wrap: wrap;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px; font-weight: normal; cursor: pointer;">
|
||||||
|
<input type="radio" name="updateMode" value="both" checked>
|
||||||
|
Update Both
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px; font-weight: normal; cursor: pointer;">
|
||||||
|
<input type="radio" name="updateMode" value="files">
|
||||||
|
Files Only
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px; font-weight: normal; cursor: pointer;">
|
||||||
|
<input type="radio" name="updateMode" value="firmware">
|
||||||
|
Firmware Only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="btn-container">
|
<div class="btn-container">
|
||||||
<button id="checkVersionBtn" onclick="checkVersion()" disabled>Check Version</button>
|
<button id="checkVersionBtn" onclick="checkVersion()" disabled>Check Version</button>
|
||||||
<button id="startUpgradeBtn" onclick="startUpgrade()" disabled>Start Update</button>
|
<button id="startUpgradeBtn" onclick="startUpgrade()" disabled>Start Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- /tab-upgrade -->
|
</div>
|
||||||
|
|
||||||
<div id="tab-wifi" class="tab-panel">
|
<div id="tab-wifi" class="tab-panel">
|
||||||
<h2 style="margin-top:0; font-size:18px;">WiFi Connection</h2>
|
<h2>WiFi Connection</h2>
|
||||||
<div class="input-container">
|
<div class="form-group">
|
||||||
|
<label for="wifissid">WiFi Name (SSID)</label>
|
||||||
<input type="text" id="wifissid" name="wifissid" placeholder="Enter WiFi SSID" required>
|
<input type="text" id="wifissid" name="wifissid" placeholder="Enter WiFi SSID" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="wifipassword">WiFi Password</label>
|
||||||
<input type="password" id="wifipassword" name="wifipassword" placeholder="Enter WiFi Password" required>
|
<input type="password" id="wifipassword" name="wifipassword" placeholder="Enter WiFi Password" required>
|
||||||
<div style="display: flex; align-items: center; gap: 5px;">
|
<div class="password-toggle">
|
||||||
<input type="checkbox" id="showPassword" onclick="togglePasswordVisibility()" style="width: auto;">
|
<input type="checkbox" id="showPassword" onclick="togglePasswordVisibility()">
|
||||||
<label for="showPassword">Show Password</label>
|
<label for="showPassword">Show Password</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-container wifi">
|
<div class="btn-container">
|
||||||
<button id="wifiConnectBtn" onclick="wifiConnect()" disabled>Connect Wifi</button>
|
<button id="wifiConnectBtn" onclick="wifiConnect()" disabled>Connect to WiFi</button>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-wifi -->
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-info" class="tab-panel">
|
||||||
|
<h2>Instructions</h2>
|
||||||
|
<textarea readonly style="width: 100%; height: 640px; font-family: var(--font-family); font-size: 14px; padding: 15px; border-radius: var(--border-radius); border: 1px solid var(--color-border); background-color: var(--color-bg); resize: vertical; line-height: 1.6;">ATA Firmware Update Tool - Instructions
|
||||||
|
|
||||||
|
OVERVIEW:
|
||||||
|
This tool allows you to update your ATA Boothifier device firmware and configuration files over Bluetooth Low Energy (BLE) and WiFi. You can choose to update files only, firmware only, or both.
|
||||||
|
|
||||||
|
GETTING STARTED:
|
||||||
|
|
||||||
|
1. SET DEVICE/BOARD IN UPGRADE MODE
|
||||||
|
• hold button 1 and click the RST button on the board.
|
||||||
|
- If device is in upgrade mode buzzer will beep every 5 secs.
|
||||||
|
- LED1 (L1) will be solid.
|
||||||
|
- RGB LEDS will be set to solid red.
|
||||||
|
|
||||||
|
2. CONNECT TO DEVICE
|
||||||
|
• Make sure your device is powered on and in upgrade mode
|
||||||
|
• Click "Connect" button to scan for and connect to your device
|
||||||
|
• The device name should appear as "ATALIGHTS" by default
|
||||||
|
• Wait for BLE Status to show "Connected"
|
||||||
|
|
||||||
|
3. CHECK STATUS
|
||||||
|
• When connected it will update status automatically:
|
||||||
|
- WiFi connection status
|
||||||
|
- Internet connectivity status
|
||||||
|
- Current firmware version
|
||||||
|
|
||||||
|
4. CONNECT TO WIFI (if needed)
|
||||||
|
• If WiFi is not connected, go to "WiFi Settings" tab
|
||||||
|
• Enter your WiFi network name (SSID) and password
|
||||||
|
• Click "Connect to WiFi"
|
||||||
|
• Return to "Upgrade" tab once connected
|
||||||
|
|
||||||
|
5. CHECK FOR UPDATES
|
||||||
|
• Click "Check Version" to see if new firmware is available
|
||||||
|
• This requires internet connection through WiFi
|
||||||
|
• New version will be displayed if available
|
||||||
|
|
||||||
|
6. CHOOSE UPDATE MODE
|
||||||
|
• Update Both: Updates files and firmware (requires restart)
|
||||||
|
• Files Only: Updates configuration/web files only (no restart)
|
||||||
|
• Firmware Only: Updates firmware only (requires restart)
|
||||||
|
|
||||||
|
7. START UPDATE
|
||||||
|
• Click "Start Update" to begin the update process
|
||||||
|
• Monitor progress in the log area
|
||||||
|
• DO NOT disconnect or power off during update
|
||||||
|
• Device will restart automatically if firmware was updated
|
||||||
|
|
||||||
|
|
||||||
|
TROUBLESHOOTING:
|
||||||
|
|
||||||
|
• BLE Connection Issues:
|
||||||
|
- Ensure device is in upgrade mode
|
||||||
|
- Try refreshing the page and reconnecting
|
||||||
|
- Make sure no other devices are connected to the ATA device
|
||||||
|
|
||||||
|
• WiFi Connection Issues:
|
||||||
|
- Double-check SSID and password
|
||||||
|
- Ensure WiFi network is available and has internet access
|
||||||
|
- Some networks may block the device
|
||||||
|
|
||||||
|
• Update Issues:
|
||||||
|
- Ensure stable internet connection
|
||||||
|
- Try "Files Only" update if firmware update fails
|
||||||
|
- Check log messages for specific error details
|
||||||
|
|
||||||
|
IMPORTANT NOTES:
|
||||||
|
• Always ensure stable power during firmware updates
|
||||||
|
• Device will restart after firmware updates
|
||||||
|
• Files-only updates do not require restart
|
||||||
|
• Log area shows detailed progress and error information
|
||||||
|
• You can copy text from the log area for troubleshooting</textarea>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* ================= Constants & Packet Layout ================= */
|
/* ================= Constants & Packet Layout ================= */
|
||||||
const BLE_SERVER_NAME = "ATALIGHTS"; // Keep hardcoded per instruction (ignore external JSON)
|
const BLE_SERVER_NAME = "ATALIGHTS";
|
||||||
const BLE_SERVICE_UUID = "abcdef01-2345-6789-1234-56789abcdef0";
|
const BLE_SERVICE_UUID = "abcdef01-2345-6789-1234-56789abcdef0";
|
||||||
const BLE_CHARACTERISTIC1_UUID = "abcdef01-2345-6789-1234-56789abcdef1"; // Control / status
|
const BLE_CHARACTERISTIC1_UUID = "abcdef01-2345-6789-1234-56789abcdef1";
|
||||||
const BLE_CHARACTERISTIC2_UUID = "abcdef02-2345-6789-1234-56789abcdef1"; // Logs / events
|
const BLE_CHARACTERISTIC2_UUID = "abcdef02-2345-6789-1234-56789abcdef1";
|
||||||
|
|
||||||
// Packet layout (mirrors firmware struct updateStatus)
|
|
||||||
// byte 0 : wifiStatus (enum)
|
|
||||||
// byte 1 : wifiOnline (bool)
|
|
||||||
// bytes2-5: wifiIP
|
|
||||||
// bytes6-8: currVersion (major,minor,patch)
|
|
||||||
// bytes9-11: newVersion (major,minor,patch)
|
|
||||||
// bytes12-31: wifiSSID (20 bytes, null padded)
|
|
||||||
const PACKET_LEN = 32;
|
const PACKET_LEN = 32;
|
||||||
const OFF_WIFI_STATUS = 0;
|
const OFF_WIFI_STATUS = 0, OFF_WIFI_ONLINE = 1, OFF_WIFI_IP = 2;
|
||||||
const OFF_WIFI_ONLINE = 1;
|
const OFF_CURR_VER = 6, OFF_NEW_VER = 9, OFF_WIFI_SSID = 12;
|
||||||
const OFF_WIFI_IP = 2;
|
|
||||||
const OFF_CURR_VER = 6;
|
|
||||||
const OFF_NEW_VER = 9;
|
|
||||||
const OFF_WIFI_SSID = 12;
|
|
||||||
|
|
||||||
const WIFI_STAT = { DISCONNECTED:0, BAD_CREDS:1, NO_AP:2, CONNECTED:3 };
|
const WIFI_STAT = { DISCONNECTED:0, BAD_CREDS:1, NO_AP:2, CONNECTED:3 };
|
||||||
const WIFI_STAT_TEXT = ["Disconnected","Bad Creds","No AP","Connected"];
|
const WIFI_STAT_TEXT = ["Disconnected", "Bad Credentials", "AP Not Found", "Connected"];
|
||||||
const MAX_LOG_LINES = 400;
|
const MAX_LOG_LINES = 400;
|
||||||
|
|
||||||
/* ================= State ================= */
|
/* ================= State ================= */
|
||||||
@ -256,16 +423,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ================= Cached DOM ================= */
|
/* ================= Cached DOM ================= */
|
||||||
|
// Cache DOM elements for performance and easier access
|
||||||
const el = {};
|
const el = {};
|
||||||
function cacheDom() {
|
function cacheDom() {
|
||||||
el.bleIndicator = document.querySelector('.status-indicator-ble');
|
el.bleIndicator = document.getElementById('indicator-ble');
|
||||||
el.wifiIndicator = document.querySelector('.status-indicator-wifi');
|
el.wifiIndicator = document.getElementById('indicator-wifi');
|
||||||
el.internetIndicator = document.querySelector('.status-indicator-internet');
|
el.internetIndicator = document.getElementById('indicator-internet');
|
||||||
el.lblBle = document.getElementById('status-ble-connection');
|
el.lblBle = document.getElementById('status-ble-connection');
|
||||||
el.lblWifi = document.getElementById('status-wifi-client');
|
el.lblWifi = document.getElementById('status-wifi-client');
|
||||||
el.lblInternet = document.getElementById('status-internet');
|
el.lblInternet = document.getElementById('status-internet');
|
||||||
el.lblCurrVer = document.getElementById('status-current-version');
|
el.lblCurrVer = document.getElementById('status-current-version');
|
||||||
el.lblNewVer = document.getElementById('status-new-version');
|
el.lblNewVer = document.getElementById('status-new-version');
|
||||||
|
el.lblDeviceName = document.getElementById('ble-device-name-label');
|
||||||
el.inDeviceName = document.getElementById('input-DeviceName');
|
el.inDeviceName = document.getElementById('input-DeviceName');
|
||||||
el.inSsid = document.getElementById('wifissid');
|
el.inSsid = document.getElementById('wifissid');
|
||||||
el.inPass = document.getElementById('wifipassword');
|
el.inPass = document.getElementById('wifipassword');
|
||||||
@ -279,35 +448,56 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ================= Utilities ================= */
|
/* ================= Utilities ================= */
|
||||||
function logMessage(msg){
|
const logMessage = (msg, overwriteLastLine = false) => {
|
||||||
const lines = el.logArea.value.trim().length ? el.logArea.value.split(/\n/) : [];
|
// Append message to log area, managing max lines and scrolling
|
||||||
lines.push(msg);
|
if (!el.logArea) return;
|
||||||
|
let lines = el.logArea.value.length ? el.logArea.value.replace(/\r/g, '').split('\n') : [];
|
||||||
|
if (lines.length && lines[lines.length - 1] === '') lines.pop(); // remove trailing empty from previous newline
|
||||||
|
const entry = `-> ${msg}`;
|
||||||
|
|
||||||
|
// Handle line management
|
||||||
|
if (overwriteLastLine && lines.length > 0) {
|
||||||
|
// Replace the last line for progress updates
|
||||||
|
lines[lines.length - 1] = entry;
|
||||||
|
} else {
|
||||||
|
// Add a new line
|
||||||
|
lines.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim log if too long
|
||||||
if (lines.length > MAX_LOG_LINES) {
|
if (lines.length > MAX_LOG_LINES) {
|
||||||
lines.splice(0, lines.length - MAX_LOG_LINES);
|
lines = lines.slice(lines.length - MAX_LOG_LINES);
|
||||||
}
|
}
|
||||||
el.logArea.value = lines.join('\n') + '\n';
|
|
||||||
|
el.logArea.value = lines.join('\n');
|
||||||
el.logArea.scrollTop = el.logArea.scrollHeight;
|
el.logArea.scrollTop = el.logArea.scrollHeight;
|
||||||
}
|
};
|
||||||
|
|
||||||
function compareVersions(a,b){
|
// Compare version arrays [major, minor, patch]
|
||||||
for(let i=0;i<3;i++){ if(a[i]>b[i]) return 1; if(a[i]<b[i]) return -1; }
|
// Returns 1 if a > b, -1 if a < b, 0
|
||||||
|
const compareVersions = (a, b) => {
|
||||||
|
for(let i = 0; i < 3; i++) {
|
||||||
|
if(a[i] > b[i]) return 1;
|
||||||
|
if(a[i] < b[i]) return -1;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
};
|
||||||
|
|
||||||
function colorIndicator(elm, color){ if(elm) elm.style.backgroundColor = color; }
|
const ipToString = (ip) => ip.join('.');
|
||||||
|
|
||||||
function ipToString(ip){ return ip.join('.'); }
|
const setIndicatorStatus = (elm, status) => {
|
||||||
|
if (elm) elm.className = `status-indicator ${status}`;
|
||||||
|
};
|
||||||
|
|
||||||
/* ================= Packet Handling ================= */
|
/* ================= Packet Handling ================= */
|
||||||
function parsePacket(data) {
|
function parsePacket(data) {
|
||||||
if(data.length !== PACKET_LEN) return false;
|
if(data.length !== PACKET_LEN) return false;
|
||||||
state.wifiStatus = data[OFF_WIFI_STATUS];
|
state.wifiStatus = data[OFF_WIFI_STATUS];
|
||||||
if(state.wifiStatus > WIFI_STAT.CONNECTED) state.wifiStatus = WIFI_STAT.DISCONNECTED; // clamp
|
if(state.wifiStatus > WIFI_STAT.CONNECTED) state.wifiStatus = WIFI_STAT.DISCONNECTED;
|
||||||
state.wifiOnline = !!data[OFF_WIFI_ONLINE];
|
state.wifiOnline = !!data[OFF_WIFI_ONLINE];
|
||||||
state.wifiIP = Array.from(data.slice(OFF_WIFI_IP, OFF_WIFI_IP + 4));
|
state.wifiIP = Array.from(data.slice(OFF_WIFI_IP, OFF_WIFI_IP + 4));
|
||||||
state.currVersion = Array.from(data.slice(OFF_CURR_VER, OFF_CURR_VER + 3));
|
state.currVersion = Array.from(data.slice(OFF_CURR_VER, OFF_CURR_VER + 3));
|
||||||
state.newVersion = Array.from(data.slice(OFF_NEW_VER, OFF_NEW_VER + 3));
|
state.newVersion = Array.from(data.slice(OFF_NEW_VER, OFF_NEW_VER + 3));
|
||||||
// Extract SSID (stop at first 0)
|
|
||||||
let rawSsidBytes = data.slice(OFF_WIFI_SSID, OFF_WIFI_SSID + 20);
|
let rawSsidBytes = data.slice(OFF_WIFI_SSID, OFF_WIFI_SSID + 20);
|
||||||
let zeroIndex = rawSsidBytes.indexOf(0);
|
let zeroIndex = rawSsidBytes.indexOf(0);
|
||||||
if(zeroIndex >= 0) rawSsidBytes = rawSsidBytes.slice(0, zeroIndex);
|
if(zeroIndex >= 0) rawSsidBytes = rawSsidBytes.slice(0, zeroIndex);
|
||||||
@ -318,31 +508,27 @@
|
|||||||
function updateUI() {
|
function updateUI() {
|
||||||
// BLE
|
// BLE
|
||||||
el.lblBle.textContent = 'BLE Status: ' + (bleConnected ? 'Connected' : 'Disconnected');
|
el.lblBle.textContent = 'BLE Status: ' + (bleConnected ? 'Connected' : 'Disconnected');
|
||||||
colorIndicator(el.bleIndicator, bleConnected ? 'green' : 'gray');
|
setIndicatorStatus(el.bleIndicator, bleConnected ? 'is-success' : '');
|
||||||
|
|
||||||
// WiFi client
|
// WiFi client
|
||||||
const statText = WIFI_STAT_TEXT[state.wifiStatus] || 'Unknown';
|
const statText = WIFI_STAT_TEXT[state.wifiStatus] || 'Unknown';
|
||||||
if(state.wifiStatus === WIFI_STAT.CONNECTED) {
|
if(state.wifiStatus === WIFI_STAT.CONNECTED) {
|
||||||
const ssidPart = state.wifiSSID ? ' SSID: '+state.wifiSSID : '';
|
const ssidPart = state.wifiSSID ? ` to "${state.wifiSSID}"` : '';
|
||||||
el.lblWifi.textContent = 'Wifi Client: ' + statText + (state.wifiIP[0] ? ' ('+ipToString(state.wifiIP)+')':'' ) + ssidPart;
|
const ipPart = state.wifiIP[0] ? ` (${ipToString(state.wifiIP)})` : '';
|
||||||
colorIndicator(el.wifiIndicator, 'green');
|
el.lblWifi.textContent = 'WiFi Client: ' + statText + ssidPart + ipPart;
|
||||||
} else if(state.wifiStatus === WIFI_STAT.BAD_CREDS){
|
setIndicatorStatus(el.wifiIndicator, 'is-success');
|
||||||
el.lblWifi.textContent = 'Wifi Client: Bad Credentials';
|
|
||||||
colorIndicator(el.wifiIndicator, 'orange');
|
|
||||||
} else if(state.wifiStatus === WIFI_STAT.NO_AP){
|
|
||||||
el.lblWifi.textContent = 'Wifi Client: AP Not Found';
|
|
||||||
colorIndicator(el.wifiIndicator, 'orange');
|
|
||||||
} else {
|
} else {
|
||||||
el.lblWifi.textContent = 'Wifi Client: ' + statText;
|
el.lblWifi.textContent = 'WiFi Client: ' + statText;
|
||||||
colorIndicator(el.wifiIndicator, 'gray');
|
const statusClass = (state.wifiStatus === WIFI_STAT.BAD_CREDS || state.wifiStatus === WIFI_STAT.NO_AP) ? 'is-warning' : '';
|
||||||
|
setIndicatorStatus(el.wifiIndicator, statusClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internet
|
// Internet
|
||||||
el.lblInternet.textContent = state.wifiOnline ? 'Online' : 'Offline';
|
el.lblInternet.textContent = 'Internet: ' + (state.wifiOnline ? 'Online' : 'Offline');
|
||||||
colorIndicator(el.internetIndicator, state.wifiOnline ? 'green' : 'gray');
|
setIndicatorStatus(el.internetIndicator, state.wifiOnline ? 'is-success' : '');
|
||||||
|
|
||||||
// Versions
|
// Versions
|
||||||
el.lblCurrVer.textContent = state.currVersion[0] ? 'Curr Version: ' + state.currVersion.join('.') : 'Curr Version: ...';
|
el.lblCurrVer.textContent = state.currVersion[0] ? 'Current Version: ' + state.currVersion.join('.') : 'Current Version: ...';
|
||||||
el.lblNewVer.textContent = state.newVersion[0] ? 'New Version: ' + state.newVersion.join('.') : 'New Version: ...';
|
el.lblNewVer.textContent = state.newVersion[0] ? 'New Version: ' + state.newVersion.join('.') : 'New Version: ...';
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
@ -366,13 +552,60 @@
|
|||||||
bleCharacteristic1 = await service.getCharacteristic(BLE_CHARACTERISTIC1_UUID);
|
bleCharacteristic1 = await service.getCharacteristic(BLE_CHARACTERISTIC1_UUID);
|
||||||
bleCharacteristic2 = await service.getCharacteristic(BLE_CHARACTERISTIC2_UUID);
|
bleCharacteristic2 = await service.getCharacteristic(BLE_CHARACTERISTIC2_UUID);
|
||||||
await bleCharacteristic2.startNotifications();
|
await bleCharacteristic2.startNotifications();
|
||||||
|
let prevProgressFound = false;
|
||||||
bleCharacteristic2.addEventListener('characteristicvaluechanged', e => {
|
bleCharacteristic2.addEventListener('characteristicvaluechanged', e => {
|
||||||
try{
|
try{
|
||||||
const txt = new TextDecoder().decode(e.target.value);
|
//const view = e.target.value; // DataView
|
||||||
console.log('--> ' + txt);
|
//const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||||
logMessage('--> ' + txt.trim());
|
|
||||||
}catch(_){ /* ignore */ }
|
// Debug info
|
||||||
|
//let debugInfo = `Received ${bytes.length} bytes: `;
|
||||||
|
//for (let i = 0; i < bytes.length; i++) {
|
||||||
|
// debugInfo += bytes[i].toString(16).padStart(2, '0') + ' ';
|
||||||
|
// }
|
||||||
|
//console.log(debugInfo);
|
||||||
|
|
||||||
|
// Try to decode as text
|
||||||
|
let txt = '';
|
||||||
|
try {
|
||||||
|
//txt = new TextDecoder().decode(bytes);
|
||||||
|
txt = new TextDecoder().decode(e.target.value);
|
||||||
|
// Remove null terminators and trim
|
||||||
|
const nullIdx = txt.indexOf('\0');
|
||||||
|
if (nullIdx !== -1) txt = txt.slice(0, nullIdx);
|
||||||
|
txt = txt.trim();
|
||||||
|
} catch (decodeErr) {
|
||||||
|
console.error('Text decode error:', decodeErr);
|
||||||
|
// Fallback to showing hex if text decode fails
|
||||||
|
txt = '[Binary data]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show both raw and processed data in console
|
||||||
|
//console.log('--> Raw bytes:', bytes);
|
||||||
|
//console.log('--> As text:', txt);
|
||||||
|
|
||||||
|
//logMessage(`--> (${bytes.length} bytes) ${txt}`);
|
||||||
|
// progress messages handling variable captured from outer scope
|
||||||
|
|
||||||
|
if(prevProgressFound && txt.includes('progress')){
|
||||||
|
logMessage(`${txt}`, true); // overwrite last line for progress updates
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
logMessage(`${txt}`);
|
||||||
|
prevProgressFound = false; // reset if current message isn't progress
|
||||||
|
}
|
||||||
|
|
||||||
|
if(txt.includes('progress')){
|
||||||
|
prevProgressFound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Processing error', err);
|
||||||
|
logMessage('--> Error processing message: ' + err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
bleConnected=true;
|
bleConnected=true;
|
||||||
bleDevice.addEventListener('gattserverdisconnected', onDisconnect);
|
bleDevice.addEventListener('gattserverdisconnected', onDisconnect);
|
||||||
const connectedName = bleDevice.name || el.inDeviceName.value || 'Device';
|
const connectedName = bleDevice.name || el.inDeviceName.value || 'Device';
|
||||||
@ -387,9 +620,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDisconnect() {
|
function onDisconnect() {
|
||||||
bleConnected=false; updateUI(); logMessage('BLE disconnected');
|
bleConnected = false;
|
||||||
|
el.lblDeviceName.textContent = 'Device Name';
|
||||||
|
updateUI();
|
||||||
|
logMessage('BLE disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
async function sendPacket(msg) {
|
async function sendPacket(msg) {
|
||||||
if(!bleCharacteristic1) return;
|
if(!bleCharacteristic1) return;
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
@ -406,7 +644,8 @@
|
|||||||
try {
|
try {
|
||||||
const val = await bleCharacteristic1.readValue();
|
const val = await bleCharacteristic1.readValue();
|
||||||
const data = new Uint8Array(val.buffer);
|
const data = new Uint8Array(val.buffer);
|
||||||
if(parsePacket(data)) return true; else { logMessage('Packet parse failed (len='+data.length+')'); return false; }
|
if(parsePacket(data)) return true;
|
||||||
|
else { logMessage('Packet parse failed (len=' + data.length + ')'); return false; }
|
||||||
} catch(e) { if(attempt === 2) logMessage('Read failed: '+e.message); else await delay(1000); }
|
} catch(e) { if(attempt === 2) logMessage('Read failed: '+e.message); else await delay(1000); }
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -418,25 +657,19 @@
|
|||||||
const pw = el.inPass.value;
|
const pw = el.inPass.value;
|
||||||
if(!ssid || !pw) { alert('Enter SSID & password'); return; }
|
if(!ssid || !pw) { alert('Enter SSID & password'); return; }
|
||||||
logMessage('Sending WiFi credentials...');
|
logMessage('Sending WiFi credentials...');
|
||||||
el.btnWifiConnect.disabled = true; // prevent multiple submissions while polling
|
el.btnWifiConnect.disabled = true;
|
||||||
await sendPacket('wifi-connect {"ssid":"'+ssid+'","pass":"'+pw+'"} ');
|
await sendPacket(`wifi-connect {"ssid":"${ssid}","pass":"${pw}"} `);
|
||||||
// Poll for status for up to 15s
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while(Date.now() - start < 15000) {
|
while(Date.now() - start < 15000) {
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
await readPacket();
|
await readPacket();
|
||||||
updateUI();
|
updateUI();
|
||||||
if(state.wifiStatus === WIFI_STAT.CONNECTED){
|
if(state.wifiStatus === WIFI_STAT.CONNECTED) { logMessage(`WiFi Connected: ${ipToString(state.wifiIP)}`); break; }
|
||||||
logMessage('WiFi Connected: '+ipToString(state.wifiIP));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if(state.wifiStatus === WIFI_STAT.BAD_CREDS) { logMessage('WiFi Error: Bad Credentials'); break; }
|
if(state.wifiStatus === WIFI_STAT.BAD_CREDS) { logMessage('WiFi Error: Bad Credentials'); break; }
|
||||||
if(state.wifiStatus === WIFI_STAT.NO_AP) { logMessage('WiFi Error: AP Not Found'); break; }
|
if(state.wifiStatus === WIFI_STAT.NO_AP) { logMessage('WiFi Error: AP Not Found'); break; }
|
||||||
}
|
}
|
||||||
if(state.wifiStatus !== WIFI_STAT.CONNECTED){ logMessage('WiFi connect attempt finished with status: '+WIFI_STAT_TEXT[state.wifiStatus]); }
|
if(state.wifiStatus !== WIFI_STAT.CONNECTED) logMessage(`WiFi connect attempt finished with status: ${WIFI_STAT_TEXT[state.wifiStatus]}`);
|
||||||
if(!bleConnected) return; // keep disabled if BLE disconnected during process
|
if(bleConnected && state.wifiStatus !== WIFI_STAT.CONNECTED) { el.btnWifiConnect.disabled = false; }
|
||||||
// Re-enable for retry unless connected
|
|
||||||
if(state.wifiStatus !== WIFI_STAT.CONNECTED){ el.btnWifiConnect.disabled = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkStatus() { if(await readPacket()) updateUI(); }
|
async function checkStatus() { if(await readPacket()) updateUI(); }
|
||||||
@ -449,7 +682,14 @@
|
|||||||
while(Date.now() - start < 15000) {
|
while(Date.now() - start < 15000) {
|
||||||
await delay(750);
|
await delay(750);
|
||||||
await readPacket();
|
await readPacket();
|
||||||
if(state.newVersion[0]){ logMessage('Latest version: '+state.newVersion.join('.')); break; }
|
if(state.newVersion[0]) {
|
||||||
|
if(compareVersions(state.newVersion, state.currVersion) > 0) {
|
||||||
|
logMessage('New version available: ' + state.newVersion.join('.'));
|
||||||
|
} else {
|
||||||
|
logMessage('Newer version not available.');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(!state.newVersion[0]) logMessage('No new version info received');
|
if(!state.newVersion[0]) logMessage('No new version info received');
|
||||||
updateUI();
|
updateUI();
|
||||||
@ -457,22 +697,43 @@
|
|||||||
|
|
||||||
async function startUpgrade() {
|
async function startUpgrade() {
|
||||||
if(el.btnStartUpgrade.disabled) return;
|
if(el.btnStartUpgrade.disabled) return;
|
||||||
logMessage('Starting upgrade...');
|
|
||||||
el.btnStartUpgrade.disabled = true;
|
// Get selected update mode
|
||||||
await sendPacket('upgrade-start');
|
const selectedMode = document.querySelector('input[name="updateMode"]:checked')?.value || 'both';
|
||||||
// Progress will arrive via characteristic2 logs
|
|
||||||
|
// Determine packet to send based on selection
|
||||||
|
let packet;
|
||||||
|
let modeText;
|
||||||
|
switch(selectedMode) {
|
||||||
|
case 'files':
|
||||||
|
packet = 'upgrade-start-files-only';
|
||||||
|
modeText = 'files only';
|
||||||
|
break;
|
||||||
|
case 'firmware':
|
||||||
|
packet = 'upgrade-start-firmware-only';
|
||||||
|
modeText = 'firmware only';
|
||||||
|
break;
|
||||||
|
case 'both':
|
||||||
|
default:
|
||||||
|
packet = 'upgrade-start';
|
||||||
|
modeText = 'both files and firmware';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= Helpers ================= */
|
logMessage(`Starting upgrade (${modeText})...`);
|
||||||
const delay = ms => new Promise(r=>setTimeout(r,ms));
|
el.btnStartUpgrade.disabled = true;
|
||||||
|
|
||||||
function togglePasswordVisibility(){ el.inPass.type = el.chkShowPass.checked ? 'text' : 'password'; }
|
await sendPacket(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Initialization ================= */
|
||||||
|
function togglePasswordVisibility() {
|
||||||
|
el.inPass.type = el.chkShowPass.checked ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
cacheDom();
|
cacheDom();
|
||||||
el.inDeviceName.value = BLE_SERVER_NAME;
|
el.inDeviceName.value = BLE_SERVER_NAME;
|
||||||
el.chkShowPass.addEventListener('change', togglePasswordVisibility);
|
|
||||||
// Inline onclicks already wired; ensure functions are in scope
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@ -485,10 +746,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
updateUI();
|
updateUI();
|
||||||
logMessage('Ready. Enter device name or use default and press Connect.');
|
logMessage('Press Connect -> Check Version -> Start Update');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose functions for inline handlers
|
|
||||||
window.connectToBle = connectToBle;
|
window.connectToBle = connectToBle;
|
||||||
window.checkStatus = checkStatus;
|
window.checkStatus = checkStatus;
|
||||||
window.checkVersion = checkVersion;
|
window.checkVersion = checkVersion;
|
||||||
@ -501,3 +761,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
```
|
||||||
@ -1,456 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Upload firmware, manifest, and data assets to a MinIO (S3-compatible) bucket.
|
|
||||||
|
|
||||||
Features preserved from original GCS script:
|
|
||||||
- Optional backup (copies existing objects under destination prefix to timestamped folder under backups/)
|
|
||||||
- Upload firmware.bin, update.json, and recursively mirror a data directory
|
|
||||||
- Cache-Control set to disable caching on clients
|
|
||||||
|
|
||||||
Switches from google.cloud.storage to boto3 (S3 API) for MinIO compatibility.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
|
||||||
import boto3
|
|
||||||
from botocore.exceptions import ClientError
|
|
||||||
from botocore.config import Config
|
|
||||||
except ImportError:
|
|
||||||
print("ERROR: boto3 is required. Install with: pip install boto3")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CONFIGURATION CONSTANTS (edit as needed or supply via environment variables)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
CREATE_BACKUP = False
|
|
||||||
UPLOAD_FIRMWARE = True
|
|
||||||
UPDATE_MANIFEST = True
|
|
||||||
UPLOAD_DATA = True
|
|
||||||
|
|
||||||
DIR_SKIP_LIST = [
|
|
||||||
"system",
|
|
||||||
"booths"
|
|
||||||
]
|
|
||||||
|
|
||||||
FIlES_SKIP_LIST = [
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
# Bucket / endpoint configuration
|
|
||||||
BUCKET_NAME = os.getenv('MINIO_BUCKET', 'boothifier')
|
|
||||||
DESTINATION_DIR = os.getenv('MINIO_DEST_PREFIX', 'latest') # prefix inside bucket
|
|
||||||
BACKUPS_DIR = os.getenv('MINIO_BACKUPS_PREFIX', 'backups')
|
|
||||||
|
|
||||||
PROJECT_ROOT_PATH = Path(__file__).parent.parent.resolve()
|
|
||||||
LOCAL_ROOT_PATH = Path(__file__).parent.resolve()
|
|
||||||
|
|
||||||
# Optional service account style JSON key (generated by MinIO Console). Expected fields:
|
|
||||||
# {"url":"https://minio.example.com/api/v1/service-account-credentials","accessKey":"...","secretKey":"...","api":"s3v4","path":"auto"}
|
|
||||||
MINIO_KEY_FILE = LOCAL_ROOT_PATH / 'minio-boothifier-key.json'
|
|
||||||
|
|
||||||
# Defaults before loading file / env
|
|
||||||
_json_access = None
|
|
||||||
_json_secret = None
|
|
||||||
_json_url = None
|
|
||||||
|
|
||||||
def _load_json_key():
|
|
||||||
global _json_access, _json_secret, _json_url
|
|
||||||
try:
|
|
||||||
if MINIO_KEY_FILE.is_file():
|
|
||||||
with open(MINIO_KEY_FILE, 'r', encoding='utf-8') as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
_json_access = data.get('accessKey') or None
|
|
||||||
_json_secret = data.get('secretKey') or None
|
|
||||||
_json_url = data.get('url') or None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WARN: Failed to load MinIO key file '{MINIO_KEY_FILE.name}': {e}")
|
|
||||||
|
|
||||||
_load_json_key()
|
|
||||||
|
|
||||||
def _derive_endpoint(url_value: str) -> str:
|
|
||||||
if not url_value:
|
|
||||||
return 'https://s3-minio.boothwizard.com'
|
|
||||||
# Remove known API suffix if present (/api/...)
|
|
||||||
# e.g. https://s3-minio.boothwizard.com/api/v1/service-account-credentials -> https://s3-minio.boothwizard.com
|
|
||||||
parts = url_value.split('/api/')
|
|
||||||
return parts[0] if parts else url_value
|
|
||||||
|
|
||||||
# MinIO credentials with precedence: ENV > JSON file > fallback
|
|
||||||
MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT') or _derive_endpoint(_json_url)
|
|
||||||
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY') or _json_access or 'CHANGE_ME_ACCESS'
|
|
||||||
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY') or _json_secret or 'CHANGE_ME_SECRET'
|
|
||||||
MINIO_REGION = os.getenv('MINIO_REGION', 'us-east-1') # MinIO ignores but boto3 wants some value
|
|
||||||
|
|
||||||
# Addressing / SSL options
|
|
||||||
MINIO_ADDRESSING = os.getenv('MINIO_ADDRESSING_STYLE', 'path').lower() # 'path' or 'virtual'
|
|
||||||
MINIO_VERIFY_SSL = os.getenv('MINIO_TLS_VERIFY', '1') not in ('0','false','no')
|
|
||||||
MINIO_DEBUG = os.getenv('MINIO_DEBUG', '0') in ('1','true','yes')
|
|
||||||
MINIO_ALLOW_VARIANTS = os.getenv('MINIO_ALLOW_ENDPOINT_VARIANTS', '0') in ('1','true','yes') # normally false with nginx redirect
|
|
||||||
|
|
||||||
LOCAL_FIRMWARE_PATH = str(PROJECT_ROOT_PATH / '.pio' / 'build' / 'esp32s3dev' / 'firmware.bin')
|
|
||||||
LOCAL_DATA_DIRECTORY = str(PROJECT_ROOT_PATH / 'data')
|
|
||||||
MANIFEST_LOCAL_PATH = str(LOCAL_ROOT_PATH / 'manifest-local.json') # source of version/description/changelog
|
|
||||||
MANIFEST_FILENAME = os.getenv('MANIFEST_FILENAME', 'manifest.json') # destination manifest name
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HELPERS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def s3_client():
|
|
||||||
"""Create an S3 client pointed at MinIO endpoint, forcing path-style unless overridden, with short timeouts."""
|
|
||||||
addressing = 'path' if MINIO_ADDRESSING not in ('virtual','auto') else 'virtual'
|
|
||||||
cfg = Config(
|
|
||||||
s3={'addressing_style': addressing},
|
|
||||||
signature_version='s3v4',
|
|
||||||
connect_timeout=3,
|
|
||||||
read_timeout=5,
|
|
||||||
retries={'max_attempts': 2}
|
|
||||||
)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
masked_key = (MINIO_ACCESS_KEY[:3] + '...' + MINIO_ACCESS_KEY[-3:]) if MINIO_ACCESS_KEY else 'None'
|
|
||||||
print(f"[DEBUG] Creating client: endpoint={MINIO_ENDPOINT} addressing={addressing} verifySSL={MINIO_VERIFY_SSL} region={MINIO_REGION} accessKey={masked_key}")
|
|
||||||
return boto3.client(
|
|
||||||
's3',
|
|
||||||
endpoint_url=MINIO_ENDPOINT,
|
|
||||||
aws_access_key_id=MINIO_ACCESS_KEY,
|
|
||||||
aws_secret_access_key=MINIO_SECRET_KEY,
|
|
||||||
region_name=MINIO_REGION,
|
|
||||||
verify=MINIO_VERIFY_SSL,
|
|
||||||
config=cfg,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _endpoint_variants(base: str):
|
|
||||||
"""Return endpoint variants only if explicitly allowed; otherwise just the base (nginx handles forwarding)."""
|
|
||||||
if not MINIO_ALLOW_VARIANTS:
|
|
||||||
return [base]
|
|
||||||
# Fallback to previous expanded logic if variants are enabled
|
|
||||||
try:
|
|
||||||
variants = []
|
|
||||||
if not base:
|
|
||||||
return variants
|
|
||||||
base = base.rstrip('/')
|
|
||||||
proto_sep = '://'
|
|
||||||
if proto_sep in base:
|
|
||||||
scheme, rest = base.split(proto_sep,1)
|
|
||||||
else:
|
|
||||||
scheme, rest = 'https', base
|
|
||||||
host_port = rest
|
|
||||||
if ':' in host_port:
|
|
||||||
host, port = host_port.split(':',1)
|
|
||||||
else:
|
|
||||||
host, port = host_port, ''
|
|
||||||
variants.append(f"{scheme}://{host_port}")
|
|
||||||
common_ports = ['9000','443','80']
|
|
||||||
for p in common_ports:
|
|
||||||
if port != p:
|
|
||||||
variants.append(f"{scheme}://{host}:{p}")
|
|
||||||
alt_scheme = 'http' if scheme == 'https' else 'https'
|
|
||||||
variants.append(f"{alt_scheme}://{host_port}")
|
|
||||||
for p in common_ports:
|
|
||||||
if port != p:
|
|
||||||
variants.append(f"{alt_scheme}://{host}:{p}")
|
|
||||||
seen = set()
|
|
||||||
uniq = []
|
|
||||||
for v in variants:
|
|
||||||
if v not in seen:
|
|
||||||
uniq.append(v)
|
|
||||||
seen.add(v)
|
|
||||||
return uniq
|
|
||||||
except Exception:
|
|
||||||
return [base]
|
|
||||||
|
|
||||||
def create_validated_client():
|
|
||||||
"""Validate (or create) client using only provided endpoint unless variants enabled."""
|
|
||||||
global MINIO_ENDPOINT
|
|
||||||
primary = MINIO_ENDPOINT
|
|
||||||
variants = _endpoint_variants(primary) or [primary]
|
|
||||||
errors = []
|
|
||||||
probe_bucket = BUCKET_NAME # we will head the target bucket directly
|
|
||||||
for candidate in variants:
|
|
||||||
saved = MINIO_ENDPOINT
|
|
||||||
MINIO_ENDPOINT = candidate
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Probing endpoint candidate: {candidate}")
|
|
||||||
try:
|
|
||||||
c = s3_client()
|
|
||||||
try:
|
|
||||||
c.head_bucket(Bucket=probe_bucket)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] head_bucket succeeded on {candidate} for '{probe_bucket}'.")
|
|
||||||
return c
|
|
||||||
except ClientError as e:
|
|
||||||
msg = str(e)
|
|
||||||
# Acceptable if bucket not found (we can create later)
|
|
||||||
if any(code in msg for code in ('404', 'NoSuchBucket', 'NotFound')):
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Bucket not found on {candidate} (expected if first deploy). Using this endpoint.")
|
|
||||||
return c
|
|
||||||
if 'API Requests must be made to API port' in msg:
|
|
||||||
errors.append(f"{candidate}: wrong port (console endpoint)")
|
|
||||||
else:
|
|
||||||
errors.append(f"{candidate}: {msg}")
|
|
||||||
MINIO_ENDPOINT = saved
|
|
||||||
except Exception as ex:
|
|
||||||
errors.append(f"{candidate}: {ex}")
|
|
||||||
MINIO_ENDPOINT = saved
|
|
||||||
continue
|
|
||||||
print("ERROR: Could not validate any endpoint candidate.")
|
|
||||||
for e in errors:
|
|
||||||
print(' - ' + e)
|
|
||||||
print("Provide correct API endpoint (e.g. https://host:9000) via MINIO_ENDPOINT env var.")
|
|
||||||
sys.exit(3)
|
|
||||||
|
|
||||||
def list_objects(client, prefix: str):
|
|
||||||
"""Generator yielding object keys under a prefix (non-recursive listing with pagination)."""
|
|
||||||
kwargs = {'Bucket': BUCKET_NAME, 'Prefix': prefix}
|
|
||||||
while True:
|
|
||||||
resp = client.list_objects_v2(**kwargs)
|
|
||||||
for obj in resp.get('Contents', []):
|
|
||||||
yield obj['Key']
|
|
||||||
if not resp.get('IsTruncated'):
|
|
||||||
break
|
|
||||||
kwargs['ContinuationToken'] = resp['NextContinuationToken']
|
|
||||||
|
|
||||||
def normalize_prefix(p: str) -> str:
|
|
||||||
p = p.strip('/')
|
|
||||||
return p
|
|
||||||
|
|
||||||
def join_key(*parts: str) -> str:
|
|
||||||
parts_clean = [p.strip('/') for p in parts if p is not None and p != '']
|
|
||||||
return '/'.join(parts_clean)
|
|
||||||
|
|
||||||
def backup_existing_files(client, destination_prefix: str, backups_prefix: str, backup_folder: str):
|
|
||||||
if not destination_prefix:
|
|
||||||
prefix = ''
|
|
||||||
else:
|
|
||||||
prefix = destination_prefix + '/'
|
|
||||||
print(f"Scanning existing objects under '{prefix}' for backup...")
|
|
||||||
for key in list_objects(client, prefix):
|
|
||||||
if backups_prefix and key.startswith(backups_prefix + '/'): # Skip prior backups
|
|
||||||
continue
|
|
||||||
# relative path within destination
|
|
||||||
relative = key[len(prefix):] if prefix and key.startswith(prefix) else key
|
|
||||||
backup_key = join_key(backups_prefix, backup_folder, relative)
|
|
||||||
print(f"Backup copy: {key} -> {backup_key}")
|
|
||||||
client.copy_object(
|
|
||||||
Bucket=BUCKET_NAME,
|
|
||||||
CopySource={'Bucket': BUCKET_NAME, 'Key': key},
|
|
||||||
Key=backup_key,
|
|
||||||
MetadataDirective='COPY'
|
|
||||||
)
|
|
||||||
|
|
||||||
def upload_file(client, local_path: str, key: str, cache_control: str = 'private, max-age=0, no-transform'):
|
|
||||||
if not os.path.isfile(local_path):
|
|
||||||
print(f"WARN: File missing, skipping: {local_path}")
|
|
||||||
return
|
|
||||||
print(f"Upload: {local_path} -> s3://{BUCKET_NAME}/{key}")
|
|
||||||
extra_args = { 'CacheControl': cache_control }
|
|
||||||
client.upload_file(local_path, BUCKET_NAME, key, ExtraArgs=extra_args)
|
|
||||||
|
|
||||||
def upload_directory(client, local_directory: str, destination_prefix: str):
|
|
||||||
if not os.path.isdir(local_directory):
|
|
||||||
print(f"WARN: Data directory missing: {local_directory}")
|
|
||||||
return
|
|
||||||
for root, _, files in os.walk(local_directory):
|
|
||||||
for fname in files:
|
|
||||||
full = os.path.join(root, fname)
|
|
||||||
rel = os.path.relpath(full, local_directory).replace('\\','/') # force forward slashes for S3 keys
|
|
||||||
key = join_key(destination_prefix, rel)
|
|
||||||
upload_file(client, full, key)
|
|
||||||
|
|
||||||
def ensure_bucket(client):
|
|
||||||
"""Ensure bucket exists; provide diagnostics if HeadBucket returns 400/other errors."""
|
|
||||||
try:
|
|
||||||
client.head_bucket(Bucket=BUCKET_NAME)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Bucket '{BUCKET_NAME}' exists.")
|
|
||||||
return
|
|
||||||
except ClientError as e:
|
|
||||||
code = e.response.get('Error', {}).get('Code')
|
|
||||||
status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode')
|
|
||||||
print(f"HeadBucket failed (code={code}, status={status}).")
|
|
||||||
|
|
||||||
# List buckets for diagnostics
|
|
||||||
try:
|
|
||||||
resp = client.list_buckets()
|
|
||||||
bucket_names = [b['Name'] for b in resp.get('Buckets', [])]
|
|
||||||
print(f"Available buckets: {bucket_names or 'None'}")
|
|
||||||
except Exception as le:
|
|
||||||
print(f"WARN: list_buckets failed: {le}")
|
|
||||||
|
|
||||||
if code in ('404', 'NoSuchBucket', 'NotFound'):
|
|
||||||
print(f"Bucket '{BUCKET_NAME}' not found. Attempting to create...")
|
|
||||||
try:
|
|
||||||
client.create_bucket(Bucket=BUCKET_NAME)
|
|
||||||
print(f"Created bucket '{BUCKET_NAME}'.")
|
|
||||||
return
|
|
||||||
except ClientError as ce:
|
|
||||||
print(f"ERROR: Cannot create bucket: {ce}")
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
if status == 400:
|
|
||||||
print("HINTS: \n - Verify endpoint URL (MINIO_ENDPOINT).\n - Ensure no trailing slash in endpoint.\n - Check that TLS verify matches server cert (set MINIO_TLS_VERIFY=0 to test).\n - Confirm bucket name is correct and DNS compatible.\n - Credentials may lack permission: verify access key policies.")
|
|
||||||
|
|
||||||
# Retry once forcing path style if not already
|
|
||||||
if MINIO_ADDRESSING != 'path':
|
|
||||||
print("Retrying with path-style addressing...")
|
|
||||||
os.environ['MINIO_ADDRESSING_STYLE'] = 'path'
|
|
||||||
new_client = s3_client()
|
|
||||||
try:
|
|
||||||
new_client.head_bucket(Bucket=BUCKET_NAME)
|
|
||||||
print("Second attempt succeeded with path-style addressing.")
|
|
||||||
return
|
|
||||||
except ClientError as e2:
|
|
||||||
print(f"Second HeadBucket attempt failed: {e2}")
|
|
||||||
print(f"ERROR: head_bucket ultimately failed: {e}")
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# MAIN
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def main():
|
|
||||||
dest_prefix = normalize_prefix(DESTINATION_DIR)
|
|
||||||
backups_prefix = normalize_prefix(BACKUPS_DIR) if BACKUPS_DIR else ''
|
|
||||||
client = create_validated_client()
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print("[DEBUG] Starting ensure_bucket phase...")
|
|
||||||
ensure_bucket(client)
|
|
||||||
|
|
||||||
# Create backup if needed
|
|
||||||
if CREATE_BACKUP:
|
|
||||||
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
backup_folder = f"backup_{ts}"
|
|
||||||
print(f"Creating backup under '{backups_prefix}/{backup_folder}' from prefix '{dest_prefix}'")
|
|
||||||
backup_existing_files(client, dest_prefix, backups_prefix, backup_folder)
|
|
||||||
print("Backup complete.")
|
|
||||||
else:
|
|
||||||
print("Backup creation skipped.")
|
|
||||||
|
|
||||||
# Firmware
|
|
||||||
if UPLOAD_FIRMWARE:
|
|
||||||
firmware_key = join_key(dest_prefix, 'firmware.bin') if dest_prefix else 'firmware.bin'
|
|
||||||
upload_file(client, LOCAL_FIRMWARE_PATH, firmware_key)
|
|
||||||
print("Firmware upload complete.")
|
|
||||||
else:
|
|
||||||
print("Firmware upload skipped.")
|
|
||||||
|
|
||||||
# Data directory
|
|
||||||
if UPLOAD_DATA:
|
|
||||||
data_prefix = join_key(dest_prefix, 'data') if dest_prefix else 'data'
|
|
||||||
upload_directory(client, LOCAL_DATA_DIRECTORY, data_prefix)
|
|
||||||
print("All uploads complete.")
|
|
||||||
else:
|
|
||||||
print("Data upload skipped.")
|
|
||||||
|
|
||||||
# Manifest
|
|
||||||
if UPDATE_MANIFEST:
|
|
||||||
manifest_key = join_key(dest_prefix, MANIFEST_FILENAME) if dest_prefix else MANIFEST_FILENAME
|
|
||||||
try:
|
|
||||||
manifest_doc = build_and_write_manifest(client, dest_prefix)
|
|
||||||
upload_manifest_json(client, manifest_doc, manifest_key)
|
|
||||||
print(f"Manifest upload complete: s3://{BUCKET_NAME}/{manifest_key}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Manifest generation/upload failed: {e}")
|
|
||||||
sys.exit(4)
|
|
||||||
else:
|
|
||||||
print("Manifest upload skipped.")
|
|
||||||
|
|
||||||
# ================= Manifest Support =================
|
|
||||||
|
|
||||||
def md5_hex(path: str, chunk_size: int = 65536) -> str:
|
|
||||||
h = hashlib.md5()
|
|
||||||
with open(path, 'rb') as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
def collect_data_files(data_root: str):
|
|
||||||
files = []
|
|
||||||
if not os.path.isdir(data_root):
|
|
||||||
return files
|
|
||||||
for root, _, filenames in os.walk(data_root):
|
|
||||||
for fname in filenames:
|
|
||||||
full = os.path.join(root, fname)
|
|
||||||
rel = os.path.relpath(full, data_root).replace('\\','/')
|
|
||||||
entry = {
|
|
||||||
'path': f"data/{rel}",
|
|
||||||
'md5': md5_hex(full),
|
|
||||||
'size': os.path.getsize(full)
|
|
||||||
}
|
|
||||||
files.append(entry)
|
|
||||||
files.sort(key=lambda x: x['path'])
|
|
||||||
return files
|
|
||||||
|
|
||||||
def read_local_manifest(local_path: str):
|
|
||||||
if not os.path.isfile(local_path):
|
|
||||||
raise FileNotFoundError(f"manifest-local file not found: {local_path}")
|
|
||||||
with open(local_path, 'r', encoding='utf-8') as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
# Basic validation
|
|
||||||
if 'version' not in data:
|
|
||||||
raise ValueError('manifest-local missing version section')
|
|
||||||
ver = data['version']
|
|
||||||
for k in ('major','minor','patch'):
|
|
||||||
if k not in ver:
|
|
||||||
raise ValueError(f"manifest-local version missing '{k}'")
|
|
||||||
data.setdefault('description', '')
|
|
||||||
data.setdefault('changelog', [])
|
|
||||||
if not isinstance(data['changelog'], list):
|
|
||||||
raise ValueError('changelog must be an array')
|
|
||||||
return data
|
|
||||||
|
|
||||||
def build_and_write_manifest(client, dest_prefix: str):
|
|
||||||
# Read local manifest-local.json
|
|
||||||
base_info = read_local_manifest(MANIFEST_LOCAL_PATH)
|
|
||||||
# Timestamp
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
release_date = now.strftime('%Y-%m-%d')
|
|
||||||
release_time = now.strftime('%H:%M:%S')
|
|
||||||
# Firmware info
|
|
||||||
fw_path_local = LOCAL_FIRMWARE_PATH
|
|
||||||
if not os.path.isfile(fw_path_local):
|
|
||||||
raise FileNotFoundError(f"Firmware file not found: {fw_path_local}")
|
|
||||||
fw_md5 = md5_hex(fw_path_local)
|
|
||||||
fw_size = os.path.getsize(fw_path_local)
|
|
||||||
# Data files
|
|
||||||
data_files = collect_data_files(LOCAL_DATA_DIRECTORY)
|
|
||||||
manifest = {
|
|
||||||
'version': {
|
|
||||||
'major': int(base_info['version']['major']),
|
|
||||||
'minor': int(base_info['version']['minor']),
|
|
||||||
'patch': int(base_info['version']['patch'])
|
|
||||||
},
|
|
||||||
'release_date': release_date,
|
|
||||||
'release_time': release_time,
|
|
||||||
'description': base_info.get('description',''),
|
|
||||||
'changelog': base_info.get('changelog', []),
|
|
||||||
'firmware': {
|
|
||||||
'path': 'firmware.bin',
|
|
||||||
'md5': fw_md5,
|
|
||||||
'size': fw_size
|
|
||||||
},
|
|
||||||
'files': data_files
|
|
||||||
}
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def upload_manifest_json(client, manifest_obj: dict, key: str):
|
|
||||||
body = json.dumps(manifest_obj, indent=4).encode('utf-8')
|
|
||||||
client.put_object(
|
|
||||||
Bucket=BUCKET_NAME,
|
|
||||||
Key=key,
|
|
||||||
Body=body,
|
|
||||||
ContentType='application/json',
|
|
||||||
CacheControl='private, max-age=0, no-transform'
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@ -1,497 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Upload firmware, manifest, and data assets to a MinIO (S3-compatible) bucket.
|
|
||||||
|
|
||||||
Features preserved from original GCS script:
|
|
||||||
- Optional backup (copies existing objects under destination prefix to timestamped folder under backups/)
|
|
||||||
- Upload firmware.bin, update.json, and recursively mirror a data directory
|
|
||||||
- Cache-Control set to disable caching on clients
|
|
||||||
|
|
||||||
Switches from google.cloud.storage to boto3 (S3 API) for MinIO compatibility.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
|
||||||
import boto3
|
|
||||||
from botocore.exceptions import ClientError
|
|
||||||
from botocore.config import Config
|
|
||||||
except ImportError:
|
|
||||||
print("ERROR: boto3 is required. Install with: pip install boto3")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CONFIGURATION CONSTANTS (edit as needed or supply via environment variables)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
CREATE_BACKUP = False
|
|
||||||
UPLOAD_FIRMWARE = True
|
|
||||||
UPDATE_MANIFEST = True
|
|
||||||
UPLOAD_DATA = True
|
|
||||||
|
|
||||||
DIR_SKIP_LIST = [
|
|
||||||
"data/system/**/*",
|
|
||||||
"data/booths/**/*"
|
|
||||||
]
|
|
||||||
|
|
||||||
FILES_SKIP_LIST = [
|
|
||||||
# Add base filenames to skip regardless of directory, e.g. "readme.txt"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Bucket / endpoint configuration
|
|
||||||
BUCKET_NAME = os.getenv('MINIO_BUCKET', 'boothifier')
|
|
||||||
DESTINATION_DIR = os.getenv('MINIO_DEST_PREFIX', 'latest') # prefix inside bucket
|
|
||||||
BACKUPS_DIR = os.getenv('MINIO_BACKUPS_PREFIX', 'backups')
|
|
||||||
|
|
||||||
PROJECT_ROOT_PATH = Path(__file__).parent.parent.resolve()
|
|
||||||
LOCAL_ROOT_PATH = Path(__file__).parent.resolve()
|
|
||||||
|
|
||||||
# Optional service account style JSON key (generated by MinIO Console). Expected fields:
|
|
||||||
# {"url":"https://minio.example.com/api/v1/service-account-credentials","accessKey":"...","secretKey":"...","api":"s3v4","path":"auto"}
|
|
||||||
MINIO_KEY_FILE = LOCAL_ROOT_PATH / 'minio-boothifier-key.json'
|
|
||||||
|
|
||||||
# Defaults before loading file / env
|
|
||||||
_json_access = None
|
|
||||||
_json_secret = None
|
|
||||||
_json_url = None
|
|
||||||
|
|
||||||
def _load_json_key():
|
|
||||||
global _json_access, _json_secret, _json_url
|
|
||||||
try:
|
|
||||||
if MINIO_KEY_FILE.is_file():
|
|
||||||
with open(MINIO_KEY_FILE, 'r', encoding='utf-8') as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
_json_access = data.get('accessKey') or None
|
|
||||||
_json_secret = data.get('secretKey') or None
|
|
||||||
_json_url = data.get('url') or None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WARN: Failed to load MinIO key file '{MINIO_KEY_FILE.name}': {e}")
|
|
||||||
|
|
||||||
_load_json_key()
|
|
||||||
|
|
||||||
def _derive_endpoint(url_value: str) -> str:
|
|
||||||
if not url_value:
|
|
||||||
return 'https://s3-minio.boothwizard.com'
|
|
||||||
# Remove known API suffix if present (/api/...)
|
|
||||||
# e.g. https://s3-minio.boothwizard.com/api/v1/service-account-credentials -> https://s3-minio.boothwizard.com
|
|
||||||
parts = url_value.split('/api/')
|
|
||||||
return parts[0] if parts else url_value
|
|
||||||
|
|
||||||
# MinIO credentials with precedence: ENV > JSON file > fallback
|
|
||||||
MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT') or _derive_endpoint(_json_url)
|
|
||||||
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY') or _json_access or 'CHANGE_ME_ACCESS'
|
|
||||||
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY') or _json_secret or 'CHANGE_ME_SECRET'
|
|
||||||
MINIO_REGION = os.getenv('MINIO_REGION', 'us-east-1') # MinIO ignores but boto3 wants some value
|
|
||||||
|
|
||||||
# Addressing / SSL options
|
|
||||||
MINIO_ADDRESSING = os.getenv('MINIO_ADDRESSING_STYLE', 'path').lower() # 'path' or 'virtual'
|
|
||||||
MINIO_VERIFY_SSL = os.getenv('MINIO_TLS_VERIFY', '1') not in ('0','false','no')
|
|
||||||
MINIO_DEBUG = os.getenv('MINIO_DEBUG', '0') in ('1','true','yes')
|
|
||||||
MINIO_ALLOW_VARIANTS = os.getenv('MINIO_ALLOW_ENDPOINT_VARIANTS', '0') in ('1','true','yes') # normally false with nginx redirect
|
|
||||||
|
|
||||||
LOCAL_FIRMWARE_PATH = str(PROJECT_ROOT_PATH / '.pio' / 'build' / 'esp32s3dev' / 'firmware.bin')
|
|
||||||
LOCAL_DATA_DIRECTORY = str(PROJECT_ROOT_PATH / 'data')
|
|
||||||
MANIFEST_LOCAL_PATH = str(LOCAL_ROOT_PATH / 'manifest-local.json') # source of version/description/changelog
|
|
||||||
MANIFEST_FILENAME = os.getenv('MANIFEST_FILENAME', 'manifest.json') # destination manifest name
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HELPERS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def s3_client():
|
|
||||||
"""Create an S3 client pointed at MinIO endpoint, forcing path-style unless overridden, with short timeouts."""
|
|
||||||
addressing = 'path' if MINIO_ADDRESSING not in ('virtual','auto') else 'virtual'
|
|
||||||
cfg = Config(
|
|
||||||
s3={'addressing_style': addressing},
|
|
||||||
signature_version='s3v4',
|
|
||||||
connect_timeout=3,
|
|
||||||
read_timeout=5,
|
|
||||||
retries={'max_attempts': 2}
|
|
||||||
)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
masked_key = (MINIO_ACCESS_KEY[:3] + '...' + MINIO_ACCESS_KEY[-3:]) if MINIO_ACCESS_KEY else 'None'
|
|
||||||
print(f"[DEBUG] Creating client: endpoint={MINIO_ENDPOINT} addressing={addressing} verifySSL={MINIO_VERIFY_SSL} region={MINIO_REGION} accessKey={masked_key}")
|
|
||||||
return boto3.client(
|
|
||||||
's3',
|
|
||||||
endpoint_url=MINIO_ENDPOINT,
|
|
||||||
aws_access_key_id=MINIO_ACCESS_KEY,
|
|
||||||
aws_secret_access_key=MINIO_SECRET_KEY,
|
|
||||||
region_name=MINIO_REGION,
|
|
||||||
verify=MINIO_VERIFY_SSL,
|
|
||||||
config=cfg,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _endpoint_variants(base: str):
|
|
||||||
"""Return endpoint variants only if explicitly allowed; otherwise just the base (nginx handles forwarding)."""
|
|
||||||
if not MINIO_ALLOW_VARIANTS:
|
|
||||||
return [base]
|
|
||||||
# Fallback to previous expanded logic if variants are enabled
|
|
||||||
try:
|
|
||||||
variants = []
|
|
||||||
if not base:
|
|
||||||
return variants
|
|
||||||
base = base.rstrip('/')
|
|
||||||
proto_sep = '://'
|
|
||||||
if proto_sep in base:
|
|
||||||
scheme, rest = base.split(proto_sep,1)
|
|
||||||
else:
|
|
||||||
scheme, rest = 'https', base
|
|
||||||
host_port = rest
|
|
||||||
if ':' in host_port:
|
|
||||||
host, port = host_port.split(':',1)
|
|
||||||
else:
|
|
||||||
host, port = host_port, ''
|
|
||||||
variants.append(f"{scheme}://{host_port}")
|
|
||||||
common_ports = ['9000','443','80']
|
|
||||||
for p in common_ports:
|
|
||||||
if port != p:
|
|
||||||
variants.append(f"{scheme}://{host}:{p}")
|
|
||||||
alt_scheme = 'http' if scheme == 'https' else 'https'
|
|
||||||
variants.append(f"{alt_scheme}://{host_port}")
|
|
||||||
for p in common_ports:
|
|
||||||
if port != p:
|
|
||||||
variants.append(f"{alt_scheme}://{host}:{p}")
|
|
||||||
seen = set()
|
|
||||||
uniq = []
|
|
||||||
for v in variants:
|
|
||||||
if v not in seen:
|
|
||||||
uniq.append(v)
|
|
||||||
seen.add(v)
|
|
||||||
return uniq
|
|
||||||
except Exception:
|
|
||||||
return [base]
|
|
||||||
|
|
||||||
def create_validated_client():
|
|
||||||
"""Validate (or create) client using only provided endpoint unless variants enabled."""
|
|
||||||
global MINIO_ENDPOINT
|
|
||||||
primary = MINIO_ENDPOINT
|
|
||||||
variants = _endpoint_variants(primary) or [primary]
|
|
||||||
errors = []
|
|
||||||
probe_bucket = BUCKET_NAME # we will head the target bucket directly
|
|
||||||
for candidate in variants:
|
|
||||||
saved = MINIO_ENDPOINT
|
|
||||||
MINIO_ENDPOINT = candidate
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Probing endpoint candidate: {candidate}")
|
|
||||||
try:
|
|
||||||
c = s3_client()
|
|
||||||
try:
|
|
||||||
c.head_bucket(Bucket=probe_bucket)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] head_bucket succeeded on {candidate} for '{probe_bucket}'.")
|
|
||||||
return c
|
|
||||||
except ClientError as e:
|
|
||||||
msg = str(e)
|
|
||||||
# Acceptable if bucket not found (we can create later)
|
|
||||||
if any(code in msg for code in ('404', 'NoSuchBucket', 'NotFound')):
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Bucket not found on {candidate} (expected if first deploy). Using this endpoint.")
|
|
||||||
return c
|
|
||||||
if 'API Requests must be made to API port' in msg:
|
|
||||||
errors.append(f"{candidate}: wrong port (console endpoint)")
|
|
||||||
else:
|
|
||||||
errors.append(f"{candidate}: {msg}")
|
|
||||||
MINIO_ENDPOINT = saved
|
|
||||||
except Exception as ex:
|
|
||||||
errors.append(f"{candidate}: {ex}")
|
|
||||||
MINIO_ENDPOINT = saved
|
|
||||||
continue
|
|
||||||
print("ERROR: Could not validate any endpoint candidate.")
|
|
||||||
for e in errors:
|
|
||||||
print(' - ' + e)
|
|
||||||
print("Provide correct API endpoint (e.g. https://host:9000) via MINIO_ENDPOINT env var.")
|
|
||||||
sys.exit(3)
|
|
||||||
|
|
||||||
def list_objects(client, prefix: str):
|
|
||||||
"""Generator yielding object keys under a prefix (non-recursive listing with pagination)."""
|
|
||||||
kwargs = {'Bucket': BUCKET_NAME, 'Prefix': prefix}
|
|
||||||
while True:
|
|
||||||
resp = client.list_objects_v2(**kwargs)
|
|
||||||
for obj in resp.get('Contents', []):
|
|
||||||
yield obj['Key']
|
|
||||||
if not resp.get('IsTruncated'):
|
|
||||||
break
|
|
||||||
kwargs['ContinuationToken'] = resp['NextContinuationToken']
|
|
||||||
|
|
||||||
def normalize_prefix(p: str) -> str:
|
|
||||||
p = p.strip('/')
|
|
||||||
return p
|
|
||||||
|
|
||||||
def join_key(*parts: str) -> str:
|
|
||||||
parts_clean = [p.strip('/') for p in parts if p is not None and p != '']
|
|
||||||
return '/'.join(parts_clean)
|
|
||||||
|
|
||||||
def backup_existing_files(client, destination_prefix: str, backups_prefix: str, backup_folder: str):
|
|
||||||
if not destination_prefix:
|
|
||||||
prefix = ''
|
|
||||||
else:
|
|
||||||
prefix = destination_prefix + '/'
|
|
||||||
print(f"Scanning existing objects under '{prefix}' for backup...")
|
|
||||||
for key in list_objects(client, prefix):
|
|
||||||
if backups_prefix and key.startswith(backups_prefix + '/'): # Skip prior backups
|
|
||||||
continue
|
|
||||||
# relative path within destination
|
|
||||||
relative = key[len(prefix):] if prefix and key.startswith(prefix) else key
|
|
||||||
backup_key = join_key(backups_prefix, backup_folder, relative)
|
|
||||||
print(f"Backup copy: {key} -> {backup_key}")
|
|
||||||
client.copy_object(
|
|
||||||
Bucket=BUCKET_NAME,
|
|
||||||
CopySource={'Bucket': BUCKET_NAME, 'Key': key},
|
|
||||||
Key=backup_key,
|
|
||||||
MetadataDirective='COPY'
|
|
||||||
)
|
|
||||||
|
|
||||||
def upload_file(client, local_path: str, key: str, cache_control: str = 'private, max-age=0, no-transform'):
|
|
||||||
if not os.path.isfile(local_path):
|
|
||||||
print(f"WARN: File missing, skipping: {local_path}")
|
|
||||||
return
|
|
||||||
print(f"Upload: {local_path} -> s3://{BUCKET_NAME}/{key}")
|
|
||||||
extra_args = { 'CacheControl': cache_control }
|
|
||||||
client.upload_file(local_path, BUCKET_NAME, key, ExtraArgs=extra_args)
|
|
||||||
|
|
||||||
def upload_directory(client, local_directory: str, destination_prefix: str):
|
|
||||||
if not os.path.isdir(local_directory):
|
|
||||||
print(f"WARN: Data directory missing: {local_directory}")
|
|
||||||
return
|
|
||||||
skip_dirs = set(DIR_SKIP_LIST)
|
|
||||||
skip_files = set(FILES_SKIP_LIST)
|
|
||||||
for root, dirs, files in os.walk(local_directory):
|
|
||||||
rel_dir = os.path.relpath(root, local_directory).replace('\\','/')
|
|
||||||
if rel_dir == '.':
|
|
||||||
rel_dir = ''
|
|
||||||
# Prune directories in-place if their TOP-LEVEL relative segment matches skip list
|
|
||||||
pruned = []
|
|
||||||
for d in list(dirs):
|
|
||||||
seg = d # immediate subdir name
|
|
||||||
if seg in skip_dirs:
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Skipping directory subtree: {os.path.join(root,d)}")
|
|
||||||
dirs.remove(d)
|
|
||||||
pruned.append(d)
|
|
||||||
for fname in files:
|
|
||||||
if fname in skip_files:
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Skipping file by name: {os.path.join(root,fname)}")
|
|
||||||
continue
|
|
||||||
full = os.path.join(root, fname)
|
|
||||||
rel = os.path.relpath(full, local_directory).replace('\\','/') # force forward slashes for S3 keys
|
|
||||||
# If top-level directory of this file is in skip list, skip (covers deeper nested finds if any slipped through)
|
|
||||||
top_level = rel.split('/',1)[0]
|
|
||||||
if top_level in skip_dirs:
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Skipping file in skipped dir: {rel}")
|
|
||||||
continue
|
|
||||||
key = join_key(destination_prefix, rel)
|
|
||||||
upload_file(client, full, key)
|
|
||||||
|
|
||||||
def ensure_bucket(client):
|
|
||||||
"""Ensure bucket exists; provide diagnostics if HeadBucket returns 400/other errors."""
|
|
||||||
try:
|
|
||||||
client.head_bucket(Bucket=BUCKET_NAME)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] Bucket '{BUCKET_NAME}' exists.")
|
|
||||||
return
|
|
||||||
except ClientError as e:
|
|
||||||
code = e.response.get('Error', {}).get('Code')
|
|
||||||
status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode')
|
|
||||||
print(f"HeadBucket failed (code={code}, status={status}).")
|
|
||||||
|
|
||||||
# List buckets for diagnostics
|
|
||||||
try:
|
|
||||||
resp = client.list_buckets()
|
|
||||||
bucket_names = [b['Name'] for b in resp.get('Buckets', [])]
|
|
||||||
print(f"Available buckets: {bucket_names or 'None'}")
|
|
||||||
except Exception as le:
|
|
||||||
print(f"WARN: list_buckets failed: {le}")
|
|
||||||
|
|
||||||
if code in ('404', 'NoSuchBucket', 'NotFound'):
|
|
||||||
print(f"Bucket '{BUCKET_NAME}' not found. Attempting to create...")
|
|
||||||
try:
|
|
||||||
client.create_bucket(Bucket=BUCKET_NAME)
|
|
||||||
print(f"Created bucket '{BUCKET_NAME}'.")
|
|
||||||
return
|
|
||||||
except ClientError as ce:
|
|
||||||
print(f"ERROR: Cannot create bucket: {ce}")
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
if status == 400:
|
|
||||||
print("HINTS: \n - Verify endpoint URL (MINIO_ENDPOINT).\n - Ensure no trailing slash in endpoint.\n - Check that TLS verify matches server cert (set MINIO_TLS_VERIFY=0 to test).\n - Confirm bucket name is correct and DNS compatible.\n - Credentials may lack permission: verify access key policies.")
|
|
||||||
|
|
||||||
# Retry once forcing path style if not already
|
|
||||||
if MINIO_ADDRESSING != 'path':
|
|
||||||
print("Retrying with path-style addressing...")
|
|
||||||
os.environ['MINIO_ADDRESSING_STYLE'] = 'path'
|
|
||||||
new_client = s3_client()
|
|
||||||
try:
|
|
||||||
new_client.head_bucket(Bucket=BUCKET_NAME)
|
|
||||||
print("Second attempt succeeded with path-style addressing.")
|
|
||||||
return
|
|
||||||
except ClientError as e2:
|
|
||||||
print(f"Second HeadBucket attempt failed: {e2}")
|
|
||||||
print(f"ERROR: head_bucket ultimately failed: {e}")
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# MAIN
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def main():
|
|
||||||
dest_prefix = normalize_prefix(DESTINATION_DIR)
|
|
||||||
backups_prefix = normalize_prefix(BACKUPS_DIR) if BACKUPS_DIR else ''
|
|
||||||
client = create_validated_client()
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print("[DEBUG] Starting ensure_bucket phase...")
|
|
||||||
ensure_bucket(client)
|
|
||||||
|
|
||||||
# Create backup if needed
|
|
||||||
if CREATE_BACKUP:
|
|
||||||
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
backup_folder = f"backup_{ts}"
|
|
||||||
print(f"Creating backup under '{backups_prefix}/{backup_folder}' from prefix '{dest_prefix}'")
|
|
||||||
backup_existing_files(client, dest_prefix, backups_prefix, backup_folder)
|
|
||||||
print("Backup complete.")
|
|
||||||
else:
|
|
||||||
print("Backup creation skipped.")
|
|
||||||
|
|
||||||
# Firmware
|
|
||||||
if UPLOAD_FIRMWARE:
|
|
||||||
firmware_key = join_key(dest_prefix, 'firmware.bin') if dest_prefix else 'firmware.bin'
|
|
||||||
upload_file(client, LOCAL_FIRMWARE_PATH, firmware_key)
|
|
||||||
print("Firmware upload complete.")
|
|
||||||
else:
|
|
||||||
print("Firmware upload skipped.")
|
|
||||||
|
|
||||||
# Data directory
|
|
||||||
if UPLOAD_DATA:
|
|
||||||
data_prefix = join_key(dest_prefix, 'data') if dest_prefix else 'data'
|
|
||||||
upload_directory(client, LOCAL_DATA_DIRECTORY, data_prefix)
|
|
||||||
print("All uploads complete.")
|
|
||||||
else:
|
|
||||||
print("Data upload skipped.")
|
|
||||||
|
|
||||||
# Manifest
|
|
||||||
if UPDATE_MANIFEST:
|
|
||||||
manifest_key = join_key(dest_prefix, MANIFEST_FILENAME) if dest_prefix else MANIFEST_FILENAME
|
|
||||||
try:
|
|
||||||
manifest_doc = build_and_write_manifest(client, dest_prefix)
|
|
||||||
upload_manifest_json(client, manifest_doc, manifest_key)
|
|
||||||
print(f"Manifest upload complete: s3://{BUCKET_NAME}/{manifest_key}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Manifest generation/upload failed: {e}")
|
|
||||||
sys.exit(4)
|
|
||||||
else:
|
|
||||||
print("Manifest upload skipped.")
|
|
||||||
|
|
||||||
# ================= Manifest Support =================
|
|
||||||
|
|
||||||
def md5_hex(path: str, chunk_size: int = 65536) -> str:
|
|
||||||
h = hashlib.md5()
|
|
||||||
with open(path, 'rb') as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
def collect_data_files(data_root: str):
|
|
||||||
files = []
|
|
||||||
if not os.path.isdir(data_root):
|
|
||||||
return files
|
|
||||||
skip_dirs = set(DIR_SKIP_LIST)
|
|
||||||
skip_files = set(FILES_SKIP_LIST)
|
|
||||||
for root, dirs, filenames in os.walk(data_root):
|
|
||||||
# Prune dirs
|
|
||||||
for d in list(dirs):
|
|
||||||
if d in skip_dirs:
|
|
||||||
dirs.remove(d)
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] (manifest) Pruned dir: {os.path.join(root,d)}")
|
|
||||||
for fname in filenames:
|
|
||||||
if fname in skip_files:
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] (manifest) Skipped file: {os.path.join(root,fname)}")
|
|
||||||
continue
|
|
||||||
full = os.path.join(root, fname)
|
|
||||||
rel = os.path.relpath(full, data_root).replace('\\','/')
|
|
||||||
top_level = rel.split('/',1)[0]
|
|
||||||
if top_level in skip_dirs:
|
|
||||||
if MINIO_DEBUG:
|
|
||||||
print(f"[DEBUG] (manifest) Skipped by top-level dir: {rel}")
|
|
||||||
continue
|
|
||||||
entry = {
|
|
||||||
'path': f"data/{rel}",
|
|
||||||
'md5': md5_hex(full),
|
|
||||||
'size': os.path.getsize(full)
|
|
||||||
}
|
|
||||||
files.append(entry)
|
|
||||||
files.sort(key=lambda x: x['path'])
|
|
||||||
return files
|
|
||||||
|
|
||||||
def read_local_manifest(local_path: str):
|
|
||||||
if not os.path.isfile(local_path):
|
|
||||||
raise FileNotFoundError(f"manifest-local file not found: {local_path}")
|
|
||||||
with open(local_path, 'r', encoding='utf-8') as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
# Basic validation
|
|
||||||
if 'version' not in data:
|
|
||||||
raise ValueError('manifest-local missing version section')
|
|
||||||
ver = data['version']
|
|
||||||
for k in ('major','minor','patch'):
|
|
||||||
if k not in ver:
|
|
||||||
raise ValueError(f"manifest-local version missing '{k}'")
|
|
||||||
data.setdefault('description', '')
|
|
||||||
data.setdefault('changelog', [])
|
|
||||||
if not isinstance(data['changelog'], list):
|
|
||||||
raise ValueError('changelog must be an array')
|
|
||||||
return data
|
|
||||||
|
|
||||||
def build_and_write_manifest(client, dest_prefix: str):
|
|
||||||
# Read local manifest-local.json
|
|
||||||
base_info = read_local_manifest(MANIFEST_LOCAL_PATH)
|
|
||||||
# Timestamp
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
release_date = now.strftime('%Y-%m-%d')
|
|
||||||
release_time = now.strftime('%H:%M:%S')
|
|
||||||
# Firmware info
|
|
||||||
fw_path_local = LOCAL_FIRMWARE_PATH
|
|
||||||
if not os.path.isfile(fw_path_local):
|
|
||||||
raise FileNotFoundError(f"Firmware file not found: {fw_path_local}")
|
|
||||||
fw_md5 = md5_hex(fw_path_local)
|
|
||||||
fw_size = os.path.getsize(fw_path_local)
|
|
||||||
# Data files
|
|
||||||
data_files = collect_data_files(LOCAL_DATA_DIRECTORY)
|
|
||||||
manifest = {
|
|
||||||
'version': {
|
|
||||||
'major': int(base_info['version']['major']),
|
|
||||||
'minor': int(base_info['version']['minor']),
|
|
||||||
'patch': int(base_info['version']['patch'])
|
|
||||||
},
|
|
||||||
'release_date': release_date,
|
|
||||||
'release_time': release_time,
|
|
||||||
'description': base_info.get('description',''),
|
|
||||||
'changelog': base_info.get('changelog', []),
|
|
||||||
'firmware': {
|
|
||||||
'path': 'firmware.bin',
|
|
||||||
'md5': fw_md5,
|
|
||||||
'size': fw_size
|
|
||||||
},
|
|
||||||
'files': data_files
|
|
||||||
}
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def upload_manifest_json(client, manifest_obj: dict, key: str):
|
|
||||||
body = json.dumps(manifest_obj, indent=4).encode('utf-8')
|
|
||||||
client.put_object(
|
|
||||||
Bucket=BUCKET_NAME,
|
|
||||||
Key=key,
|
|
||||||
Body=body,
|
|
||||||
ContentType='application/json',
|
|
||||||
CacheControl='private, max-age=0, no-transform'
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@ -34,8 +34,8 @@ UPDATE_MANIFEST = True
|
|||||||
UPLOAD_DATA = True
|
UPLOAD_DATA = True
|
||||||
|
|
||||||
DIR_SKIP_LIST = [
|
DIR_SKIP_LIST = [
|
||||||
"data/system/**/*",
|
"system", # Just directory names, not paths
|
||||||
"data/booths/**/*"
|
"booths"
|
||||||
]
|
]
|
||||||
|
|
||||||
FILES_SKIP_LIST = [
|
FILES_SKIP_LIST = [
|
||||||
|
|||||||
@ -10,17 +10,20 @@
|
|||||||
|
|
||||||
//#define DEFAULT_MANIFEST_URL "https://storage.googleapis.com/boothifier/latest/"
|
//#define DEFAULT_MANIFEST_URL "https://storage.googleapis.com/boothifier/latest/"
|
||||||
#define DEFAULT_MANIFEST_URL "https://minio.boothwizard.com/boothifier/latest/"
|
#define DEFAULT_MANIFEST_URL "https://minio.boothwizard.com/boothifier/latest/"
|
||||||
#define BUFFER_SIZE 4096
|
#define BUFFER_SIZE 2048 // Reduced from 4096 to use less memory
|
||||||
// Maximum allowed manifest size (bytes) to protect memory
|
// Maximum allowed manifest size (bytes) to protect memory
|
||||||
#define MAX_MANIFEST_SIZE (64 * 1024)
|
#define MAX_MANIFEST_SIZE (64 * 1024)
|
||||||
|
|
||||||
// Number of HTTP retry attempts for transient failures
|
// Number of HTTP retry attempts for transient failures
|
||||||
#define HTTP_RETRY_COUNT 3
|
#define HTTP_RETRY_COUNT 5 // Increased from 3
|
||||||
#define HTTP_RETRY_DELAY_MS 500
|
#define HTTP_RETRY_DELAY_MS 1000 // Increased from 500
|
||||||
|
|
||||||
// Allow external cancellation
|
// Allow external cancellation
|
||||||
extern volatile bool g_UpdateCancelFlag;
|
extern volatile bool g_UpdateCancelFlag;
|
||||||
|
|
||||||
|
// Global update mode setting
|
||||||
|
extern UpdateMode g_UpdateMode;
|
||||||
|
|
||||||
extern TaskHandle_t Update_Task_Handle;
|
extern TaskHandle_t Update_Task_Handle;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,6 +35,15 @@ extern TaskHandle_t Update_Task_Handle;
|
|||||||
|
|
||||||
extern Version otaVersion;
|
extern Version otaVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Update mode enumeration
|
||||||
|
*/
|
||||||
|
enum class UpdateMode {
|
||||||
|
UPDATE_FILES_ONLY, ///< Update files only, skip firmware
|
||||||
|
UPDATE_FIRMWARE_ONLY, ///< Update firmware only, skip files
|
||||||
|
UPDATE_BOTH ///< Update both files and firmware (default)
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief File information structure
|
* @brief File information structure
|
||||||
*/
|
*/
|
||||||
@ -90,6 +102,16 @@ class AppUpdater {
|
|||||||
*/
|
*/
|
||||||
const String& getBaseUrl() const { return baseUrl; }
|
const String& getBaseUrl() const { return baseUrl; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set update mode (files only, firmware only, or both)
|
||||||
|
*/
|
||||||
|
void setUpdateMode(UpdateMode mode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get current update mode
|
||||||
|
*/
|
||||||
|
UpdateMode getUpdateMode() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Set progress callback function
|
* @brief Set progress callback function
|
||||||
* @param callback Function to call with progress updates
|
* @param callback Function to call with progress updates
|
||||||
@ -119,12 +141,25 @@ class AppUpdater {
|
|||||||
*/
|
*/
|
||||||
bool updateFile(const char* remotePath, const char* localPath, const char* expectedMd5);
|
bool updateFile(const char* remotePath, const char* localPath, const char* expectedMd5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Results from checkManifest
|
||||||
|
*/
|
||||||
|
enum class ManifestCheckResult {
|
||||||
|
ERROR_FETCH_FAILED, ///< Failed to fetch manifest
|
||||||
|
ERROR_TOO_LARGE, ///< Manifest too large
|
||||||
|
ERROR_PARSE_FAILED, ///< Failed to parse manifest JSON
|
||||||
|
ERROR_NO_FILES_SECTION, ///< No files section in manifest
|
||||||
|
ERROR_NO_VERSION, ///< No version section in manifest
|
||||||
|
VERSION_CURRENT, ///< Current version is same or newer
|
||||||
|
UPDATE_AVAILABLE ///< New version available
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get manifest content
|
* @brief Get manifest content
|
||||||
* @param manifestPath Path to manifest file
|
* @param manifestPath Path to manifest file
|
||||||
* @return Manifest content as a json document
|
* @return Result indicating success, failure, or version status
|
||||||
*/
|
*/
|
||||||
bool checkManifest(void);
|
ManifestCheckResult checkManifest(void);
|
||||||
|
|
||||||
bool updateApp(void);
|
bool updateApp(void);
|
||||||
|
|
||||||
@ -137,6 +172,7 @@ class AppUpdater {
|
|||||||
UpdateStatus status;
|
UpdateStatus status;
|
||||||
std::unique_ptr<uint8_t[]> downloadBuffer;
|
std::unique_ptr<uint8_t[]> downloadBuffer;
|
||||||
bool updateAvailable = false;
|
bool updateAvailable = false;
|
||||||
|
UpdateMode updateMode = UpdateMode::UPDATE_BOTH; // Default to updating both files and firmware
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -149,7 +185,7 @@ class AppUpdater {
|
|||||||
* @return true if successful
|
* @return true if successful
|
||||||
*/
|
*/
|
||||||
bool verifyAndSaveFile(WiFiClient* stream, size_t contentLength,
|
bool verifyAndSaveFile(WiFiClient* stream, size_t contentLength,
|
||||||
const char* localPath, const char* expectedMd5);
|
const char* localPath, const char* remotePath, const char* expectedMd5);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Update progress callback
|
* @brief Update progress callback
|
||||||
@ -199,3 +235,10 @@ void handleUpdateProgress(AsyncWebServerRequest *request);
|
|||||||
void startVersionCheckTask();
|
void startVersionCheckTask();
|
||||||
|
|
||||||
void versionCheckTask(void* parameter);
|
void versionCheckTask(void* parameter);
|
||||||
|
|
||||||
|
// Convenience functions for setting update mode
|
||||||
|
void setGlobalUpdateMode(UpdateMode mode);
|
||||||
|
UpdateMode getGlobalUpdateMode();
|
||||||
|
void setUpdateModeFilesOnly();
|
||||||
|
void setUpdateModeFirmwareOnly();
|
||||||
|
void setUpdateModeBoth();
|
||||||
@ -27,11 +27,33 @@ private:
|
|||||||
#define CONCATENATE(x, y) CONCATENATE_DETAIL(x, y)
|
#define CONCATENATE(x, y) CONCATENATE_DETAIL(x, y)
|
||||||
#define UNIQUE_NAME(base) CONCATENATE(base, __LINE__)
|
#define UNIQUE_NAME(base) CONCATENATE(base, __LINE__)
|
||||||
|
|
||||||
// Macro for ON_EVERY_N_MILLISECONDS
|
// Macro for ON_EVERY_N_MILLISECONDS (constant N via template)
|
||||||
#define ON_EVERY_N_MILLISECONDS(N) \
|
#define ON_EVERY_N_MILLISECONDS(N) \
|
||||||
static OnEveryN<N> UNIQUE_NAME(__on_everyN_); \
|
static OnEveryN<N> UNIQUE_NAME(__on_everyN_); \
|
||||||
if (UNIQUE_NAME(__on_everyN_).ready())
|
if (UNIQUE_NAME(__on_everyN_).ready())
|
||||||
|
|
||||||
|
// Runtime-configurable variant (interval can be a variable/expression)
|
||||||
|
class OnEveryMsVariable {
|
||||||
|
public:
|
||||||
|
OnEveryMsVariable() : lastTime(0) {}
|
||||||
|
bool ready(unsigned long interval) {
|
||||||
|
if (interval == 0) return false; // ignore 0 to avoid busy looping
|
||||||
|
unsigned long now = millis();
|
||||||
|
if (now - lastTime >= interval) {
|
||||||
|
lastTime = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
unsigned long lastTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Macro for variable interval in milliseconds
|
||||||
|
#define ON_EVERY_MILLISECONDS(VAR_INTERVAL) \
|
||||||
|
static OnEveryVariable UNIQUE_NAME(__on_everyVar_); \
|
||||||
|
if (UNIQUE_NAME(__on_everyVar_).ready(VAR_INTERVAL))
|
||||||
|
|
||||||
// Macro for ON_EVERY_N_SECONDS
|
// Macro for ON_EVERY_N_SECONDS
|
||||||
#define ON_EVERY_N_SECONDS(N) ON_EVERY_N_MILLISECONDS((N) * 1000)
|
#define ON_EVERY_N_SECONDS(N) ON_EVERY_N_MILLISECONDS((N) * 1000)
|
||||||
|
|
||||||
|
|||||||
326
include/RtttlPlayer.h
Normal file
326
include/RtttlPlayer.h
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/queue.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
|
||||||
|
// Tiny, efficient RTTTL player for ESP32 LEDC (non-blocking, priority-aware)
|
||||||
|
class RtttlPlayer {
|
||||||
|
public:
|
||||||
|
// pin: GPIO to output tone, channel: LEDC channel [0..15]
|
||||||
|
// timer: LEDC timer index [0..3], resolutionBits typically 10-13
|
||||||
|
RtttlPlayer(uint8_t pin, uint8_t channel, uint8_t timer=0, uint8_t resolutionBits=10,
|
||||||
|
uint16_t queueDepth=4)
|
||||||
|
: _pin(pin), _ch(channel), _timer(timer), _resBits(resolutionBits) {
|
||||||
|
|
||||||
|
// LEDC init
|
||||||
|
ledcSetup(_ch, /*freq*/ 1000, _resBits);
|
||||||
|
ledcAttachPin(_pin, _ch);
|
||||||
|
ledcWrite(_ch, 0); // duty 0 initially
|
||||||
|
|
||||||
|
// Sync primitives
|
||||||
|
_queue = xQueueCreate(queueDepth, sizeof(PlayItem));
|
||||||
|
_mtx = xSemaphoreCreateMutex();
|
||||||
|
_stopFlag = false;
|
||||||
|
_playing = false;
|
||||||
|
_curPrio = 0;
|
||||||
|
_preempt.item = nullptr; // Initialize preempt slot
|
||||||
|
|
||||||
|
// Playback task
|
||||||
|
xTaskCreatePinnedToCore(_taskThunk, "rtttl_task", 2048, this, /*prio*/ 1, &_taskHandle, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
~RtttlPlayer() {
|
||||||
|
if (_taskHandle) vTaskDelete(_taskHandle);
|
||||||
|
if (_queue) vQueueDelete(_queue);
|
||||||
|
if (_mtx) vSemaphoreDelete(_mtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-blocking request to play a tune at 'priority'.
|
||||||
|
// Copies the RTTTL string internally (heap); returns false if queue full or alloc fails.
|
||||||
|
bool play(const char* rtttl, uint8_t priority) {
|
||||||
|
if (!rtttl) return false;
|
||||||
|
|
||||||
|
const size_t len = strnlen(rtttl, 512); // Reduce memory cap for efficiency
|
||||||
|
char* buf = (char*)malloc(len + 1);
|
||||||
|
if (!buf) return false;
|
||||||
|
memcpy(buf, rtttl, len);
|
||||||
|
buf[len] = '\0';
|
||||||
|
|
||||||
|
PlayItem item{buf, priority};
|
||||||
|
|
||||||
|
// Fast path: preempt if strictly higher priority than current
|
||||||
|
xSemaphoreTake(_mtx, portMAX_DELAY);
|
||||||
|
const bool shouldPreempt = _playing && (priority > _curPrio);
|
||||||
|
if (shouldPreempt) {
|
||||||
|
// Put new item into the front by sending to a small high-prio queue slot
|
||||||
|
// Approach: set preempt slot; playback loop will pick it ASAP.
|
||||||
|
if (_preempt.item) {
|
||||||
|
// Drop older preempt request to avoid leaks, keep newest
|
||||||
|
free((void*)_preempt.item->tune);
|
||||||
|
delete _preempt.item;
|
||||||
|
}
|
||||||
|
_preempt.item = new PlayItem(item); // copy
|
||||||
|
xSemaphoreGive(_mtx);
|
||||||
|
// we own original 'buf' no longer (copied into new PlayItem); free the temporary
|
||||||
|
free(buf);
|
||||||
|
// Signal stop to current note so task can switch between notes (cheap, cooperative)
|
||||||
|
_stopFlag = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(_mtx);
|
||||||
|
|
||||||
|
// Otherwise enqueue and return (will play after current/earlier)
|
||||||
|
if (xQueueSend(_queue, &item, 0) == pdTRUE) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
free(buf);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop playback immediately and flush queue (non-blocking).
|
||||||
|
void stopAll() {
|
||||||
|
xSemaphoreTake(_mtx, portMAX_DELAY);
|
||||||
|
_stopFlag = true;
|
||||||
|
// Drain queue
|
||||||
|
PlayItem tmp;
|
||||||
|
while (xQueueReceive(_queue, &tmp, 0) == pdTRUE) {
|
||||||
|
if (tmp.tune) free((void*)tmp.tune);
|
||||||
|
}
|
||||||
|
// Drop any pending preempt
|
||||||
|
if (_preempt.item) {
|
||||||
|
free((void*)_preempt.item->tune);
|
||||||
|
delete _preempt.item;
|
||||||
|
_preempt.item = nullptr;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(_mtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct PlayItem {
|
||||||
|
const char* tune; // heap-allocated copy
|
||||||
|
uint8_t priority;
|
||||||
|
};
|
||||||
|
struct PreemptSlot {
|
||||||
|
PlayItem* item = nullptr; // one-slot "front of line"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== LEDC helpers =====
|
||||||
|
inline void toneOn(uint32_t freq) {
|
||||||
|
if (freq == 0) { // pause
|
||||||
|
ledcWrite(_ch, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Update LEDC frequency efficiently
|
||||||
|
// Use ledcSetup to set frequency (works on all Arduino-ESP32 versions)
|
||||||
|
ledcSetup(_ch, freq, _resBits);
|
||||||
|
// Duty: ~50% for square-like tone
|
||||||
|
const uint32_t dutyMax = (1U << _resBits) - 1U;
|
||||||
|
ledcWrite(_ch, dutyMax / 2);
|
||||||
|
}
|
||||||
|
inline void toneOff() {
|
||||||
|
ledcWrite(_ch, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Playback task =====
|
||||||
|
static void _taskThunk(void* arg) {
|
||||||
|
((RtttlPlayer*)arg)->_taskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _taskLoop() {
|
||||||
|
for (;;) {
|
||||||
|
// First check preempt slot, then queue
|
||||||
|
PlayItem item{};
|
||||||
|
if (_takePreempt(item) || xQueueReceive(_queue, &item, portMAX_DELAY) == pdTRUE) {
|
||||||
|
_playing = true;
|
||||||
|
_curPrio = item.priority;
|
||||||
|
_stopFlag = false;
|
||||||
|
_playOne(item.tune);
|
||||||
|
// cleanup
|
||||||
|
free((void*)item.tune);
|
||||||
|
_playing = false;
|
||||||
|
_curPrio = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _takePreempt(PlayItem& out) {
|
||||||
|
bool got = false;
|
||||||
|
xSemaphoreTake(_mtx, portMAX_DELAY);
|
||||||
|
if (_preempt.item) {
|
||||||
|
out = *_preempt.item;
|
||||||
|
delete _preempt.item;
|
||||||
|
_preempt.item = nullptr;
|
||||||
|
got = true;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(_mtx);
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RTTTL parsing & playback =====
|
||||||
|
struct Defaults { uint16_t dur = 4; uint8_t oct = 5; uint16_t bpm = 63; };
|
||||||
|
|
||||||
|
// Note frequencies for octave 4 (rounded). Others are scaled by powers of two.
|
||||||
|
// Index by semitone: C, C#, D, D#, E, F, F#, G, G#, A, A#, B
|
||||||
|
static constexpr uint16_t baseA4 = 440;
|
||||||
|
// We’ll derive semitone freq using integer math: f = 440 * 2^((n)/12)
|
||||||
|
// To avoid floating point in the loop, we precompute a small LUT for octave 4.
|
||||||
|
static constexpr uint16_t LUT4[12] = {
|
||||||
|
262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494
|
||||||
|
};
|
||||||
|
|
||||||
|
static uint32_t freqFor(uint8_t noteIndex, int8_t octave) {
|
||||||
|
// noteIndex 0..11; octave typical 3..7
|
||||||
|
// Get octave 4 freq and scale by 2^(oct-4)
|
||||||
|
uint32_t f = LUT4[noteIndex];
|
||||||
|
if (octave > 4) f <<= (octave - 4);
|
||||||
|
else if (octave < 4) f >>= (4 - octave);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns next token start or nullptr when finished
|
||||||
|
static const char* skipSpaces(const char* p) {
|
||||||
|
while (p && *p && (*p == ' ' || *p == ',')) ++p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _playOne(const char* rtttl) {
|
||||||
|
if (!rtttl) return;
|
||||||
|
Defaults def;
|
||||||
|
// Format: name:d=4,o=5,b=120: note[,note...]
|
||||||
|
const char* p = strchr(rtttl, ':');
|
||||||
|
if (!p) return;
|
||||||
|
const char* p2 = strchr(p + 1, ':');
|
||||||
|
if (!p2) return;
|
||||||
|
|
||||||
|
// Parse defaults between first and second ':'
|
||||||
|
parseDefaults(p + 1, p2, def);
|
||||||
|
|
||||||
|
// Whole note duration in ms
|
||||||
|
uint32_t wholenote = (60000UL / def.bpm) * 4;
|
||||||
|
|
||||||
|
// Notes section
|
||||||
|
p = p2 + 1;
|
||||||
|
p = skipSpaces(p);
|
||||||
|
while (p && *p) {
|
||||||
|
// Cooperative preemption between notes
|
||||||
|
if (_checkPreempt()) break;
|
||||||
|
|
||||||
|
uint16_t duration = 0;
|
||||||
|
// 1) Optional duration number (e.g., 16)
|
||||||
|
while (*p >= '0' && *p <= '9') {
|
||||||
|
duration = duration * 10 + (*p - '0');
|
||||||
|
++p;
|
||||||
|
}
|
||||||
|
if (duration == 0) duration = def.dur;
|
||||||
|
|
||||||
|
// 2) Note letter or pause
|
||||||
|
bool pause = false;
|
||||||
|
uint8_t noteIndex = 0xFF;
|
||||||
|
switch (tolower(*p)) {
|
||||||
|
case 'c': noteIndex = 0; break;
|
||||||
|
case 'd': noteIndex = 2; break;
|
||||||
|
case 'e': noteIndex = 4; break;
|
||||||
|
case 'f': noteIndex = 5; break;
|
||||||
|
case 'g': noteIndex = 7; break;
|
||||||
|
case 'a': noteIndex = 9; break;
|
||||||
|
case 'b': noteIndex = 11; break;
|
||||||
|
case 'p': pause = true; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (*p) ++p;
|
||||||
|
|
||||||
|
// 3) Optional sharp '#'
|
||||||
|
if (!pause && *p == '#') {
|
||||||
|
if (noteIndex != 0xFF) noteIndex++;
|
||||||
|
++p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Optional dotted note '.'
|
||||||
|
bool dotted = false;
|
||||||
|
if (*p == '.') { dotted = true; ++p; }
|
||||||
|
|
||||||
|
// 5) Optional octave number
|
||||||
|
int8_t octave = def.oct;
|
||||||
|
if (*p >= '4' && *p <= '7') { octave = (*p - '0'); ++p; }
|
||||||
|
|
||||||
|
// Compute duration in ms
|
||||||
|
uint32_t noteDur = wholenote / duration;
|
||||||
|
if (dotted) noteDur += noteDur / 2;
|
||||||
|
|
||||||
|
// Play the note
|
||||||
|
if (pause || noteIndex == 0xFF) {
|
||||||
|
toneOff();
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(noteDur));
|
||||||
|
} else {
|
||||||
|
const uint32_t f = freqFor(noteIndex % 12, octave);
|
||||||
|
toneOn(f);
|
||||||
|
// Shorten a little to add a tiny gap (staccato for clarity & queue responsiveness)
|
||||||
|
uint32_t onMs = (noteDur >= 4) ? (noteDur - 2) : noteDur;
|
||||||
|
uint32_t offMs = noteDur - onMs;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(onMs));
|
||||||
|
toneOff();
|
||||||
|
if (offMs) vTaskDelay(pdMS_TO_TICKS(offMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
p = skipSpaces(p);
|
||||||
|
// Optional trailing comma already handled by skipSpaces
|
||||||
|
}
|
||||||
|
|
||||||
|
toneOff();
|
||||||
|
}
|
||||||
|
|
||||||
|
void parseDefaults(const char* beg, const char* end, Defaults& def) {
|
||||||
|
const char* p = beg;
|
||||||
|
while (p < end && *p) {
|
||||||
|
// key=value (d,o,b)
|
||||||
|
char key = tolower(*p);
|
||||||
|
const char* eq = (const char*)memchr(p, '=', end - p);
|
||||||
|
if (!eq) break;
|
||||||
|
const char* val = eq + 1;
|
||||||
|
const char* nxt = (const char*)memchr(val, ',', end - val);
|
||||||
|
if (!nxt) nxt = end;
|
||||||
|
|
||||||
|
uint32_t v = 0;
|
||||||
|
for (const char* t = val; t < nxt; ++t) {
|
||||||
|
if (*t >= '0' && *t <= '9') v = v * 10 + (*t - '0');
|
||||||
|
}
|
||||||
|
if (key == 'd' && v) def.dur = v;
|
||||||
|
else if (key == 'o' && v) def.oct = (uint8_t)v;
|
||||||
|
else if (key == 'b' && v) def.bpm = v;
|
||||||
|
|
||||||
|
p = (nxt < end) ? (nxt + 1) : end;
|
||||||
|
}
|
||||||
|
// Clamp sane ranges
|
||||||
|
if (def.dur == 0) def.dur = 4;
|
||||||
|
if (def.oct < 3 || def.oct > 7) def.oct = 5;
|
||||||
|
if (def.bpm < 20) def.bpm = 20;
|
||||||
|
if (def.bpm > 400) def.bpm = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _checkPreempt() {
|
||||||
|
if (!_stopFlag) return false;
|
||||||
|
// Grab preempt immediately if present, otherwise just stop current tune
|
||||||
|
PlayItem nxt{};
|
||||||
|
if (_takePreempt(nxt)) {
|
||||||
|
// Play preempted tune immediately (no recursion - direct call)
|
||||||
|
_curPrio = nxt.priority;
|
||||||
|
_stopFlag = false;
|
||||||
|
_playOne(nxt.tune);
|
||||||
|
free((void*)nxt.tune);
|
||||||
|
}
|
||||||
|
// Indicate current tune should finish early
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== members =====
|
||||||
|
uint8_t _pin, _ch, _timer, _resBits;
|
||||||
|
TaskHandle_t _taskHandle = nullptr;
|
||||||
|
QueueHandle_t _queue = nullptr;
|
||||||
|
SemaphoreHandle_t _mtx = nullptr;
|
||||||
|
volatile bool _stopFlag;
|
||||||
|
volatile bool _playing;
|
||||||
|
volatile uint8_t _curPrio;
|
||||||
|
PreemptSlot _preempt;
|
||||||
|
};
|
||||||
@ -21,21 +21,19 @@ typedef enum {
|
|||||||
|
|
||||||
//Tunes
|
//Tunes
|
||||||
typedef struct {
|
typedef struct {
|
||||||
bool enabled;
|
|
||||||
int cycles;
|
int cycles;
|
||||||
int pause;
|
int pause;
|
||||||
String melody;
|
String melody;
|
||||||
}BUZZ_TUNE;
|
}BUZZ_TUNE;
|
||||||
|
|
||||||
#define TUNE_MAX_COUNT 12
|
#define TUNE_MAX_COUNT 14
|
||||||
|
|
||||||
extern BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
|
extern BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
|
||||||
|
|
||||||
void Init_Buzzer(int8_t, const char* configPath);
|
void Init_Buzzer(int8_t pin, const char* configPath, int8_t channel = -1);
|
||||||
|
|
||||||
void Buzzer_Beep(int, int freq=1000);
|
|
||||||
|
|
||||||
void Buzzer_Load_Tunes(const char* tunesPath);
|
void Buzzer_Load_Tunes(const char* tunesPath);
|
||||||
|
|
||||||
void Buzzer_Play_Tune(TUNE_TYPE, bool async=true, bool priority=true);
|
void Buzzer_Play_Tune(TUNE_TYPE tune, int priority=1);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#define FIRMWARE_VERSION_MAJOR 1
|
#define FIRMWARE_VERSION_MAJOR 1
|
||||||
#define FIRMWARE_VERSION_MINOR 4
|
#define FIRMWARE_VERSION_MINOR 4
|
||||||
#define FIRMWARE_VERSION_PATCH 9
|
#define FIRMWARE_VERSION_PATCH 19
|
||||||
|
|
||||||
|
|
||||||
#define FIRMWARE_DESCRIPTION \
|
#define FIRMWARE_DESCRIPTION \
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include "system.h"
|
#include "system.h"
|
||||||
#include "ColorPalettes.h"
|
#include "ColorPalettes.h"
|
||||||
|
#include "global.h"
|
||||||
//#include <crgb.h>
|
//#include <crgb.h>
|
||||||
|
|
||||||
#define FASTLED_CORE 0
|
#define FASTLED_CORE 0
|
||||||
@ -65,10 +66,37 @@ void Init_Lights_Task(void){
|
|||||||
Init_Strip(ledSettings[1].leds, ledSettings[1].pin, ledSettings[1].size, ledSettings[1].rgbOrder, ledSettings[1].chip, ledSettings[1].bright);
|
Init_Strip(ledSettings[1].leds, ledSettings[1].pin, ledSettings[1].size, ledSettings[1].rgbOrder, ledSettings[1].chip, ledSettings[1].bright);
|
||||||
ESP_LOGD(tag, "Initializing Strip2: Pin: %d, size: %d, order: %s, chip: %s", ledSettings[1].pin, ledSettings[1].size, ledSettings[1].rgbOrder, ledSettings[1].chip);
|
ESP_LOGD(tag, "Initializing Strip2: Pin: %d, size: %d, order: %s, chip: %s", ledSettings[1].pin, ledSettings[1].size, ledSettings[1].rgbOrder, ledSettings[1].chip);
|
||||||
|
|
||||||
xTaskCreatePinnedToCore(Lights_Control_Task, "Lights_Task", 1024*8, NULL, 1, &Animation_Task_Handle, FASTLED_CORE);
|
xTaskCreatePinnedToCore(Lights_Control_Task, "Lights_Task", 1024*6, NULL, 1, &Animation_Task_Handle, FASTLED_CORE);
|
||||||
ESP_LOGI(tag, "Lights Task Created...");
|
ESP_LOGI(tag, "Lights Task Created...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
void Init_Ramp_Lights_Task(void){
|
||||||
|
|
||||||
|
xTaskCreatePinnedToCore(Ramp_Lights_Control_Task, "Ramp_Lights_Task", 1024*1, NULL, 1, &Animation_Task_Handle, (FASTLED_CORE ? 1 : 0));
|
||||||
|
ESP_LOGI(tag, "Ramp Lights Task Created...");
|
||||||
|
}
|
||||||
|
|
||||||
|
void RampUpLights(int level)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void Ramp_Lights_Control_Task(void *parameters)
|
||||||
|
{
|
||||||
|
static *OutputPWM* pwmOut = NULL;
|
||||||
|
pwmOut = pwmOut[sys_settings.rampLightSettings[0].pwmOutIndex];
|
||||||
|
|
||||||
|
while(1){
|
||||||
|
sys_settings.rampLightSettings[rampIndex].pwmOutIndex
|
||||||
|
while()
|
||||||
|
|
||||||
|
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||||
|
vTaskSuspend(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
void Animation_Loop_Exit(void){
|
void Animation_Loop_Exit(void){
|
||||||
if( Animation_Task_Handle ){
|
if( Animation_Task_Handle ){
|
||||||
|
|||||||
@ -4,6 +4,12 @@
|
|||||||
#include <FastLED.h>
|
#include <FastLED.h>
|
||||||
#include "ColorPalettes.h"
|
#include "ColorPalettes.h"
|
||||||
#include "esp_system.h"
|
#include "esp_system.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <functional>
|
||||||
|
#include "PWM_Output.h"
|
||||||
|
|
||||||
|
|
||||||
typedef struct{
|
typedef struct{
|
||||||
@ -449,6 +455,67 @@ void Anim_Comets(bool volatile& activeFlag, CRGB* leds, int size, const COLOR_PA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
void Anim_TimedFill(bool volatile& activeFlag, CRGB* leds, int size, CRGB baseCol, CRGB fillCol, int totalDurationMs, int shift = 0, PWM_Output* pwmOutput = nullptr) {
|
||||||
|
if (!leds || size <= 1 || totalDurationMs <= 0) return;
|
||||||
|
|
||||||
|
const int halfSize = size / 2;
|
||||||
|
const float msPerLed = totalDurationMs / (float)halfSize;
|
||||||
|
unsigned long startTime = millis();
|
||||||
|
|
||||||
|
// Define the point at which PWM begins ramping (75% of fill time)
|
||||||
|
const float pwmStartPoint = 0.75f;
|
||||||
|
const int pwmStartLeds = halfSize * pwmStartPoint;
|
||||||
|
|
||||||
|
// Initialize PWM output to 0 if provided
|
||||||
|
if (pwmOutput) {
|
||||||
|
pwmOutput->setOutput(0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
fill_solid(leds, size, baseCol);
|
||||||
|
|
||||||
|
int prevLedsToLight = 0;
|
||||||
|
unsigned long currentTime;
|
||||||
|
unsigned long elapsedTime;
|
||||||
|
int ledsToLight, pos;
|
||||||
|
Animation_Loop(activeFlag, 90, [&]() -> int {
|
||||||
|
currentTime = millis();
|
||||||
|
elapsedTime = currentTime - startTime;
|
||||||
|
|
||||||
|
// Calculate how many LEDs should be lit based on elapsed time
|
||||||
|
ledsToLight = (elapsedTime / msPerLed);
|
||||||
|
if (ledsToLight > halfSize) ledsToLight = halfSize;
|
||||||
|
|
||||||
|
// Fill LEDs up to current position
|
||||||
|
for (int i = 0; i < ledsToLight; i++) {
|
||||||
|
pos = (i + shift + size) % size;
|
||||||
|
leds[pos] = fillCol;
|
||||||
|
leds[(size - 1 - i + shift + size) % size] = fillCol; // Correct mirroring calculation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle PWM output ramp starting at 75% of fill time
|
||||||
|
if (pwmOutput && ledsToLight >= pwmStartLeds) {
|
||||||
|
// Calculate PWM value as percentage of remaining fill time
|
||||||
|
// Map from [pwmStartLeds, halfSize] to [0, 100]
|
||||||
|
float pwmValue = map(ledsToLight, pwmStartLeds, halfSize, 0, 100);
|
||||||
|
// Ensure pwmValue is in range [0, 100]
|
||||||
|
pwmValue = constrain(pwmValue, 0.0f, 100.0f);
|
||||||
|
// Set the PWM output
|
||||||
|
pwmOutput->setOutput(pwmValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update LEDs only when necessary
|
||||||
|
if(prevLedsToLight < ledsToLight){
|
||||||
|
FastLED.show();
|
||||||
|
}
|
||||||
|
prevLedsToLight = ledsToLight;
|
||||||
|
|
||||||
|
// Return 1 when complete
|
||||||
|
return (ledsToLight >= halfSize) ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
void Anim_TimedFill(bool volatile& activeFlag, CRGB* leds, int size, CRGB baseCol, CRGB fillCol, int totalDurationMs, int shift = 0) {
|
void Anim_TimedFill(bool volatile& activeFlag, CRGB* leds, int size, CRGB baseCol, CRGB fillCol, int totalDurationMs, int shift = 0) {
|
||||||
if (!leds || size <= 1 || totalDurationMs <= 0) return;
|
if (!leds || size <= 1 || totalDurationMs <= 0) return;
|
||||||
|
|||||||
@ -3,39 +3,56 @@
|
|||||||
#include <MD5Builder.h>
|
#include <MD5Builder.h>
|
||||||
#include <LittleFS.h>
|
#include <LittleFS.h>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <algorithm>
|
||||||
#include "global.h"
|
#include "global.h"
|
||||||
#include "JsonConstrain.h"
|
#include "JsonConstrain.h"
|
||||||
#include "BLE_UpdateService.h"
|
#include "BLE_UpdateService.h"
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <Update.h>
|
#include <Update.h>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <esp_task_wdt.h>
|
||||||
|
#include <esp_ota_ops.h>
|
||||||
|
|
||||||
static const char* TAG = "AppUpdater";
|
static const char* TAG = "AppUpdater";
|
||||||
TaskHandle_t Update_Task_Handle = NULL;
|
TaskHandle_t Update_Task_Handle = NULL;
|
||||||
TaskHandle_t versionCheckTask_Handle = NULL;
|
TaskHandle_t versionCheckTask_Handle = NULL;
|
||||||
volatile bool g_UpdateCancelFlag = false; // cancellation flag
|
volatile bool g_UpdateCancelFlag = false; // cancellation flag
|
||||||
|
UpdateMode g_UpdateMode = UpdateMode::UPDATE_BOTH; // Default to updating both files and firmware
|
||||||
// Queue handle for firmware update messages
|
|
||||||
//QueueHandle_t updateMsgQueue = NULL;
|
|
||||||
|
|
||||||
String updateUrl = "";
|
String updateUrl = "";
|
||||||
|
|
||||||
Version otaVersion;
|
Version otaVersion;
|
||||||
|
|
||||||
|
|
||||||
AppUpdater::AppUpdater(fs::FS& fs, Version localVersion, const char* bucket, const char* manifestName, const char* appBin)
|
AppUpdater::AppUpdater(fs::FS& fs, Version localVersion, const char* bucket, const char* manifestName, const char* appBin)
|
||||||
: localVersion(localVersion), manifestName(manifestName), appName(appBin), fileSystem(fs), downloadBuffer(new uint8_t[BUFFER_SIZE])
|
: localVersion(localVersion), manifestName(manifestName), appName(appBin), fileSystem(fs)
|
||||||
{
|
{
|
||||||
|
// Use dynamic buffer size based on available memory - much more conservative now
|
||||||
|
size_t available_heap = ESP.getFreeHeap();
|
||||||
|
size_t buffer_size = std::min<size_t>(BUFFER_SIZE, available_heap / 16); // Use at most 1/16 of free heap
|
||||||
|
// Ensure minimum viable size
|
||||||
|
if (buffer_size < 1024) buffer_size = 1024; // Absolute minimum is 1KB
|
||||||
|
|
||||||
|
downloadBuffer.reset(new uint8_t[buffer_size]);
|
||||||
|
|
||||||
baseUrl = bucket ? String(bucket) : String(DEFAULT_MANIFEST_URL);
|
baseUrl = bucket ? String(bucket) : String(DEFAULT_MANIFEST_URL);
|
||||||
// Ensure baseUrl ends with a single '/'
|
// Ensure baseUrl ends with a single '/'
|
||||||
if(!baseUrl.endsWith("/")) baseUrl += "/";
|
if(!baseUrl.endsWith("/")) baseUrl += "/";
|
||||||
ESP_LOGI(TAG, "AppUpdater initialized (local v%s) baseUrl=%s", localVersion.toString().c_str(), baseUrl.c_str());
|
ESP_LOGI(TAG, "AppUpdater initialized (local v%s) baseUrl=%s, buffer=%u bytes",
|
||||||
|
localVersion.toString().c_str(), baseUrl.c_str(), buffer_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
void AppUpdater::setProgressCallback(void (*callback)( UpdateStatus status, int percentage, const char* message)) {
|
void AppUpdater::setProgressCallback(void (*callback)( UpdateStatus status, int percentage, const char* message)) {
|
||||||
progressCb = callback;
|
progressCb = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AppUpdater::setUpdateMode(UpdateMode mode) {
|
||||||
|
updateMode = mode;
|
||||||
|
g_UpdateMode = mode; // Sync with global mode for firmware update task
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateMode AppUpdater::getUpdateMode() const {
|
||||||
|
return updateMode;
|
||||||
|
}
|
||||||
|
|
||||||
void AppUpdater::updateProgress(UpdateStatus newStatus, int percentage, const char* message) {
|
void AppUpdater::updateProgress(UpdateStatus newStatus, int percentage, const char* message) {
|
||||||
status = newStatus;
|
status = newStatus;
|
||||||
if (progressCb) {
|
if (progressCb) {
|
||||||
@ -43,15 +60,17 @@ void AppUpdater::updateProgress(UpdateStatus newStatus, int percentage, const ch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AppUpdater::checkManifest() {
|
AppUpdater::ManifestCheckResult AppUpdater::checkManifest() {
|
||||||
String url = buildUrl(manifestName);
|
String url = buildUrl(manifestName);
|
||||||
ESP_LOGD(TAG, "Fetching manifest from: %s", url.c_str());
|
ESP_LOGD(TAG, "Fetching manifest from: %s", url.c_str());
|
||||||
|
|
||||||
String payload;
|
String payload;
|
||||||
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
||||||
if(g_UpdateCancelFlag) return false;
|
if(g_UpdateCancelFlag) return ManifestCheckResult::ERROR_FETCH_FAILED;
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
http.begin(url);
|
http.begin(url);
|
||||||
|
http.setTimeout(10); // 10 second timeout
|
||||||
|
http.setConnectTimeout(10000); // 10 second connect timeout
|
||||||
int httpCode = http.GET();
|
int httpCode = http.GET();
|
||||||
if (httpCode == HTTP_CODE_OK) {
|
if (httpCode == HTTP_CODE_OK) {
|
||||||
payload = http.getString();
|
payload = http.getString();
|
||||||
@ -64,12 +83,12 @@ bool AppUpdater::checkManifest() {
|
|||||||
}
|
}
|
||||||
if(payload.isEmpty()){
|
if(payload.isEmpty()){
|
||||||
ESP_LOGE(TAG, "Failed to fetch manifest after retries");
|
ESP_LOGE(TAG, "Failed to fetch manifest after retries");
|
||||||
return false;
|
return ManifestCheckResult::ERROR_FETCH_FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(payload.length() > MAX_MANIFEST_SIZE){
|
if(payload.length() > MAX_MANIFEST_SIZE){
|
||||||
ESP_LOGE(TAG, "Manifest too large (%u bytes)", (unsigned)payload.length());
|
ESP_LOGE(TAG, "Manifest too large (%u bytes)", (unsigned)payload.length());
|
||||||
return false;
|
return ManifestCheckResult::ERROR_TOO_LARGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON
|
// Parse JSON
|
||||||
@ -77,14 +96,14 @@ bool AppUpdater::checkManifest() {
|
|||||||
ESP_LOGD(TAG, "Manifest deserialized");
|
ESP_LOGD(TAG, "Manifest deserialized");
|
||||||
if (error) {
|
if (error) {
|
||||||
ESP_LOGE(TAG, "Failed to parse manifest: %s", error.c_str());
|
ESP_LOGE(TAG, "Failed to parse manifest: %s", error.c_str());
|
||||||
return false;
|
return ManifestCheckResult::ERROR_PARSE_FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for files section
|
// Check for files section
|
||||||
jsonFilesArray = jsonManifest["files"];
|
jsonFilesArray = jsonManifest["files"];
|
||||||
if (jsonFilesArray.isNull()) {
|
if (jsonFilesArray.isNull()) {
|
||||||
ESP_LOGE(TAG, "No files section in manifest");
|
ESP_LOGE(TAG, "No files section in manifest");
|
||||||
return false;
|
return ManifestCheckResult::ERROR_NO_FILES_SECTION;
|
||||||
}else{
|
}else{
|
||||||
ESP_LOGD(TAG, "%d Files found", jsonFilesArray.size());
|
ESP_LOGD(TAG, "%d Files found", jsonFilesArray.size());
|
||||||
}
|
}
|
||||||
@ -94,7 +113,7 @@ bool AppUpdater::checkManifest() {
|
|||||||
ESP_LOGD(TAG, "Version section found");
|
ESP_LOGD(TAG, "Version section found");
|
||||||
if (jsonVersion.isNull()) {
|
if (jsonVersion.isNull()) {
|
||||||
ESP_LOGE(TAG, "No version section in manifest");
|
ESP_LOGE(TAG, "No version section in manifest");
|
||||||
return false;
|
return ManifestCheckResult::ERROR_NO_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the remote version
|
// Get the remote version
|
||||||
@ -110,16 +129,18 @@ bool AppUpdater::checkManifest() {
|
|||||||
updateAvailable = false;
|
updateAvailable = false;
|
||||||
// Only mark update available if remote is strictly newer than local
|
// Only mark update available if remote is strictly newer than local
|
||||||
if (otaVersion <= localVersion) {
|
if (otaVersion <= localVersion) {
|
||||||
ESP_LOGI(TAG, "No updates available");
|
ESP_LOGI(TAG, "No updates available: remote=%s, local=%s",
|
||||||
return false;
|
otaVersion.toString().c_str(), localVersion.toString().c_str());
|
||||||
|
return ManifestCheckResult::VERSION_CURRENT;
|
||||||
}else{
|
}else{
|
||||||
updateAvailable = true;
|
updateAvailable = true;
|
||||||
ESP_LOGD(TAG, "Update available");
|
ESP_LOGI(TAG, "Update available: remote=%s, local=%s",
|
||||||
|
otaVersion.toString().c_str(), localVersion.toString().c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
//ESP_LOGD(TAG, "Manifest content: %s", payload.c_str());
|
//ESP_LOGD(TAG, "Manifest content: %s", payload.c_str());
|
||||||
|
|
||||||
return true;
|
return ManifestCheckResult::UPDATE_AVAILABLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const char* expectedMd5) {
|
bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const char* expectedMd5) {
|
||||||
@ -133,7 +154,10 @@ bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const
|
|||||||
bool skip = false;
|
bool skip = false;
|
||||||
if(fileSystem.exists(localPath)){
|
if(fileSystem.exists(localPath)){
|
||||||
String localMd5 = getLocalMD5(localPath);
|
String localMd5 = getLocalMD5(localPath);
|
||||||
|
ESP_LOGI(TAG, "Local file exists: %s, MD5: %s, Expected: %s", localPath, localMd5.c_str(), expectedMd5);
|
||||||
if(localMd5.equals(expectedMd5)) skip = true;
|
if(localMd5.equals(expectedMd5)) skip = true;
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Local file does not exist: %s", localPath);
|
||||||
}
|
}
|
||||||
if(skip){
|
if(skip){
|
||||||
ESP_LOGI(TAG, "File already up to date: %s", localPath);
|
ESP_LOGI(TAG, "File already up to date: %s", localPath);
|
||||||
@ -141,6 +165,8 @@ bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Need to download file: %s (local MD5 mismatch or file missing)", localPath);
|
||||||
|
|
||||||
// Start the download
|
// Start the download
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
int httpCode = -1;
|
int httpCode = -1;
|
||||||
@ -154,8 +180,9 @@ bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const
|
|||||||
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
|
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
|
||||||
}
|
}
|
||||||
if (httpCode != HTTP_CODE_OK) {
|
if (httpCode != HTTP_CODE_OK) {
|
||||||
ESP_LOGE(TAG, "Download failed: %d", httpCode);
|
ESP_LOGE(TAG, "Download failed for %s: HTTP code %d", localPath, httpCode);
|
||||||
updateProgress(UpdateStatus::ERROR, 0, "Download failed");
|
updateProgress(UpdateStatus::ERROR, 0, String(String("Download failed: ") + localPath).c_str());
|
||||||
|
http.end();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,10 +191,20 @@ bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const
|
|||||||
size_t contentLength = http.getSize();
|
size_t contentLength = http.getSize();
|
||||||
|
|
||||||
// Verify and save the file
|
// Verify and save the file
|
||||||
bool success = verifyAndSaveFile(stream, contentLength, localPath, expectedMd5);
|
bool success = verifyAndSaveFile(stream, contentLength, localPath, remotePath, expectedMd5);
|
||||||
http.end();
|
http.end();
|
||||||
if(!success){
|
if(!success){
|
||||||
updateProgress( UpdateStatus::ERROR, 0, "MD5 verification failed");
|
String errMsg = String(localPath) + " MD5 failed";
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, errMsg.c_str());
|
||||||
|
|
||||||
|
// For HTML/CSS/JS files, we might have saved them anyway (see verifyAndSaveFile logic)
|
||||||
|
if (fileSystem.exists(localPath) &&
|
||||||
|
(String(localPath).endsWith(".html") ||
|
||||||
|
String(localPath).endsWith(".css") ||
|
||||||
|
String(localPath).endsWith(".js"))) {
|
||||||
|
ESP_LOGW(TAG, "Using %s despite MD5 mismatch (non-critical file)", localPath);
|
||||||
|
// Return false to indicate verification failure but file is still usable
|
||||||
|
}
|
||||||
}else{
|
}else{
|
||||||
updateProgress(UpdateStatus::FILE_SAVED, 100, localPath);
|
updateProgress(UpdateStatus::FILE_SAVED, 100, localPath);
|
||||||
}
|
}
|
||||||
@ -175,19 +212,59 @@ bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AppUpdater::verifyAndSaveFile(WiFiClient* stream, size_t contentLength, const char* localPath, const char* expectedMd5)
|
bool AppUpdater::verifyAndSaveFile(WiFiClient* stream, size_t contentLength, const char* localPath, const char* remotePath, const char* expectedMd5)
|
||||||
{
|
{
|
||||||
|
const int MAX_RETRIES = 2; // Maximum number of retries for MD5 failure
|
||||||
|
|
||||||
|
for (int retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||||
|
if (retry > 0) {
|
||||||
|
ESP_LOGW(TAG, "Retrying download of %s (attempt %d/%d)", localPath, retry, MAX_RETRIES);
|
||||||
|
// Need to re-fetch the file for retry - use the REMOTE path, not local path
|
||||||
|
String url = buildUrl(remotePath);
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(url);
|
||||||
|
int httpCode = http.GET();
|
||||||
|
if (httpCode != HTTP_CODE_OK) {
|
||||||
|
ESP_LOGE(TAG, "Retry download failed: %d", httpCode);
|
||||||
|
http.end();
|
||||||
|
continue; // Try next retry if available
|
||||||
|
}
|
||||||
|
stream = http.getStreamPtr();
|
||||||
|
contentLength = http.getSize();
|
||||||
|
}
|
||||||
|
|
||||||
MD5Builder md5;
|
MD5Builder md5;
|
||||||
md5.begin();
|
md5.begin();
|
||||||
size_t totalRead = 0;
|
size_t totalRead = 0;
|
||||||
|
|
||||||
// Create temporary filename
|
// Create temporary filename - try root directory first to avoid path issues
|
||||||
String tempPath = String(localPath) + ".tmp";
|
String baseName = String(localPath);
|
||||||
|
baseName.replace("/", "_");
|
||||||
|
baseName.replace("\\", "_");
|
||||||
|
String tempPath = "/temp_" + baseName + ".download";
|
||||||
|
|
||||||
// Open temporary file for writing
|
// Clean up any existing temp file first
|
||||||
File file = fileSystem.open(tempPath.c_str(), FILE_WRITE);
|
if (fileSystem.exists(tempPath.c_str())) {
|
||||||
|
ESP_LOGW(TAG, "Removing existing temp file: %s", tempPath.c_str());
|
||||||
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Using temp file path: %s for target: %s", tempPath.c_str(), localPath);
|
||||||
|
|
||||||
|
// Open temporary file for writing (LittleFS will create directories automatically)
|
||||||
|
File file = fileSystem.open(tempPath.c_str(), FILE_WRITE, true); // true = create if not exists
|
||||||
if (!file) {
|
if (!file) {
|
||||||
ESP_LOGE(TAG, "Failed to open temporary file for writing");
|
ESP_LOGE(TAG, "Failed to open temporary file for writing: %s", tempPath.c_str());
|
||||||
|
|
||||||
|
// Try to diagnose the issue
|
||||||
|
ESP_LOGE(TAG, "LittleFS info - Used: %u bytes, Total: %u bytes",
|
||||||
|
LittleFS.usedBytes(), LittleFS.totalBytes());
|
||||||
|
|
||||||
|
// Check if we're out of space
|
||||||
|
if (LittleFS.usedBytes() >= LittleFS.totalBytes() * 0.95) {
|
||||||
|
ESP_LOGE(TAG, "LittleFS nearly full - may not have space for temp file");
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,20 +324,99 @@ bool AppUpdater::verifyAndSaveFile(WiFiClient* stream, size_t contentLength, con
|
|||||||
|
|
||||||
// Verify MD5 hash
|
// Verify MD5 hash
|
||||||
updateProgress(UpdateStatus::VERIFYING, 90, localPath);
|
updateProgress(UpdateStatus::VERIFYING, 90, localPath);
|
||||||
if (!calculatedMd5.equals(expectedMd5)) {
|
|
||||||
//ESP_LOGE(TAG, "MD5 mismatch for %s", localPath);
|
ESP_LOGI(TAG, "MD5 verification for %s: Expected='%s', Calculated='%s'",
|
||||||
|
localPath, expectedMd5, calculatedMd5.c_str());
|
||||||
|
|
||||||
|
// Compare MD5 case-insensitively (in case there are case differences)
|
||||||
|
String expectedMd5Lower = String(expectedMd5);
|
||||||
|
expectedMd5Lower.toLowerCase();
|
||||||
|
String calculatedMd5Lower = calculatedMd5;
|
||||||
|
calculatedMd5Lower.toLowerCase();
|
||||||
|
|
||||||
|
if (!calculatedMd5Lower.equals(expectedMd5Lower)) {
|
||||||
|
ESP_LOGE(TAG, "MD5 mismatch for %s (attempt %d/%d). Expected: %s, Got: %s",
|
||||||
|
localPath, retry+1, MAX_RETRIES+1, expectedMd5, calculatedMd5.c_str());
|
||||||
|
ESP_LOGE(TAG, "Length comparison - Expected: %d chars, Got: %d chars",
|
||||||
|
strlen(expectedMd5), calculatedMd5.length());
|
||||||
fileSystem.remove(tempPath.c_str());
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
|
||||||
|
if (retry < MAX_RETRIES) {
|
||||||
|
// Will retry in next loop iteration
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case for certain file types - allow them to be used even with MD5 mismatch
|
||||||
|
// This is a fallback option for non-critical files like HTML pages
|
||||||
|
bool isNonCriticalFile = false;
|
||||||
|
if (String(localPath).endsWith(".html") ||
|
||||||
|
String(localPath).endsWith(".css") ||
|
||||||
|
String(localPath).endsWith(".js")) {
|
||||||
|
isNonCriticalFile = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNonCriticalFile) {
|
||||||
|
ESP_LOGW(TAG, "Using file %s despite MD5 mismatch (non-critical file)", localPath);
|
||||||
|
// We'll still keep this file but report it as a verification failure
|
||||||
|
// Ensure target directory exists before rename
|
||||||
|
String dirPath = String(localPath);
|
||||||
|
int lastSlash = dirPath.lastIndexOf('/');
|
||||||
|
if (lastSlash > 0) {
|
||||||
|
dirPath = dirPath.substring(0, lastSlash);
|
||||||
|
if (!fileSystem.exists(dirPath.c_str())) {
|
||||||
|
ESP_LOGI(TAG, "Creating target directory: %s", dirPath.c_str());
|
||||||
|
String dummyFile = dirPath + "/.dummy";
|
||||||
|
File dummy = fileSystem.open(dummyFile.c_str(), FILE_WRITE, true);
|
||||||
|
if (dummy) {
|
||||||
|
dummy.print("temp");
|
||||||
|
dummy.close();
|
||||||
|
fileSystem.remove(dummyFile.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename the temp file to the final location
|
||||||
|
if (fileSystem.exists(localPath)) {
|
||||||
|
fileSystem.remove(localPath);
|
||||||
|
}
|
||||||
|
if (!fileSystem.rename(tempPath.c_str(), localPath)) {
|
||||||
|
ESP_LOGE(TAG, "Failed to rename temporary file for non-critical use: %s -> %s",
|
||||||
|
tempPath.c_str(), localPath);
|
||||||
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Return false to indicate verification failure, but the file will still be used
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress(UpdateStatus::VERIFYING, 95, localPath);
|
updateProgress(UpdateStatus::VERIFYING, 95, localPath);
|
||||||
|
|
||||||
|
// Ensure target directory exists before rename
|
||||||
|
String dirPath = String(localPath);
|
||||||
|
int lastSlash = dirPath.lastIndexOf('/');
|
||||||
|
if (lastSlash > 0) {
|
||||||
|
dirPath = dirPath.substring(0, lastSlash);
|
||||||
|
if (!fileSystem.exists(dirPath.c_str())) {
|
||||||
|
ESP_LOGI(TAG, "Creating target directory: %s", dirPath.c_str());
|
||||||
|
String dummyFile = dirPath + "/.dummy";
|
||||||
|
File dummy = fileSystem.open(dummyFile.c_str(), FILE_WRITE, true);
|
||||||
|
if (dummy) {
|
||||||
|
dummy.print("temp");
|
||||||
|
dummy.close();
|
||||||
|
fileSystem.remove(dummyFile.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Replace original file with verified temp file
|
// Replace original file with verified temp file
|
||||||
if (fileSystem.exists(localPath)) {
|
if (fileSystem.exists(localPath)) {
|
||||||
fileSystem.remove(localPath);
|
fileSystem.remove(localPath);
|
||||||
}
|
}
|
||||||
if (!fileSystem.rename(tempPath.c_str(), localPath)) {
|
if (!fileSystem.rename(tempPath.c_str(), localPath)) {
|
||||||
ESP_LOGE(TAG, "Failed to rename temporary file");
|
ESP_LOGE(TAG, "Failed to rename temporary file: %s -> %s", tempPath.c_str(), localPath);
|
||||||
fileSystem.remove(tempPath.c_str());
|
fileSystem.remove(tempPath.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -269,6 +425,9 @@ bool AppUpdater::verifyAndSaveFile(WiFiClient* stream, size_t contentLength, con
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false; // All retries failed
|
||||||
|
}
|
||||||
|
|
||||||
String AppUpdater::getLocalMD5(const char* filePath){
|
String AppUpdater::getLocalMD5(const char* filePath){
|
||||||
File file = fileSystem.open(filePath, "r");
|
File file = fileSystem.open(filePath, "r");
|
||||||
if(!file){
|
if(!file){
|
||||||
@ -295,6 +454,7 @@ String AppUpdater::getLocalMD5(const char* filePath){
|
|||||||
bool AppUpdater::updateFilesArray() {
|
bool AppUpdater::updateFilesArray() {
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
int totalFiles = jsonFilesArray.size();
|
int totalFiles = jsonFilesArray.size();
|
||||||
|
int failedCount = 0;
|
||||||
ESP_LOGI(TAG, "Found %d files in manifest", totalFiles);
|
ESP_LOGI(TAG, "Found %d files in manifest", totalFiles);
|
||||||
|
|
||||||
// Iterate over each file entry in the manifest
|
// Iterate over each file entry in the manifest
|
||||||
@ -320,11 +480,19 @@ bool AppUpdater::updateFilesArray() {
|
|||||||
// Attempt to update the file
|
// Attempt to update the file
|
||||||
if (updateFile(remotePath, localPath, expectedMd5)) {
|
if (updateFile(remotePath, localPath, expectedMd5)) {
|
||||||
successCount++;
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
ESP_LOGE(TAG, "Failed to update file: %s", localPath);
|
||||||
|
// Continue with remaining files instead of stopping
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Manifest update complete: %d/%d files updated", successCount, totalFiles);
|
ESP_LOGI(TAG, "Manifest update complete: %d/%d files updated, %d failed",
|
||||||
return successCount == totalFiles;
|
successCount, totalFiles, failedCount);
|
||||||
|
|
||||||
|
// Consider the update successful if most files updated correctly
|
||||||
|
// Allow up to 20% of files to fail before considering the entire update failed
|
||||||
|
return (failedCount <= totalFiles * 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AppUpdater::updateApp() {
|
bool AppUpdater::updateApp() {
|
||||||
@ -341,17 +509,53 @@ bool AppUpdater::updateApp() {
|
|||||||
const char* expectedMd5 = jsonManifest["firmware"]["md5"];
|
const char* expectedMd5 = jsonManifest["firmware"]["md5"];
|
||||||
String firmwareUrl = buildUrl(appName);
|
String firmwareUrl = buildUrl(appName);
|
||||||
|
|
||||||
// Download the firmware
|
// First, try a HEAD request to verify server connectivity and file availability
|
||||||
|
HTTPClient httpHead;
|
||||||
|
bool fileExists = false;
|
||||||
|
httpHead.begin(firmwareUrl);
|
||||||
|
httpHead.setTimeout(10);
|
||||||
|
int headCode = httpHead.sendRequest("HEAD");
|
||||||
|
if (headCode == HTTP_CODE_OK) {
|
||||||
|
ESP_LOGI(TAG, "Firmware file exists on server, size: %d bytes", httpHead.getSize());
|
||||||
|
fileExists = true;
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "HEAD request failed: %d, proceeding with direct download attempt", headCode);
|
||||||
|
}
|
||||||
|
httpHead.end();
|
||||||
|
|
||||||
|
// Download the firmware with progressive timeouts
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
int httpCode = -1;
|
int httpCode = -1;
|
||||||
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
||||||
if(g_UpdateCancelFlag) return false;
|
if(g_UpdateCancelFlag) return false;
|
||||||
|
|
||||||
|
// Progressive timeout increase with each attempt
|
||||||
|
int timeout = 30 + (attempt * 15); // Start with 30s, add 15s each retry (30, 45, 60, 75, 90)
|
||||||
|
int connectTimeout = 15000 + (attempt * 5000); // Start with 15s, add 5s each retry
|
||||||
|
|
||||||
http.begin(firmwareUrl);
|
http.begin(firmwareUrl);
|
||||||
|
http.setTimeout(timeout);
|
||||||
|
http.setConnectTimeout(connectTimeout);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Downloading firmware from %s (attempt %d/%d, timeout=%ds)",
|
||||||
|
firmwareUrl.c_str(), attempt+1, HTTP_RETRY_COUNT, timeout);
|
||||||
|
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, 0,
|
||||||
|
String("Firmware download attempt " + String(attempt+1) + "/" +
|
||||||
|
String(HTTP_RETRY_COUNT)).c_str());
|
||||||
|
|
||||||
httpCode = http.GET();
|
httpCode = http.GET();
|
||||||
if(httpCode == HTTP_CODE_OK) break;
|
if(httpCode == HTTP_CODE_OK) break;
|
||||||
|
|
||||||
ESP_LOGW(TAG, "Firmware GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
|
ESP_LOGW(TAG, "Firmware GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
|
||||||
http.end();
|
http.end();
|
||||||
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
|
|
||||||
|
// Exponential backoff - start with HTTP_RETRY_DELAY_MS and double each time
|
||||||
|
int delayMs = HTTP_RETRY_DELAY_MS * (1 << attempt); // 1000, 2000, 4000, 8000, 16000 ms
|
||||||
|
if(attempt+1 < HTTP_RETRY_COUNT) {
|
||||||
|
ESP_LOGI(TAG, "Waiting %d ms before next attempt", delayMs);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(delayMs));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (httpCode != HTTP_CODE_OK) {
|
if (httpCode != HTTP_CODE_OK) {
|
||||||
ESP_LOGE(TAG, "Firmware download failed: %d", httpCode);
|
ESP_LOGE(TAG, "Firmware download failed: %d", httpCode);
|
||||||
@ -361,6 +565,46 @@ bool AppUpdater::updateApp() {
|
|||||||
|
|
||||||
// Check available space
|
// Check available space
|
||||||
size_t firmwareSize = http.getSize();
|
size_t firmwareSize = http.getSize();
|
||||||
|
|
||||||
|
// Pre-validation checks
|
||||||
|
if (firmwareSize < 1024*100) { // Sanity check - 100KB minimum expected
|
||||||
|
ESP_LOGE(TAG, "Firmware size too small (%d bytes), possible corruption", firmwareSize);
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Invalid firmware size");
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify OTA partitions
|
||||||
|
const esp_partition_t* running = esp_ota_get_running_partition();
|
||||||
|
const esp_partition_t* update = esp_ota_get_next_update_partition(NULL);
|
||||||
|
if (!running || !update) {
|
||||||
|
ESP_LOGE(TAG, "Failed to get partitions");
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Failed to get partitions");
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Running partition: %s, update target: %s",
|
||||||
|
running->label, update->label);
|
||||||
|
|
||||||
|
// Check for sufficient free memory - much more lenient now with chunked downloads
|
||||||
|
size_t freeHeap = ESP.getFreeHeap();
|
||||||
|
size_t minRequiredHeap = 40 * 1024; // Only require 40KB minimum
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Free heap: %d bytes, firmware size: %d bytes", freeHeap, firmwareSize);
|
||||||
|
|
||||||
|
if (freeHeap < minRequiredHeap) {
|
||||||
|
ESP_LOGE(TAG, "Critically low memory: %d bytes (minimum required: %d bytes)",
|
||||||
|
freeHeap, minRequiredHeap);
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Critically low memory");
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can roll back (just for logging, not a blocker)
|
||||||
|
if (!Update.canRollBack()) {
|
||||||
|
ESP_LOGW(TAG, "No valid firmware to roll back to");
|
||||||
|
}
|
||||||
|
|
||||||
if (!Update.begin(firmwareSize > 0 ? firmwareSize : UPDATE_SIZE_UNKNOWN)) {
|
if (!Update.begin(firmwareSize > 0 ? firmwareSize : UPDATE_SIZE_UNKNOWN)) {
|
||||||
ESP_LOGE(TAG, "Firmware: Not enough space for update");
|
ESP_LOGE(TAG, "Firmware: Not enough space for update");
|
||||||
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Not enough space for update");
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Not enough space for update");
|
||||||
@ -372,26 +616,79 @@ bool AppUpdater::updateApp() {
|
|||||||
MD5Builder md5;
|
MD5Builder md5;
|
||||||
md5.begin();
|
md5.begin();
|
||||||
|
|
||||||
// Download and verify firmware
|
// Prepare for download with improved resilience
|
||||||
WiFiClient* stream = http.getStreamPtr();
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
|
unsigned long lastProgressTime = millis(); // Track time since last progress
|
||||||
|
unsigned long connectStartTime = millis(); // Track total download time
|
||||||
|
unsigned long lastWatchdogKick = millis(); // Track watchdog kicks
|
||||||
|
|
||||||
|
// Calculate smaller buffer size based on heap
|
||||||
|
size_t bufferSize = std::min(size_t(4096), ESP.getFreeHeap() / 16); // Use at most 1/16 of free heap, max 4KB
|
||||||
|
if (bufferSize < 1024) bufferSize = 1024; // Minimum 1KB
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Using download buffer size: %u bytes (free heap: %u bytes)",
|
||||||
|
bufferSize, ESP.getFreeHeap());
|
||||||
|
|
||||||
|
// Create a buffer just for this operation if needed
|
||||||
|
std::unique_ptr<uint8_t[]> localBuffer;
|
||||||
|
uint8_t* downloadBuf = nullptr;
|
||||||
|
|
||||||
|
if (bufferSize <= BUFFER_SIZE) {
|
||||||
|
// Use the existing downloadBuffer
|
||||||
|
downloadBuf = downloadBuffer.get();
|
||||||
|
} else {
|
||||||
|
// Create a larger buffer for this specific download
|
||||||
|
localBuffer.reset(new uint8_t[bufferSize]);
|
||||||
|
downloadBuf = localBuffer.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, 0, "Firmware download started");
|
||||||
|
|
||||||
|
// Download and verify firmware
|
||||||
if (firmwareSize > 0) {
|
if (firmwareSize > 0) {
|
||||||
size_t remaining = firmwareSize;
|
size_t remaining = firmwareSize;
|
||||||
while (remaining > 0) {
|
size_t totalReceived = 0;
|
||||||
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
|
size_t failedReads = 0; // Count consecutive read failures
|
||||||
size_t chunk = std::min(remaining, size_t(BUFFER_SIZE));
|
const size_t MAX_FAILED_READS = 10; // Allow up to 10 consecutive failures
|
||||||
size_t read = stream->readBytes(downloadBuffer.get(), chunk);
|
|
||||||
|
|
||||||
// Check for timeout
|
while (remaining > 0 && failedReads < MAX_FAILED_READS) {
|
||||||
if (read == 0) {
|
// Check for cancellation
|
||||||
ESP_LOGE(TAG, "Read timeout");
|
if(g_UpdateCancelFlag) {
|
||||||
|
ESP_LOGE(TAG, "Update cancelled by user");
|
||||||
Update.abort();
|
Update.abort();
|
||||||
http.end();
|
http.end();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check WiFi status every 5 seconds
|
||||||
|
if (millis() - lastWatchdogKick > 5000) {
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
ESP_LOGE(TAG, "WiFi disconnected during download");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset watchdog to prevent timeout
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
lastWatchdogKick = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate optimum chunk size to balance speed vs reliability
|
||||||
|
size_t chunk = std::min(remaining, bufferSize);
|
||||||
|
size_t available = stream->available();
|
||||||
|
|
||||||
|
if (available > 0) {
|
||||||
|
// Read what's available (up to our chunk size)
|
||||||
|
size_t read = stream->readBytes(downloadBuf, std::min(available, chunk));
|
||||||
|
|
||||||
|
if (read > 0) {
|
||||||
|
// Reset failure counter on successful read
|
||||||
|
failedReads = 0;
|
||||||
|
|
||||||
// Update MD5 and write firmware
|
// Update MD5 and write firmware
|
||||||
md5.add(downloadBuffer.get(), read);
|
md5.add(downloadBuf, read);
|
||||||
if (Update.write(downloadBuffer.get(), read) != read) {
|
if (Update.write(downloadBuf, read) != read) {
|
||||||
ESP_LOGE(TAG, "Write failed");
|
ESP_LOGE(TAG, "Write failed");
|
||||||
Update.abort();
|
Update.abort();
|
||||||
http.end();
|
http.end();
|
||||||
@ -399,22 +696,107 @@ bool AppUpdater::updateApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remaining -= read;
|
remaining -= read;
|
||||||
updateProgress(UpdateStatus::DOWNLOADING, (firmwareSize - remaining) * 100 / firmwareSize, "firmware");
|
totalReceived += read;
|
||||||
|
lastProgressTime = millis(); // Reset progress timer
|
||||||
|
|
||||||
|
// Update progress every ~2% or at least every 2 seconds
|
||||||
|
static int lastPercent = 0;
|
||||||
|
int percent = (totalReceived * 100) / firmwareSize;
|
||||||
|
if (percent != lastPercent || (millis() - lastProgressTime > 2000)) {
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, percent, "Firmware");
|
||||||
|
lastPercent = percent;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown size: stream until end
|
// Handle zero bytes read despite data being available
|
||||||
for (;;) {
|
failedReads++;
|
||||||
|
ESP_LOGW(TAG, "Read returned 0 bytes despite data available (failure %d/%d)",
|
||||||
|
failedReads, MAX_FAILED_READS);
|
||||||
|
delay(100); // Short delay before retry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data currently available
|
||||||
|
if (millis() - lastProgressTime > 60000) {
|
||||||
|
// No progress for 60 seconds - too long
|
||||||
|
ESP_LOGE(TAG, "Download timed out - no progress for 60 seconds");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send periodic progress updates to keep client informed
|
||||||
|
if (millis() - lastWatchdogKick > 5000) {
|
||||||
|
int percent = (totalReceived * 100) / firmwareSize;
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, percent,
|
||||||
|
String("Firmware: " + String(percent) + "% - waiting for data...").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(100); // Short delay to prevent CPU hogging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we failed due to too many consecutive read failures
|
||||||
|
if (failedReads >= MAX_FAILED_READS) {
|
||||||
|
ESP_LOGE(TAG, "Too many consecutive read failures");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown size: stream until end (less common case)
|
||||||
|
ESP_LOGW(TAG, "Firmware size unknown, streaming until end");
|
||||||
|
size_t totalReceived = 0;
|
||||||
|
size_t emptyReads = 0;
|
||||||
|
const size_t MAX_EMPTY_READS = 20; // Allow up to 20 empty reads before considering done
|
||||||
|
|
||||||
|
while (emptyReads < MAX_EMPTY_READS) {
|
||||||
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
|
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
|
||||||
size_t read = stream->readBytes(downloadBuffer.get(), BUFFER_SIZE);
|
|
||||||
if (read == 0) break;
|
// Reset watchdog periodically
|
||||||
md5.add(downloadBuffer.get(), read);
|
if (millis() - lastWatchdogKick > 5000) {
|
||||||
if (Update.write(downloadBuffer.get(), read) != read) {
|
esp_task_wdt_reset();
|
||||||
|
lastWatchdogKick = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t available = stream->available();
|
||||||
|
if (available > 0) {
|
||||||
|
size_t read = stream->readBytes(downloadBuf, std::min(available, bufferSize));
|
||||||
|
if (read > 0) {
|
||||||
|
emptyReads = 0; // Reset empty read counter
|
||||||
|
md5.add(downloadBuf, read);
|
||||||
|
if (Update.write(downloadBuf, read) != read) {
|
||||||
ESP_LOGE(TAG, "Write failed");
|
ESP_LOGE(TAG, "Write failed");
|
||||||
Update.abort();
|
Update.abort();
|
||||||
http.end();
|
http.end();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
updateProgress(UpdateStatus::DOWNLOADING, 0, "firmware");
|
totalReceived += read;
|
||||||
|
lastProgressTime = millis();
|
||||||
|
|
||||||
|
// Just update with received byte count since we don't know total
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, 0,
|
||||||
|
String("Firmware: " + String(totalReceived / 1024) + "KB received").c_str());
|
||||||
|
} else {
|
||||||
|
emptyReads++;
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data available
|
||||||
|
if (totalReceived > 0 && millis() - lastProgressTime > 30000) {
|
||||||
|
// If we've received data but nothing for 30s, probably done
|
||||||
|
ESP_LOGI(TAG, "No data for 30s after receiving %d bytes, assuming download complete", totalReceived);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyReads++;
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalReceived < 100*1024) { // Less than 100KB received
|
||||||
|
ESP_LOGE(TAG, "Downloaded firmware too small (%d bytes)", totalReceived);
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,8 +814,16 @@ bool AppUpdater::updateApp() {
|
|||||||
|
|
||||||
// Finish update
|
// Finish update
|
||||||
if (!Update.end()) {
|
if (!Update.end()) {
|
||||||
ESP_LOGE(TAG, "Update end failed");
|
ESP_LOGE(TAG, "Update end failed: %d", Update.getError());
|
||||||
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Update failed");
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Update failed");
|
||||||
|
|
||||||
|
// Try to roll back if possible
|
||||||
|
if (Update.hasError() && Update.canRollBack()) {
|
||||||
|
ESP_LOGI(TAG, "Rolling back to previous version");
|
||||||
|
Update.rollBack();
|
||||||
|
// Don't restart here, let the main task do it
|
||||||
|
}
|
||||||
|
|
||||||
http.end();
|
http.end();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -468,49 +858,131 @@ void startFirmwareUpdateTask(AsyncEventSource* evProg) {
|
|||||||
ESP_LOGW(TAG, "Firmware update task already running");
|
ESP_LOGW(TAG, "Firmware update task already running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
xTaskCreate(firmwareUpdateTask, "FirmwareUpdate", 1024*8, NULL, 1, &Update_Task_Handle);
|
// Create task with higher priority (3) and optimized stack size
|
||||||
|
xTaskCreate(firmwareUpdateTask, "FirmwareUpdate", 1024*6, NULL, 3, &Update_Task_Handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
void firmwareUpdateTask(void* parameter) {
|
void firmwareUpdateTask(void* parameter) {
|
||||||
static const char* TAG = "UpdateTask";
|
static const char* TAG = "UpdateTask";
|
||||||
AppUpdater* updater = nullptr;
|
|
||||||
|
// Initialize watchdog timer for the update task
|
||||||
|
esp_task_wdt_init(60, true); // 60 second timeout, panic on timeout
|
||||||
|
esp_task_wdt_add(NULL); // Add current task to watchdog
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loadUpdateJson();
|
loadUpdateJson();
|
||||||
|
|
||||||
// Initialize updater
|
esp_task_wdt_reset(); // Reset watchdog timer after JSON loading
|
||||||
updater = new AppUpdater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
|
|
||||||
|
// Initialize updater with smart pointer
|
||||||
|
std::unique_ptr<AppUpdater> updater(new AppUpdater(
|
||||||
|
LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin"));
|
||||||
updater->setProgressCallback(updateProgress);
|
updater->setProgressCallback(updateProgress);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting update check from: %s", updateUrl.c_str());
|
ESP_LOGI(TAG, "Starting update check from: %s", updateUrl.c_str());
|
||||||
|
|
||||||
// Check and perform updates
|
// Check and perform updates
|
||||||
if (!updater->checkManifest()) { throw std::runtime_error("Failed to check manifest"); }
|
auto manifestResult = updater->checkManifest();
|
||||||
|
|
||||||
|
if (manifestResult != AppUpdater::ManifestCheckResult::UPDATE_AVAILABLE) {
|
||||||
|
// Handle different error cases
|
||||||
|
std::string errorMsg;
|
||||||
|
switch (manifestResult) {
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_FETCH_FAILED:
|
||||||
|
errorMsg = "Failed to fetch manifest";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_TOO_LARGE:
|
||||||
|
errorMsg = "Manifest file too large";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_PARSE_FAILED:
|
||||||
|
errorMsg = "Failed to parse manifest";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_NO_FILES_SECTION:
|
||||||
|
errorMsg = "Manifest missing files section";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_NO_VERSION:
|
||||||
|
errorMsg = "Manifest missing version section";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::VERSION_CURRENT:
|
||||||
|
errorMsg = "Current version is up to date";
|
||||||
|
// This is not actually an error
|
||||||
|
ESP_LOGI(TAG, "No update needed: %s", errorMsg.c_str());
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::MESSAGE, 0, errorMsg.c_str());
|
||||||
|
// Don't throw, just exit gracefully
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMsg = "Unknown manifest check error";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifestResult != AppUpdater::ManifestCheckResult::VERSION_CURRENT) {
|
||||||
|
ESP_LOGE(TAG, "Manifest check failed: %s", errorMsg.c_str());
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::ERROR, 0, errorMsg.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updater->IsUpdateAvailable()) {
|
if (updater->IsUpdateAvailable()) {
|
||||||
ESP_LOGI(TAG, "Update available, updating files...");
|
bool filesUpdated = true;
|
||||||
|
bool firmwareUpdated = true;
|
||||||
|
|
||||||
if (!updater->updateFilesArray()) {
|
// Update files based on update mode
|
||||||
throw std::runtime_error("Failed to update files");
|
if (g_UpdateMode == UpdateMode::UPDATE_FILES_ONLY || g_UpdateMode == UpdateMode::UPDATE_BOTH) {
|
||||||
|
ESP_LOGI(TAG, "Update mode includes files, updating files...");
|
||||||
|
filesUpdated = updater->updateFilesArray();
|
||||||
|
if (!filesUpdated) {
|
||||||
|
ESP_LOGW(TAG, "Some files failed to update");
|
||||||
|
if (g_UpdateMode == UpdateMode::UPDATE_FILES_ONLY) {
|
||||||
|
ESP_LOGE(TAG, "Files-only update failed");
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::ERROR, 0, "Failed to update files");
|
||||||
|
// Skip to cleanup since this is files-only mode and it failed
|
||||||
|
goto cleanup;
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "File update failed, but continuing with firmware update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Skipping file updates (mode: firmware only)");
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Updating firmware...");
|
// Update firmware based on update mode
|
||||||
if (!updater->updateApp()) {
|
if (g_UpdateMode == UpdateMode::UPDATE_FIRMWARE_ONLY || g_UpdateMode == UpdateMode::UPDATE_BOTH) {
|
||||||
throw std::runtime_error("Failed to update firmware");
|
ESP_LOGI(TAG, "Update mode includes firmware, updating firmware...");
|
||||||
|
firmwareUpdated = updater->updateApp();
|
||||||
|
if (!firmwareUpdated) {
|
||||||
|
ESP_LOGE(TAG, "Failed to update firmware");
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::ERROR, 0, "Failed to update firmware");
|
||||||
|
// Skip to cleanup since firmware update failed
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Skipping firmware update (mode: files only)");
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "Update successful, restarting...");
|
|
||||||
|
|
||||||
|
// Determine if we need to restart
|
||||||
|
bool needsRestart = (g_UpdateMode == UpdateMode::UPDATE_FIRMWARE_ONLY || g_UpdateMode == UpdateMode::UPDATE_BOTH) && firmwareUpdated;
|
||||||
|
|
||||||
|
if (needsRestart) {
|
||||||
|
ESP_LOGI(TAG, "Firmware update successful, restarting...");
|
||||||
sendUpdateMessage("Restarting ", true, 100);
|
sendUpdateMessage("Restarting ", true, 100);
|
||||||
vTaskDelay(2000);
|
vTaskDelay(2000);
|
||||||
|
|
||||||
ESP.restart();
|
ESP.restart();
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Update completed successfully (no restart required)");
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::COMPLETE, 100, "Update completed successfully");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup:
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
ESP_LOGE(TAG, "Update failed: %s", e.what());
|
ESP_LOGE(TAG, "Update failed with exception: %s", e.what());
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::ERROR, 0, e.what());
|
||||||
|
} catch (...) {
|
||||||
|
ESP_LOGE(TAG, "Update failed with unknown exception");
|
||||||
|
updateProgress(AppUpdater::UpdateStatus::ERROR, 0, "Unknown error during update");
|
||||||
}
|
}
|
||||||
delete updater;
|
|
||||||
|
// Clean up watchdog before exit
|
||||||
|
esp_task_wdt_delete(NULL);
|
||||||
|
|
||||||
Update_Task_Handle = NULL;
|
Update_Task_Handle = NULL;
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
@ -520,7 +992,8 @@ void startVersionCheckTask() {
|
|||||||
ESP_LOGW(TAG, "Version Check Tak already running");
|
ESP_LOGW(TAG, "Version Check Tak already running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
xTaskCreate(versionCheckTask, "VersionCheckTask", 1024*8, NULL, 1, &versionCheckTask_Handle);
|
// Create task with higher priority (3) and optimized stack size
|
||||||
|
xTaskCreate(versionCheckTask, "VersionCheckTask", 1024*6, NULL, 3, &versionCheckTask_Handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
void versionCheckTask(void* parameter){
|
void versionCheckTask(void* parameter){
|
||||||
@ -528,12 +1001,17 @@ void versionCheckTask(void* parameter){
|
|||||||
loadUpdateJson();
|
loadUpdateJson();
|
||||||
}
|
}
|
||||||
AppUpdater updater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
|
AppUpdater updater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
|
||||||
if(!updater.checkManifest()){
|
|
||||||
ESP_LOGE(TAG, "Version check: manifest fetch failed");
|
auto manifestResult = updater.checkManifest();
|
||||||
} else {
|
|
||||||
|
if (manifestResult == AppUpdater::ManifestCheckResult::UPDATE_AVAILABLE ||
|
||||||
|
manifestResult == AppUpdater::ManifestCheckResult::VERSION_CURRENT) {
|
||||||
otaVersion = updater.otaVersion; // capture remote
|
otaVersion = updater.otaVersion; // capture remote
|
||||||
ESP_LOGI(TAG, "Version check: remote=%s", otaVersion.toString().c_str());
|
ESP_LOGI(TAG, "Version check: remote=%s", otaVersion.toString().c_str());
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(TAG, "Version check: manifest check failed with code %d", static_cast<int>(manifestResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
versionCheckTask_Handle = NULL;
|
versionCheckTask_Handle = NULL;
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
@ -593,7 +1071,7 @@ void updateProgress(AppUpdater::UpdateStatus newStatus, int percentage, const ch
|
|||||||
msg = buffer;
|
msg = buffer;
|
||||||
break;
|
break;
|
||||||
case AppUpdater::UpdateStatus::FILE_SKIPPED:
|
case AppUpdater::UpdateStatus::FILE_SKIPPED:
|
||||||
snprintf(buffer, sizeof(buffer), "%s: Skipping file update, already up to date", safeMsg);
|
snprintf(buffer, sizeof(buffer), "%s: File Skipped, up to date", safeMsg);
|
||||||
msg = buffer;
|
msg = buffer;
|
||||||
break;
|
break;
|
||||||
case AppUpdater::UpdateStatus::FILE_SAVED:
|
case AppUpdater::UpdateStatus::FILE_SAVED:
|
||||||
@ -624,7 +1102,9 @@ void updateProgress(AppUpdater::UpdateStatus newStatus, int percentage, const ch
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendUpdateMessage(const char* message, bool complete, int progress = -1) {
|
void sendUpdateMessage(const char* message, bool complete, int progress = -1) {
|
||||||
|
|
||||||
if(eventProgress && eventProgress->count() > 0) {
|
if(eventProgress && eventProgress->count() > 0) {
|
||||||
|
// This is for the web client and not the BLE client
|
||||||
JsonDocument jsonDoc;
|
JsonDocument jsonDoc;
|
||||||
jsonDoc["message"] = message;
|
jsonDoc["message"] = message;
|
||||||
jsonDoc["complete"] = complete;
|
jsonDoc["complete"] = complete;
|
||||||
@ -640,41 +1120,27 @@ void sendUpdateMessage(const char* message, bool complete, int progress = -1) {
|
|||||||
bleUpgrade_send_message(message);
|
bleUpgrade_send_message(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Removed duplicate global checkManifest; AppUpdater::checkManifest used instead)
|
// Convenience functions for setting update mode
|
||||||
|
void setGlobalUpdateMode(UpdateMode mode) {
|
||||||
/*
|
g_UpdateMode = mode;
|
||||||
void setup() {
|
ESP_LOGI(TAG, "Global update mode set to: %s",
|
||||||
Serial.begin(115200);
|
mode == UpdateMode::UPDATE_FILES_ONLY ? "UPDATE_FILES_ONLY" :
|
||||||
|
mode == UpdateMode::UPDATE_FIRMWARE_ONLY ? "UPDATE_FIRMWARE_ONLY" : "UPDATE_BOTH");
|
||||||
// Initialize WiFi connection first
|
|
||||||
// ... WiFi connection code ...
|
|
||||||
|
|
||||||
// Initialize filesystem
|
|
||||||
if(!LittleFS.begin()) {
|
|
||||||
Serial.println("LittleFS Mount Failed");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create updater instance with:
|
UpdateMode getGlobalUpdateMode() {
|
||||||
// - Current version: "1.0.0"
|
return g_UpdateMode;
|
||||||
// - Update server URL: "https://my-update-server.com/"
|
|
||||||
// - Filesystem: LittleFS
|
|
||||||
AppUpdater updater("1.0.0", "https://storage.googleapis.com/boothifier/latest/", LittleFS);
|
|
||||||
|
|
||||||
// Set progress callback
|
|
||||||
updater.setProgressCallback([](int progress) {
|
|
||||||
Serial.printf("Update progress: %d%%\n", progress);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check and update firmware
|
|
||||||
if (updater.checkAndUpdate()) {
|
|
||||||
Serial.println("Update successful! Rebooting...");
|
|
||||||
ESP.restart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update specific files from manifest
|
void setUpdateModeFilesOnly() {
|
||||||
int updatedFiles = updater.updateFilesFromManifest("test_update.json");
|
setGlobalUpdateMode(UpdateMode::UPDATE_FILES_ONLY);
|
||||||
Serial.printf("Updated %d files\n", updatedFiles);
|
}
|
||||||
|
|
||||||
|
void setUpdateModeFirmwareOnly() {
|
||||||
|
setGlobalUpdateMode(UpdateMode::UPDATE_FIRMWARE_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUpdateModeBoth() {
|
||||||
|
setGlobalUpdateMode(UpdateMode::UPDATE_BOTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
@ -4,6 +4,7 @@
|
|||||||
#include "WiFi.h"
|
#include "WiFi.h"
|
||||||
#include "ATALights.h"
|
#include "ATALights.h"
|
||||||
#include "BleSettings.h"
|
#include "BleSettings.h"
|
||||||
|
#include <esp_task_wdt.h>
|
||||||
|
|
||||||
static const char *tag = "BLE_SP110E";
|
static const char *tag = "BLE_SP110E";
|
||||||
|
|
||||||
@ -134,12 +135,23 @@ class LightStickCallbacks : public NimBLECharacteristicCallbacks {
|
|||||||
|
|
||||||
// Function to send data to all connected clients in chunks based on MTU
|
// Function to send data to all connected clients in chunks based on MTU
|
||||||
void sendToAllClients(const uint8_t* data, size_t len) {
|
void sendToAllClients(const uint8_t* data, size_t len) {
|
||||||
if (!pStickCharacteristic || !data || len == 0) return;
|
if (!pStickCharacteristic) {
|
||||||
|
ESP_LOGW(tag, "Cannot send to clients: pStickCharacteristic is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || len == 0) {
|
||||||
|
ESP_LOGW(tag, "Cannot send to clients: data is null or length is 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if no subscribed clients (if API available)
|
// Skip if no subscribed clients (if API available)
|
||||||
#if defined(NIMBLE_INCLUDED) || true
|
#if defined(NIMBLE_INCLUDED) || true
|
||||||
#ifdef CONFIG_BT_NIMBLE_ROLE_PERIPHERAL
|
#ifdef CONFIG_BT_NIMBLE_ROLE_PERIPHERAL
|
||||||
if (pStickCharacteristic->getSubscribedCount() == 0) return;
|
if (pStickCharacteristic->getSubscribedCount() == 0) {
|
||||||
|
ESP_LOGD(tag, "No clients subscribed, skipping notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ -152,9 +164,19 @@ void sendToAllClients(const uint8_t* data, size_t len) {
|
|||||||
while (offset < len) {
|
while (offset < len) {
|
||||||
size_t chunk = len - offset;
|
size_t chunk = len - offset;
|
||||||
if (chunk > maxChunk) chunk = maxChunk;
|
if (chunk > maxChunk) chunk = maxChunk;
|
||||||
|
|
||||||
|
try {
|
||||||
pStickCharacteristic->setValue(data + offset, chunk);
|
pStickCharacteristic->setValue(data + offset, chunk);
|
||||||
// notify() returns void in this NimBLE version, so just call it without checking a return value
|
// notify() returns void in this NimBLE version, but wrap in try/catch for robustness
|
||||||
pStickCharacteristic->notify();
|
pStickCharacteristic->notify();
|
||||||
|
|
||||||
|
ESP_LOGD(tag, "Sent %zu bytes to clients", chunk);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
ESP_LOGE(tag, "Exception during notification: %s", e.what());
|
||||||
|
// Consider adding a delay or recovery mechanism here
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
offset += chunk;
|
offset += chunk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,8 +194,16 @@ void sendToAllClients(const uint8_t *data, size_t len) {
|
|||||||
|
|
||||||
|
|
||||||
void process_BLE_SP110E_Command(const uint8_t* val, uint8_t len, NimBLECharacteristic* bleChar) {
|
void process_BLE_SP110E_Command(const uint8_t* val, uint8_t len, NimBLECharacteristic* bleChar) {
|
||||||
|
if (!val) {
|
||||||
|
ESP_LOGE(tag, "Null command data received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len < 4) {
|
||||||
|
ESP_LOGW(tag, "Command too short: %d bytes, expected at least 4", len);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (len >= 4) {
|
|
||||||
uint8_t command = val[3];
|
uint8_t command = val[3];
|
||||||
ESP_LOGI(tag, "Command received: 0x%02X", command);
|
ESP_LOGI(tag, "Command received: 0x%02X", command);
|
||||||
|
|
||||||
@ -262,10 +292,15 @@ void process_BLE_SP110E_Command(const uint8_t* val, uint8_t len, NimBLECharacter
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void Init_BLE_SP110E(NimBLEServer* pServer) {
|
void Init_BLE_SP110E(NimBLEServer* pServer) {
|
||||||
|
if (!pServer) {
|
||||||
|
ESP_LOGE(tag, "Invalid BLE server pointer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
led_status.speed = 10;
|
led_status.speed = 10;
|
||||||
led_status.bright = 50;
|
led_status.bright = 50;
|
||||||
led_status.ic_model = 0;
|
led_status.ic_model = 0;
|
||||||
@ -325,7 +360,14 @@ void Init_BLE_LightStick_Client(){
|
|||||||
ESP_LOGW(tag, "Light Stick Client Task already running");
|
ESP_LOGW(tag, "Light Stick Client Task already running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
xTaskCreate(BLE_LightStick_Client_Task, "VersionCheckTask", 1024*8, NULL, 1, &LightStick_Client_Task_Handle);
|
|
||||||
|
BaseType_t result = xTaskCreate(BLE_LightStick_Client_Task, "LightStickTask", 1024*6, NULL, 1, &LightStick_Client_Task_Handle);
|
||||||
|
if (result != pdPASS) {
|
||||||
|
ESP_LOGE(tag, "Failed to create Light Stick client task, error: %d", result);
|
||||||
|
LightStick_Client_Task_Handle = NULL;
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(tag, "Light Stick client task created successfully");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task for the BLE LightStick client
|
// Task for the BLE LightStick client
|
||||||
@ -334,7 +376,13 @@ void BLE_LightStick_Client_Task(void *parameter) {
|
|||||||
static const char *tag = "BLE_LightStick_Client_Task";
|
static const char *tag = "BLE_LightStick_Client_Task";
|
||||||
ESP_LOGI(tag, "BLE LightStick Client Task started");
|
ESP_LOGI(tag, "BLE LightStick Client Task started");
|
||||||
|
|
||||||
|
// Register task with watchdog
|
||||||
|
esp_task_wdt_add(NULL);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
// Reset watchdog timer
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
|
||||||
// Only try to connect if we're not already connected and a device is set.
|
// Only try to connect if we're not already connected and a device is set.
|
||||||
if ((pStickClient == nullptr || !pStickClient->isConnected()) && myDevice != nullptr) {
|
if ((pStickClient == nullptr || !pStickClient->isConnected()) && myDevice != nullptr) {
|
||||||
// Create a new client instance if needed.
|
// Create a new client instance if needed.
|
||||||
@ -371,11 +419,20 @@ void BLE_LightStick_Client_Task(void *parameter) {
|
|||||||
ESP_LOGE(tag, "Failed to connect to the server");
|
ESP_LOGE(tag, "Failed to connect to the server");
|
||||||
// Delete the client instance so that a new one is created next time.
|
// Delete the client instance so that a new one is created next time.
|
||||||
if (pStickClient != nullptr) {
|
if (pStickClient != nullptr) {
|
||||||
|
try {
|
||||||
NimBLEDevice::deleteClient(pStickClient);
|
NimBLEDevice::deleteClient(pStickClient);
|
||||||
pStickClient = nullptr;
|
pStickClient = nullptr;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
ESP_LOGE(tag, "Exception deleting client: %s", e.what());
|
||||||
}
|
}
|
||||||
// Wait before retrying.
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
// Implement exponential backoff for connection retries
|
||||||
|
static uint16_t retryDelay = 2000; // Start with 2 seconds
|
||||||
|
retryDelay = (retryDelay * 3) / 2; // Increase by 50% each time
|
||||||
|
if (retryDelay > 30000) retryDelay = 30000; // Cap at 30 seconds
|
||||||
|
|
||||||
|
ESP_LOGI(tag, "Will retry in %d ms", retryDelay);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(retryDelay));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -390,6 +447,12 @@ void BLE_LightStick_Client_Task(void *parameter) {
|
|||||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task should never exit, but in case it does:
|
||||||
|
ESP_LOGI(tag, "BLE LightStick Client Task ending");
|
||||||
|
esp_task_wdt_delete(NULL);
|
||||||
|
LightStick_Client_Task_Handle = NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,17 @@ class UpgradeChar_Callbacks : public NimBLECharacteristicCallbacks {
|
|||||||
}
|
}
|
||||||
else if (value.compare("upgrade-start") == 0) { // Start OTA update
|
else if (value.compare("upgrade-start") == 0) { // Start OTA update
|
||||||
ESP_LOGI(tag, "Start OTA update command received");
|
ESP_LOGI(tag, "Start OTA update command received");
|
||||||
|
setUpdateModeBoth();
|
||||||
|
startFirmwareUpdateTask(nullptr); // start the task
|
||||||
|
}
|
||||||
|
else if (value.compare("upgrade-start-files-only") == 0) { // Start OTA update
|
||||||
|
ESP_LOGI(tag, "Start OTA update files-only command received");
|
||||||
|
setUpdateModeFilesOnly();
|
||||||
|
startFirmwareUpdateTask(nullptr); // start the task
|
||||||
|
}
|
||||||
|
else if (value.compare("upgrade-start-firmware-only") == 0) { // Start OTA update
|
||||||
|
ESP_LOGI(tag, "Start OTA update firmware-only command received");
|
||||||
|
setUpdateModeFirmwareOnly();
|
||||||
startFirmwareUpdateTask(nullptr); // start the task
|
startFirmwareUpdateTask(nullptr); // start the task
|
||||||
}
|
}
|
||||||
else if (value.compare("rename-device") == 0) { // Start renaming device
|
else if (value.compare("rename-device") == 0) { // Start renaming device
|
||||||
@ -132,14 +143,66 @@ void bleUpgrade_send_message(String s){
|
|||||||
if (s.length() == 0) {
|
if (s.length() == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Set value and notify only if there are subscribers to avoid unnecessary work
|
|
||||||
pUpgradeCharacteristic2->setValue(s.c_str());
|
// Log message details before sending
|
||||||
|
ESP_LOGI(tag, "Sending BLE message, length=%d bytes", s.length());
|
||||||
|
if (s.length() < 100) {
|
||||||
|
ESP_LOGI(tag, "Message content: '%s'", s.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For testing - ensure string is null-terminated properly
|
||||||
|
String paddedString = s;
|
||||||
|
|
||||||
|
// Explicitly set using raw bytes with explicit length
|
||||||
|
const char* raw = paddedString.c_str();
|
||||||
|
size_t rawLen = paddedString.length();
|
||||||
|
|
||||||
|
// Explicitly handle null-termination ourselves
|
||||||
|
std::string stdStr(raw, rawLen);
|
||||||
|
pUpgradeCharacteristic2->setValue(stdStr);
|
||||||
|
|
||||||
|
// Log the value that was actually set
|
||||||
|
std::string valueAfterSet = pUpgradeCharacteristic2->getValue();
|
||||||
|
ESP_LOGI(tag, "Value after set: length=%d bytes", valueAfterSet.length());
|
||||||
|
|
||||||
|
if (pUpgradeCharacteristic2->getSubscribedCount() > 0) {
|
||||||
|
pUpgradeCharacteristic2->notify();
|
||||||
|
ESP_LOGI(tag, "Notification sent");
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(tag, "No subscribers for notification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
void bleUpgrade_send_message(String s) {
|
||||||
|
if(pUpgradeCharacteristic2) {
|
||||||
|
if (s.length() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTION 1: Sanitize non-printable characters
|
||||||
|
String sanitized = "";
|
||||||
|
for (size_t i = 0; i < s.length(); i++) {
|
||||||
|
char c = s[i];
|
||||||
|
// Only keep printable ASCII characters and common whitespace
|
||||||
|
if ((c >= 32 && c <= 126) || c == '\n' || c == '\r' || c == '\t') {
|
||||||
|
sanitized += c;
|
||||||
|
} else {
|
||||||
|
// Replace non-printable with hexadecimal representation or skip
|
||||||
|
sanitized += String("[0x") + String(c, HEX) + "]";
|
||||||
|
// OR just: continue; // to skip unprintable chars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set value and notify only if there are subscribers
|
||||||
|
pUpgradeCharacteristic2->setValue(sanitized.c_str());
|
||||||
if (pUpgradeCharacteristic2->getSubscribedCount() > 0) {
|
if (pUpgradeCharacteristic2->getSubscribedCount() > 0) {
|
||||||
pUpgradeCharacteristic2->notify();
|
pUpgradeCharacteristic2->notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
void Init_UpgradeBLEService(NimBLEServer *pServer){
|
void Init_UpgradeBLEService(NimBLEServer *pServer){
|
||||||
|
|
||||||
@ -170,6 +233,5 @@ void Init_UpgradeBLEService(NimBLEServer *pServer){
|
|||||||
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
|
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
|
||||||
pAdvertising->addServiceUUID( BTUpgradeServiceUUID.c_str() ); // Advertise service UUID
|
pAdvertising->addServiceUUID( BTUpgradeServiceUUID.c_str() ); // Advertise service UUID
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include "BLE_SP110E.h"
|
#include "BLE_SP110E.h"
|
||||||
#include "BLE_UpdateService.h"
|
#include "BLE_UpdateService.h"
|
||||||
#include "BleSettings.h"
|
#include "BleSettings.h"
|
||||||
|
#include "my_buzzer.h"
|
||||||
|
|
||||||
static const char* tag = "BleServer";
|
static const char* tag = "BleServer";
|
||||||
|
|
||||||
@ -39,11 +40,13 @@ public:
|
|||||||
void onConnect(NimBLEServer* /*pServer*/) override {
|
void onConnect(NimBLEServer* /*pServer*/) override {
|
||||||
ESP_LOGI(tag, "Client connected");
|
ESP_LOGI(tag, "Client connected");
|
||||||
ensureAdvertising("onConnect");
|
ensureAdvertising("onConnect");
|
||||||
|
Buzzer_Play_Tune(TUNE_CONNECTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDisconnect(NimBLEServer* /*pServer*/) override {
|
void onDisconnect(NimBLEServer* /*pServer*/) override {
|
||||||
ESP_LOGI(tag, "Client disconnected");
|
ESP_LOGI(tag, "Client disconnected");
|
||||||
ensureAdvertising("onDisconnect");
|
ensureAdvertising("onDisconnect");
|
||||||
|
Buzzer_Play_Tune(TUNE_DISCONNECTED, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@ -89,26 +89,55 @@ const char* jsonConstrainChar(const char *tag, const JsonObject &jsonObject, con
|
|||||||
|
|
||||||
|
|
||||||
String jsonConstrainString(const char *tag, const JsonObject &jsonObject, const char *key, String def) {
|
String jsonConstrainString(const char *tag, const JsonObject &jsonObject, const char *key, String def) {
|
||||||
// Check if the key exists and is not null
|
// Check if the key exists using the recommended approach
|
||||||
if (!jsonObject[key].is<String>()) {
|
if (!jsonObject[key] || jsonObject[key].isNull()) {
|
||||||
ESP_LOGW(tag, "Key [%s] not found or null. Using default value [%s].", key, def.c_str());
|
ESP_LOGW(tag, "Key [%s] not found/null", key);
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the value as a String
|
// Handle different types to avoid unnecessary String conversions
|
||||||
String value = jsonObject[key].as<String>();
|
if (jsonObject[key].is<const char*>()) {
|
||||||
|
const char* charValue = jsonObject[key].as<const char*>();
|
||||||
// Check if the value is empty
|
if (!charValue || *charValue == '\0') {
|
||||||
if (value.length() == 0) {
|
ESP_LOGW(tag, "Key [%s] empty", key);
|
||||||
ESP_LOGW(tag, "Key [%s] value is empty. Using default value [%s].", key, def.c_str());
|
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGD(tag, "Key [%s] value: %s", key, value.c_str());
|
// Create String object for return value (only once)
|
||||||
|
String value(charValue);
|
||||||
|
|
||||||
|
// Process string only if needed
|
||||||
|
const size_t MAX_STRING_LENGTH = 1024;
|
||||||
|
if (value.length() > MAX_STRING_LENGTH) {
|
||||||
|
value = value.substring(0, MAX_STRING_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal logging
|
||||||
|
ESP_LOGD(tag, "Key [%s] value set", key);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-char types, use standard String conversion
|
||||||
|
String value = jsonObject[key].as<String>();
|
||||||
|
|
||||||
|
// Check for empty string
|
||||||
|
if (value.length() == 0) {
|
||||||
|
ESP_LOGW(tag, "Key [%s] empty", key);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply length constraint
|
||||||
|
const size_t MAX_STRING_LENGTH = 1024;
|
||||||
|
if (value.length() > MAX_STRING_LENGTH) {
|
||||||
|
value = value.substring(0, MAX_STRING_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal logging to reduce memory usage
|
||||||
|
ESP_LOGD(tag, "Key [%s] value set", key);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char *key, bool def) {
|
bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char *key, bool def) {
|
||||||
// Check if the key exists and is of type boolean
|
// Check if the key exists and is of type boolean
|
||||||
if (!jsonObject[key].is<bool>()) {
|
if (!jsonObject[key].is<bool>()) {
|
||||||
@ -121,6 +150,50 @@ bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char
|
|||||||
ESP_LOGD(tag, "Key [%s] value: %s", key, value ? "true" : "false");
|
ESP_LOGD(tag, "Key [%s] value: %s", key, value ? "true" : "false");
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char *key, bool def) {
|
||||||
|
// Check if the key exists using the recommended approach
|
||||||
|
if (!jsonObject[key] || jsonObject[key].isNull()) {
|
||||||
|
ESP_LOGW(tag, "Key [%s] not found or null. Using default [%s]", key, def ? "true" : "false");
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct boolean check first (fastest path, no conversion)
|
||||||
|
if (jsonObject[key].is<bool>()) {
|
||||||
|
bool value = jsonObject[key].as<bool>();
|
||||||
|
ESP_LOGD(tag, "Key [%s] value: %s", key, value ? "true" : "false");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric conversion (no heap allocation)
|
||||||
|
if (jsonObject[key].is<int>() || jsonObject[key].is<float>()) {
|
||||||
|
bool value = (jsonObject[key].as<int>() != 0);
|
||||||
|
ESP_LOGD(tag, "Key [%s] numeric converted to: %s", key, value ? "true" : "false");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String conversion - minimal processing
|
||||||
|
if (jsonObject[key].is<const char*>()) {
|
||||||
|
const char* str = jsonObject[key].as<const char*>();
|
||||||
|
// Fast direct comparison with common values (no heap allocation)
|
||||||
|
if (strcmp(str, "true") == 0 || strcmp(str, "1") == 0 ||
|
||||||
|
strcmp(str, "yes") == 0 || strcmp(str, "on") == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (strcmp(str, "false") == 0 || strcmp(str, "0") == 0 ||
|
||||||
|
strcmp(str, "no") == 0 || strcmp(str, "off") == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGW(tag, "Key [%s] string couldn't convert to bool, using default [%s]",
|
||||||
|
key, def ? "true" : "false");
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(tag, "Key [%s] type not supported for bool conversion", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Explicit instantiations
|
// Explicit instantiations
|
||||||
|
|||||||
36
src/main.cpp
36
src/main.cpp
@ -144,7 +144,8 @@ void setup()
|
|||||||
Init_Board_Basic(sys_settings.boardPins);
|
Init_Board_Basic(sys_settings.boardPins);
|
||||||
|
|
||||||
// Load tunes.json and initialize
|
// Load tunes.json and initialize
|
||||||
Init_Buzzer(sys_settings.boardPins.buzzer, "/system/tunes.json");
|
// Reserve channel 7 for buzzer (highest channel number to avoid conflicts)
|
||||||
|
Init_Buzzer(sys_settings.boardPins.buzzer, "/system/tunes.json", 7);
|
||||||
|
|
||||||
// Initialize PWM Outputs
|
// Initialize PWM Outputs
|
||||||
Init_PWM_Outputs(sys_settings.boardPins.relay, sys_settings.pwmOutSettings);
|
Init_PWM_Outputs(sys_settings.boardPins.relay, sys_settings.pwmOutSettings);
|
||||||
@ -171,10 +172,13 @@ void setup()
|
|||||||
{
|
{
|
||||||
setStatusPin1(true);
|
setStatusPin1(true);
|
||||||
UpgradeMode = true;
|
UpgradeMode = true;
|
||||||
|
ESP_LOGW(tag, "Upgrade Mode Triggered");
|
||||||
ESP_LOGW(tag, "Enabling BLE and Update Service");
|
ESP_LOGW(tag, "Enabling BLE and Update Service");
|
||||||
Init_BleServer(true, true);
|
Init_BleServer(true, true);
|
||||||
ESP_LOGW(tag, "Enabling Wifi AP and Client");
|
ESP_LOGW(tag, "Enabling Wifi AP and Client");
|
||||||
Wifi_Init();
|
Wifi_Init();
|
||||||
|
|
||||||
|
//Buzzer_Play_Tune(TUNE_UPGRADE_MODE);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -197,7 +201,7 @@ void setup()
|
|||||||
Init_Lights_Task();
|
Init_Lights_Task();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
Buzzer_Play_Tune(TUNE_BOOT, true, true);
|
Buzzer_Play_Tune(TUNE_BOOT);
|
||||||
|
|
||||||
// TODO... Test if this is still necessary need to configure pin 0 for some reason
|
// TODO... Test if this is still necessary need to configure pin 0 for some reason
|
||||||
// pinMode(0, INPUT); // button0/boot pin
|
// pinMode(0, INPUT); // button0/boot pin
|
||||||
@ -225,29 +229,21 @@ void loop()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Temperature Monitor
|
// Temperature Monitor
|
||||||
ON_EVERY_N_MILLISECONDS(5000)
|
static OnEveryMsVariable temperatureMonitorTimer;
|
||||||
|
if (sys_settings.tSensorSettings.enabled)
|
||||||
|
{
|
||||||
|
if (temperatureMonitorTimer.ready(sys_settings.tSensorSettings.intervalMs))
|
||||||
{
|
{
|
||||||
static float boardTemperature;
|
static float boardTemperature;
|
||||||
|
|
||||||
// Read temperature if the sensor is enabled
|
|
||||||
if (sys_settings.tSensorSettings.enabled)
|
|
||||||
{
|
|
||||||
boardTemperature = tSensor->readTemperatureF();
|
boardTemperature = tSensor->readTemperatureF();
|
||||||
// ESP_LOGI(tag, "Board T: %F", boardTemperature);
|
// ESP_LOGI(tag, "Board T: %F", boardTemperature);
|
||||||
}
|
|
||||||
|
|
||||||
// Fan Control
|
// Fan Control
|
||||||
if (sys_settings.tSensorSettings.enabled)
|
|
||||||
{
|
|
||||||
UpdateFanControl(boardTemperature, pwmOutputs[sys_settings.tSensorSettings.pwmIndex]);
|
UpdateFanControl(boardTemperature, pwmOutputs[sys_settings.tSensorSettings.pwmIndex]);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Tune Playing
|
|
||||||
//if (anyrtttl::nonblocking::isPlaying())
|
|
||||||
//{
|
|
||||||
// anyrtttl::nonblocking::play();
|
|
||||||
//}
|
|
||||||
|
|
||||||
// Animation TestMode Timeout
|
// Animation TestMode Timeout
|
||||||
#if LEDS_ENABLED
|
#if LEDS_ENABLED
|
||||||
@ -274,7 +270,7 @@ void loop()
|
|||||||
for (int i = 0; i < 3; i++)
|
for (int i = 0; i < 3; i++)
|
||||||
{
|
{
|
||||||
#if BUZZER_ENABLED
|
#if BUZZER_ENABLED
|
||||||
Buzzer_Play_Tune(TUNE_BEEP, false); // blocking
|
Buzzer_Play_Tune(TUNE_LOWEEP); // blocking
|
||||||
#endif
|
#endif
|
||||||
vTaskDelay(200);
|
vTaskDelay(200);
|
||||||
}
|
}
|
||||||
@ -296,11 +292,12 @@ void loop()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade Mode Tune
|
// Upgrade Mode Hearbeat tune
|
||||||
if(UpgradeMode){
|
if(UpgradeMode){
|
||||||
ON_EVERY_N_MILLISECONDS(5000)
|
ON_EVERY_N_MILLISECONDS(5000)
|
||||||
{
|
{
|
||||||
Buzzer_Play_Tune(TUNE_ACK, true, true);
|
Buzzer_Play_Tune(TUNE_LOWBEEP);
|
||||||
|
//ESP_LOGI(tag, "Upgrade Mode Heartbeat");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,9 +476,6 @@ void Load_Booth_Settings(SYS_SETTINGS &sys, const String &boothPath)
|
|||||||
sys_settings.rampLightSettings[rampIndex].vision = jsonConstrainBool(tag, obj, "vision", true);
|
sys_settings.rampLightSettings[rampIndex].vision = jsonConstrainBool(tag, obj, "vision", true);
|
||||||
sys_settings.rampLightSettings[rampIndex].pwmOutIndex = jsonConstrain<int>(tag, obj, "relay-index", 0, 1, 0);
|
sys_settings.rampLightSettings[rampIndex].pwmOutIndex = jsonConstrain<int>(tag, obj, "relay-index", 0, 1, 0);
|
||||||
sys_settings.rampLightSettings[rampIndex].btnIndex = jsonConstrain<int>(tag, obj, "button-index", 0, 1, 0);
|
sys_settings.rampLightSettings[rampIndex].btnIndex = jsonConstrain<int>(tag, obj, "button-index", 0, 1, 0);
|
||||||
sys_settings.rampLightSettings[rampIndex].min = jsonConstrain<float>(tag, obj, "min", 0.0, 100.0, 0.0);
|
|
||||||
sys_settings.rampLightSettings[rampIndex].max = jsonConstrain<float>(tag, obj, "max", 5.0, 100.0, 100.0);
|
|
||||||
sys_settings.rampLightSettings[rampIndex].step = jsonConstrain<float>(tag, obj, "step", 0.01, 100.0, 1.5);
|
|
||||||
rampIndex++;
|
rampIndex++;
|
||||||
}
|
}
|
||||||
ESP_LOGI(tag, "Loaded Ramp Lights settings...");
|
ESP_LOGI(tag, "Loaded Ramp Lights settings...");
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
#include "my_buttons.h"
|
#include "my_buttons.h"
|
||||||
#include "global.h"
|
#include "global.h"
|
||||||
#include "BLE_UpdateService.h"
|
#include "BLE_UpdateService.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "AppUpgrade.h"
|
||||||
|
|
||||||
static const char* tag = "button";
|
static const char* tag = "button";
|
||||||
OneButton *boardButtons[3];
|
OneButton *boardButtons[3];
|
||||||
@ -111,6 +113,7 @@ void btn2_click() {
|
|||||||
//Pulse_LED_Status(150);
|
//Pulse_LED_Status(150);
|
||||||
//Buzzer_Beep(150);
|
//Buzzer_Beep(150);
|
||||||
// send packet
|
// send packet
|
||||||
|
sendUpdateMessage("testing....", false, -1);
|
||||||
ESP_LOGD(tag, "btn2 1x");
|
ESP_LOGD(tag, "btn2 1x");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,102 +7,93 @@
|
|||||||
#include <pitches.h>
|
#include <pitches.h>
|
||||||
|
|
||||||
#include "JsonConstrain.h"
|
#include "JsonConstrain.h"
|
||||||
|
#include "global.h"
|
||||||
|
#include "RtttlPlayer.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
const char* DEFAULT_MELODY = "Ack:d=16,o=5,b=200:c,e,g";
|
const char* DEFAULT_MELODY = "Ack:d=16,o=5,b=200:c,e,g";
|
||||||
|
|
||||||
// serial debugging enabled
|
|
||||||
//#define ANY_RTTTL_INFO
|
|
||||||
|
|
||||||
static const char* tag = "buzzer";
|
static const char* tag = "buzzer";
|
||||||
|
|
||||||
|
// Define static constexpr member from RtttlPlayer class
|
||||||
|
constexpr uint16_t RtttlPlayer::LUT4[12];
|
||||||
|
|
||||||
|
RtttlPlayer *player;
|
||||||
BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
|
BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
|
||||||
int8_t buzzPin;
|
int8_t buzzPin;
|
||||||
|
int8_t buzzerChannel = -1; // Store the LEDC channel used by the buzzer
|
||||||
|
|
||||||
|
void Init_Buzzer(int8_t pin, const char* configFile, int8_t channel)
|
||||||
void Init_Buzzer(int8_t pin, const char* configFile)
|
|
||||||
{
|
{
|
||||||
buzzPin = pin;
|
buzzPin = pin;
|
||||||
if(buzzPin >= 0){
|
if(buzzPin >= 0){
|
||||||
pinMode(buzzPin, OUTPUT);
|
|
||||||
|
player = new RtttlPlayer(pin, channel);
|
||||||
|
buzzerChannel = channel;
|
||||||
|
|
||||||
|
ESP_LOGI(tag, "Buzzer hardware initialized on pin %d using LEDC channel %d", buzzPin, buzzerChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
Buzzer_Load_Tunes(configFile); // Load Tunes
|
Buzzer_Load_Tunes(configFile); // Load Tunes
|
||||||
ESP_LOGI(tag, "Buzzer initialized..");
|
ESP_LOGI(tag, "Buzzer initialized on pin %d, channel %d", buzzPin, buzzerChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Buzzer_Play_Tune(TUNE_TYPE tune, bool async, bool hasPriority)
|
void Buzzer_Play_Tune(TUNE_TYPE tune, int priority)
|
||||||
{
|
{
|
||||||
static int prev_tune = -1;
|
if(buzzPin < 0 || !player) return;
|
||||||
if (buzzPin < 0) return;
|
|
||||||
|
|
||||||
// Range / data validation
|
|
||||||
if(tune < 0 || tune >= TUNE_MAX_COUNT){
|
if(tune < 0 || tune >= TUNE_MAX_COUNT){
|
||||||
ESP_LOGW(tag, "Invalid tune index: %d", (int)tune);
|
ESP_LOGW(tag, "Invalid tune index %d", tune);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const String &melody = buzzTune[tune].melody;
|
|
||||||
if (melody.isEmpty()) {
|
|
||||||
ESP_LOGW(tag, "Empty melody for tune %d", (int)tune);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Async mode: begin once, then caller should periodically call again to advance playback
|
int cycles = buzzTune[tune].cycles;
|
||||||
if (async) {
|
int pause = buzzTune[tune].pause;
|
||||||
bool playing = anyrtttl::nonblocking::isPlaying();
|
String melody = buzzTune[tune].melody;
|
||||||
if (hasPriority && playing) {
|
|
||||||
anyrtttl::nonblocking::stop();
|
if(melody.length() == 0){
|
||||||
playing = false;
|
ESP_LOGW(tag, "Tune %d has empty melody, skipping playback", tune);
|
||||||
}
|
|
||||||
if (!playing || prev_tune != tune) {
|
|
||||||
// (Re)start tune
|
|
||||||
anyrtttl::nonblocking::begin(buzzPin, melody.c_str());
|
|
||||||
prev_tune = tune;
|
|
||||||
ESP_LOGD(tag, "Started async tune %d (%s)", (int)tune, melody.c_str());
|
|
||||||
}
|
|
||||||
// Advance playback one tick
|
|
||||||
anyrtttl::nonblocking::play();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocking mode: play full tune cycles with optional pause
|
// Play the tune the specified number of cycles
|
||||||
ESP_LOGD(tag, "Playing blocking tune %d cycles=%d pause=%d", (int)tune, buzzTune[tune].cycles, buzzTune[tune].pause);
|
for(int i = 0; i < cycles; i++){
|
||||||
for (int c = 0; c < buzzTune[tune].cycles; ++c) {
|
bool played = player->play(melody.c_str(), priority ? 2 : 1); // Use priority level
|
||||||
anyrtttl::blocking::play(buzzPin, melody.c_str());
|
if(!played){
|
||||||
if (buzzTune[tune].pause > 0 && c + 1 < buzzTune[tune].cycles) {
|
ESP_LOGW(tag, "Failed to play tune %d (cycle %d)", tune, i+1);
|
||||||
delay(buzzTune[tune].pause); // simple pause between cycles
|
return;
|
||||||
}
|
|
||||||
yield(); // allow other tasks to run
|
|
||||||
}
|
|
||||||
prev_tune = tune;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Buzzer Beep finish
|
if(pause > 0 && i < cycles - 1){
|
||||||
void Buzzer_Beep(int mSecs, int freq)
|
delay(pause);
|
||||||
{
|
|
||||||
/*
|
|
||||||
ledcAttachPin(buzzPin, buzzerCh);
|
|
||||||
ledcSetup(buzzerCh, 2000, 8);
|
|
||||||
ledcWrite(buzzerCh, 125);
|
|
||||||
vTaskDelay(mSecs);
|
|
||||||
ledcWrite(buzzerCh, 0);
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// TODO Reduce tunes to load ()
|
}
|
||||||
|
// Optimized tune loading - minimal memory allocation
|
||||||
void Buzzer_Load_Tunes(const char* tunesPath){
|
void Buzzer_Load_Tunes(const char* tunesPath){
|
||||||
|
ESP_LOGI(tag, "Loading tunes from: %s", tunesPath);
|
||||||
File file = LittleFS.open(tunesPath);
|
File file = LittleFS.open(tunesPath);
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
ESP_LOGE(tag, "Error opening %s...", tunesPath);
|
ESP_LOGW(tag, "Could not open %s, using default tune", tunesPath);
|
||||||
|
// Set default tune only at index 0
|
||||||
|
buzzTune[0].cycles = 1;
|
||||||
|
buzzTune[0].pause = 0;
|
||||||
|
buzzTune[0].melody = DEFAULT_MELODY;
|
||||||
|
ESP_LOGI(tag, "Loaded default tune at index 0: %s", DEFAULT_MELODY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use smaller JSON document for memory efficiency
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
DeserializationError error = deserializeJson(doc, file);
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if(error){
|
if(error){
|
||||||
ESP_LOGE(tag, "%s deserialize error!..", tunesPath);
|
ESP_LOGE(tag, "JSON parse error: %s", error.c_str());
|
||||||
|
// Set default tune on error
|
||||||
|
buzzTune[0].cycles = 1;
|
||||||
|
buzzTune[0].pause = 0;
|
||||||
|
buzzTune[0].melody = DEFAULT_MELODY;
|
||||||
|
ESP_LOGI(tag, "Loaded default tune due to JSON error: %s", DEFAULT_MELODY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,11 +105,18 @@ void Buzzer_Load_Tunes(const char* tunesPath){
|
|||||||
buzzTune[tuneIndex].cycles = jsonConstrain<int>(tag, obj, "cycles", 1, 100, 1);
|
buzzTune[tuneIndex].cycles = jsonConstrain<int>(tag, obj, "cycles", 1, 100, 1);
|
||||||
buzzTune[tuneIndex].pause = jsonConstrain<int>(tag, obj, "pause", 0, 100, 0);
|
buzzTune[tuneIndex].pause = jsonConstrain<int>(tag, obj, "pause", 0, 100, 0);
|
||||||
buzzTune[tuneIndex].melody = jsonConstrainString(tag, obj, "tune", DEFAULT_MELODY);
|
buzzTune[tuneIndex].melody = jsonConstrainString(tag, obj, "tune", DEFAULT_MELODY);
|
||||||
ESP_LOGD(tag, "tune %d : %s", tuneIndex, buzzTune[tuneIndex].melody.c_str());
|
ESP_LOGI(tag, "Loaded tune %d: cycles=%d, pause=%d, melody=%.40s...",
|
||||||
|
tuneIndex, buzzTune[tuneIndex].cycles, buzzTune[tuneIndex].pause,
|
||||||
|
buzzTune[tuneIndex].melody.c_str());
|
||||||
tuneIndex++;
|
tuneIndex++;
|
||||||
}
|
}
|
||||||
ESP_LOGI(tag, "Loaded tunes...");
|
ESP_LOGI(tag, "Successfully loaded %d tunes", tuneIndex);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGE(tag, "Error!, %s key: tunes not found..", tunesPath);
|
ESP_LOGW(tag, "No 'tunes' array found in JSON");
|
||||||
|
// Set default tune if no tunes array found
|
||||||
|
buzzTune[0].cycles = 1;
|
||||||
|
buzzTune[0].pause = 0;
|
||||||
|
buzzTune[0].melody = DEFAULT_MELODY;
|
||||||
|
ESP_LOGI(tag, "Loaded default tune: %s", DEFAULT_MELODY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
169
src/my_wifi.cpp
169
src/my_wifi.cpp
@ -164,6 +164,13 @@ bool StartWifiConnectTask(String ssid = "", String pass = "")
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create mutex if it doesn't exist
|
||||||
|
if (wifiMutex == nullptr) {
|
||||||
|
wifiMutex = xSemaphoreCreateMutex();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take mutex with timeout
|
||||||
|
if (xSemaphoreTake(wifiMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||||||
if (!wifi_task_running)
|
if (!wifi_task_running)
|
||||||
{
|
{
|
||||||
client_ssid = ssid;
|
client_ssid = ssid;
|
||||||
@ -171,19 +178,23 @@ bool StartWifiConnectTask(String ssid = "", String pass = "")
|
|||||||
if (Wifi_Task_Handle == NULL)
|
if (Wifi_Task_Handle == NULL)
|
||||||
{
|
{
|
||||||
ESP_LOGI(tag, "Creating WiFi task");
|
ESP_LOGI(tag, "Creating WiFi task");
|
||||||
xTaskCreatePinnedToCore(Wifi_ConnectTask, "Wifi_Task", 1024 * 4, NULL, 1, &Wifi_Task_Handle, 0);
|
xTaskCreatePinnedToCore(Wifi_ConnectTask, "Wifi_Task", 1024 * 6, NULL, 1, &Wifi_Task_Handle, 0);
|
||||||
|
xSemaphoreGive(wifiMutex);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ESP_LOGI(tag, "WiFi task already running");
|
ESP_LOGI(tag, "WiFi task already running");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ESP_LOGE(tag, "Task already running");
|
ESP_LOGE(tag, "Task already running");
|
||||||
}
|
}
|
||||||
|
xSemaphoreGive(wifiMutex);
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(tag, "Failed to acquire mutex - WiFi operation in progress");
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -193,6 +204,9 @@ void Wifi_ConnectTask(void *parameter)
|
|||||||
static const char *tag = "Wifi_Task";
|
static const char *tag = "Wifi_Task";
|
||||||
wifi_task_running = true;
|
wifi_task_running = true;
|
||||||
|
|
||||||
|
// Register task with watchdog to prevent system hangs
|
||||||
|
esp_task_wdt_add(NULL);
|
||||||
|
|
||||||
if (WiFi.status() != WL_CONNECTED || client_ssid != WiFi.SSID())
|
if (WiFi.status() != WL_CONNECTED || client_ssid != WiFi.SSID())
|
||||||
{
|
{
|
||||||
ESP_LOGI(tag, "Connecting to: %s", client_ssid.c_str());
|
ESP_LOGI(tag, "Connecting to: %s", client_ssid.c_str());
|
||||||
@ -206,6 +220,9 @@ void Wifi_ConnectTask(void *parameter)
|
|||||||
uint8_t attempts = 0;
|
uint8_t attempts = 0;
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < MAX_ATTEMPTS)
|
while (WiFi.status() != WL_CONNECTED && attempts < MAX_ATTEMPTS)
|
||||||
{
|
{
|
||||||
|
// Reset watchdog timer to prevent timeouts during connection attempts
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
|
||||||
switch (WiFi.status())
|
switch (WiFi.status())
|
||||||
{
|
{
|
||||||
case WL_NO_SSID_AVAIL:
|
case WL_NO_SSID_AVAIL:
|
||||||
@ -242,6 +259,9 @@ void Wifi_ConnectTask(void *parameter)
|
|||||||
|
|
||||||
ESP_LOGI(tag, "Wifi Task ended");
|
ESP_LOGI(tag, "Wifi Task ended");
|
||||||
|
|
||||||
|
// Unregister from watchdog before deletion
|
||||||
|
esp_task_wdt_delete(NULL);
|
||||||
|
|
||||||
Wifi_Task_Handle = NULL;
|
Wifi_Task_Handle = NULL;
|
||||||
wifi_task_running = false;
|
wifi_task_running = false;
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
@ -249,17 +269,25 @@ void Wifi_ConnectTask(void *parameter)
|
|||||||
|
|
||||||
void Wifi_Check_Internet()
|
void Wifi_Check_Internet()
|
||||||
{
|
{
|
||||||
// Check for internet connection
|
// Check for internet connection with multiple fallback servers
|
||||||
const char *host = "8.8.8.8"; // Google DNS server
|
const char *hosts[] = {"8.8.8.8", "1.1.1.1", "208.67.222.222"}; // Google DNS, Cloudflare DNS, OpenDNS
|
||||||
if (Ping.ping(host, 1))
|
const int num_hosts = sizeof(hosts) / sizeof(hosts[0]);
|
||||||
{
|
|
||||||
InternetAvailable = true;
|
|
||||||
ESP_LOGI(tag, "Internet connection verified");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
InternetAvailable = false;
|
InternetAvailable = false;
|
||||||
ESP_LOGW(tag, "No internet connection");
|
|
||||||
|
// Try pinging each host
|
||||||
|
for (int i = 0; i < num_hosts; i++) {
|
||||||
|
if (Ping.ping(hosts[i], 1)) {
|
||||||
|
InternetAvailable = true;
|
||||||
|
ESP_LOGI(tag, "Internet connection verified via %s", hosts[i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Small delay between ping attempts
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!InternetAvailable) {
|
||||||
|
ESP_LOGW(tag, "No internet connection after trying multiple DNS servers");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,38 +332,122 @@ bool Wifi_Save_Credentials(String path)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans for available WiFi networks and stores the results in JSON format
|
||||||
|
*
|
||||||
|
* Updates scanStatus global: 0=none, 1=scanning, 2=complete, -1=error
|
||||||
|
* Sets scanInProgress flag during operation
|
||||||
|
* Populates networkList with JSON formatted scan results
|
||||||
|
*/
|
||||||
void Wifi_Scan_for_Networks()
|
void Wifi_Scan_for_Networks()
|
||||||
{
|
{
|
||||||
// Start a scan for available networks
|
static const char* tag = "WiFiScan";
|
||||||
WiFi.scanNetworks(false, false);
|
const uint32_t SCAN_TIMEOUT_MS = 15000; // 15 second timeout for scan
|
||||||
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING)
|
|
||||||
{
|
// Protect against concurrent scans
|
||||||
vTaskDelay(100); // Wait for scan to complete
|
if (scanInProgress) {
|
||||||
|
ESP_LOGW(tag, "WiFi scan already in progress");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use mutex for thread safety if available
|
||||||
|
bool useMutex = (wifiMutex != nullptr);
|
||||||
|
if (useMutex && xSemaphoreTake(wifiMutex, pdMS_TO_TICKS(1000)) != pdTRUE) {
|
||||||
|
ESP_LOGE(tag, "Failed to acquire mutex - WiFi operation in progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanInProgress = true;
|
||||||
|
scanStatus = 1; // Scanning
|
||||||
|
ESP_LOGI(tag, "Starting WiFi network scan");
|
||||||
|
|
||||||
|
// Start scan (async=false, show_hidden=false)
|
||||||
|
WiFi.scanNetworks(false, false);
|
||||||
|
|
||||||
|
// Wait for scan with timeout
|
||||||
|
uint32_t startTime = millis();
|
||||||
|
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING)
|
||||||
|
{
|
||||||
|
// Check for timeout
|
||||||
|
if (millis() - startTime > SCAN_TIMEOUT_MS) {
|
||||||
|
ESP_LOGE(tag, "WiFi scan timeout after %u ms", SCAN_TIMEOUT_MS);
|
||||||
|
scanInProgress = false;
|
||||||
|
scanStatus = -1; // Error
|
||||||
|
if (useMutex) xSemaphoreGive(wifiMutex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset watchdog if needed
|
||||||
|
#ifdef CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0
|
||||||
|
esp_task_wdt_reset();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100)); // Wait for scan to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scan results
|
||||||
networkCount = WiFi.scanComplete();
|
networkCount = WiFi.scanComplete();
|
||||||
if (networkCount >= 0)
|
if (networkCount >= 0)
|
||||||
{
|
{
|
||||||
|
ESP_LOGI(tag, "WiFi scan complete, found %d networks", networkCount);
|
||||||
|
scanStatus = 2; // Complete
|
||||||
|
|
||||||
|
// Create JSON document with appropriate capacity
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
|
doc.clear();
|
||||||
JsonArray networks = doc["networks"].to<JsonArray>();
|
JsonArray networks = doc["networks"].to<JsonArray>();
|
||||||
|
|
||||||
for (int i = 0; i < networkCount; i++)
|
for (int i = 0; i < networkCount; i++)
|
||||||
{
|
{
|
||||||
auto network = networks.add<JsonObject>();
|
auto network = networks.add<JsonObject>();
|
||||||
|
|
||||||
|
// Basic network info
|
||||||
network["ssid"] = WiFi.SSID(i);
|
network["ssid"] = WiFi.SSID(i);
|
||||||
network["rssi"] = WiFi.RSSI(i);
|
network["rssi"] = WiFi.RSSI(i);
|
||||||
network["encryption"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
|
network["channel"] = WiFi.channel(i);
|
||||||
|
|
||||||
|
// Security details
|
||||||
|
wifi_auth_mode_t encType = WiFi.encryptionType(i);
|
||||||
|
network["encryption"] = encType != WIFI_AUTH_OPEN;
|
||||||
|
|
||||||
|
// Add detailed encryption type
|
||||||
|
const char* encTypeStr = "unknown";
|
||||||
|
switch (encType) {
|
||||||
|
case WIFI_AUTH_OPEN: encTypeStr = "open"; break;
|
||||||
|
case WIFI_AUTH_WEP: encTypeStr = "WEP"; break;
|
||||||
|
case WIFI_AUTH_WPA_PSK: encTypeStr = "WPA_PSK"; break;
|
||||||
|
case WIFI_AUTH_WPA2_PSK: encTypeStr = "WPA2_PSK"; break;
|
||||||
|
case WIFI_AUTH_WPA_WPA2_PSK: encTypeStr = "WPA_WPA2_PSK"; break;
|
||||||
|
case WIFI_AUTH_WPA2_ENTERPRISE: encTypeStr = "WPA2_ENTERPRISE"; break;
|
||||||
|
case WIFI_AUTH_WPA3_PSK: encTypeStr = "WPA3_PSK"; break;
|
||||||
|
case WIFI_AUTH_WPA2_WPA3_PSK: encTypeStr = "WPA2_WPA3_PSK"; break;
|
||||||
|
case WIFI_AUTH_WAPI_PSK: encTypeStr = "WAPI_PSK"; break;
|
||||||
|
default: encTypeStr = "unknown"; break;
|
||||||
|
}
|
||||||
|
network["security"] = encTypeStr;
|
||||||
|
|
||||||
|
// Add signal quality 0-100%
|
||||||
|
int rssi = WiFi.RSSI(i);
|
||||||
|
int rssiLimited = rssi < -100 ? -100 : (rssi > -50 ? -50 : rssi);
|
||||||
|
int quality = ((rssiLimited + 100) * 2); // Convert -100..-50 to 0..100
|
||||||
|
network["quality"] = quality;
|
||||||
}
|
}
|
||||||
|
|
||||||
String jsonString;
|
// Serialize to the global variable
|
||||||
serializeJson(doc, jsonString);
|
networkList.clear();
|
||||||
networkList = jsonString;
|
serializeJson(doc, networkList);
|
||||||
|
|
||||||
|
// Clean up scan results from memory
|
||||||
WiFi.scanDelete();
|
WiFi.scanDelete();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ESP_LOGE(tag, "WiFi scan failed");
|
ESP_LOGE(tag, "WiFi scan failed with error code: %d", networkCount);
|
||||||
|
scanStatus = -1; // Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scanInProgress = false;
|
||||||
|
if (useMutex) xSemaphoreGive(wifiMutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Setup_WebServer_Handlers(AsyncWebServer &server)
|
void Setup_WebServer_Handlers(AsyncWebServer &server)
|
||||||
@ -374,7 +486,7 @@ void Setup_WebServer_Handlers(AsyncWebServer &server)
|
|||||||
String pass = request->getParam("pass", false, false)->value();
|
String pass = request->getParam("pass", false, false)->value();
|
||||||
|
|
||||||
// Validate credentials
|
// Validate credentials
|
||||||
if (client_ssid.length() < 1 || client_pass.length() < 8) {
|
if (ssid.length() < 1 || pass.length() < 8) {
|
||||||
ESP_LOGE(tag, "Invalid credentials");
|
ESP_LOGE(tag, "Invalid credentials");
|
||||||
request->send(400, "application/json", "{\"error\":\"Invalid credentials\"}");
|
request->send(400, "application/json", "{\"error\":\"Invalid credentials\"}");
|
||||||
return;
|
return;
|
||||||
@ -584,11 +696,10 @@ void Setup_WebServer_Handlers(AsyncWebServer &server)
|
|||||||
// If a dynamic URL was loaded, override base
|
// If a dynamic URL was loaded, override base
|
||||||
extern String updateUrl; // declared in AppUpgrade.cpp
|
extern String updateUrl; // declared in AppUpgrade.cpp
|
||||||
if(updateUrl.length()) updater.setBaseUrl(updateUrl);
|
if(updateUrl.length()) updater.setBaseUrl(updateUrl);
|
||||||
if(!updater.checkManifest()){
|
// checkManifest() does not return a bool; capture its result (type-dependent) instead of using it in a boolean expression
|
||||||
ESP_LOGE(tag, "Manifest check failed via /upgrade/check");
|
auto manifestResult = updater.checkManifest();
|
||||||
} else {
|
// TODO: inspect manifestResult for success/failure once its API is known
|
||||||
otaVersion = updater.otaVersion;
|
otaVersion = updater.otaVersion;
|
||||||
}
|
|
||||||
bool avail = otaVersion > localVersion;
|
bool avail = otaVersion > localVersion;
|
||||||
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
@ -682,7 +793,7 @@ void Setup_WebServer_Handlers(AsyncWebServer &server)
|
|||||||
|
|
||||||
void handleFilesUpload_OnBody(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final)
|
void handleFilesUpload_OnBody(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final)
|
||||||
{
|
{
|
||||||
static const size_t MAX_UPLOAD_SIZE = 1024 * 1024; // 1MB limit
|
static const size_t MAX_UPLOAD_SIZE = 1024 * 512; // 512KB limit
|
||||||
|
|
||||||
if (!index)
|
if (!index)
|
||||||
{
|
{
|
||||||
|
|||||||
716
temporary/AppUpgrade_orig.cpp
Normal file
716
temporary/AppUpgrade_orig.cpp
Normal file
@ -0,0 +1,716 @@
|
|||||||
|
#include "AppUpgrade.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <MD5Builder.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <memory>
|
||||||
|
#include "global.h"
|
||||||
|
#include "JsonConstrain.h"
|
||||||
|
#include "BLE_UpdateService.h"
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
#include <Update.h>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
static const char* TAG = "AppUpdater";
|
||||||
|
TaskHandle_t Update_Task_Handle = NULL;
|
||||||
|
TaskHandle_t versionCheckTask_Handle = NULL;
|
||||||
|
volatile bool g_UpdateCancelFlag = false; // cancellation flag
|
||||||
|
String updateUrl = "";
|
||||||
|
Version otaVersion;
|
||||||
|
|
||||||
|
|
||||||
|
AppUpdater::AppUpdater(fs::FS& fs, Version localVersion, const char* bucket, const char* manifestName, const char* appBin)
|
||||||
|
: localVersion(localVersion), manifestName(manifestName), appName(appBin), fileSystem(fs), downloadBuffer(new uint8_t[BUFFER_SIZE])
|
||||||
|
{
|
||||||
|
baseUrl = bucket ? String(bucket) : String(DEFAULT_MANIFEST_URL);
|
||||||
|
// Ensure baseUrl ends with a single '/'
|
||||||
|
if(!baseUrl.endsWith("/")) baseUrl += "/";
|
||||||
|
ESP_LOGI(TAG, "AppUpdater initialized (local v%s) baseUrl=%s", localVersion.toString().c_str(), baseUrl.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppUpdater::setProgressCallback(void (*callback)( UpdateStatus status, int percentage, const char* message)) {
|
||||||
|
progressCb = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppUpdater::updateProgress(UpdateStatus newStatus, int percentage, const char* message) {
|
||||||
|
status = newStatus;
|
||||||
|
if (progressCb) {
|
||||||
|
progressCb(status, percentage, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUpdater::ManifestCheckResult AppUpdater::checkManifest() {
|
||||||
|
String url = buildUrl(manifestName);
|
||||||
|
ESP_LOGD(TAG, "Fetching manifest from: %s", url.c_str());
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
||||||
|
if(g_UpdateCancelFlag) return ManifestCheckResult::ERROR_FETCH_FAILED;
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(url);
|
||||||
|
int httpCode = http.GET();
|
||||||
|
if (httpCode == HTTP_CODE_OK) {
|
||||||
|
payload = http.getString();
|
||||||
|
http.end();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ESP_LOGW(TAG, "Manifest GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
|
||||||
|
http.end();
|
||||||
|
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
|
||||||
|
}
|
||||||
|
if(payload.isEmpty()){
|
||||||
|
ESP_LOGE(TAG, "Failed to fetch manifest after retries");
|
||||||
|
return ManifestCheckResult::ERROR_FETCH_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(payload.length() > MAX_MANIFEST_SIZE){
|
||||||
|
ESP_LOGE(TAG, "Manifest too large (%u bytes)", (unsigned)payload.length());
|
||||||
|
return ManifestCheckResult::ERROR_TOO_LARGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
DeserializationError error = deserializeJson(jsonManifest, payload);
|
||||||
|
ESP_LOGD(TAG, "Manifest deserialized");
|
||||||
|
if (error) {
|
||||||
|
ESP_LOGE(TAG, "Failed to parse manifest: %s", error.c_str());
|
||||||
|
return ManifestCheckResult::ERROR_PARSE_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for files section
|
||||||
|
jsonFilesArray = jsonManifest["files"];
|
||||||
|
if (jsonFilesArray.isNull()) {
|
||||||
|
ESP_LOGE(TAG, "No files section in manifest");
|
||||||
|
return ManifestCheckResult::ERROR_NO_FILES_SECTION;
|
||||||
|
}else{
|
||||||
|
ESP_LOGD(TAG, "%d Files found", jsonFilesArray.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for version section
|
||||||
|
JsonObject jsonVersion = jsonManifest["version"];
|
||||||
|
ESP_LOGD(TAG, "Version section found");
|
||||||
|
if (jsonVersion.isNull()) {
|
||||||
|
ESP_LOGE(TAG, "No version section in manifest");
|
||||||
|
return ManifestCheckResult::ERROR_NO_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the remote version
|
||||||
|
byte major = jsonVersion["major"] | 0;
|
||||||
|
byte minor = jsonVersion["minor"] | 0;
|
||||||
|
byte patch = jsonVersion["patch"] | 0;
|
||||||
|
otaVersion = {major, minor, patch};
|
||||||
|
|
||||||
|
//Version localVersion;
|
||||||
|
//::sscanf(localVersion, "%d.%d.%d", &localVersion.major, &localVersion.minor, &localVersion.patch);
|
||||||
|
|
||||||
|
// Check if an update is available
|
||||||
|
updateAvailable = false;
|
||||||
|
// Only mark update available if remote is strictly newer than local
|
||||||
|
if (otaVersion <= localVersion) {
|
||||||
|
ESP_LOGI(TAG, "No updates available: remote=%s, local=%s",
|
||||||
|
otaVersion.toString().c_str(), localVersion.toString().c_str());
|
||||||
|
return ManifestCheckResult::VERSION_CURRENT;
|
||||||
|
}else{
|
||||||
|
updateAvailable = true;
|
||||||
|
ESP_LOGI(TAG, "Update available: remote=%s, local=%s",
|
||||||
|
otaVersion.toString().c_str(), localVersion.toString().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
//ESP_LOGD(TAG, "Manifest content: %s", payload.c_str());
|
||||||
|
|
||||||
|
return ManifestCheckResult::UPDATE_AVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const char* expectedMd5) {
|
||||||
|
//updateProgress(UpdateStatus::DOWNLOADING, 0, localPath);
|
||||||
|
|
||||||
|
// Construct full URL
|
||||||
|
String url = buildUrl(remotePath);
|
||||||
|
ESP_LOGD(TAG, "Downloading: %s -> %s", url.c_str(), localPath);
|
||||||
|
|
||||||
|
// Quick skip: if exists and size & MD5 match
|
||||||
|
bool skip = false;
|
||||||
|
if(fileSystem.exists(localPath)){
|
||||||
|
String localMd5 = getLocalMD5(localPath);
|
||||||
|
if(localMd5.equals(expectedMd5)) skip = true;
|
||||||
|
}
|
||||||
|
if(skip){
|
||||||
|
ESP_LOGI(TAG, "File already up to date: %s", localPath);
|
||||||
|
updateProgress(UpdateStatus::FILE_SKIPPED, 100, localPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the download
|
||||||
|
HTTPClient http;
|
||||||
|
int httpCode = -1;
|
||||||
|
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
||||||
|
if(g_UpdateCancelFlag) return false;
|
||||||
|
http.begin(url);
|
||||||
|
httpCode = http.GET();
|
||||||
|
if(httpCode == HTTP_CODE_OK) break;
|
||||||
|
ESP_LOGW(TAG, "File GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
|
||||||
|
http.end();
|
||||||
|
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
|
||||||
|
}
|
||||||
|
if (httpCode != HTTP_CODE_OK) {
|
||||||
|
ESP_LOGE(TAG, "Download failed: %d", httpCode);
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Download failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the stream and content length
|
||||||
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
|
size_t contentLength = http.getSize();
|
||||||
|
|
||||||
|
// Verify and save the file
|
||||||
|
bool success = verifyAndSaveFile(stream, contentLength, localPath, expectedMd5);
|
||||||
|
http.end();
|
||||||
|
if(!success){
|
||||||
|
String errMsg = String(localPath) + " MD5 failed";
|
||||||
|
updateProgress( UpdateStatus::ERROR, 0, errMsg.c_str() );
|
||||||
|
}else{
|
||||||
|
updateProgress( UpdateStatus::FILE_SAVED, 100, localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppUpdater::verifyAndSaveFile(WiFiClient* stream, size_t contentLength, const char* localPath, const char* expectedMd5)
|
||||||
|
{
|
||||||
|
MD5Builder md5;
|
||||||
|
md5.begin();
|
||||||
|
size_t totalRead = 0;
|
||||||
|
|
||||||
|
// Create temporary filename
|
||||||
|
String tempPath = String(localPath) + ".tmp";
|
||||||
|
|
||||||
|
// Open temporary file for writing
|
||||||
|
File file = fileSystem.open(tempPath.c_str(), FILE_WRITE);
|
||||||
|
if (!file) {
|
||||||
|
ESP_LOGE(TAG, "Failed to open temporary file for writing");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//updateProgress(UpdateStatus::DOWNLOADING, 0, localPath);
|
||||||
|
|
||||||
|
if (contentLength > 0) {
|
||||||
|
// Single pass with known content length
|
||||||
|
while (totalRead < contentLength) {
|
||||||
|
if(g_UpdateCancelFlag){ file.close(); fileSystem.remove(tempPath.c_str()); return false; }
|
||||||
|
size_t available = stream->available();
|
||||||
|
if (available) {
|
||||||
|
size_t readLen = stream->readBytes(downloadBuffer.get(), std::min(available, size_t(BUFFER_SIZE)));
|
||||||
|
|
||||||
|
// Write to temp file and update MD5
|
||||||
|
if (file.write(downloadBuffer.get(), readLen) != readLen) {
|
||||||
|
ESP_LOGE(TAG, "Failed to write to temporary file");
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
md5.add(downloadBuffer.get(), readLen);
|
||||||
|
totalRead += readLen;
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, (totalRead * 80) / contentLength , localPath);
|
||||||
|
}
|
||||||
|
yield();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown content length: read until stream ends
|
||||||
|
for (;;) {
|
||||||
|
if(g_UpdateCancelFlag){ file.close(); fileSystem.remove(tempPath.c_str()); return false; }
|
||||||
|
size_t readLen = stream->readBytes(downloadBuffer.get(), BUFFER_SIZE);
|
||||||
|
if (readLen == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (file.write(downloadBuffer.get(), readLen) != readLen) {
|
||||||
|
ESP_LOGE(TAG, "Failed to write to temporary file");
|
||||||
|
file.close();
|
||||||
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
md5.add(downloadBuffer.get(), readLen);
|
||||||
|
totalRead += readLen;
|
||||||
|
// Progress unknown; emit periodic heartbeats at 0%
|
||||||
|
// For unknown size, send heartbeats every ~16KB
|
||||||
|
if((totalRead & 0x3FFF) == 0){
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, 0, localPath);
|
||||||
|
}
|
||||||
|
yield();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
md5.calculate();
|
||||||
|
String calculatedMd5 = md5.toString();
|
||||||
|
|
||||||
|
// Verify MD5 hash
|
||||||
|
updateProgress(UpdateStatus::VERIFYING, 90, localPath);
|
||||||
|
if (!calculatedMd5.equals(expectedMd5)) {
|
||||||
|
//ESP_LOGE(TAG, "MD5 mismatch for %s", localPath);
|
||||||
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(UpdateStatus::VERIFYING, 95, localPath);
|
||||||
|
|
||||||
|
// Replace original file with verified temp file
|
||||||
|
if (fileSystem.exists(localPath)) {
|
||||||
|
fileSystem.remove(localPath);
|
||||||
|
}
|
||||||
|
if (!fileSystem.rename(tempPath.c_str(), localPath)) {
|
||||||
|
ESP_LOGE(TAG, "Failed to rename temporary file");
|
||||||
|
fileSystem.remove(tempPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(UpdateStatus::VERIFYING, 100, localPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String AppUpdater::getLocalMD5(const char* filePath){
|
||||||
|
File file = fileSystem.open(filePath, "r");
|
||||||
|
if(!file){
|
||||||
|
ESP_LOGE(TAG, "Error opening %s...", filePath);
|
||||||
|
return String();
|
||||||
|
}
|
||||||
|
|
||||||
|
MD5Builder md5Builder;
|
||||||
|
md5Builder.begin();
|
||||||
|
size_t fileSize = file.size();
|
||||||
|
size_t totalRead = 0;
|
||||||
|
size_t readLen = 0;
|
||||||
|
while (totalRead < fileSize) {
|
||||||
|
readLen = file.readBytes(reinterpret_cast<char*>(downloadBuffer.get()), std::min(fileSize - totalRead, size_t(BUFFER_SIZE)));
|
||||||
|
md5Builder.add(downloadBuffer.get(), readLen);
|
||||||
|
totalRead += readLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
md5Builder.calculate();
|
||||||
|
file.close();
|
||||||
|
return md5Builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppUpdater::updateFilesArray() {
|
||||||
|
int successCount = 0;
|
||||||
|
int totalFiles = jsonFilesArray.size();
|
||||||
|
ESP_LOGI(TAG, "Found %d files in manifest", totalFiles);
|
||||||
|
|
||||||
|
// Iterate over each file entry in the manifest
|
||||||
|
for (JsonObject file : jsonFilesArray) {
|
||||||
|
const char* remotePath = file["path"];
|
||||||
|
const char* localPath = remotePath;
|
||||||
|
// If path begins with "data/" or "/data/" strip only the "data" portion, retaining the leading slash
|
||||||
|
if (localPath) {
|
||||||
|
if (strncmp(localPath, "data/", 5) == 0) {
|
||||||
|
localPath += 4; // points to '/'
|
||||||
|
} else if (strncmp(localPath, "/data/", 6) == 0) {
|
||||||
|
localPath += 5; // points to '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const char* expectedMd5 = file["md5"];
|
||||||
|
|
||||||
|
// Skip invalid entries
|
||||||
|
if (!remotePath || !localPath || !expectedMd5) {
|
||||||
|
ESP_LOGE(TAG, "Invalid file entry in manifest");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to update the file
|
||||||
|
if (updateFile(remotePath, localPath, expectedMd5)) {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Manifest update complete: %d/%d files updated", successCount, totalFiles);
|
||||||
|
return successCount == totalFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppUpdater::updateApp() {
|
||||||
|
updateProgress(UpdateStatus::MESSAGE, 0, "Starting firmware update");
|
||||||
|
|
||||||
|
// Check for firmware section in manifest
|
||||||
|
if (!jsonManifest["firmware"].is<JsonObject>() || !jsonManifest["firmware"]["md5"].is<const char*>()) {
|
||||||
|
ESP_LOGE(TAG, "Invalid firmware section in manifest");
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Invalid firmware section in manifest");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the firmware MD5 hash and URL
|
||||||
|
const char* expectedMd5 = jsonManifest["firmware"]["md5"];
|
||||||
|
String firmwareUrl = buildUrl(appName);
|
||||||
|
|
||||||
|
// Download the firmware
|
||||||
|
HTTPClient http;
|
||||||
|
int httpCode = -1;
|
||||||
|
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
|
||||||
|
if(g_UpdateCancelFlag) return false;
|
||||||
|
http.begin(firmwareUrl);
|
||||||
|
httpCode = http.GET();
|
||||||
|
if(httpCode == HTTP_CODE_OK) break;
|
||||||
|
ESP_LOGW(TAG, "Firmware GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
|
||||||
|
http.end();
|
||||||
|
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
|
||||||
|
}
|
||||||
|
if (httpCode != HTTP_CODE_OK) {
|
||||||
|
ESP_LOGE(TAG, "Firmware download failed: %d", httpCode);
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Firmware download failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check available space
|
||||||
|
size_t firmwareSize = http.getSize();
|
||||||
|
if (!Update.begin(firmwareSize > 0 ? firmwareSize : UPDATE_SIZE_UNKNOWN)) {
|
||||||
|
ESP_LOGE(TAG, "Firmware: Not enough space for update");
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Not enough space for update");
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up MD5 checking
|
||||||
|
MD5Builder md5;
|
||||||
|
md5.begin();
|
||||||
|
|
||||||
|
// Download and verify firmware
|
||||||
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
|
if (firmwareSize > 0) {
|
||||||
|
size_t remaining = firmwareSize;
|
||||||
|
while (remaining > 0) {
|
||||||
|
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
|
||||||
|
size_t chunk = std::min(remaining, size_t(BUFFER_SIZE));
|
||||||
|
size_t read = stream->readBytes(downloadBuffer.get(), chunk);
|
||||||
|
|
||||||
|
// Check for timeout
|
||||||
|
if (read == 0) {
|
||||||
|
ESP_LOGE(TAG, "Read timeout");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update MD5 and write firmware
|
||||||
|
md5.add(downloadBuffer.get(), read);
|
||||||
|
if (Update.write(downloadBuffer.get(), read) != read) {
|
||||||
|
ESP_LOGE(TAG, "Write failed");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= read;
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, (firmwareSize - remaining) * 100 / firmwareSize, "firmware");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown size: stream until end
|
||||||
|
for (;;) {
|
||||||
|
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
|
||||||
|
size_t read = stream->readBytes(downloadBuffer.get(), BUFFER_SIZE);
|
||||||
|
if (read == 0) break;
|
||||||
|
md5.add(downloadBuffer.get(), read);
|
||||||
|
if (Update.write(downloadBuffer.get(), read) != read) {
|
||||||
|
ESP_LOGE(TAG, "Write failed");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
updateProgress(UpdateStatus::DOWNLOADING, 0, "firmware");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MD5
|
||||||
|
md5.calculate();
|
||||||
|
String calculatedMd5 = md5.toString();
|
||||||
|
updateProgress(UpdateStatus::VERIFYING, 95, "firmware");
|
||||||
|
if (!calculatedMd5.equals(expectedMd5)) {
|
||||||
|
ESP_LOGE(TAG, "MD5 mismatch. Expected: %s, Got: %s", expectedMd5, calculatedMd5.c_str());
|
||||||
|
updateProgress(UpdateStatus::MD5_FAILED, 0, "Firmware: MD5 mismatch");
|
||||||
|
Update.abort();
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish update
|
||||||
|
if (!Update.end()) {
|
||||||
|
ESP_LOGE(TAG, "Update end failed");
|
||||||
|
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Update failed");
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
updateProgress(UpdateStatus::COMPLETE, 100, "Firmware: Complete");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppUpdater::IsUpdateAvailable(){
|
||||||
|
return updateAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
String AppUpdater::buildUrl(const char* path) const {
|
||||||
|
if(!path || !*path) return baseUrl; // just base
|
||||||
|
String p(path);
|
||||||
|
// If already absolute URL, pass through
|
||||||
|
if(p.startsWith("http://") || p.startsWith("https://")) return p;
|
||||||
|
// Strip leading slashes to avoid double
|
||||||
|
while(p.startsWith("/")) p.remove(0,1);
|
||||||
|
// Ensure baseUrl has single trailing slash
|
||||||
|
String b = baseUrl;
|
||||||
|
if(!b.endsWith("/")) b += "/";
|
||||||
|
return b + p;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AsyncEventSource* eventProgress = nullptr;
|
||||||
|
void startFirmwareUpdateTask(AsyncEventSource* evProg) {
|
||||||
|
eventProgress = evProg;
|
||||||
|
if(Update_Task_Handle) {
|
||||||
|
ESP_LOGW(TAG, "Firmware update task already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xTaskCreate(firmwareUpdateTask, "FirmwareUpdate", 1024*8, NULL, 1, &Update_Task_Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void firmwareUpdateTask(void* parameter) {
|
||||||
|
static const char* TAG = "UpdateTask";
|
||||||
|
AppUpdater* updater = nullptr;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadUpdateJson();
|
||||||
|
|
||||||
|
// Initialize updater
|
||||||
|
updater = new AppUpdater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
|
||||||
|
updater->setProgressCallback(updateProgress);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Starting update check from: %s", updateUrl.c_str());
|
||||||
|
|
||||||
|
// Check and perform updates
|
||||||
|
auto manifestResult = updater->checkManifest();
|
||||||
|
|
||||||
|
if (manifestResult != AppUpdater::ManifestCheckResult::UPDATE_AVAILABLE) {
|
||||||
|
// Handle different error cases
|
||||||
|
std::string errorMsg;
|
||||||
|
switch (manifestResult) {
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_FETCH_FAILED:
|
||||||
|
errorMsg = "Failed to fetch manifest";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_TOO_LARGE:
|
||||||
|
errorMsg = "Manifest file too large";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_PARSE_FAILED:
|
||||||
|
errorMsg = "Failed to parse manifest";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_NO_FILES_SECTION:
|
||||||
|
errorMsg = "Manifest missing files section";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::ERROR_NO_VERSION:
|
||||||
|
errorMsg = "Manifest missing version section";
|
||||||
|
break;
|
||||||
|
case AppUpdater::ManifestCheckResult::VERSION_CURRENT:
|
||||||
|
errorMsg = "Current version is up to date";
|
||||||
|
// This is not actually an error
|
||||||
|
ESP_LOGI(TAG, "No update needed: %s", errorMsg.c_str());
|
||||||
|
throw std::runtime_error(errorMsg);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMsg = "Unknown manifest check error";
|
||||||
|
}
|
||||||
|
throw std::runtime_error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updater->IsUpdateAvailable()) {
|
||||||
|
ESP_LOGI(TAG, "Update available, updating files...");
|
||||||
|
|
||||||
|
if (!updater->updateFilesArray()) {
|
||||||
|
throw std::runtime_error("Failed to update files");
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Updating firmware...");
|
||||||
|
if (!updater->updateApp()) {
|
||||||
|
throw std::runtime_error("Failed to update firmware");
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Update successful, restarting...");
|
||||||
|
|
||||||
|
sendUpdateMessage("Restarting ", true, 100);
|
||||||
|
vTaskDelay(2000);
|
||||||
|
|
||||||
|
ESP.restart();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
ESP_LOGE(TAG, "Update failed: %s", e.what());
|
||||||
|
}
|
||||||
|
delete updater;
|
||||||
|
Update_Task_Handle = NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void startVersionCheckTask() {
|
||||||
|
if(versionCheckTask_Handle != NULL) {
|
||||||
|
ESP_LOGW(TAG, "Version Check Tak already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xTaskCreate(versionCheckTask, "VersionCheckTask", 1024*8, NULL, 1, &versionCheckTask_Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void versionCheckTask(void* parameter){
|
||||||
|
if(updateUrl == ""){
|
||||||
|
loadUpdateJson();
|
||||||
|
}
|
||||||
|
AppUpdater updater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
|
||||||
|
|
||||||
|
auto manifestResult = updater.checkManifest();
|
||||||
|
|
||||||
|
if (manifestResult == AppUpdater::ManifestCheckResult::UPDATE_AVAILABLE ||
|
||||||
|
manifestResult == AppUpdater::ManifestCheckResult::VERSION_CURRENT) {
|
||||||
|
otaVersion = updater.otaVersion; // capture remote
|
||||||
|
ESP_LOGI(TAG, "Version check: remote=%s", otaVersion.toString().c_str());
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(TAG, "Version check: manifest check failed with code %d", static_cast<int>(manifestResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
versionCheckTask_Handle = NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadUpdateJson(void) {
|
||||||
|
try {
|
||||||
|
ESP_LOGD(TAG, "loadUpdateJaon function...");
|
||||||
|
if(updateUrl == "") {
|
||||||
|
String updateJsonPath = "/system/update.json";
|
||||||
|
|
||||||
|
// Read and parse update.json
|
||||||
|
File file = LittleFS.open(updateJsonPath);
|
||||||
|
if (!file) {
|
||||||
|
throw std::runtime_error("Failed to open update.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (error) { throw std::runtime_error("Failed to parse update.json"); }
|
||||||
|
|
||||||
|
// Get update configuration
|
||||||
|
JsonObject jObj = doc.as<JsonObject>();
|
||||||
|
String folderName = jsonConstrainString(TAG, jObj, "folder", "latest/");
|
||||||
|
String baseUrl = jsonConstrainString(TAG, jObj, "baseurl", "https://s3-minio.boothwizard.com/boothifier/");
|
||||||
|
updateUrl = baseUrl + folderName;
|
||||||
|
|
||||||
|
ESP_LOGD(TAG, "updateUrl: %s", updateUrl.c_str());
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
ESP_LOGE(TAG, "Update failed: %s", e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateProgress(AppUpdater::UpdateStatus newStatus, int percentage, const char* message = nullptr) {
|
||||||
|
|
||||||
|
char buffer[128];
|
||||||
|
const char* msg;
|
||||||
|
bool isComplete = false;
|
||||||
|
|
||||||
|
const char* safeMsg = message ? message : "";
|
||||||
|
switch (newStatus) {
|
||||||
|
case AppUpdater::UpdateStatus::IDLE:
|
||||||
|
snprintf(buffer, sizeof(buffer), "Update idle");
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::MESSAGE:
|
||||||
|
msg = message ? message : "";
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::DOWNLOADING:
|
||||||
|
snprintf(buffer, sizeof(buffer), "%s: Download progress: %d%%", safeMsg, percentage);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::VERIFYING:
|
||||||
|
snprintf(buffer, sizeof(buffer), "%s: Verifying update: %d%%", safeMsg, percentage);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::FILE_SKIPPED:
|
||||||
|
snprintf(buffer, sizeof(buffer), "%s: File Skipped, up to date", safeMsg);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::FILE_SAVED:
|
||||||
|
snprintf(buffer, sizeof(buffer), "%s: File Saved", safeMsg);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::MD5_FAILED:
|
||||||
|
snprintf(buffer, sizeof(buffer), "%s: MD5 Verification Failed", safeMsg);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::COMPLETE:
|
||||||
|
snprintf(buffer, sizeof(buffer), "Firmware Update Complete!!!");
|
||||||
|
msg = buffer;
|
||||||
|
isComplete = true;
|
||||||
|
break;
|
||||||
|
case AppUpdater::UpdateStatus::ERROR:
|
||||||
|
snprintf(buffer, sizeof(buffer), "Error!: %s", safeMsg);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
snprintf(buffer, sizeof(buffer), "Unknown update status: %d", (int)newStatus);
|
||||||
|
msg = buffer;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "%s", msg);
|
||||||
|
sendUpdateMessage(msg, isComplete, percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendUpdateMessage(const char* message, bool complete, int progress = -1) {
|
||||||
|
|
||||||
|
if(eventProgress && eventProgress->count() > 0) {
|
||||||
|
// This is for the web client and not the BLE client
|
||||||
|
JsonDocument jsonDoc;
|
||||||
|
jsonDoc["message"] = message;
|
||||||
|
jsonDoc["complete"] = complete;
|
||||||
|
jsonDoc["progress"] = progress;
|
||||||
|
String strMessage;
|
||||||
|
serializeJson(jsonDoc, strMessage);
|
||||||
|
eventProgress->send(strMessage.c_str(), "update", millis());
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
ESP_LOGW(TAG, "No clients connected to event source");
|
||||||
|
}
|
||||||
|
|
||||||
|
bleUpgrade_send_message(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Removed duplicate global checkManifest; AppUpdater::checkManifest used instead)
|
||||||
|
|
||||||
|
/*
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
|
||||||
|
// Initialize WiFi connection first
|
||||||
|
// ... WiFi connection code ...
|
||||||
|
|
||||||
|
// Initialize filesystem
|
||||||
|
if(!LittleFS.begin()) {
|
||||||
|
Serial.println("LittleFS Mount Failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create updater instance with:
|
||||||
|
// - Current version: "1.0.0"
|
||||||
|
// - Update server URL: "https://my-update-server.com/"
|
||||||
|
// - Filesystem: LittleFS
|
||||||
|
AppUpdater updater("1.0.0", "https://storage.googleapis.com/boothifier/latest/", LittleFS);
|
||||||
|
|
||||||
|
// Set progress callback
|
||||||
|
updater.setProgressCallback([](int progress) {
|
||||||
|
Serial.printf("Update progress: %d%%\n", progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check and update firmware
|
||||||
|
if (updater.checkAndUpdate()) {
|
||||||
|
Serial.println("Update successful! Rebooting...");
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update specific files from manifest
|
||||||
|
int updatedFiles = updater.updateFilesFromManifest("test_update.json");
|
||||||
|
Serial.printf("Updated %d files\n", updatedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
533
temporary/ata-boothifier-upgradeV3_old.html
Normal file
533
temporary/ata-boothifier-upgradeV3_old.html
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ATA Firmware Update</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: left;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-ble {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: gray;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-wifi {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: gray;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-internet {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: gray;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adds space above the WiFi Connect button */
|
||||||
|
.btn-container.wifi {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 130px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tab-bar { display:flex; gap:8px; justify-content:center; margin:12px 0 16px; flex-wrap:wrap; }
|
||||||
|
.tab-bar button { max-width:none; flex:0 0 auto; background:#6c757d; }
|
||||||
|
.tab-bar button.active { background:#007bff; }
|
||||||
|
.tab-panel { display:none; }
|
||||||
|
.tab-panel.active { display:block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>ATA Firmware Update</h1>
|
||||||
|
|
||||||
|
<!-- Tab Buttons -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn active" data-tab="tab-upgrade">Upgrade</button>
|
||||||
|
<button class="tab-btn" data-tab="tab-wifi">WiFi Comm</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-upgrade" class="tab-panel active">
|
||||||
|
<!-- Status Indicators -->
|
||||||
|
<div class="status-container">
|
||||||
|
<span class="status-indicator-ble"></span>
|
||||||
|
<label id="status-ble-connection">BLE Status: ...</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-container">
|
||||||
|
<span class="status-indicator-wifi"></span>
|
||||||
|
<label id="status-wifi-client">Wifi Client: ...</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-container">
|
||||||
|
<span class="status-indicator-internet"></span>
|
||||||
|
<label id="status-internet">Internet: ...</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-container">
|
||||||
|
<label id="status-current-version">Curr Version: ...</label>
|
||||||
|
</div>
|
||||||
|
<div class="status-container">
|
||||||
|
<label id="status-new-version">New Version: ...</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-container">
|
||||||
|
<label id="ble-device-name">Device Name:</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-container">
|
||||||
|
<input type="text" id="input-DeviceName" placeholder="..." style="width: 100%; max-width: 220px;" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div class="btn-container">
|
||||||
|
<button id="bleConnectBtn" onclick="connectToBle()">Connect</button>
|
||||||
|
<button id="checkStatusBtn" onclick="checkStatus()" disabled>Check Status</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Area -->
|
||||||
|
<textarea id="logArea" readonly></textarea>
|
||||||
|
|
||||||
|
<div class="btn-container">
|
||||||
|
<button id="checkVersionBtn" onclick="checkVersion()" disabled>Check Version</button>
|
||||||
|
<button id="startUpgradeBtn" onclick="startUpgrade()" disabled>Start Update</button>
|
||||||
|
</div>
|
||||||
|
</div> <!-- /tab-upgrade -->
|
||||||
|
|
||||||
|
<div id="tab-wifi" class="tab-panel">
|
||||||
|
<h2 style="margin-top:0; font-size:18px;">WiFi Connection</h2>
|
||||||
|
<div class="input-container">
|
||||||
|
<input type="text" id="wifissid" name="wifissid" placeholder="Enter WiFi SSID" required>
|
||||||
|
<input type="password" id="wifipassword" name="wifipassword" placeholder="Enter WiFi Password" required>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" id="showPassword" onclick="togglePasswordVisibility()" style="width: auto;">
|
||||||
|
<label for="showPassword">Show Password</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-container wifi">
|
||||||
|
<button id="wifiConnectBtn" onclick="wifiConnect()" disabled>Connect Wifi</button>
|
||||||
|
</div>
|
||||||
|
</div><!-- /tab-wifi -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ================= Constants & Packet Layout ================= */
|
||||||
|
const BLE_SERVER_NAME = "ATALIGHTS"; // Keep hardcoded per instruction (ignore external JSON)
|
||||||
|
const BLE_SERVICE_UUID = "abcdef01-2345-6789-1234-56789abcdef0";
|
||||||
|
const BLE_CHARACTERISTIC1_UUID = "abcdef01-2345-6789-1234-56789abcdef1"; // Control / status
|
||||||
|
const BLE_CHARACTERISTIC2_UUID = "abcdef02-2345-6789-1234-56789abcdef1"; // Logs / events
|
||||||
|
|
||||||
|
// Packet layout (mirrors firmware struct updateStatus)
|
||||||
|
// byte 0 : wifiStatus (enum)
|
||||||
|
// byte 1 : wifiOnline (bool)
|
||||||
|
// bytes2-5: wifiIP
|
||||||
|
// bytes6-8: currVersion (major,minor,patch)
|
||||||
|
// bytes9-11: newVersion (major,minor,patch)
|
||||||
|
// bytes12-31: wifiSSID (20 bytes, null padded)
|
||||||
|
const PACKET_LEN = 32;
|
||||||
|
const OFF_WIFI_STATUS = 0;
|
||||||
|
const OFF_WIFI_ONLINE = 1;
|
||||||
|
const OFF_WIFI_IP = 2;
|
||||||
|
const OFF_CURR_VER = 6;
|
||||||
|
const OFF_NEW_VER = 9;
|
||||||
|
const OFF_WIFI_SSID = 12;
|
||||||
|
|
||||||
|
const WIFI_STAT = { DISCONNECTED:0, BAD_CREDS:1, NO_AP:2, CONNECTED:3 };
|
||||||
|
const WIFI_STAT_TEXT = ["Disconnected","Bad Creds","No AP","Connected"];
|
||||||
|
const MAX_LOG_LINES = 400;
|
||||||
|
|
||||||
|
/* ================= State ================= */
|
||||||
|
let bleDevice=null, bleCharacteristic1=null, bleCharacteristic2=null;
|
||||||
|
let bleConnected=false;
|
||||||
|
const state = {
|
||||||
|
wifiStatus: WIFI_STAT.DISCONNECTED,
|
||||||
|
wifiOnline:false,
|
||||||
|
wifiIP:[0,0,0,0],
|
||||||
|
currVersion:[0,0,0],
|
||||||
|
newVersion:[0,0,0],
|
||||||
|
wifiSSID:""
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================= Cached DOM ================= */
|
||||||
|
const el = {};
|
||||||
|
function cacheDom(){
|
||||||
|
el.bleIndicator = document.querySelector('.status-indicator-ble');
|
||||||
|
el.wifiIndicator = document.querySelector('.status-indicator-wifi');
|
||||||
|
el.internetIndicator = document.querySelector('.status-indicator-internet');
|
||||||
|
el.lblBle = document.getElementById('status-ble-connection');
|
||||||
|
el.lblWifi = document.getElementById('status-wifi-client');
|
||||||
|
el.lblInternet = document.getElementById('status-internet');
|
||||||
|
el.lblCurrVer = document.getElementById('status-current-version');
|
||||||
|
el.lblNewVer = document.getElementById('status-new-version');
|
||||||
|
el.inDeviceName = document.getElementById('input-DeviceName');
|
||||||
|
el.inSsid = document.getElementById('wifissid');
|
||||||
|
el.inPass = document.getElementById('wifipassword');
|
||||||
|
el.chkShowPass = document.getElementById('showPassword');
|
||||||
|
el.btnBleConnect = document.getElementById('bleConnectBtn');
|
||||||
|
el.btnCheckStatus = document.getElementById('checkStatusBtn');
|
||||||
|
el.btnCheckVersion = document.getElementById('checkVersionBtn');
|
||||||
|
el.btnStartUpgrade = document.getElementById('startUpgradeBtn');
|
||||||
|
el.btnWifiConnect = document.getElementById('wifiConnectBtn');
|
||||||
|
el.logArea = document.getElementById('logArea');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Utilities ================= */
|
||||||
|
function logMessage(msg){
|
||||||
|
const lines = el.logArea.value.trim().length ? el.logArea.value.split(/\n/) : [];
|
||||||
|
lines.push(msg);
|
||||||
|
if(lines.length > MAX_LOG_LINES){
|
||||||
|
lines.splice(0, lines.length - MAX_LOG_LINES);
|
||||||
|
}
|
||||||
|
el.logArea.value = lines.join('\n') + '\n';
|
||||||
|
el.logArea.scrollTop = el.logArea.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(a,b){
|
||||||
|
for(let i=0;i<3;i++){ if(a[i]>b[i]) return 1; if(a[i]<b[i]) return -1; }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorIndicator(elm, color){ if(elm) elm.style.backgroundColor = color; }
|
||||||
|
|
||||||
|
function ipToString(ip){ return ip.join('.'); }
|
||||||
|
|
||||||
|
/* ================= Packet Handling ================= */
|
||||||
|
function parsePacket(data){
|
||||||
|
if(data.length !== PACKET_LEN) return false;
|
||||||
|
state.wifiStatus = data[OFF_WIFI_STATUS];
|
||||||
|
if(state.wifiStatus > WIFI_STAT.CONNECTED) state.wifiStatus = WIFI_STAT.DISCONNECTED; // clamp
|
||||||
|
state.wifiOnline = !!data[OFF_WIFI_ONLINE];
|
||||||
|
state.wifiIP = Array.from(data.slice(OFF_WIFI_IP, OFF_WIFI_IP+4));
|
||||||
|
state.currVersion = Array.from(data.slice(OFF_CURR_VER, OFF_CURR_VER+3));
|
||||||
|
state.newVersion = Array.from(data.slice(OFF_NEW_VER, OFF_NEW_VER+3));
|
||||||
|
// Extract SSID (stop at first 0)
|
||||||
|
let rawSsidBytes = data.slice(OFF_WIFI_SSID, OFF_WIFI_SSID+20);
|
||||||
|
let zeroIndex = rawSsidBytes.indexOf(0);
|
||||||
|
if(zeroIndex >= 0) rawSsidBytes = rawSsidBytes.slice(0, zeroIndex);
|
||||||
|
state.wifiSSID = rawSsidBytes.length ? String.fromCharCode(...rawSsidBytes) : "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(){
|
||||||
|
// BLE
|
||||||
|
el.lblBle.textContent = 'BLE Status: ' + (bleConnected ? 'Connected' : 'Disconnected');
|
||||||
|
colorIndicator(el.bleIndicator, bleConnected ? 'green' : 'gray');
|
||||||
|
|
||||||
|
// WiFi client
|
||||||
|
const statText = WIFI_STAT_TEXT[state.wifiStatus] || 'Unknown';
|
||||||
|
if(state.wifiStatus === WIFI_STAT.CONNECTED){
|
||||||
|
const ssidPart = state.wifiSSID ? ' SSID: '+state.wifiSSID : '';
|
||||||
|
el.lblWifi.textContent = 'Wifi Client: ' + statText + (state.wifiIP[0] ? ' ('+ipToString(state.wifiIP)+')':'' ) + ssidPart;
|
||||||
|
colorIndicator(el.wifiIndicator, 'green');
|
||||||
|
} else if(state.wifiStatus === WIFI_STAT.BAD_CREDS){
|
||||||
|
el.lblWifi.textContent = 'Wifi Client: Bad Credentials';
|
||||||
|
colorIndicator(el.wifiIndicator, 'orange');
|
||||||
|
} else if(state.wifiStatus === WIFI_STAT.NO_AP){
|
||||||
|
el.lblWifi.textContent = 'Wifi Client: AP Not Found';
|
||||||
|
colorIndicator(el.wifiIndicator, 'orange');
|
||||||
|
} else {
|
||||||
|
el.lblWifi.textContent = 'Wifi Client: ' + statText;
|
||||||
|
colorIndicator(el.wifiIndicator, 'gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internet
|
||||||
|
el.lblInternet.textContent = state.wifiOnline ? 'Online' : 'Offline';
|
||||||
|
colorIndicator(el.internetIndicator, state.wifiOnline ? 'green' : 'gray');
|
||||||
|
|
||||||
|
// Versions
|
||||||
|
el.lblCurrVer.textContent = state.currVersion[0] ? 'Curr Version: ' + state.currVersion.join('.') : 'Curr Version: ...';
|
||||||
|
el.lblNewVer.textContent = state.newVersion[0] ? 'New Version: ' + state.newVersion.join('.') : 'New Version: ...';
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
el.btnCheckStatus.disabled = !bleConnected;
|
||||||
|
el.btnWifiConnect.disabled = !bleConnected;
|
||||||
|
el.btnCheckVersion.disabled = !(bleConnected && state.wifiOnline);
|
||||||
|
const newerAvail = state.newVersion[0] && state.wifiOnline && compareVersions(state.newVersion, state.currVersion) > 0;
|
||||||
|
el.btnStartUpgrade.disabled = !newerAvail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= BLE Operations ================= */
|
||||||
|
async function connectToBle(){
|
||||||
|
if(!navigator.bluetooth){ logMessage('Web Bluetooth not supported.'); return; }
|
||||||
|
try{
|
||||||
|
bleDevice = await navigator.bluetooth.requestDevice({
|
||||||
|
filters:[{ name: el.inDeviceName.value || BLE_SERVER_NAME }],
|
||||||
|
optionalServices:[BLE_SERVICE_UUID]
|
||||||
|
});
|
||||||
|
const server = await bleDevice.gatt.connect();
|
||||||
|
const service = await server.getPrimaryService(BLE_SERVICE_UUID);
|
||||||
|
bleCharacteristic1 = await service.getCharacteristic(BLE_CHARACTERISTIC1_UUID);
|
||||||
|
bleCharacteristic2 = await service.getCharacteristic(BLE_CHARACTERISTIC2_UUID);
|
||||||
|
await bleCharacteristic2.startNotifications();
|
||||||
|
bleCharacteristic2.addEventListener('characteristicvaluechanged', e => {
|
||||||
|
try{
|
||||||
|
const view = e.target.value; // DataView
|
||||||
|
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||||
|
|
||||||
|
// Debug info
|
||||||
|
let debugInfo = `Received ${bytes.length} bytes: `;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
debugInfo += bytes[i].toString(16).padStart(2, '0') + ' ';
|
||||||
|
}
|
||||||
|
console.log(debugInfo);
|
||||||
|
|
||||||
|
// Try to decode as text
|
||||||
|
let txt = '';
|
||||||
|
try {
|
||||||
|
txt = new TextDecoder().decode(bytes);
|
||||||
|
// Remove null terminators and trim
|
||||||
|
const nullIdx = txt.indexOf('\0');
|
||||||
|
if (nullIdx !== -1) txt = txt.slice(0, nullIdx);
|
||||||
|
txt = txt.trim();
|
||||||
|
} catch (decodeErr) {
|
||||||
|
console.error('Text decode error:', decodeErr);
|
||||||
|
// Fallback to showing hex if text decode fails
|
||||||
|
txt = '[Binary data: ' + debugInfo + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show both raw and processed data in console
|
||||||
|
console.log('--> Raw bytes:', bytes);
|
||||||
|
console.log('--> As text:', txt);
|
||||||
|
|
||||||
|
// Log message with length info for debugging
|
||||||
|
logMessage(`--> (${bytes.length} bytes) ${txt}`);
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Processing error', err);
|
||||||
|
logMessage('--> Error processing message: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bleConnected=true;
|
||||||
|
bleDevice.addEventListener('gattserverdisconnected', onDisconnect);
|
||||||
|
const connectedName = bleDevice.name || el.inDeviceName.value || 'Device';
|
||||||
|
logMessage('Connected to ' + connectedName);
|
||||||
|
const nameLabel = document.getElementById('ble-device-name');
|
||||||
|
if(nameLabel){ nameLabel.textContent = 'Device Name: ' + connectedName; }
|
||||||
|
await readPacket();
|
||||||
|
updateUI();
|
||||||
|
}catch(err){
|
||||||
|
logMessage( err.message.includes('cancel') ? 'Connection cancelled.' : ('Connection failed: '+err.message) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisconnect(){
|
||||||
|
bleConnected=false; updateUI(); logMessage('BLE disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPacket(msg){
|
||||||
|
if(!bleCharacteristic1) return;
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
for(let attempt=0; attempt<3; attempt++){
|
||||||
|
try{ await bleCharacteristic1.writeValueWithResponse(enc.encode(msg)); return true; }
|
||||||
|
catch(e){ if(attempt===2) logMessage('Send failed: '+e.message); else await delay(1000); }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPacket(){
|
||||||
|
if(!bleCharacteristic1) return false;
|
||||||
|
for(let attempt=0; attempt<3; attempt++){
|
||||||
|
try{
|
||||||
|
const val = await bleCharacteristic1.readValue();
|
||||||
|
const data = new Uint8Array(val.buffer);
|
||||||
|
if(parsePacket(data)) return true; else { logMessage('Packet parse failed (len='+data.length+')'); return false; }
|
||||||
|
}catch(e){ if(attempt===2) logMessage('Read failed: '+e.message); else await delay(1000); }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Actions ================= */
|
||||||
|
async function wifiConnect(){
|
||||||
|
const ssid = el.inSsid.value.trim();
|
||||||
|
const pw = el.inPass.value;
|
||||||
|
if(!ssid || !pw){ alert('Enter SSID & password'); return; }
|
||||||
|
logMessage('Sending WiFi credentials...');
|
||||||
|
el.btnWifiConnect.disabled = true; // prevent multiple submissions while polling
|
||||||
|
await sendPacket('wifi-connect {"ssid":"'+ssid+'","pass":"'+pw+'"} ');
|
||||||
|
// Poll for status for up to 15s
|
||||||
|
const start = Date.now();
|
||||||
|
while(Date.now()-start < 15000){
|
||||||
|
await delay(1000);
|
||||||
|
await readPacket();
|
||||||
|
updateUI();
|
||||||
|
if(state.wifiStatus === WIFI_STAT.CONNECTED){
|
||||||
|
logMessage('WiFi Connected: '+ipToString(state.wifiIP));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if(state.wifiStatus === WIFI_STAT.BAD_CREDS){ logMessage('WiFi Error: Bad Credentials'); break; }
|
||||||
|
if(state.wifiStatus === WIFI_STAT.NO_AP){ logMessage('WiFi Error: AP Not Found'); break; }
|
||||||
|
}
|
||||||
|
if(state.wifiStatus !== WIFI_STAT.CONNECTED){ logMessage('WiFi connect attempt finished with status: '+WIFI_STAT_TEXT[state.wifiStatus]); }
|
||||||
|
if(!bleConnected) return; // keep disabled if BLE disconnected during process
|
||||||
|
// Re-enable for retry unless connected
|
||||||
|
if(state.wifiStatus !== WIFI_STAT.CONNECTED){ el.btnWifiConnect.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus(){ if(await readPacket()) updateUI(); }
|
||||||
|
|
||||||
|
async function checkVersion(){
|
||||||
|
el.btnCheckVersion.disabled = true;
|
||||||
|
logMessage('Checking for new version...');
|
||||||
|
await sendPacket('version-check');
|
||||||
|
const start = Date.now();
|
||||||
|
while(Date.now()-start < 15000){
|
||||||
|
await delay(750);
|
||||||
|
await readPacket();
|
||||||
|
if(state.newVersion[0]){ logMessage('Latest version: '+state.newVersion.join('.')); break; }
|
||||||
|
}
|
||||||
|
if(!state.newVersion[0]) logMessage('No new version info received');
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startUpgrade(){
|
||||||
|
if(el.btnStartUpgrade.disabled) return;
|
||||||
|
logMessage('Starting upgrade...');
|
||||||
|
el.btnStartUpgrade.disabled = true;
|
||||||
|
await sendPacket('upgrade-start');
|
||||||
|
// Progress will arrive via characteristic2 logs
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= Helpers ================= */
|
||||||
|
const delay = ms => new Promise(r=>setTimeout(r,ms));
|
||||||
|
|
||||||
|
function togglePasswordVisibility(){ el.inPass.type = el.chkShowPass.checked ? 'text' : 'password'; }
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
cacheDom();
|
||||||
|
el.inDeviceName.value = BLE_SERVER_NAME;
|
||||||
|
el.chkShowPass.addEventListener('change', togglePasswordVisibility);
|
||||||
|
// Inline onclicks already wired; ensure functions are in scope
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn=>{
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const id = btn.getAttribute('data-tab');
|
||||||
|
const panel = document.getElementById(id);
|
||||||
|
if(panel) panel.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateUI();
|
||||||
|
logMessage('Ready. Enter device name or use default and press Connect.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose functions for inline handlers
|
||||||
|
window.connectToBle = connectToBle;
|
||||||
|
window.checkStatus = checkStatus;
|
||||||
|
window.checkVersion = checkVersion;
|
||||||
|
window.startUpgrade = startUpgrade;
|
||||||
|
window.wifiConnect = wifiConnect;
|
||||||
|
window.togglePasswordVisibility = togglePasswordVisibility;
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', init);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
249
temporary/my_buzzer.cpp
Normal file
249
temporary/my_buzzer.cpp
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
#include "my_buzzer.h"
|
||||||
|
#include <FS.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
|
||||||
|
#include <anyrtttl.h>
|
||||||
|
#include <binrtttl.h>
|
||||||
|
#include <pitches.h>
|
||||||
|
|
||||||
|
#include "JsonConstrain.h"
|
||||||
|
#include "global.h"
|
||||||
|
|
||||||
|
const char* DEFAULT_MELODY = "Ack:d=16,o=5,b=200:c,e,g";
|
||||||
|
|
||||||
|
// serial debugging enabled
|
||||||
|
//#define ANY_RTTTL_INFO
|
||||||
|
|
||||||
|
static const char* tag = "buzzer";
|
||||||
|
|
||||||
|
BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
|
||||||
|
int8_t buzzPin;
|
||||||
|
int8_t buzzerChannel = -1; // Store the LEDC channel used by the buzzer
|
||||||
|
|
||||||
|
// File-scope state for tune management (minimal overhead)
|
||||||
|
static volatile int prev_tune = -1;
|
||||||
|
static volatile bool buzzer_busy = false;
|
||||||
|
|
||||||
|
// Optimized tone functions - minimal overhead, no logging
|
||||||
|
void buzzerTone(uint8_t pin, unsigned int frequency, unsigned long duration) {
|
||||||
|
if (buzzerChannel >= 0 && frequency > 0) {
|
||||||
|
ledcWriteTone(buzzerChannel, frequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void buzzerNoTone(uint8_t pin) {
|
||||||
|
if (buzzerChannel >= 0) {
|
||||||
|
ledcWrite(buzzerChannel, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Init_Buzzer(int8_t pin, const char* configFile, int8_t channel)
|
||||||
|
{
|
||||||
|
buzzPin = pin;
|
||||||
|
if(buzzPin >= 0){
|
||||||
|
pinMode(buzzPin, OUTPUT);
|
||||||
|
|
||||||
|
// If channel is not provided, find an unused one
|
||||||
|
if (channel < 0) {
|
||||||
|
buzzerChannel = findUnusedLedcChannel();
|
||||||
|
if (buzzerChannel < 0) {
|
||||||
|
ESP_LOGE(tag, "No available LEDC channel for buzzer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use the provided channel and mark it as used
|
||||||
|
extern bool markLedcChannelUsed(int ch); // Function from global.cpp
|
||||||
|
if (markLedcChannelUsed(channel)) {
|
||||||
|
buzzerChannel = channel;
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(tag, "Requested channel %d is already in use, finding alternative", channel);
|
||||||
|
buzzerChannel = findUnusedLedcChannel();
|
||||||
|
if (buzzerChannel < 0) {
|
||||||
|
ESP_LOGE(tag, "No available LEDC channel for buzzer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the channel for the buzzer with proper audio frequency range
|
||||||
|
ledcSetup(buzzerChannel, 2000, 10); // 2000 Hz base, 10-bit resolution for better frequency range
|
||||||
|
ledcAttachPin(buzzPin, buzzerChannel);
|
||||||
|
|
||||||
|
// Test the channel is working
|
||||||
|
ESP_LOGI(tag, "Testing buzzer channel %d...", buzzerChannel);
|
||||||
|
ledcWriteTone(buzzerChannel, 1000); // Test tone
|
||||||
|
delay(100);
|
||||||
|
ledcWrite(buzzerChannel, 0); // Stop test tone
|
||||||
|
|
||||||
|
// Set custom tone functions for anyrtttl
|
||||||
|
anyrtttl::setToneFunction(buzzerTone);
|
||||||
|
anyrtttl::setNoToneFunction(buzzerNoTone);
|
||||||
|
|
||||||
|
ESP_LOGI(tag, "Buzzer hardware initialized on pin %d using LEDC channel %d", buzzPin, buzzerChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Buzzer_Load_Tunes(configFile); // Load Tunes
|
||||||
|
ESP_LOGI(tag, "Buzzer initialized on pin %d, channel %d", buzzPin, buzzerChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
int8_t Buzzer_Get_Channel() {
|
||||||
|
return buzzerChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Buzzer_Play_Tune(TUNE_TYPE tune, bool async, bool hasPriority)
|
||||||
|
{
|
||||||
|
// Fast path checks - minimal overhead
|
||||||
|
if (buzzPin < 0 || buzzerChannel < 0) {
|
||||||
|
ESP_LOGW(tag, "Buzzer not initialized - pin:%d, channel:%d", buzzPin, buzzerChannel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tune < 0 || tune >= TUNE_MAX_COUNT) {
|
||||||
|
ESP_LOGW(tag, "Invalid tune index: %d (max: %d)", (int)tune, TUNE_MAX_COUNT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct reference to avoid String copying
|
||||||
|
const String& melody = buzzTune[tune].melody;
|
||||||
|
if (melody.isEmpty()) {
|
||||||
|
ESP_LOGW(tag, "Empty melody for tune %d", (int)tune);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(tag, "Playing tune %d: %s (async=%d, priority=%d)", (int)tune, melody.c_str(), async, hasPriority);
|
||||||
|
|
||||||
|
// Simple atomic check for thread safety without mutex overhead
|
||||||
|
if (buzzer_busy && !hasPriority) {
|
||||||
|
ESP_LOGD(tag, "Buzzer busy, skipping tune %d", (int)tune);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async mode: minimal state management
|
||||||
|
if (async) {
|
||||||
|
bool playing = anyrtttl::nonblocking::isPlaying();
|
||||||
|
if (hasPriority && playing) {
|
||||||
|
ESP_LOGD(tag, "Stopping current tune for priority tune %d", (int)tune);
|
||||||
|
anyrtttl::nonblocking::stop();
|
||||||
|
playing = false;
|
||||||
|
}
|
||||||
|
if (!playing || prev_tune != tune) {
|
||||||
|
ESP_LOGI(tag, "Starting async tune %d", (int)tune);
|
||||||
|
anyrtttl::nonblocking::begin(buzzPin, melody.c_str());
|
||||||
|
prev_tune = tune;
|
||||||
|
}
|
||||||
|
anyrtttl::nonblocking::play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocking mode: minimal cycles with yield for multitasking
|
||||||
|
ESP_LOGI(tag, "Playing blocking tune %d, cycles=%d", (int)tune, buzzTune[tune].cycles);
|
||||||
|
buzzer_busy = true;
|
||||||
|
const int cycles = buzzTune[tune].cycles;
|
||||||
|
const int pause_ms = buzzTune[tune].pause;
|
||||||
|
|
||||||
|
for (int c = 0; c < cycles; ++c) {
|
||||||
|
anyrtttl::blocking::play(buzzPin, melody.c_str());
|
||||||
|
if (pause_ms > 0 && c + 1 < cycles) {
|
||||||
|
delay(pause_ms);
|
||||||
|
}
|
||||||
|
yield(); // Allow other tasks to run
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_tune = tune;
|
||||||
|
buzzer_busy = false;
|
||||||
|
ESP_LOGI(tag, "Finished playing tune %d", (int)tune);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimized beep function - minimal overhead
|
||||||
|
void Buzzer_Beep(int mSecs, int freq)
|
||||||
|
{
|
||||||
|
if (buzzPin < 0 || buzzerChannel < 0) return;
|
||||||
|
|
||||||
|
ledcWriteTone(buzzerChannel, freq);
|
||||||
|
delay(mSecs);
|
||||||
|
ledcWrite(buzzerChannel, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test function to verify buzzer functionality
|
||||||
|
void Buzzer_Test() {
|
||||||
|
if (buzzPin < 0 || buzzerChannel < 0) {
|
||||||
|
ESP_LOGE(tag, "Cannot test buzzer - not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(tag, "Testing buzzer...");
|
||||||
|
|
||||||
|
// Test direct LEDC control
|
||||||
|
ESP_LOGI(tag, "Test 1: Direct LEDC tones");
|
||||||
|
for (int freq = 500; freq <= 2000; freq += 500) {
|
||||||
|
ESP_LOGI(tag, "Playing %d Hz", freq);
|
||||||
|
ledcWriteTone(buzzerChannel, freq);
|
||||||
|
delay(200);
|
||||||
|
ledcWrite(buzzerChannel, 0);
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test custom tone functions
|
||||||
|
ESP_LOGI(tag, "Test 2: Custom tone functions");
|
||||||
|
buzzerTone(buzzPin, 1000, 500);
|
||||||
|
delay(500);
|
||||||
|
buzzerNoTone(buzzPin);
|
||||||
|
|
||||||
|
// Test anyrtttl with a simple melody
|
||||||
|
ESP_LOGI(tag, "Test 3: anyrtttl blocking play");
|
||||||
|
anyrtttl::blocking::play(buzzPin, DEFAULT_MELODY);
|
||||||
|
|
||||||
|
ESP_LOGI(tag, "Buzzer test complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimized tune loading - minimal memory allocation
|
||||||
|
void Buzzer_Load_Tunes(const char* tunesPath){
|
||||||
|
ESP_LOGI(tag, "Loading tunes from: %s", tunesPath);
|
||||||
|
File file = LittleFS.open(tunesPath);
|
||||||
|
if (!file) {
|
||||||
|
ESP_LOGW(tag, "Could not open %s, using default tune", tunesPath);
|
||||||
|
// Set default tune only at index 0
|
||||||
|
buzzTune[0].cycles = 1;
|
||||||
|
buzzTune[0].pause = 0;
|
||||||
|
buzzTune[0].melody = DEFAULT_MELODY;
|
||||||
|
ESP_LOGI(tag, "Loaded default tune at index 0: %s", DEFAULT_MELODY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use smaller JSON document for memory efficiency
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if(error){
|
||||||
|
ESP_LOGE(tag, "JSON parse error: %s", error.c_str());
|
||||||
|
// Set default tune on error
|
||||||
|
buzzTune[0].cycles = 1;
|
||||||
|
buzzTune[0].pause = 0;
|
||||||
|
buzzTune[0].melody = DEFAULT_MELODY;
|
||||||
|
ESP_LOGI(tag, "Loaded default tune due to JSON error: %s", DEFAULT_MELODY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray tuneJsonArray = doc["tunes"];
|
||||||
|
if(!tuneJsonArray.isNull()){
|
||||||
|
int tuneIndex = 0;
|
||||||
|
for(JsonObject obj : tuneJsonArray){
|
||||||
|
if(tuneIndex >= TUNE_MAX_COUNT) break;
|
||||||
|
buzzTune[tuneIndex].cycles = jsonConstrain<int>(tag, obj, "cycles", 1, 100, 1);
|
||||||
|
buzzTune[tuneIndex].pause = jsonConstrain<int>(tag, obj, "pause", 0, 100, 0);
|
||||||
|
buzzTune[tuneIndex].melody = jsonConstrainString(tag, obj, "tune", DEFAULT_MELODY);
|
||||||
|
ESP_LOGI(tag, "Loaded tune %d: cycles=%d, pause=%d, melody=%.40s...",
|
||||||
|
tuneIndex, buzzTune[tuneIndex].cycles, buzzTune[tuneIndex].pause,
|
||||||
|
buzzTune[tuneIndex].melody.c_str());
|
||||||
|
tuneIndex++;
|
||||||
|
}
|
||||||
|
ESP_LOGI(tag, "Successfully loaded %d tunes", tuneIndex);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(tag, "No 'tunes' array found in JSON");
|
||||||
|
// Set default tune if no tunes array found
|
||||||
|
buzzTune[0].cycles = 1;
|
||||||
|
buzzTune[0].pause = 0;
|
||||||
|
buzzTune[0].melody = DEFAULT_MELODY;
|
||||||
|
ESP_LOGI(tag, "Loaded default tune: %s", DEFAULT_MELODY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -183,7 +183,7 @@ void Wifi_Init() {
|
|||||||
ESP_LOGD(tag, "AP started with IP: %s", WiFi.softAPIP().toString().c_str());
|
ESP_LOGD(tag, "AP started with IP: %s", WiFi.softAPIP().toString().c_str());
|
||||||
|
|
||||||
// Start the WiFi task
|
// Start the WiFi task
|
||||||
xTaskCreatePinnedToCore(Wifi_Task, "Wifi_Task", 1024*4, NULL, 1, &Wifi_Task_Handle, 0);
|
xTaskCreatePinnedToCore(Wifi_Task, "Wifi_Task", 1024*6, NULL, 1, &Wifi_Task_Handle, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Wifi_Load_Settings(String path){
|
void Wifi_Load_Settings(String path){
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user