diff --git a/CHANGELOG.md b/CHANGELOG.md index 080ec42d..30d8fd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Hardware +## [24.12.8] + +### Added + +### Changed +- Modernized web interface. +- Refactored http server. + +### Hardware + ## [24.12.7] ### Added diff --git a/data/bluetoothscanner.html b/data/bluetoothscanner.html index 575663f3..0715ff43 100644 --- a/data/bluetoothscanner.html +++ b/data/bluetoothscanner.html @@ -1,238 +1,177 @@ - - + - + + SmartSpin2k Bluetooth Scanner - - + - -
- http://github.com/doudar/SmartSpin2k -

Main Index

-

-
Loading
-

-

Select Bluetooth Devices

-

-

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

-
-

-
-

-
-

-
-

-
-

-
-
Power Correction FactorIncrease or decrease - to correct the power transmitted from your bike.
-
-
- 1x -
- - - -


-
- -
-

-
- - -

Page Help

-
-

- - - - - \ No newline at end of file + function loadCss() { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'style.css'; + document.head.appendChild(link); + } + + window.addEventListener('load', () => { + setTimeout(loadCss, 100); + requestConfigValues(); + + // Retry loading if values are still default + setInterval(() => { + if (document.getElementById('connectedPowerMeter').textContent === 'loading') { + requestConfigValues(); + } + }, 1000); + }); + + + diff --git a/data/btsimulator.html b/data/btsimulator.html index c362cfad..c65db1c2 100644 --- a/data/btsimulator.html +++ b/data/btsimulator.html @@ -1,327 +1,399 @@ - - + - - SmartSpin2k Web Server - -
-

-
Loading
-

- http://github.com/doudar/SmartSpin2k -

Main Index

-

BLE Device Simulator

- -

Sim Heart Rate

-

-

-

- -

Sim Power Output

-
-

- -   - - - - -

-

- -

-
-

- -

- -

Sim CAD Output

-
-

- -   - - - - -

-

- -

+
+
+ +
+ +
+ +

BLE Simulator

+
+
+ +

Simulate Heart Rate

+ +
+ +
+ -- BPM +
+
+
+ + +
+

Simulate Power Output

+ +
+ +
+ -- W + +
+
+
+ + +
+

Simulate Cadence

+ +
+ +
+ -- RPM + +
+
+
+ + +
+

Simulate ERG Mode

+ +
+ +
+ -- W +
+ +
+
+
+
+
-

- -

- -

Trainer Simulator

-

Enable ERG

-

- -

-

ERG Target Watts

-

-

-

- -

- - + \ No newline at end of file diff --git a/data/develop.html b/data/develop.html index da2d3955..36e617cf 100644 --- a/data/develop.html +++ b/data/develop.html @@ -1,48 +1,103 @@ - - + - + + + + + - - \ No newline at end of file + window.addEventListener('load', () => { + setTimeout(loadCss, 100); + }); + + + diff --git a/data/hrtowatts.html b/data/hrtowatts.html index bcf94fe3..c0d7ae26 100644 --- a/data/hrtowatts.html +++ b/data/hrtowatts.html @@ -1,126 +1,170 @@ - - - + - - SmartSpin2k Web Server - + + + SmartSpin2k Heart Rate to Power + - -
- http://github.com/doudar/SmartSpin2k -

Main Index

-

-
Loading
-

-

Physical Working Capacity

-

-

For the most accurate power estimation when not using a power meter, please submit the following information. -
Note: You can get estimated watts from any outdoor ride recorded in Strava with heart rate information. -

-
- - - - - - - - - - - - - - - - - - - - - - -
-

Easy Session Average HRSession 1 HR

Average - Heartrate over an easy 1 Hour course.

-
-

Easy Session Average PowerAverage Power over an easy 1 hour - course in watts.

-
-
-

Hard Session Average HRAverage HR over a hard 1 hour - course.

-
-
-

Hard Session Average PowerAverage Power over a hard 1 hour - course in watts.

-
-
-

HR->PWRAutomatically calculate watts using - heart rate when power meter not connected

-
-

- -

Page Help

-
-

- +
+
+ +
- - - \ No newline at end of file + + fetch('/send_settings?' + params.toString(), { method: 'GET' }) + .then(() => { + showSaveStatus('Settings saved successfully', 'success'); + }) + .catch(error => { + showSaveStatus('Failed to save settings', 'error'); + console.error('Error:', error); + }); + }); + + function showSaveStatus(message, type = 'info') { + saveStatus.textContent = message; + saveStatus.className = `status-message ${type}`; + setTimeout(() => { + saveStatus.textContent = ''; + saveStatus.className = 'status-message'; + }, 3000); + } + + function requestConfigValues() { + fetch('/PWCJSON') + .then(response => response.json()) + .then(data => { + document.getElementById('session1HR').value = data.session1HR; + document.getElementById('session1Pwr').value = data.session1Pwr; + document.getElementById('session2HR').value = data.session2HR; + document.getElementById('session2Pwr').value = data.session2Pwr; + document.getElementById('hr2Pwr').checked = data.hr2Pwr; + document.getElementById('loadingWatermark')?.remove(); + }) + .catch(error => console.error('Error:', error)); + } + + function loadCss() { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'style.css'; + document.head.appendChild(link); + } + + window.addEventListener('load', () => { + setTimeout(loadCss, 100); + requestConfigValues(); + + // Retry loading if values are still default + setInterval(() => { + if (document.getElementById('session1HR').value === '0') { + requestConfigValues(); + } + }, 1000); + }); + + + diff --git a/data/index.html b/data/index.html index 516e25c6..aaee8a58 100644 --- a/data/index.html +++ b/data/index.html @@ -1,67 +1,139 @@ - - + - - - SmartSpin2k Web Server - + + + SmartSpin2k + -
http://github.com/doudar/SmartSpin2k -

SmartSpin2k

-

-

Web Shifter

-

Heartrate to Watts Setup

-

Settings

-

Bluetooth Scanner

-

Developer Tools

-

SS2K Help

-

-
+
+
+ +
-

- +
+

SmartSpin2k

+ +
+ +
⚙️
+
+

Web Shifter

+

Control gear shifting manually

+
    +
  • Manual gear control
  • +
  • Real-time shifting
  • +
+
+
+
- + + + diff --git a/data/settings.html b/data/settings.html index bfdb371a..c9bd5368 100644 --- a/data/settings.html +++ b/data/settings.html @@ -1,380 +1,387 @@ - - + - - SmartSpin2k Web Server - + + + SmartSpin2k Settings + - -
- http://github.com/doudar/SmartSpin2k -

Main Index

-

-
Loading
-

-

Settings

-

-

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

- SSID - - WiFi network name. -
- If it doesn't exist I will create a network with this name. -
-

-
- -
-

- Password - - Password for the WiFi network. - -

-
- -

- Show - - Show the password for the WiFi network. - -

-
-

- MDNS Name - - DNS Name the device will use on the network. - -

-
-
-

- Sim Mode
Shift Amount - - Amount to move stepper per gear shift.
Try to target ~30watt changes. -
-

-
-
- 0 -
- - - -
-

- Sim Mode
Incline Multiplier - - Increase to make incline changes more noticeable. -

- Adjust until hills feel realistic. -
-

-
-
- 0x -
- - - - -
-

ERG Mode
Sensitivity - - Increase to make ERG Mode more aggressive. - -

-
-
- 1.0 -
- - - -
-

Min Bike
Brake Watts - - Set the minimum watts until stepper stops.
0 disables check. -
-

-
-
- 0W -
- - - -
-

Max Bike
Brake Watts - - Set the most watts you've ever seen your bike absorb while you're pedaling.
0 disables check. -
-

-
-
- 0W -
- - - -
-

Stepper Motor
Power - - Amount in milli amps for stepper motor -

- Set to the minimum required to move knob smoothly -
-

-
-
- 0ma -
- - - -
-

Stepper StealthChop - - Make stepper silent at expense of torque - -

-
- -
-

Automatic Updates - - Check for new firmware on boot? - -

-
- -
-

Stepper Motor
Direction - - Change Stepper Direction - -

-
- -
-

Shifter DirectionChange Shifter Direction

-
- -
-

Enable UDP LoggingSending log-messages via UDP Port - 10.000

-
- -
- -
-

-
- -
-

-

Page Help

-

-
- -

-
- - - \ No newline at end of file + function showSaveStatus(message, type = 'info') { + saveStatus.textContent = message; + saveStatus.className = `status-message ${type}`; + setTimeout(() => { + saveStatus.textContent = ''; + saveStatus.className = 'status-message'; + }, 3000); + } + + function startConfigUpdate() { + setTimeout(() => { + if (document.getElementById('ssid').value === 'loading') { + requestConfigValues(); + } + }, 1500); + } + + function requestConfigValues() { + fetch('/configJSON') + .then(response => response.json()) + .then(data => { + Object.entries(data).forEach(([key, value]) => { + const element = document.getElementById(key); + if (element) { + if (element.type === 'checkbox') { + element.checked = !!value; + } else { + element.value = value; + const valueElement = document.getElementById(key + 'Value'); + if (valueElement) updateSlider(value, valueElement); + } + } + }); + document.getElementById('loadingWatermark')?.remove(); + }) + .catch(() => startConfigUpdate()); + } + + function updateSlider(value, valueElement) { + valueElement.textContent = value; + } + + function updateSliderAndSend(element, valueElement) { + updateSlider(element.value, valueElement); + sendSetting(element); + } + + function clickStep(element, direction) { + const step = parseFloat(element.step); + const newValue = parseFloat(element.value) + (direction === '+' ? step : -step); + element.value = newValue; + updateSliderAndSend(element, document.getElementById(element.name + 'Value')); + } + + function toggleShowPassword() { + const input = document.getElementById('password'); + input.type = input.type === 'password' ? 'text' : 'password'; + } + + function sendSetting(element) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const params = new URLSearchParams(); + + if (element.type === 'checkbox') { + if (element.checked) params.append(element.name, 'true'); + } else { + params.append(element.name, element.value); + } + + ['stealthChop', 'autoUpdate', 'stepperDir', 'shifterDir', 'udpLogEnabled'].forEach(id => { + if (document.getElementById(id).checked) { + params.append(id, 'true'); + } + }); + + fetch('/send_settings?' + params.toString(), { method: 'GET' }) + .then(() => showSaveStatus('Settings saved', 'success')) + .catch(error => showSaveStatus('Failed to save settings', 'error')); + }, 300); + } + + function loadCss() { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'style.css'; + document.head.appendChild(link); + } + + window.addEventListener('load', () => { + setTimeout(loadCss, 100); + startConfigUpdate(); + }); + + + diff --git a/data/shift.html b/data/shift.html index 079fc13a..9afc24e6 100644 --- a/data/shift.html +++ b/data/shift.html @@ -1,93 +1,98 @@ - - - + - - SmartSpin2k Web Server - + + + SmartSpin2k Web Shifter + - -
-

-
Loading
-

- http://github.com/doudar/SmartSpin2k -

Main Index

-

Shift

+
+
+ +
-

-

-

-

- Current Gear -
-

-
- - + function loadCss() { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'style.css'; + document.head.appendChild(link); + } - \ No newline at end of file + window.addEventListener('load', () => { + setTimeout(loadCss, 100); + setTimeout(requestConfigValues, 500); + startUpdate(); + }); + + + diff --git a/data/status.html b/data/status.html index 067dac5b..c127db7d 100644 --- a/data/status.html +++ b/data/status.html @@ -1,260 +1,270 @@ - + - - SmartSpin2k Web Server - -
- http://github.com/doudar/SmartSpin2k -

Main Index

-

Status

-

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

-
-

-
-

-
-

-
-

-
-

-
- - -

-
-

-
- -

-
-

-
-

-
- -

-
-
- - - - - - - - - - -
-

Debugging Info:

-
-
- - -
-
-
Loading
-
-
-

- +
+
+ +
- + function loadCss() { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'style.css'; + document.head.appendChild(link); + } + + window.addEventListener('load', () => { + setTimeout(loadCss, 100); + requestConfigValues(); + setupLogging(); + setTimeout(requestRuntimeValues, 200); + startUpdate(); + }); + + document.getElementById('saveLogButton').addEventListener('click', () => { + const debugElement = document.getElementById('debug'); + logContent = debugElement.textContent; + logContent = logContent.replace(/(\[\s*\d+\])/g, '\n$1'); + const blob = new Blob([logContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'debug_log.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + + + \ No newline at end of file diff --git a/data/streamfit.html b/data/streamfit.html index 72138f73..fe4b299d 100644 --- a/data/streamfit.html +++ b/data/streamfit.html @@ -1,34 +1,94 @@ - - - - SmartSpin2k Web Server + + + SmartSpin2k StreamFit + - -
- http://github.com/doudar/SmartSpin2k -

Main Index

-

StreamFit

- - - - -

-

-
REQUIRES INTERNET CONNECTION
-
-
-
-

-

- -

- -
- - - - \ No newline at end of file +
+
+ +
+ +
+

StreamFit

+ +
+
+

Upload a .fit file to simulate a recorded workout.

+

Requires internet connection for file processing.

+
+ +
+ + +
REQUIRES INTERNET CONNECTION
+
+ +
+
+
❤️
+
Heart Rate
+
--
+
BPM
+
+ +
+
+
Power
+
--
+
W
+
+ +
+
🔄
+
Cadence
+
--
+
RPM
+
+
+ +
+ +
+
+ + +
+
+ + + + + diff --git a/data/style.css b/data/style.css index d5e8b2a6..61690d36 100644 --- a/data/style.css +++ b/data/style.css @@ -1,237 +1,891 @@ -html { - font-family: sans-serif; - display: inline-block; - margin: 5px auto; - text-align: center; - background-color: #03245c; - line-height: 1em; -} - -label { - font-size: medium; +header, +nav { + display: flex; + justify-content: space-between; + padding: 1rem 0; +} +.brand, +.menu-item, +html, +nav a { + color: #fff; +} +.brand, +h1 { + font-weight: 700; +} +.menu-item, +.status-group { + background: rgba(3, 37, 76, 0.6); +} +.brand, +.gear-display, +.gear-value { + text-align: center; +} +.debug-console, +.gear-display, +.status-item input { + box-sizing: border-box; + width: 100%; +} +.dev-tool-card, +.menu-item, +a, +nav a { + text-decoration: none; +} +.switch, +.tooltip { + position: relative; } - -div { - font-size: medium; +html { + font-family: system-ui, -apple-system, sans-serif; + line-height: 1.4; + background: #03245c; + -webkit-text-size-adjust: 100%; } +body { + margin: 0; + min-height: 100vh; + background: #1167b1; +} +.page-container { + max-width: 1200px; + margin: 0 auto; + padding: 1rem; +} +header { + align-items: center; +} +.brand { + font-size: 2.5rem; + text-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); + margin: 1rem 0; + width: 100%; + letter-spacing: 1px; +} +nav { + align-items: center; + width: 100%; +} +.card-header, +.debug-header { + justify-content: space-between; +} +nav a { + padding: 0.5rem; +} +nav a:hover { + text-decoration: underline; +} +.menu-grid { + display: grid; + gap: 1.5rem; + padding: 2rem 0; + max-width: 800px; + margin: 0 auto; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} +.menu-item { + border-radius: 12px; + padding: 1.5rem; + transition: 0.3s; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + display: block; +} +.shift-button, +.shifter-container, -a { - color: #000000; +.dev-tool-card:hover, +.menu-item:hover { + background: rgba(42, 157, 244, 0.15); + border-color: rgba(42, 157, 244, 0.5); + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25); +} +.menu-content { + padding: 0.5rem; +} +.menu-content h2 { + margin: 0 0 0.75rem; + font-size: 1.3rem; + font-weight: 600; + color: #2a9df4; +} +.menu-content p { + margin: 0; + opacity: 0.9; + font-size: 1rem; + line-height: 1.5; + color: #fff; +} +a:hover, +h1, +h2 { + color: #2a9df4; } -a:visited { - color: #000000; +h1, +h2 { + margin: 0 0 1rem; } h1 { - color: #03245c; - padding: 0.5rem; - line-height: 1em; + font-size: 2rem; } h2 { - color: #000000; - font-size: 1.5rem; - font-weight: bold; + font-size: 1.2rem; + font-weight: 600; } p { - font-size: 1rem; -} -.button { - display: inline-block; - background-color: #2a9df4; - border: line; - border-radius: 4px; - color: #d0efff; - padding: 10px 40px; - text-decoration: none; - font-size: 20px; - margin: 0px; - cursor: pointer; -} -.button2 { - background-color: #f44336; - padding: 10px 35px; + margin: 0.5rem 0; + line-height: 1.6; } -.switch { - position: relative; - display: inline-block; - width: 80px; - height: 40px; +a { + color: #fff; + transition: color 0.2s; +} +.status-group { + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.1); +} +.device-status h2, +.status-group h2 { + margin: 0 0 1.5rem; + color: #2a9df4; + font-size: 1.2rem; + border-bottom: 1px solid rgba(42, 157, 244, 0.3); + padding-bottom: 0.5rem; +} +.follow-toggle, +.status-item label { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; +} +.status-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} +.status-item { + background: rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 8px; +} +.status-item label { + display: block; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.status-item input { + padding: 0.75rem; + background: rgba(3, 37, 76, 0.6); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: #2a9df4; + font-size: 1.1rem; + font-weight: 500; + text-align: center; +} +.debug-section { + background: rgba(3, 37, 76, 0.6); + border-radius: 12px; + padding: 1.5rem; + margin-top: 2rem; +} +.debug-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} +.debug-header h2 { + margin: 0; + color: #2a9df4; + font-size: 1.2rem; +} +.follow-toggle { + display: flex; + align-items: center; + gap: 0.5rem; +} +.debug-console { + margin: 0; + padding: 1rem; + background-color: #000; + background-image: radial-gradient(rgba(0, 150, 0, 0.75), #000 120%); + height: 40vh; + resize: both; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #fff; + font-family: Inconsolata, monospace; + font-size: 1.1rem; + line-height: 1.4; + overflow: auto; + text-shadow: 0 0 4px rgba(200, 200, 200, 0.5); } + +.shifter-container { + max-width: 600px; + margin: 2rem auto; + padding: 2rem; + background: rgba(3, 37, 76, 0.6); + border-radius: 12px; +} +.shift-controls { + display: flex; + flex-direction: column; + gap: 2rem; + align-items: center; +} +.shift-button { + width: 100%; + height: 80px; + border: none; + border-radius: 8px; + color: #fff; + font-size: 1.2rem; + font-weight: 600; + cursor: pointer; + transition: 0.2s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem; +} +.scan-button:active, +.shift-button.active, +.shift-button:active { + transform: translateY(1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} +.device-select:focus, +.number-input:focus, +input:focus + .slider { + box-shadow: 0 0 0 3px rgba(42, 157, 244, 0.4); +} +.shift-up { + background: linear-gradient(to bottom, #2ecc71, #27ae60); +} +.shift-up:hover { + background: linear-gradient(to bottom, #27ae60, #219a52); +} +.shift-down { + background: linear-gradient(to bottom, #e74c3c, #c0392b); +} +.shift-down:hover { + background: linear-gradient(to bottom, #c0392b, #a93224); +} +.shift-arrow { + font-size: 1.5rem; + line-height: 1; +} +.shift-label { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 1px; +} +.metric-label, +.reset-defaults-button { + text-transform: uppercase; + letter-spacing: .5px; +} +.gear-display { + background: rgba(0, 0, 0, 0.2); + padding: 2rem; + border-radius: 8px; +} +.gear-label { + display: block; + font-size: 1.2rem; + margin-bottom: 1rem; + color: rgba(255, 255, 255, 0.9); +} +.gear-value { + font-size: 2.5rem; + font-weight: 700; + color: #2a9df4; + background: 0 0; + border: none; + width: 100%; + padding: 0; +} +.input-group { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + background: rgba(0, 0, 0, 0.2); + padding: 2em; + border-radius: 4px; +} +.number-input { + width: 120px; + padding: 0rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 1.1rem; + text-align: center; + transition: .3s; +} +.unit, +.value-display { + font-weight: 500; + color: #2a9df4; +} +.device-select:focus, +.number-input:focus { + outline: 0; + border-color: #2a9df4; +} +.number-input::-webkit-inner-spin-button, +.number-input::-webkit-outer-spin-button { + opacity: 1; +} +.unit { + min-width: 40px; +} +.toggle-group { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} +.settings-grid { + display: grid; + gap: 2rem; + padding: 1rem 0; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + max-width: 100%; + margin: 0 auto; +} +.settings-section { + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 0.5rem; +} +.setting-group { + margin-bottom: 1.5rem; + background: rgba(3, 37, 76, 0.6); + border-radius: 6px; + padding: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.1); +} +.setting-group input[type="password"], +.setting-group input[type="text"] { + width: 100%; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.219); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: #fff; + font-size: 1rem; + box-sizing: border-box; +} +.slider-group { + display: flex; + align-items: center; + gap: 1rem; + min-height: 60px; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + justify-content: center; +} +.slider-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + max-width: 400px; + margin: 0 auto; +} +.slider-container input[type="range"] { + width: 100%; +} +.value-display { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 5.5rem; +} +.value-display span { + margin-left: 20px; +} +.adjust-button { + width: 50px; + height: 50px; + border: none; + background: #03254c; + color: #fff; + border-radius: 50%; + cursor: pointer; + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + flex-shrink: 0; +} +.adjust-button:hover, +input:checked + .slider { + background: #2a9df4; +} +.button-group { + display: flex; + gap: 1rem; + margin-top: 2rem; + justify-content: flex-end; +} +.reset-defaults-button { + font-size: 1.1em; + padding: 1rem 2rem; + border: 2px solid rgba(255, 255, 255, 0.2); + font-weight: 600; + animation: 2s infinite pulse; +} +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); + } +} +.device-status { + background: rgba(3, 37, 76, 0.8); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; +} +.status-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} +.status-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-weight: 500; + color: #2a9df4; +} +.device-select { + width: 100%; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 1rem; + cursor: pointer; + transition: 0.2s; +} +.device-select:hover { + background: rgba(255, 255, 255, 0.15); +} +.device-select option { + background: #03245c; + color: #fff; + padding: 0.5rem; +} +.scan-section { + margin-top: 3rem; + text-align: center; + padding: 2rem; + background: rgba(3, 37, 76, 0.6); + border-radius: 8px; +} +.scan-button { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + width: 100%; + max-width: 300px; + margin: 0 auto; + padding: 1rem; + background: linear-gradient(to right, #2a9df4, #1b8fe3); + border: none; + border-radius: 8px; + color: #fff; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: 0.3s; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +.dev-tool-card, +.upload-container { + background: rgba(3, 37, 76, 0.6); + border-radius: 12px; +} +.scan-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + background: linear-gradient(to right, #1b8fe3, #0d7ac9); +} +.scan-icon { + font-size: 1.5rem; + animation: 2s linear infinite spin; + display: inline-block; +} +@keyframes spin { + from { + transform: rotate(0); + } + to { + transform: rotate(360deg); + } +} +.scan-note { + margin-top: 1.5rem; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + line-height: 1.6; +} +.scan-note em { + color: #2a9df4; + font-style: normal; +} +.upload-container { + text-align: center; + padding: 2rem; + margin-bottom: 2rem; +} +.file-upload { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1rem; + border: 2px dashed rgba(255, 255, 255, 0.2); + border-radius: 8px; + cursor: pointer; + transition: 0.3s; +} +.file-upload:hover { + border-color: #2a9df4; + background: rgba(42, 157, 244, 0.1); +} +.upload-icon { + font-size: 2.5rem; + color: #2a9df4; +} +.file-input, .switch input { - display: none; -} -.slider { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - transition: 0.4s; - background-color: #03254c; - border-radius: 34px; -} -.slider:before { - position: absolute; - content: ""; - height: 37px; - width: 37px; - left: 2px; - bottom: 2px; - background-color: #d0efff; - -webkit-transition: 0.4s; - transition: 0.4s; - border-radius: 68px; + display: none; +} +.dev-tool-card, +.dev-tools-grid, +.metrics-grid { + display: grid; + gap: 1.5rem; +} +.metrics-grid { + grid-template-columns: repeat(auto-fit, minmax(100px, 0.5fr)); + margin: 0.5rem; +} +.metric-card { + background: rgba(3, 37, 76, 0.6); + border-radius: 8px; + padding: 0.5rem; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.1); +} +.metric-icon { + font-size: 2rem; + margin-bottom: 0.5rem; +} +.metric-label { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 0.5rem; +} +.metric-value { + font-size: 2rem; + font-weight: 600; + color: #2a9df4; + margin-bottom: 0.25rem; +} +.metric-unit { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); +} +.dev-tools-grid { + padding: 2rem 0; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} +.dev-tool-card { + padding: 1.5rem; + color: #fff; + transition: 0.3s; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + grid-template-columns: auto 1fr auto; + align-items: start; +} +.info-box, +.tool-icon { + background: rgba(42, 157, 244, 0.1); +} +.tool-icon { + font-size: 2rem; + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; +} +.tool-content { + flex: 1; +} +.tool-features { + list-style: none; + padding: 0; + margin: 0; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); +} +.tool-features li { + margin-bottom: 0.5rem; + display: flex; + align-items: center; +} +.tool-features li:before { + content: "•"; + color: #2a9df4; + margin-right: 0.5rem; + font-size: 1.2em; +} +.card-arrow { + font-size: 1.5rem; + color: rgba(255, 255, 255, 0.3); + transition: transform 0.2s; +} +.dev-tool-card:hover .card-arrow { + transform: translateX(4px); + color: #2a9df4; } -input:checked + .slider { - transition: 0.4s; - background-color: #2a9df4; +.switch { + display: inline-block; + width: 60px; + height: 34px; +} +.switch .slider { + position: absolute; + inset: 0; + background: #03254c; + border-radius: 34px; + transition: 0.4s; +} +.switch .slider:before { + position: absolute; + content: ""; + height: 26px; + width: 26px; + left: 4px; + bottom: 4px; + background: #fff; + border-radius: 50%; + transition: 0.4s; } input:checked + .slider:before { - -webkit-transform: translateX(38px); - -ms-transform: translateX(38px); - transform: translateX(38px); -} -.slider2 { - -webkit-appearance: none; - margin: 5px; - width: 270px; - height: 20px; - background: #d0efff; - /*outline:8px ridge rgba(170,50,220, .6); - border-radius: 2rem;*/ - outline: none; - -webkit-transition: 0.2s; - transition: opacity 0.2s; -} -.slider2::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 30px; - height: 30px; - background: #03254c; - cursor: pointer; -} -.slider2::-moz-range-thumb { - width: 30px; - height: 30px; - background: #1167b1; - cursor: pointer; + transform: translateX(26px); } - -table.center { - margin-left: auto; - margin-right: auto; -} - .tooltip { - position: relative; - display: inline-block; - border-bottom: 1px dotted #03254c; + border-bottom: 1px dotted rgba(255, 255, 255, 0.3); + cursor: help; } - .tooltip .tooltiptext { - visibility: hidden; - width: 120px; - background-color: #03254c; - color: #d0efff; - text-align: center; - border-radius: 6px; - padding: 5px 0; - - /* Position the tooltip */ - position: absolute; - z-index: 1; + visibility: hidden; + width: 200px; + background: #03254c; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 0.5rem; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; } - .tooltip:hover .tooltiptext { - visibility: visible; + visibility: visible; + opacity: 1; +} +.info-box { + border-left: 4px solid #2a9df4; + padding: 1.5rem; + margin-bottom: 2rem; + border-radius: 0 4px 4px 0; +} +.note { + font-style: italic; + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; + margin-top: 1rem; +} +.button-container { + text-align: center; + margin-top: 2rem; +} +.primary-button, +.secondary-button, +.warning-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: 0.2s; +} +.primary-button { + background: #2a9df4; + color: #fff; +} +.primary-button:hover { + background: #1b8fe3; +} +.secondary-button, +.status-message.info { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} +.secondary-button:hover { + background: rgba(255, 255, 255, 0.2); +} +.status-message.error, +.warning-button { + background: #dc3545; + color: #fff; +} +.warning-button:hover { + background: #c82333; +} +.status-message { + padding: 1rem; + margin: 1rem 0; + border-radius: 4px; + animation: 0.3s fadeIn; +} +.status-message.success { + background: #28a745; + color: #fff; } - .watermark { - display: inline; - position: fixed; - top: 0px; - left: 0px; - transform: translate(calc(50vw - 200px), calc(50vh - 170px)) rotate(45deg); - transition: 0.4s ease-in-out; - opacity: 0.7; - z-index: 99; - color: grey; - font-size: 7rem; -} - -.watermark:hidden { - transition: visibility 0s 2s, opacity 2s linear; -} - -.shiftButton { - -webkit-appearance: none; - -webkit-text-stroke: 2px rgba(104, 104, 104, 0.412); - appearance: auto; - width: 16%; - height: 6rem; - background: #03254c; - color: white; - cursor: pointer; - font-weight: bold; - font-size: calc(1vw + 1vh); -} - -.shiftBox { - background: #2a9df4; - color: white; - font-weight: bold; - font-size: calc(1vw + 1vh); - width: 4%; - text-align: center; -} - -body { - display: block; - margin: 0 auto; - background-color: #1167b1; - opacity: 1; - transition: 0.5s ease-in-out; - height: 100%; - width: 100%; -} - -fieldset { - border: 10px solid; - border-color: #1e3252; - box-sizing: border-box; - grid-area: 1 / 1; - padding: 5px; - margin: 0 auto; - z-index: -1; -} - -.confirmation-dialog { - display: flex; - align-items: center; - justify-content: center; - position: fixed; - top: 0; - left: 0; - height: 100%; - width: 100%; - background-color: rgba(201, 201, 201, 0.7); - z-index: 100; -} - -.confirmation-dialog > .confirmation-panel { - background-color: #03245c; - color: white; - padding: 20px; - border-radius: 10px; -} - -.confirmation-panel > .confirmation-buttongroup { - padding-top: 10px; -} - -.confirmation-buttongroup > input[type="button"] { - width: 75px; - height: 40px; - font-size: 1em; - border-radius: 10px; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(45deg); + font-size: 5rem; + color: rgba(255, 255, 255, 0.2); + pointer-events: none; + z-index: 0; +} +footer { + margin-top: 2rem; + padding: 1rem 0; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} +.dev-links { + display: flex; + align-items: center; + gap: 1rem; +} +.dev-link { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; +} +.separator { + color: rgba(255, 255, 255, 0.3); +} +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@media (max-width: 768px) { + .button-group, + .input-group { + flex-direction: column; + } + .dev-tools-grid, + .menu-grid, + .metrics-grid, + .status-grid { + grid-template-columns: 1fr; + } + .number-input, + .scan-button { + width: 100%; + } + .settings-section { + padding: 1rem; + } + .adjust-button { + min-width: 32px; + } + .slider-container { + min-width: 0; + } + .input-group { + align-items: stretch; + } + .unit { + text-align: right; + } + .shift-button { + height: 100px; + } + .menu-item, + .metric-card, + .status-group { + padding: 1.25rem; + } + .value-display { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } + .auto-update { + justify-content: center; + } + .debug-console { + height: 50vh; + } + .dev-links { + flex-direction: column; + gap: 0.5rem; + } + .separator { + display: none; + } } diff --git a/include/HTTP_Server_Basic.h b/include/HTTP_Server_Basic.h deleted file mode 100644 index d2c3b1df..00000000 --- a/include/HTTP_Server_Basic.h +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2020 Anthony Doud & Joel Baranick - * All rights reserved - * - * SPDX-License-Identifier: GPL-2.0-only - */ - -#pragma once - -#include - -#define HTTP_SERVER_LOG_TAG "HTTP_Server" - -class HTTP_Server { - private: - public: - bool internetConnection; - - void start(); - void stop(); - static void handleBTScanner(); - static void handleLittleFSFile(); - static void handleIndexFile(); - static void settingsProcessor(); - static void handleHrSlider(); - static void FirmwareUpdate(); - - static void webClientUpdate(); - - HTTP_Server() { internetConnection = false; } -}; - -#ifdef USE_TELEGRAM -#define SEND_TO_TELEGRAM(message) sendTelegram(message); - -void sendTelegram(String textToSend); -void telegramUpdate(void *pvParameters); -#else -#define SEND_TO_TELEGRAM(message) (void)message -#endif - -// wifi Function -void startWifi(); -void stopWifi(); - - -extern HTTP_Server httpServer; diff --git a/include/Main.h b/include/Main.h index 54805573..6fde63a7 100644 --- a/include/Main.h +++ b/include/Main.h @@ -7,10 +7,12 @@ #pragma once -#include "HTTP_Server_Basic.h" +#include "http/HTTPCore.h" +#include "http/HTTPRoutes.h" +#include "http/HTTPSettings.h" +#include "http/HTTPFirmware.h" #include "SmartSpin_parameters.h" #include "BLE_Common.h" -// #include "LittleFS_Upgrade.h" #include "boards.h" #include "SensorCollector.h" #include "SS2KLog.h" diff --git a/include/http/HTTPCore.h b/include/http/HTTPCore.h new file mode 100644 index 00000000..b75d0fcd --- /dev/null +++ b/include/http/HTTPCore.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include +#include +#include +#include +#include "http/HTTPRoutes.h" +#include "http/HTTPFileSystem.h" +#include "http/HTTPSettings.h" +#include "http/HTTPFirmware.h" +#include "wifi/WiFiManager.h" + +#define HTTP_SERVER_LOG_TAG "HTTP_Server" + +class HTTPCore { +public: + HTTPCore(); + void start(); + void stop(); + void update(); + bool hasInternetConnection() const; + void setInternetConnection(bool connected); + WebServer& getServer(); + +private: + bool internetConnection; + WebServer server; + unsigned long lastUpdateTime; + unsigned long lastMDNSUpdate; + + void setupRoutes(); + void updateMDNS(); + static const unsigned long MDNS_UPDATE_INTERVAL = 30000; // 30 seconds +}; + +extern HTTPCore httpServer; diff --git a/include/http/HTTPFileSystem.h b/include/http/HTTPFileSystem.h new file mode 100644 index 00000000..9ef21dfa --- /dev/null +++ b/include/http/HTTPFileSystem.h @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include +#include +#include +#include + +#define HTTP_SERVER_LOG_TAG "HTTP_Server" + +class HTTPFileSystem { +public: + static bool initialize(); + static void handleFileRead(WebServer& server, const String& path, const String& contentType = ""); + static void handleFileUpload(WebServer& server); + static void handleFileDelete(WebServer& server); + static void handleFileList(WebServer& server); + + // File upload status tracking + static bool isUploadInProgress() { return uploadInProgress; } + static size_t getUploadProgress() { return uploadProgress; } + static size_t getUploadTotal() { return uploadTotal; } + +private: + static String getContentType(const String& filename); + static bool exists(const String& path); + static bool isDirectory(const String& path); + static File openFile(const String& path, const char* mode); + + // File upload handling + static File fsUploadFile; + static bool uploadInProgress; + static size_t uploadProgress; + static size_t uploadTotal; + + static void beginFileUpload(const String& filename); + static void continueFileUpload(uint8_t* data, size_t len); + static void endFileUpload(); + + // Constants + static const char* const TEXT_PLAIN; + static const char* const TEXT_HTML; + static const char* const TEXT_CSS; + static const char* const TEXT_JAVASCRIPT; + static const char* const TEXT_JSON; + static const char* const IMAGE_PNG; + static const char* const IMAGE_JPG; + static const char* const IMAGE_GIF; + static const char* const IMAGE_ICO; + static const char* const APPLICATION_OCTET_STREAM; +}; diff --git a/include/http/HTTPFirmware.h b/include/http/HTTPFirmware.h new file mode 100644 index 00000000..a91bbb16 --- /dev/null +++ b/include/http/HTTPFirmware.h @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "settings.h" +#include "http/HTTPCore.h" + +#define HTTP_SERVER_LOG_TAG "HTTP_Server" + +// Forward declaration of Version class +class Version { +public: + Version(const char* version); + bool operator>(const Version& other) const; +private: + int major; + int minor; + int patch; + void parseVersion(const char* version); +}; + +class HTTPFirmware { +public: + static void checkForUpdates(); + static void handleOTAUpdate(WebServer& server); + +private: + // Update components + static void updateLittleFS(); + static void updateFirmware(); + static bool downloadFile(const String& url, const String& filename); + + // Version management + static bool needsUpdate(const String& currentVersion, const String& availableVersion); + static bool validateVersion(const String& version); + + // Security + static void setupSecureClient(WiFiClientSecure& client); + static const char* getRootCACertificate(); + + // Update handlers + static void handleFirmwareUpdate(WebServer& server); + static void handleFileSystemUpdate(WebServer& server); + static void handleUpdateProgress(size_t progress, size_t total); + + // Error handling + static void handleUpdateError(int error); + static String getUpdateErrorString(int error); + + // File system operations + static bool downloadFileList(); + static bool processFileList(const String& fileListContent); + static bool downloadAndSaveFile(const String& filename); +}; diff --git a/include/http/HTTPRoutes.h b/include/http/HTTPRoutes.h new file mode 100644 index 00000000..aebb7c74 --- /dev/null +++ b/include/http/HTTPRoutes.h @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include +#include +#include +#include +#include + +class HTTPRoutes { +public: + using HandlerFunction = std::function; + + // Static handler functions that can be used directly with WebServer + static HandlerFunction handleIndexFile; + static HandlerFunction handleBTScanner; + static HandlerFunction handleLittleFSFile; + static HandlerFunction handleConfigJSON; + static HandlerFunction handleRuntimeConfigJSON; + static HandlerFunction handlePWCJSON; + static HandlerFunction handleShift; + static HandlerFunction handleHRSlider; + static HandlerFunction handleWattsSlider; + static HandlerFunction handleCadSlider; + static HandlerFunction handleERGMode; + static HandlerFunction handleTargetWattsSlider; + static HandlerFunction handleLogin; + static HandlerFunction handleOTAUpdate; + static HandlerFunction handleFileUpload; + static HandlerFunction handleSendSettings; + static HandlerFunction handleReboot; + static HandlerFunction handleBLEScan; + + // Setup function to register all routes + static void setupRoutes(WebServer& server); + + // Initialize handlers with server reference + static void initialize(WebServer& server); + +private: + static void setupDefaultRoutes(WebServer& server); + static void setupFileRoutes(WebServer& server); + static void setupControlRoutes(WebServer& server); + static void setupUpdateRoutes(WebServer& server); + + // Store WebServer reference for handlers + static WebServer* currentServer; + static File fsUploadFile; + + // Actual handler implementations + static void _handleIndexFile(); + static void _handleBTScanner(); + static void _handleLittleFSFile(); + static void _handleConfigJSON(); + static void _handleRuntimeConfigJSON(); + static void _handlePWCJSON(); + static void _handleShift(); + static void _handleHRSlider(); + static void _handleWattsSlider(); + static void _handleCadSlider(); + static void _handleERGMode(); + static void _handleTargetWattsSlider(); + static void _handleLogin(); + static void _handleOTAUpdate(); + static void _handleFileUpload(); + static void _handleSendSettings(); + static void _handleReboot(); + static void _handleBLEScan(); +}; diff --git a/include/http/HTTPSettings.h b/include/http/HTTPSettings.h new file mode 100644 index 00000000..9a4d0795 --- /dev/null +++ b/include/http/HTTPSettings.h @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include +#include +#include "settings.h" + +#define HTTP_SERVER_LOG_TAG "HTTP_Server" + +class HTTPSettings { +public: + static void processSettings(WebServer& server); + +private: + // Network settings + static void processNetworkSettings(WebServer& server); + static void processDeviceSettings(WebServer& server); + static bool processStepperSettings(WebServer& server); + static void processPowerSettings(WebServer& server); + static void processERGSettings(WebServer& server); + static void processFeatureSettings(WebServer& server); + static bool processBluetoothSettings(WebServer& server); + static void processPWCSettings(WebServer& server); + + // Helper functions + static bool processCheckbox(WebServer& server, const char* name, bool defaultValue = false); + static float processFloatValue(WebServer& server, const char* name, float min, float max); + static int processIntValue(WebServer& server, const char* name, int min, int max); + static String processStringValue(WebServer& server, const char* name); + + // Response handling + static void sendSettingsResponse(WebServer& server, bool wasBTUpdate, bool wasSettingsUpdate, bool requiresReboot = false); + static String buildRedirectResponse(const String& message, const String& page, int delay = 1000); +}; diff --git a/include/settings.h b/include/settings.h index 51314d62..be362a24 100644 --- a/include/settings.h +++ b/include/settings.h @@ -58,9 +58,21 @@ const char* const DEFAULT_PASSWORD = "password"; // Stepper peak current in ma. This is hardware restricted to a maximum of 2000ma on the TMC2225. RMS current is less. #define DEFAULT_STEPPER_POWER 900 +// Minimum stepper power setting +#define MIN_STEPPER_POWER 100 + +// Maximum stepper power setting +#define MAX_STEPPER_POWER 2000 + // Default Shift Step. The amount to move the stepper motor for a shift press. #define DEFAULT_SHIFT_STEP 1200 +// Minimum shift step setting +#define MIN_SHIFT_STEP 10 + +// Maximum shift step setting +#define MAX_SHIFT_STEP 6000 + // Stepper Acceleration in steps/s^2 #define STEPPER_ACCELERATION 3000 diff --git a/include/telegram/TelegramManager.h b/include/telegram/TelegramManager.h new file mode 100644 index 00000000..ddfb3c04 --- /dev/null +++ b/include/telegram/TelegramManager.h @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include + +#ifdef USE_TELEGRAM +#include +#include + +class TelegramManager { +public: + static void initialize(WiFiClientSecure& client); + static void sendMessage(const String& message); + static void update(); + static void stop(); + +private: + static TaskHandle_t telegramTask; + static bool messageWaiting; + static String pendingMessage; + static UniversalTelegramBot* bot; + static int failureCount; + + // Task management + static void telegramUpdateTask(void* pvParameters); + static void resetFailureCount(); + + // Message handling + static void processPendingMessage(); + static bool isMessageRateLimited(); + static void clearPendingMessage(); + + // Constants + static const int MAX_FAILURES = 3; + static const int MAX_MESSAGES = 5; + static const unsigned long MESSAGE_TIMEOUT = 120000; // 2 minutes +}; + +#define SEND_TO_TELEGRAM(message) TelegramManager::sendMessage(message) + +#else +#define SEND_TO_TELEGRAM(message) (void)message +#endif // USE_TELEGRAM diff --git a/include/wifi/WiFiManager.h b/include/wifi/WiFiManager.h new file mode 100644 index 00000000..ce490b2c --- /dev/null +++ b/include/wifi/WiFiManager.h @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#pragma once + +#include +#include +#include +#include + +class WiFiManager { +public: + static void startWifi(); + static void stopWifi(); + static IPAddress getIP(); + static bool isConnected(); + static void processDNS(); + +private: + static void setupStationMode(); + static void setupAPMode(); + static void setupMDNS(); + static void syncClock(); + + static const byte DNS_PORT = 53; + + static DNSServer dnsServer; + static IPAddress myIP; +}; diff --git a/src/HTTP_Server_Basic.cpp b/src/HTTP_Server_Basic.cpp deleted file mode 100644 index 180d7862..00000000 --- a/src/HTTP_Server_Basic.cpp +++ /dev/null @@ -1,818 +0,0 @@ -/* - * Copyright (C) 2020 Anthony Doud & Joel Baranick - * All rights reserved - * - * SPDX-License-Identifier: GPL-2.0-only - */ - -#include "Main.h" -#include "Version_Converter.h" -#include "Builtin_Pages.h" -#include "HTTP_Server_Basic.h" -#include "cert.h" -#include "SS2KLog.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -File fsUploadFile; - -IPAddress myIP; - -// DNS server -const byte DNS_PORT = 53; -DNSServer dnsServer; -HTTP_Server httpServer; -WiFiClientSecure client; -WebServer server(80); - -#ifdef USE_TELEGRAM -#include -TaskHandle_t telegramTask; -bool telegramMessageWaiting = false; -UniversalTelegramBot bot(TELEGRAM_TOKEN, client); -String telegramMessage = ""; -#endif // USE_TELEGRAM - -void _staSetup() { - WiFi.setHostname(userConfig->getDeviceName()); - WiFi.mode(WIFI_STA); - WiFi.begin(userConfig->getSsid(), userConfig->getPassword()); - WiFi.setAutoReconnect(true); -} - -void _APSetup() { - // WiFi.eraseAP(); //Needed if we switch back to espressif32 @6.5.0 - WiFi.mode(WIFI_AP); - WiFi.setHostname("reset"); // Fixes a bug when switching Arduino Core Versions - WiFi.softAPsetHostname("reset"); - WiFi.setHostname(userConfig->getDeviceName()); - WiFi.softAPsetHostname(userConfig->getDeviceName()); - WiFi.enableAP(true); - vTaskDelay(500); // Micro controller requires some time to reset the mode -} - -// ********************************WIFI Setup************************* -void startWifi() { - int i = 0; - - // Trying Station mode first: - if (strcmp(userConfig->getSsid(), DEVICE_NAME) != 0) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Connecting to: %s", userConfig->getSsid()); - _staSetup(); - while (WiFi.status() != WL_CONNECTED) { - vTaskDelay(1000 / portTICK_RATE_MS); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Waiting for connection to be established..."); - i++; - if (i > WIFI_CONNECT_TIMEOUT) { - i = 0; - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Couldn't Connect. Switching to AP mode"); - WiFi.disconnect(true, true); - WiFi.setAutoReconnect(false); - WiFi.mode(WIFI_MODE_NULL); - vTaskDelay(1000 / portTICK_RATE_MS); - break; - } - } - } - - // Did we connect in STA mode? - if (WiFi.status() == WL_CONNECTED) { - myIP = WiFi.localIP(); - httpServer.internetConnection = true; - } - - // Couldn't connect to existing network, Create SoftAP - if (WiFi.status() != WL_CONNECTED) { - _APSetup(); - if (strcmp(userConfig->getSsid(), DEVICE_NAME) == 0) { - // If default SSID is still in use, let the user select a new password. - // Else fall back to the default password. - WiFi.softAP(userConfig->getDeviceName(), userConfig->getPassword()); - } else { - WiFi.softAP(userConfig->getDeviceName(), DEFAULT_PASSWORD); - } - vTaskDelay(50); - myIP = WiFi.softAPIP(); - /* Setup the DNS server redirecting all the domains to the apIP */ - dnsServer.setErrorReplyCode(DNSReplyCode::NoError); - dnsServer.start(DNS_PORT, "*", myIP); - } - - if (!MDNS.begin(userConfig->getDeviceName())) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error setting up MDNS responder!"); - } - - MDNS.addService("http", "_tcp", 80); - MDNS.addServiceTxt("http", "_tcp", "lf", "0"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Connected to %s IP address: %s", userConfig->getSsid(), myIP.toString().c_str()); -#ifdef USE_TELEGRAM - SEND_TO_TELEGRAM("Connected to " + String(userConfig->getSsid()) + " IP address: " + myIP.toString()); -#endif - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Open http://%s.local/", userConfig->getDeviceName()); - WiFi.setTxPower(WIFI_POWER_19_5dBm); - - if (WiFi.getMode() == WIFI_STA) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Syncing clock..."); - configTime(0, 0, "pool.ntp.org"); // get UTC time via NTP - time_t now = time(nullptr); - while (now < 10) { // wait 10 seconds - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Waiting for clock sync..."); - delay(100); - now = time(nullptr); - } - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Clock synced to: %.f", difftime(now, (time_t)0)); - } -} - -void stopWifi() { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Closing connection to: %s", userConfig->getSsid()); - WiFi.disconnect(); -} - -void HTTP_Server::start() { - server.enableCORS(true); - server.onNotFound(handleIndexFile); - - /***************************Begin Handlers*******************/ - server.on("/", handleIndexFile); - server.on("/index.html", handleIndexFile); - server.on("/generate_204", handleIndexFile); // Android captive portal - server.on("/fwlink", handleIndexFile); // Microsoft captive portal - server.on("/hotspot-detect.html", handleIndexFile); // Apple captive portal - server.on("/style.css", handleLittleFSFile); - server.on("/btsimulator.html", handleLittleFSFile); - server.on("/develop.html", handleLittleFSFile); - server.on("/shift.html", handleLittleFSFile); - server.on("/settings.html", handleLittleFSFile); - server.on("/status.html", handleLittleFSFile); - server.on("/bluetoothscanner.html", handleBTScanner); - server.on("/streamfit.html", handleLittleFSFile); - server.on("/hrtowatts.html", handleLittleFSFile); - server.on("/favicon.ico", handleLittleFSFile); - server.on("/send_settings", settingsProcessor); - server.on("/jquery.js.gz", handleLittleFSFile); - - server.on("/BLEScan", []() { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Scanning from web request"); - String response = - "Scanning for BLE Devices. Please wait " - "15 seconds."; - // spinBLEClient.resetDevices(); - spinBLEClient.dontBlockScan = true; - spinBLEClient.doScan = true; - server.send(200, "text/html", response); - }); - - server.on("/load_defaults.html", []() { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Setting Defaults from Web Request"); - ss2k->resetDefaultsFlag = true; - String response = - "

Defaults have been " - "loaded.



Please reconnect to the device on WiFi " - "network: " + - myIP.toString() + "

"; - server.send(200, "text/html", response); - }); - - server.on("/reboot.html", []() { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Rebooting from Web Request"); - String response = "Rebooting...."; - server.send(200, "text/html", response); - ss2k->rebootFlag = true; - }); - - server.on("/hrslider", []() { - String value = server.arg("value"); - if (value == "enable") { - rtConfig->hr.setSimulate(true); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HR Simulator turned on"); - } else if (value == "disable") { - rtConfig->hr.setSimulate(false); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HR Simulator turned off"); - } else { - rtConfig->hr.setValue(value.toInt()); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HR is now: %d", rtConfig->hr.getValue()); - server.send(200, "text/plain", "OK"); - } - }); - - server.on("/wattsslider", []() { - String value = server.arg("value"); - if (value == "enable") { - rtConfig->watts.setSimulate(true); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Watt Simulator turned on"); - } else if (value == "disable") { - rtConfig->watts.setSimulate(false); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Watt Simulator turned off"); - } else { - rtConfig->watts.setValue(value.toInt()); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Watts are now: %d", rtConfig->watts.getValue()); - server.send(200, "text/plain", "OK"); - } - }); - - server.on("/cadslider", []() { - String value = server.arg("value"); - if (value == "enable") { - rtConfig->cad.setSimulate(true); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "CAD Simulator turned on"); - } else if (value == "disable") { - rtConfig->cad.setSimulate(false); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "CAD Simulator turned off"); - } else { - rtConfig->cad.setValue(value.toInt()); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "CAD is now: %d", rtConfig->cad.getValue()); - server.send(200, "text/plain", "OK"); - } - }); - - server.on("/ergmode", []() { - String value = server.arg("value"); - if (value == "enable") { - rtConfig->setFTMSMode(FitnessMachineControlPointProcedure::SetTargetPower); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "ERG Mode turned on"); - } else { - rtConfig->setFTMSMode(FitnessMachineControlPointProcedure::RequestControl); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "ERG Mode turned off"); - } - }); - - server.on("/targetwattsslider", []() { - String value = server.arg("value"); - if (value == "enable") { - rtConfig->setSimTargetWatts(true); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Target Watts Simulator turned on"); - } else if (value == "disable") { - rtConfig->setSimTargetWatts(false); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Target Watts Simulator turned off"); - } else { - rtConfig->watts.setTarget(value.toInt()); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Target Watts are now: %d", rtConfig->watts.getTarget()); - server.send(200, "text/plain", "OK"); - } - }); - - server.on("/shift", []() { - int value = server.arg("value").toInt(); - if ((value > -10) && (value < 10)) { - rtConfig->setShifterPosition(rtConfig->getShifterPosition() + value); - server.send(200, "text/plain", "OK"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Shift From HTML"); - } else { - rtConfig->setShifterPosition(value); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Invalid HTML Shift"); - server.send(200, "text/plain", "OK"); - } - // BLE Shift notifications are handles by the shift processing in main.cpp - }); - - server.on("/configJSON", []() { - String tString; - tString = userConfig->returnJSON(); - server.send(200, "text/plain", tString); - }); - - server.on("/runtimeConfigJSON", []() { - String tString; - tString = rtConfig->returnJSON(); - server.send(200, "text/plain", tString); - }); - - server.on("/PWCJSON", []() { - String tString; - tString = userPWC->returnJSON(); - server.send(200, "text/plain", tString); - }); - - server.on("/login", HTTP_GET, []() { - server.sendHeader("Connection", "close"); - server.send(200, "text/html", OTALoginIndex); - }); - - server.on("/OTAIndex", HTTP_GET, []() { - ss2k->stopTasks(); - server.sendHeader("Connection", "close"); - server.send(200, "text/html", OTAServerIndex); - }); - - /*handling uploading firmware file */ - server.on( - "/update", HTTP_POST, - []() { - server.sendHeader("Connection", "close"); - server.send(200, "text/plain", (Update.hasError()) ? "FAIL" : "OK"); - }, - []() { - HTTPUpload &upload = server.upload(); - if (upload.filename == String("firmware.bin").c_str()) { - if (upload.status == UPLOAD_FILE_START) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Update: %s", upload.filename.c_str()); - if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { // start with max - // available size - Update.printError(Serial); - } - } else if (upload.status == UPLOAD_FILE_WRITE) { - /* flashing firmware to ESP*/ - if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { - Update.printError(Serial); - } - } else if (upload.status == UPLOAD_FILE_END) { - if (Update.end(true)) { // true to set the size to the - // current progress - server.send(200, "text/plain", "Firmware Uploaded Successfully. Rebooting..."); - ESP.restart(); - } else { - Update.printError(Serial); - } - } - } else if (upload.filename == String("littlefs.bin").c_str()) { - if (upload.status == UPLOAD_FILE_START) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Update: %s", upload.filename.c_str()); - if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { // start with max - // available size - Update.printError(Serial); - } - } else if (upload.status == UPLOAD_FILE_WRITE) { - /* flashing firmware to ESP*/ - if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { - Update.printError(Serial); - } - } else if (upload.status == UPLOAD_FILE_END) { - if (Update.end(true)) { // true to set the size to the - // current progress - server.send(200, "text/plain", "Littlefs Uploaded Successfully. Rebooting..."); - userConfig->saveToLittleFS(); - userPWC->saveToLittleFS(); - ss2k->rebootFlag == true; - } else { - Update.printError(Serial); - } - } - } else { - if (upload.status == UPLOAD_FILE_START) { - String filename = upload.filename; - if (!filename.startsWith("/")) { - filename = "/" + filename; - } - SS2K_LOG(HTTP_SERVER_LOG_TAG, "handleFileUpload Name: %s", filename.c_str()); - fsUploadFile = LittleFS.open(filename, "w"); - filename = String(); - } else if (upload.status == UPLOAD_FILE_WRITE) { - if (fsUploadFile) { - fsUploadFile.write(upload.buf, upload.currentSize); - } - } else if (upload.status == UPLOAD_FILE_END) { - if (fsUploadFile) { - fsUploadFile.close(); - } - SS2K_LOG(HTTP_SERVER_LOG_TAG, "handleFileUpload Size: %zu", upload.totalSize); - server.send(200, "text/plain", String(upload.filename + " Uploaded Successfully.")); - } - } - }); - - /********************************************End Server - * Handlers*******************************/ - -#ifdef USE_TELEGRAM - xTaskCreatePinnedToCore(telegramUpdate, /* Task function. */ - "telegramUpdate", /* name of task. */ - 4900, /* Stack size of task*/ - NULL, /* parameter of the task */ - 1, /* priority of the task - higher number is higher priority*/ - &telegramTask, /* Task handle to keep track of created task */ - 1); /* pin task to core 1 */ -#endif // USE_TELEGRAM - server.begin(); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP server started"); -} - -void HTTP_Server::webClientUpdate() { - static unsigned long int _webClientTimer = millis(); - if (millis() - _webClientTimer > WEBSERVER_DELAY) { - _webClientTimer = millis(); - static unsigned long mDnsTimer = millis(); // NOLINT: There is no overload in String for uint64_t - server.handleClient(); - if (WiFi.getMode() != WIFI_MODE_STA) { - dnsServer.processNextRequest(); - } - // Keep MDNS alive - if ((millis() - mDnsTimer) > 30000) { - MDNS.addServiceTxt("http", "_tcp", "lf", String(mDnsTimer)); - mDnsTimer = millis(); - } - } -} - -void HTTP_Server::handleBTScanner() { - spinBLEClient.doScan = true; - handleLittleFSFile(); -} - -void HTTP_Server::handleIndexFile() { - String filename = "/index.html"; - if (LittleFS.exists(filename)) { - File file = LittleFS.open(filename, FILE_READ); - server.streamFile(file, "text/html"); - file.close(); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Served %s", filename.c_str()); - } else { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found. Sending builtin Index.html", filename.c_str()); - server.send(200, "text/html", noIndexHTML); - } -} - -void HTTP_Server::handleLittleFSFile() { - String filename = server.uri(); - int dotPosition = filename.lastIndexOf("."); - String fileType = filename.substring((dotPosition + 1), filename.length()); - if (LittleFS.exists(filename)) { - File file = LittleFS.open(filename, FILE_READ); - if (fileType == "gz") { - fileType = "html"; // no need to change content type as it's done automatically by .streamfile below VV - } - server.streamFile(file, "text/" + fileType); - file.close(); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Served %s", filename.c_str()); - } else if (!LittleFS.exists("/index.html")) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found and no filesystem. Sending builtin index.html", filename.c_str()); - handleIndexFile(); - } else { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found. Sending 404.", filename.c_str()); - String outputhtml = "

ERROR 404
FILE NOT FOUND!" + filename + "

"; - server.send(404, "text/html", outputhtml); - } -} - -void HTTP_Server::settingsProcessor() { - String tString; - bool wasBTUpdate = false; - bool wasSettingsUpdate = false; - bool reboot = false; - if (!server.arg("ssid").isEmpty()) { - tString = server.arg("ssid"); - tString.trim(); - userConfig->setSsid(tString); - } - if (!server.arg("password").isEmpty()) { - tString = server.arg("password"); - tString.trim(); - userConfig->setPassword(tString); - } - if (!server.arg("deviceName").isEmpty()) { - tString = server.arg("deviceName"); - tString.trim(); - userConfig->setDeviceName(tString); - } - if (!server.arg("shiftStep").isEmpty()) { - uint64_t shiftStep = server.arg("shiftStep").toInt(); - if (shiftStep >= 10 && shiftStep <= 6000) { - userConfig->setShiftStep(shiftStep); - } - wasSettingsUpdate = true; - } - if (!server.arg("stepperPower").isEmpty()) { - uint64_t stepperPower = server.arg("stepperPower").toInt(); - if (stepperPower >= 100 && stepperPower <= 2000) { - userConfig->setStepperPower(stepperPower); - ss2k->updateStepperPower(); - } - } - if (!server.arg("maxWatts").isEmpty()) { - uint64_t maxWatts = server.arg("maxWatts").toInt(); - if (maxWatts >= 0 && maxWatts <= 2000) { - userConfig->setMaxWatts(maxWatts); - } - } - if (!server.arg("minWatts").isEmpty()) { - uint64_t minWatts = server.arg("minWatts").toInt(); - if (minWatts >= 0 && minWatts <= 200) { - userConfig->setMinWatts(minWatts); - } - } - if (!server.arg("ERGSensitivity").isEmpty()) { - float ERGSensitivity = server.arg("ERGSensitivity").toFloat(); - if (ERGSensitivity >= .1 && ERGSensitivity <= 20) { - userConfig->setERGSensitivity(ERGSensitivity); - } - } - // checkboxes don't report off, so need to check using another parameter - // that's always present on that page - if (!server.arg("autoUpdate").isEmpty()) { - userConfig->setAutoUpdate(true); - } else if (wasSettingsUpdate) { - userConfig->setAutoUpdate(false); - } - if (!server.arg("stepperDir").isEmpty()) { - userConfig->setStepperDir(true); - } else if (wasSettingsUpdate) { - userConfig->setStepperDir(false); - } - if (!server.arg("shifterDir").isEmpty()) { - userConfig->setShifterDir(true); - } else if (wasSettingsUpdate) { - userConfig->setShifterDir(false); - } - if (!server.arg("udpLogEnabled").isEmpty()) { - userConfig->setUdpLogEnabled(true); - } else if (wasSettingsUpdate) { - userConfig->setUdpLogEnabled(false); - } - if (!server.arg("stealthChop").isEmpty()) { - userConfig->setStealthChop(true); - ss2k->updateStealthChop(); - } else if (wasSettingsUpdate) { - userConfig->setStealthChop(false); - ss2k->updateStealthChop(); - } - if (!server.arg("inclineMultiplier").isEmpty()) { - float inclineMultiplier = server.arg("inclineMultiplier").toFloat(); - if (inclineMultiplier >= 0 && inclineMultiplier <= 10) { - userConfig->setInclineMultiplier(inclineMultiplier); - } - } - if (!server.arg("powerCorrectionFactor").isEmpty()) { - float powerCorrectionFactor = server.arg("powerCorrectionFactor").toFloat(); - if (powerCorrectionFactor >= MIN_PCF && powerCorrectionFactor <= MAX_PCF) { - userConfig->setPowerCorrectionFactor(powerCorrectionFactor); - } - } - if (!server.arg("blePMDropdown").isEmpty()) { - wasBTUpdate = true; - if (server.arg("blePMDropdown")) { - tString = server.arg("blePMDropdown"); - if (tString != userConfig->getConnectedPowerMeter()) { - userConfig->setConnectedPowerMeter(tString); - spinBLEClient.reconnectAllDevices(); - } - } else { - userConfig->setConnectedPowerMeter("any"); - } - } - if (!server.arg("bleHRDropdown").isEmpty()) { - wasBTUpdate = true; - if (server.arg("bleHRDropdown")) { - bool reset = false; - tString = server.arg("bleHRDropdown"); - if (tString != userConfig->getConnectedHeartMonitor()) { - spinBLEClient.reconnectAllDevices(); - } - userConfig->setConnectedHeartMonitor(server.arg("bleHRDropdown")); - } else { - userConfig->setConnectedHeartMonitor("any"); - } - } - if (!server.arg("bleRemoteDropdown").isEmpty()) { - wasBTUpdate = true; - if (server.arg("bleRemoteDropdown")) { - bool reset = false; - tString = server.arg("bleRemoteDropdown"); - if (tString != userConfig->getConnectedRemote()) { - spinBLEClient.reconnectAllDevices(); - } - userConfig->setConnectedRemote(server.arg("bleRemoteDropdown")); - } else { - userConfig->setConnectedRemote("any"); - } - } - if (!server.arg("session1HR").isEmpty()) { // Needs checking for unrealistic numbers. - userPWC->session1HR = server.arg("session1HR").toInt(); - } - if (!server.arg("session1Pwr").isEmpty()) { - userPWC->session1Pwr = server.arg("session1Pwr").toInt(); - } - if (!server.arg("session2HR").isEmpty()) { - userPWC->session2HR = server.arg("session2HR").toInt(); - } - if (!server.arg("session2Pwr").isEmpty()) { - userPWC->session2Pwr = server.arg("session2Pwr").toInt(); - - if (!server.arg("hr2Pwr").isEmpty()) { - userPWC->hr2Pwr = true; - } else { - userPWC->hr2Pwr = false; - } - } - String response = "

"; - - if (wasBTUpdate) { // Special BT page update response - response += - "Selections Saved!

"; - } else if (wasSettingsUpdate) { // Special Settings Page update response - response += - "Network settings will be applied at next reboot.
Everything " - "else is available immediately."; - } else { // Normal response - response += - "Network settings will be applied at next reboot.
Everything " - "else is available immediately."; - } - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Config Updated From Web"); - ss2k->saveFlag = true; - if (reboot) { - response += - "Please wait while your settings are saved and SmartSpin2k reboots."; - server.send(200, "text/html", response); - ss2k->rebootFlag = true; - } - server.send(200, "text/html", response); -} - -void HTTP_Server::stop() { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Stopping Http Server"); - server.stop(); - server.close(); -} - -// github fingerprint -// 70:94:DE:DD:E6:C4:69:48:3A:92:70:A1:48:56:78:2D:18:64:E0:B7 - -void HTTP_Server::FirmwareUpdate() { - HTTPClient http; - // WiFiClientSecure client; - client.setCACert(rootCACertificate); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Checking for newer firmware:"); - http.begin(userConfig->getFirmwareUpdateURL() + String(FW_VERSIONFILE), - rootCACertificate); // check version URL - delay(100); - int httpCode = http.GET(); // get data from version file - delay(100); - String payload; - if (httpCode == HTTP_CODE_OK) { // if version received - payload = http.getString(); // save received version - payload.trim(); - SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Server version: %s", payload.c_str()); - httpServer.internetConnection = true; - } else { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "error downloading %s %d", FW_VERSIONFILE, httpCode); - httpServer.internetConnection = false; - } - - http.end(); - if (httpCode == HTTP_CODE_OK) { // if version received - bool updateAnyway = false; - if (!LittleFS.exists("/index.html")) { - // updateAnyway = true; - SS2K_LOG(HTTP_SERVER_LOG_TAG, " -index.html not found."); - } - Version availableVer(payload.c_str()); - Version currentVer(FIRMWARE_VERSION); - - if (((availableVer > currentVer) && (userConfig->getAutoUpdate())) || (!LittleFS.exists("/index.html"))) { - //////////////// Update LittleFS////////////// - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Updating FileSystem"); - http.begin(DATA_UPDATEURL + String(DATA_FILELIST), - rootCACertificate); // check version URL - vTaskDelay(100 / portTICK_PERIOD_MS); - httpCode = http.GET(); // get data from version file - vTaskDelay(100 / portTICK_PERIOD_MS); - StaticJsonDocument<500> doc; - if (httpCode == HTTP_CODE_OK) { // if version received - String payload; - payload = http.getString(); // save received version - payload.trim(); - // Deserialize the JSON document - DeserializationError error = deserializeJson(doc, payload); - if (error) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to read file list"); - return; - } - httpServer.internetConnection = true; - } else { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "error downloading %s %d", DATA_FILELIST, httpCode); - httpServer.internetConnection = false; - } - JsonArray files = doc.as(); - // iterate through file list and download files individually - for (JsonVariant v : files) { - String fileName = "/" + v.as(); - http.begin(DATA_UPDATEURL + fileName, - rootCACertificate); // check version URL - vTaskDelay(100 / portTICK_PERIOD_MS); - httpCode = http.GET(); - vTaskDelay(100 / portTICK_PERIOD_MS); - if (httpCode == HTTP_CODE_OK) { - String payload; - payload = http.getString(); - payload.trim(); - LittleFS.remove(fileName); - File file = LittleFS.open(fileName, FILE_WRITE, true); - if (!file) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to create file, %s", fileName); - return; - } - file.print(payload); - file.close(); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Created: %s", fileName); - httpServer.internetConnection = true; - } else { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error downloading %s %d", fileName, httpCode); - httpServer.internetConnection = false; - } - } - - //////// Update Firmware ///////// - if (((availableVer > currentVer) || updateAnyway) && (userConfig->getAutoUpdate())) { - SS2K_LOG(HTTP_SERVER_LOG_TAG, "New firmware detected!"); - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Upgrading from %s to %s", FIRMWARE_VERSION, payload.c_str()); - t_httpUpdate_return ret = httpUpdate.update(client, userConfig->getFirmwareUpdateURL() + String(FW_BINFILE)); - switch (ret) { - case HTTP_UPDATE_FAILED: - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_FAILED Error %d : %s", httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str()); - break; - - case HTTP_UPDATE_NO_UPDATES: - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_NO_UPDATES"); - break; - - case HTTP_UPDATE_OK: - SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_OK"); - break; - } - } - } else { // don't update - SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Current Version: %s", FIRMWARE_VERSION); - } - } -} - -#ifdef USE_TELEGRAM -// Function to handle sending telegram text to the non blocking task -void sendTelegram(String textToSend) { - static int numberOfMessages = 0; - static uint64_t timeout = 120000; // reset every two minutes - static uint64_t startTime = millis(); - - if (millis() - startTime > timeout) { // Let one message send every two minutes - numberOfMessages = MAX_TELEGRAM_MESSAGES - 1; - telegramMessage += " " + String(userConfig->getSsid()) + " "; - startTime = millis(); - } - - if ((numberOfMessages < MAX_TELEGRAM_MESSAGES) && (WiFi.getMode() == WIFI_STA)) { - telegramMessage += "\n" + textToSend; - telegramMessageWaiting = true; - numberOfMessages++; - } -} - -// Non blocking task to send telegram message -void telegramUpdate(void *pvParameters) { - // client.setInsecure(); - client.setCACert(TELEGRAM_CERTIFICATE_ROOT); - for (;;) { - static int telegramFailures = 0; - if (telegramMessageWaiting && internetConnection) { - telegramMessageWaiting = false; - bool rm = (bot.sendMessage(TELEGRAM_CHAT_ID, telegramMessage, "")); - if (!rm) { - telegramFailures++; - SS2K_LOG(HTTP_SERVER_LOG_TAG, "Telegram failed to send! %s", TELEGRAM_CHAT_ID); - if (telegramFailures > 2) { - internetConnection = false; - } - } else { // Success - reset Telegram Failures - telegramFailures = 0; - } - - client.stop(); - telegramMessage = ""; - } -#ifdef DEBUG_STACK - Serial.printf("Telegram: %d \n", uxTaskGetStackHighWaterMark(telegramTask)); - Serial.printf("Web: %d \n", uxTaskGetStackHighWaterMark(webClientTask)); - Serial.printf("Free: %d \n", ESP.getFreeHeap()); -#endif // DEBUG_STACK - vTaskDelay(4000 / portTICK_RATE_MS); - } -} -#endif // USE_TELEGRAM diff --git a/src/Main.cpp b/src/Main.cpp index b495a40b..83b5c446 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -121,8 +121,8 @@ void setup() { // Check for firmware update. It's important that this stays before BLE & // HTTP setup because otherwise they use too much traffic and the device // fails to update which really sucks when it corrupts your settings. - startWifi(); - httpServer.FirmwareUpdate(); + WiFiManager::startWifi(); + HTTPFirmware::checkForUpdates(); pinMode(currentBoard.shiftUpPin, INPUT_PULLUP); // Push-Button with input Pullup pinMode(currentBoard.shiftDownPin, INPUT_PULLUP); // Push-Button with input Pullup @@ -192,7 +192,7 @@ void SS2K::maintenanceLoop(void *pvParameters) { // Run what used to be in the ERG Mode Task. powerTable->runERG(); // Run what used to be in the WebClient Task. - httpServer.webClientUpdate(); + httpServer.update(); // If we're in ERG mode, modify shift commands to inc/dec the target watts instead. ss2k->FTMSModeShiftModifier(); // If we have a resistance bike attached, slow down when we're close to the limits. @@ -236,11 +236,12 @@ void SS2K::maintenanceLoop(void *pvParameters) { // Handle flag set for rebooting if (ss2k->rebootFlag) { static bool _loopOnce = false; - vTaskDelay(1000 / portTICK_RATE_MS); // Let the main task loop complete once before rebooting if (_loopOnce) { + SS2K_LOG(MAIN_LOG_TAG, "Reboot flag set."); // Important to keep this delay high in order to allow coms to finish. - vTaskDelay(1000 / portTICK_RATE_MS); + logHandler.writeLogs(); + vTaskDelay(2000 / portTICK_RATE_MS); ESP.restart(); } _loopOnce = true; @@ -283,7 +284,7 @@ void SS2K::maintenanceLoop(void *pvParameters) { // Inactivity detected if (((millis() - rebootTimer) > 1800000)) { // Timer expired - SS2K_LOGW(MAIN_LOG_TAG, "Rebooting due to inactivity."); + SS2K_LOG(MAIN_LOG_TAG, "Rebooting due to inactivity."); ss2k->rebootFlag = true; logHandler.writeLogs(); webSocketAppender.Loop(); @@ -389,9 +390,9 @@ void SS2K::FTMSModeShiftModifier() { void SS2K::restartWifi() { httpServer.stop(); vTaskDelay(100 / portTICK_RATE_MS); - stopWifi(); + WiFiManager::stopWifi(); vTaskDelay(100 / portTICK_RATE_MS); - startWifi(); + WiFiManager::startWifi(); httpServer.start(); } @@ -471,7 +472,7 @@ void SS2K::moveStepper() { if (rtConfig->cad.getValue() > 1) { stepper->enableOutputs(); stepper->setAutoEnable(false); - }else{ + } else { stepper->setAutoEnable(true); } @@ -535,7 +536,7 @@ void SS2K::resetIfShiftersHeld() { userConfig->saveToLittleFS(); vTaskDelay(200 / portTICK_PERIOD_MS); } - ESP.restart(); + ss2k->rebootFlag = true; } } diff --git a/src/SmartSpin_parameters.cpp b/src/SmartSpin_parameters.cpp index d44ed0f5..a02f90dc 100644 --- a/src/SmartSpin_parameters.cpp +++ b/src/SmartSpin_parameters.cpp @@ -125,43 +125,12 @@ void userParameters::saveToLittleFS() { return; } - // Allocate a temporary JsonDocument - // Don't forget to change the capacity to match your requirements. - // Use arduinojson.org/assistant to compute the capacity. - DynamicJsonDocument doc(USERCONFIG_JSON_SIZE); - - // Set the values in the document - // commented items are not needed in save file - - doc["firmwareUpdateURL"] = firmwareUpdateURL; - doc["deviceName"] = deviceName; - doc["shiftStep"] = shiftStep; - doc["stepperPower"] = stepperPower; - doc["stepperSpeed"] = stepperSpeed; - doc["stealthChop"] = stealthChop; - doc["inclineMultiplier"] = inclineMultiplier; - doc["powerCorrectionFactor"] = powerCorrectionFactor; - doc["ERGSensitivity"] = ERGSensitivity; - doc["autoUpdate"] = autoUpdate; - doc["ssid"] = ssid; - doc["password"] = password; - doc["connectedPowerMeter"] = connectedPowerMeter; - doc["connectedHeartMonitor"] = connectedHeartMonitor; - doc["connectedRemote"] = connectedRemote; - // doc["foundDevices"] = foundDevices; - doc["maxWatts"] = maxWatts; - doc["minWatts"] = minWatts; - doc["shifterDir"] = shifterDir; - doc["stepperDir"] = stepperDir; - doc["udpLogEnabled"] = udpLogEnabled; - doc["hMin"] = hMin; - doc["hMax"] = hMax; - doc["homingSensitivity"] = homingSensitivity; - - // Serialize JSON to file - if (serializeJson(doc, file) == 0) { + // Get JSON string from returnJSON() and write to file + String jsonStr = returnJSON(); + if (file.print(jsonStr) == 0) { SS2K_LOG(CONFIG_LOG_TAG, "Failed to write to file"); } + // Close the file file.close(); } @@ -300,16 +269,9 @@ void physicalWorkingCapacity::saveToLittleFS() { return; } - StaticJsonDocument<500> doc; - - doc["session1HR"] = session1HR; - doc["session1Pwr"] = session1Pwr; - doc["session2HR"] = session2HR; - doc["session2Pwr"] = session2Pwr; - doc["hr2Pwr"] = hr2Pwr; - - // Serialize JSON to file - if (serializeJson(doc, file) == 0) { + // Get JSON string from returnJSON() and write to file + String jsonStr = returnJSON(); + if (file.print(jsonStr) == 0) { SS2K_LOG(CONFIG_LOG_TAG, "Failed to write to file"); } // Close the file diff --git a/src/http/HTTPCore.cpp b/src/http/HTTPCore.cpp new file mode 100644 index 00000000..1de7ebdb --- /dev/null +++ b/src/http/HTTPCore.cpp @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "http/HTTPCore.h" +#include "Main.h" +#include "SS2KLog.h" +#include + +// Initialize the global instance +HTTPCore httpServer; + +HTTPCore::HTTPCore() + : internetConnection(false) + , server(80) + , lastUpdateTime(0) + , lastMDNSUpdate(0) { +} + +void HTTPCore::start() { + server.enableCORS(true); + + // Setup all routes through HTTPRoutes + HTTPRoutes::setupRoutes(server); + + server.begin(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP server started"); +} + +void HTTPCore::stop() { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Stopping Http Server"); + server.stop(); + server.close(); +} + +void HTTPCore::update() { + unsigned long currentTime = millis(); + + // Handle client requests with rate limiting + if (currentTime - lastUpdateTime > WEBSERVER_DELAY) { + lastUpdateTime = currentTime; + server.handleClient(); + + // Process DNS requests if in AP mode + WiFiManager::processDNS(); + + // Update MDNS periodically + updateMDNS(); + } +} + +void HTTPCore::updateMDNS() { + unsigned long currentTime = millis(); + if (currentTime - lastMDNSUpdate > MDNS_UPDATE_INTERVAL) { + MDNS.addServiceTxt("http", "_tcp", "lf", String(currentTime)); + lastMDNSUpdate = currentTime; + } +} + +bool HTTPCore::hasInternetConnection() const { + return internetConnection; +} + +void HTTPCore::setInternetConnection(bool connected) { + internetConnection = connected; +} + +WebServer& HTTPCore::getServer() { + return server; +} diff --git a/src/http/HTTPFileSystem.cpp b/src/http/HTTPFileSystem.cpp new file mode 100644 index 00000000..b05c75bd --- /dev/null +++ b/src/http/HTTPFileSystem.cpp @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "http/HTTPFileSystem.h" +#include "SS2KLog.h" + +// Initialize static members +File HTTPFileSystem::fsUploadFile; +bool HTTPFileSystem::uploadInProgress = false; +size_t HTTPFileSystem::uploadProgress = 0; +size_t HTTPFileSystem::uploadTotal = 0; + +// Initialize content type constants +const char* const HTTPFileSystem::TEXT_PLAIN = "text/plain"; +const char* const HTTPFileSystem::TEXT_HTML = "text/html"; +const char* const HTTPFileSystem::TEXT_CSS = "text/css"; +const char* const HTTPFileSystem::TEXT_JAVASCRIPT = "application/javascript"; +const char* const HTTPFileSystem::TEXT_JSON = "application/json"; +const char* const HTTPFileSystem::IMAGE_PNG = "image/png"; +const char* const HTTPFileSystem::IMAGE_JPG = "image/jpeg"; +const char* const HTTPFileSystem::IMAGE_GIF = "image/gif"; +const char* const HTTPFileSystem::IMAGE_ICO = "image/x-icon"; +const char* const HTTPFileSystem::APPLICATION_OCTET_STREAM = "application/octet-stream"; + +bool HTTPFileSystem::initialize() { + if (!LittleFS.begin()) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "LittleFS Mount Failed"); + return false; + } + SS2K_LOG(HTTP_SERVER_LOG_TAG, "LittleFS Mounted Successfully"); + return true; +} + +void HTTPFileSystem::handleFileRead(WebServer& server, const String& path, const String& contentType) { + String finalPath = path; + if (finalPath.endsWith("/")) { + finalPath += "index.html"; + } + + String mimeType = contentType; + if (mimeType.isEmpty()) { + mimeType = getContentType(finalPath); + } + + if (exists(finalPath)) { + File file = openFile(finalPath, "r"); + if (file) { + server.streamFile(file, mimeType); + file.close(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Served %s", finalPath.c_str()); + return; + } + } + + SS2K_LOG(HTTP_SERVER_LOG_TAG, "File Not Found: %s", finalPath.c_str()); + server.send(404, TEXT_PLAIN, "File Not Found"); +} + +void HTTPFileSystem::handleFileUpload(WebServer& server) { + HTTPUpload& upload = server.upload(); + + if (upload.status == UPLOAD_FILE_START) { + uploadInProgress = true; + uploadProgress = 0; + uploadTotal = 0; + + String filename = upload.filename; + if (!filename.startsWith("/")) { + filename = "/" + filename; + } + SS2K_LOG(HTTP_SERVER_LOG_TAG, "handleFileUpload Name: %s", filename.c_str()); + beginFileUpload(filename); + + } else if (upload.status == UPLOAD_FILE_WRITE) { + uploadProgress += upload.currentSize; + uploadTotal = upload.totalSize; + + if (fsUploadFile) { + continueFileUpload(upload.buf, upload.currentSize); + } + + } else if (upload.status == UPLOAD_FILE_END) { + if (fsUploadFile) { + endFileUpload(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "handleFileUpload Size: %zu", upload.totalSize); + } + uploadInProgress = false; + } +} + +void HTTPFileSystem::handleFileDelete(WebServer& server) { + if (!server.hasArg("path")) { + server.send(400, TEXT_PLAIN, "Path Argument Missing"); + return; + } + + String path = server.arg("path"); + if (!exists(path)) { + server.send(404, TEXT_PLAIN, "File Not Found"); + return; + } + + if (LittleFS.remove(path)) { + server.send(200, TEXT_PLAIN, "File Deleted"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "File Deleted: %s", path.c_str()); + } else { + server.send(500, TEXT_PLAIN, "Delete Failed"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Delete Failed: %s", path.c_str()); + } +} + +void HTTPFileSystem::handleFileList(WebServer& server) { + String path = server.hasArg("dir") ? server.arg("dir") : "/"; + + File dir = LittleFS.open(path); + if (!dir || !dir.isDirectory()) { + server.send(400, TEXT_PLAIN, "Directory Not Found"); + return; + } + + String output = "["; + File entry = dir.openNextFile(); + while (entry) { + if (output != "[") { + output += ','; + } + output += "{\"name\":\"" + String(entry.name()) + "\","; + output += "\"size\":" + String(entry.size()) + ","; + output += "\"isDir\":" + String(entry.isDirectory() ? "true" : "false") + "}"; + entry = dir.openNextFile(); + } + output += "]"; + + server.send(200, TEXT_JSON, output); +} + +String HTTPFileSystem::getContentType(const String& filename) { + if (filename.endsWith(".html")) return TEXT_HTML; + else if (filename.endsWith(".css")) return TEXT_CSS; + else if (filename.endsWith(".js")) return TEXT_JAVASCRIPT; + else if (filename.endsWith(".json")) return TEXT_JSON; + else if (filename.endsWith(".png")) return IMAGE_PNG; + else if (filename.endsWith(".jpg")) return IMAGE_JPG; + else if (filename.endsWith(".gif")) return IMAGE_GIF; + else if (filename.endsWith(".ico")) return IMAGE_ICO; + else if (filename.endsWith(".gz")) { + String baseFilename = filename.substring(0, filename.length() - 3); + return getContentType(baseFilename); + } + return TEXT_PLAIN; +} + +bool HTTPFileSystem::exists(const String& path) { + return LittleFS.exists(path); +} + +bool HTTPFileSystem::isDirectory(const String& path) { + File file = LittleFS.open(path); + bool isDir = file && file.isDirectory(); + file.close(); + return isDir; +} + +File HTTPFileSystem::openFile(const String& path, const char* mode) { + return LittleFS.open(path, mode); +} + +void HTTPFileSystem::beginFileUpload(const String& filename) { + if (fsUploadFile) { + fsUploadFile.close(); + } + fsUploadFile = LittleFS.open(filename, "w"); +} + +void HTTPFileSystem::continueFileUpload(uint8_t* data, size_t len) { + if (fsUploadFile && data && len > 0) { + fsUploadFile.write(data, len); + } +} + +void HTTPFileSystem::endFileUpload() { + if (fsUploadFile) { + fsUploadFile.close(); + } +} diff --git a/src/http/HTTPFirmware.cpp b/src/http/HTTPFirmware.cpp new file mode 100644 index 00000000..9cd9c589 --- /dev/null +++ b/src/http/HTTPFirmware.cpp @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "http/HTTPFirmware.h" +#include "Main.h" +#include "SS2KLog.h" +#include "cert.h" +#include + +// Version class implementation +Version::Version(const char* version) : major(0), minor(0), patch(0) { + parseVersion(version); +} + +void Version::parseVersion(const char* version) { + sscanf(version, "%d.%d.%d", &major, &minor, &patch); +} + +bool Version::operator>(const Version& other) const { + if (major != other.major) return major > other.major; + if (minor != other.minor) return minor > other.minor; + return patch > other.patch; +} + +void HTTPFirmware::checkForUpdates() { + HTTPClient http; + WiFiClientSecure client; + client.setCACert(getRootCACertificate()); + + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Checking for newer firmware:"); + http.begin(userConfig->getFirmwareUpdateURL() + String(FW_VERSIONFILE), getRootCACertificate()); + + delay(100); + int httpCode = http.GET(); + delay(100); + + String payload; + if (httpCode == HTTP_CODE_OK) { + payload = http.getString(); + payload.trim(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Server version: %s", payload.c_str()); + httpServer.setInternetConnection(true); + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "error downloading %s %d", FW_VERSIONFILE, httpCode); + httpServer.setInternetConnection(false); + } + http.end(); + + if (httpCode == HTTP_CODE_OK) { + bool updateAnyway = false; + if (!LittleFS.exists("/index.html")) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, " -index.html not found."); + } + + Version availableVer(payload.c_str()); + Version currentVer(FIRMWARE_VERSION); + + if (((availableVer > currentVer) && (userConfig->getAutoUpdate())) || (!LittleFS.exists("/index.html"))) { + // Update LittleFS first + updateLittleFS(); + + // Then update firmware if needed + if ((availableVer > currentVer) || updateAnyway) { + if (userConfig->getAutoUpdate()) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "New firmware detected!"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Upgrading from %s to %s", FIRMWARE_VERSION, payload.c_str()); + updateFirmware(); + } + } + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, " - Current Version: %s", FIRMWARE_VERSION); + } + } +} + +void HTTPFirmware::updateLittleFS() { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Updating FileSystem"); + + if (!downloadFileList()) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to download file list"); + return; + } +} + +void HTTPFirmware::updateFirmware() { + WiFiClientSecure client; + client.setCACert(getRootCACertificate()); + + t_httpUpdate_return ret = httpUpdate.update(client, userConfig->getFirmwareUpdateURL() + String(FW_BINFILE)); + + switch (ret) { + case HTTP_UPDATE_FAILED: + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_FAILED Error %d : %s", + httpUpdate.getLastError(), + httpUpdate.getLastErrorString().c_str()); + break; + + case HTTP_UPDATE_NO_UPDATES: + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_NO_UPDATES"); + break; + + case HTTP_UPDATE_OK: + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HTTP_UPDATE_OK"); + break; + } +} + +bool HTTPFirmware::downloadFileList() { + HTTPClient http; + http.begin(DATA_UPDATEURL + String(DATA_FILELIST), getRootCACertificate()); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "error downloading %s %d", DATA_FILELIST, httpCode); + http.end(); + return false; + } + + String payload = http.getString(); + payload.trim(); + http.end(); + + return processFileList(payload); +} + +bool HTTPFirmware::processFileList(const String& fileListContent) { + StaticJsonDocument<500> doc; + DeserializationError error = deserializeJson(doc, fileListContent); + + if (error) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to parse file list"); + return false; + } + + JsonArray files = doc.as(); + bool success = true; + + for (JsonVariant v : files) { + String fileName = "/" + v.as(); + if (!downloadAndSaveFile(fileName)) { + success = false; + } + } + + return success; +} + +bool HTTPFirmware::downloadAndSaveFile(const String& filename) { + HTTPClient http; + http.begin(DATA_UPDATEURL + filename, getRootCACertificate()); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error downloading %s %d", filename.c_str(), httpCode); + http.end(); + return false; + } + + String payload = http.getString(); + payload.trim(); + http.end(); + + LittleFS.remove(filename); + File file = LittleFS.open(filename, FILE_WRITE, true); + if (!file) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to create file, %s", filename.c_str()); + return false; + } + + if (!file.print(payload)) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Failed to write to file, %s", filename.c_str()); + file.close(); + return false; + } + + file.close(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Created: %s", filename.c_str()); + return true; +} + +void HTTPFirmware::handleOTAUpdate(WebServer& server) { + HTTPUpload& upload = server.upload(); + + if (upload.status == UPLOAD_FILE_START) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Update: %s", upload.filename.c_str()); + if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { + Update.printError(Serial); + } + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { + Update.printError(Serial); + } + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Update Success: %u bytes\nRebooting...", upload.totalSize); + server.send(200, "text/plain", "Update successful. Rebooting..."); + delay(1000); + ss2k->rebootFlag = true; + } else { + Update.printError(Serial); + } + } else if (upload.status == UPLOAD_FILE_ABORTED) { + Update.end(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Update aborted"); + } +} + +const char* HTTPFirmware::getRootCACertificate() { + return rootCACertificate; +} diff --git a/src/http/HTTPRoutes.cpp b/src/http/HTTPRoutes.cpp new file mode 100644 index 00000000..01d919a9 --- /dev/null +++ b/src/http/HTTPRoutes.cpp @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "http/HTTPRoutes.h" +#include "Main.h" +#include "SS2KLog.h" +#include "Builtin_Pages.h" +#include "HTTPUpdateServer.h" +#include +#include + +// Initialize static members +WebServer* HTTPRoutes::currentServer = nullptr; +File HTTPRoutes::fsUploadFile; + +// Initialize static handler functions +HTTPRoutes::HandlerFunction HTTPRoutes::handleIndexFile = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleBTScanner = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleLittleFSFile = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleConfigJSON = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleRuntimeConfigJSON = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handlePWCJSON = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleShift = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleHRSlider = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleWattsSlider = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleCadSlider = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleERGMode = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleTargetWattsSlider = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleLogin = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleOTAUpdate = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleFileUpload = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleSendSettings = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleReboot = nullptr; +HTTPRoutes::HandlerFunction HTTPRoutes::handleBLEScan = nullptr; + +void HTTPRoutes::initialize(WebServer& server) { + currentServer = &server; + + // Initialize handler functions using lambda functions to capture the static methods + handleIndexFile = []() { _handleIndexFile(); }; + handleBTScanner = []() { _handleBTScanner(); }; + handleLittleFSFile = []() { _handleLittleFSFile(); }; + handleConfigJSON = []() { _handleConfigJSON(); }; + handleRuntimeConfigJSON = []() { _handleRuntimeConfigJSON(); }; + handlePWCJSON = []() { _handlePWCJSON(); }; + handleShift = []() { _handleShift(); }; + handleHRSlider = []() { _handleHRSlider(); }; + handleWattsSlider = []() { _handleWattsSlider(); }; + handleCadSlider = []() { _handleCadSlider(); }; + handleERGMode = []() { _handleERGMode(); }; + handleTargetWattsSlider = []() { _handleTargetWattsSlider(); }; + handleLogin = []() { _handleLogin(); }; + handleOTAUpdate = []() { _handleOTAUpdate(); }; + handleFileUpload = []() { _handleFileUpload(); }; + handleSendSettings = []() { _handleSendSettings(); }; + handleReboot = []() { _handleReboot(); }; + handleBLEScan = []() { _handleBLEScan(); }; +} + +void HTTPRoutes::_handleIndexFile() { + String filename = "/index.html"; + if (LittleFS.exists(filename)) { + File file = LittleFS.open(filename, FILE_READ); + currentServer->streamFile(file, "text/html"); + file.close(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Served %s", filename.c_str()); + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found. Sending builtin Index.html", filename.c_str()); + currentServer->send(200, "text/html", noIndexHTML); + } +} + +void HTTPRoutes::_handleBTScanner() { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Scanning from web request"); + spinBLEClient.dontBlockScan = true; + spinBLEClient.doScan = true; + _handleLittleFSFile(); +} + +void HTTPRoutes::_handleLittleFSFile() { + String filename = currentServer->uri(); + int dotPosition = filename.lastIndexOf("."); + String fileType = filename.substring((dotPosition + 1), filename.length()); + + if (LittleFS.exists(filename)) { + File file = LittleFS.open(filename, FILE_READ); + String mimeType; + if (fileType == "js") { + mimeType = "application/javascript"; + } else if (fileType == "css") { + mimeType = "text/css"; + } else if (fileType == "html" || fileType == "gz") { + mimeType = "text/html"; + } else { + mimeType = "text/" + fileType; + } + + // Add caching headers + currentServer->sendHeader("Cache-Control", "public, max-age=31536000"); + currentServer->sendHeader("Expires", "Thu, 31 Dec 2037 23:59:59 GMT"); + currentServer->streamFile(file, mimeType); + file.close(); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Served %s", filename.c_str()); + } else if (!LittleFS.exists("/index.html")) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found and no filesystem. Sending builtin index.html", filename.c_str()); + _handleIndexFile(); + } else { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "%s not found. Sending 404.", filename.c_str()); + String outputhtml = "

ERROR 404
FILE NOT FOUND!" + filename + "

"; + currentServer->send(404, "text/html", outputhtml); + } +} + +void HTTPRoutes::_handleConfigJSON() { + String tString = userConfig->returnJSON(); + currentServer->send(200, "text/plain", tString); +} + +void HTTPRoutes::_handleRuntimeConfigJSON() { + String tString = rtConfig->returnJSON(); + currentServer->send(200, "text/plain", tString); +} + +void HTTPRoutes::_handlePWCJSON() { + String tString = userPWC->returnJSON(); + currentServer->send(200, "text/plain", tString); +} + +void HTTPRoutes::_handleShift() { + int value = currentServer->arg("value").toInt(); + if ((value > -10) && (value < 10)) { + rtConfig->setShifterPosition(rtConfig->getShifterPosition() + value); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Shift From HTML"); + } else { + rtConfig->setShifterPosition(value); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Invalid HTML Shift"); + currentServer->send(200, "text/plain", "OK"); + } +} + +void HTTPRoutes::_handleHRSlider() { + String value = currentServer->arg("value"); + if (value == "enable") { + rtConfig->hr.setSimulate(true); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HR Simulator turned on"); + } else if (value == "disable") { + rtConfig->hr.setSimulate(false); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HR Simulator turned off"); + } else { + rtConfig->hr.setValue(value.toInt()); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "HR is now: %d", rtConfig->hr.getValue()); + currentServer->send(200, "text/plain", "OK"); + } +} + +void HTTPRoutes::_handleWattsSlider() { + String value = currentServer->arg("value"); + if (value == "enable") { + rtConfig->watts.setSimulate(true); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Watt Simulator turned on"); + } else if (value == "disable") { + rtConfig->watts.setSimulate(false); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Watt Simulator turned off"); + } else { + rtConfig->watts.setValue(value.toInt()); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Watts are now: %d", rtConfig->watts.getValue()); + currentServer->send(200, "text/plain", "OK"); + } +} + +void HTTPRoutes::_handleCadSlider() { + String value = currentServer->arg("value"); + if (value == "enable") { + rtConfig->cad.setSimulate(true); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "CAD Simulator turned on"); + } else if (value == "disable") { + rtConfig->cad.setSimulate(false); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "CAD Simulator turned off"); + } else { + rtConfig->cad.setValue(value.toInt()); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "CAD is now: %d", rtConfig->cad.getValue()); + currentServer->send(200, "text/plain", "OK"); + } +} + +void HTTPRoutes::_handleERGMode() { + String value = currentServer->arg("value"); + if (value == "enable") { + rtConfig->setFTMSMode(FitnessMachineControlPointProcedure::SetTargetPower); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "ERG Mode turned on"); + } else { + rtConfig->setFTMSMode(FitnessMachineControlPointProcedure::RequestControl); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "ERG Mode turned off"); + } +} + +void HTTPRoutes::_handleTargetWattsSlider() { + String value = currentServer->arg("value"); + if (value == "enable") { + rtConfig->setSimTargetWatts(true); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Target Watts Simulator turned on"); + } else if (value == "disable") { + rtConfig->setSimTargetWatts(false); + currentServer->send(200, "text/plain", "OK"); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Target Watts Simulator turned off"); + } else { + rtConfig->watts.setTarget(value.toInt()); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Target Watts are now: %d", rtConfig->watts.getTarget()); + currentServer->send(200, "text/plain", "OK"); + } +} + +void HTTPRoutes::_handleLogin() { + currentServer->sendHeader("Connection", "close"); + currentServer->send(200, "text/html", OTALoginIndex); +} + +void HTTPRoutes::_handleOTAUpdate() { + ss2k->stopTasks(); + currentServer->sendHeader("Connection", "close"); + currentServer->send(200, "text/html", OTAServerIndex); +} + +void HTTPRoutes::_handleFileUpload() { HTTPFirmware::handleOTAUpdate(*currentServer); } + +void HTTPRoutes::setupRoutes(WebServer& server) { + initialize(server); + setupDefaultRoutes(server); + setupFileRoutes(server); + setupControlRoutes(server); + setupUpdateRoutes(server); +} + +void HTTPRoutes::setupDefaultRoutes(WebServer& server) { + server.on("/", HTTP_GET, handleIndexFile); + server.on("/index.html", HTTP_GET, handleIndexFile); + server.on("/generate_204", HTTP_GET, handleIndexFile); // Android captive portal + server.on("/fwlink", HTTP_GET, handleIndexFile); // Microsoft captive portal + server.on("/hotspot-detect.html", HTTP_GET, handleIndexFile); // Apple captive portal +} + +void HTTPRoutes::setupFileRoutes(WebServer& server) { + server.on("/style.css", HTTP_GET, handleLittleFSFile); + server.on("/btsimulator.html", HTTP_GET, handleLittleFSFile); + server.on("/develop.html", HTTP_GET, handleLittleFSFile); + server.on("/shift.html", HTTP_GET, handleLittleFSFile); + server.on("/settings.html", HTTP_GET, handleLittleFSFile); + server.on("/status.html", HTTP_GET, handleLittleFSFile); + server.on("/bluetoothscanner.html", HTTP_GET, handleBTScanner); + server.on("/streamfit.html", HTTP_GET, handleLittleFSFile); + server.on("/hrtowatts.html", HTTP_GET, handleLittleFSFile); + server.on("/favicon.ico", HTTP_GET, handleLittleFSFile); + server.on("/jquery.js.gz", HTTP_GET, handleLittleFSFile); +} + +void HTTPRoutes::setupControlRoutes(WebServer& server) { + server.on("/hrslider", HTTP_GET, handleHRSlider); + server.on("/wattsslider", HTTP_GET, handleWattsSlider); + server.on("/cadslider", HTTP_GET, handleCadSlider); + server.on("/ergmode", HTTP_GET, handleERGMode); + server.on("/targetwattsslider", HTTP_GET, handleTargetWattsSlider); + server.on("/shift", HTTP_GET, handleShift); + server.on("/configJSON", HTTP_GET, handleConfigJSON); + server.on("/runtimeConfigJSON", HTTP_GET, handleRuntimeConfigJSON); + server.on("/PWCJSON", HTTP_GET, handlePWCJSON); + server.on("/send_settings", HTTP_GET, handleSendSettings); + server.on("/reboot.html", HTTP_GET, handleReboot); + server.on("/BLEScan", HTTP_GET, handleBLEScan); +} + +void HTTPRoutes::setupUpdateRoutes(WebServer& server) { + server.on("/login", HTTP_GET, handleLogin); + server.on("/OTAIndex", HTTP_GET, handleOTAUpdate); + + server.on( + "/update", HTTP_POST, + []() { + currentServer->sendHeader("Connection", "close"); + currentServer->send(200, "text/plain", (Update.hasError()) ? "FAIL" : "OK"); + }, + handleFileUpload); +} + +void HTTPRoutes::_handleSendSettings() { HTTPSettings::processSettings(*currentServer); } + +void HTTPRoutes::_handleReboot() { + ss2k->rebootFlag = true; + String response = + "Please wait while your SmartSpin2k reboots."; + currentServer->send(200, "text/html", response); + ss2k->rebootFlag = true; +} + +void HTTPRoutes::_handleBLEScan() { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Scanning from web request"); + String response = + "Scanning for BLE Devices. Please wait " + "10 seconds."; + spinBLEClient.dontBlockScan = true; + spinBLEClient.doScan = true; + currentServer->send(200, "text/html", response); +} diff --git a/src/http/HTTPSettings.cpp b/src/http/HTTPSettings.cpp new file mode 100644 index 00000000..732f2cf6 --- /dev/null +++ b/src/http/HTTPSettings.cpp @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "http/HTTPSettings.h" +#include "Main.h" +#include "SS2KLog.h" + +void HTTPSettings::processSettings(WebServer& server) { + bool wasBTUpdate = false; + bool wasSettingsUpdate = false; + bool reboot = false; + + // Process Network Settings + processNetworkSettings(server); + processDeviceSettings(server); + wasSettingsUpdate = processStepperSettings(server); + processPowerSettings(server); + processERGSettings(server); + if (wasSettingsUpdate) { + processFeatureSettings(server); + } + // Process Bluetooth settings and capture the result + wasBTUpdate = processBluetoothSettings(server); + + processPWCSettings(server); + + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Config Updated From Web"); + ss2k->saveFlag = true; + + if (reboot) { + sendSettingsResponse(server, wasBTUpdate, wasSettingsUpdate, true); + ss2k->rebootFlag = true; + } else { + sendSettingsResponse(server, wasBTUpdate, wasSettingsUpdate, false); + } +} + +void HTTPSettings::processNetworkSettings(WebServer& server) { + if (!server.arg("ssid").isEmpty()) { + String ssid = server.arg("ssid"); + ssid.trim(); + userConfig->setSsid(ssid); + } + + if (!server.arg("password").isEmpty()) { + String password = server.arg("password"); + password.trim(); + userConfig->setPassword(password); + } +} + +void HTTPSettings::processDeviceSettings(WebServer& server) { + if (!server.arg("deviceName").isEmpty()) { + String deviceName = server.arg("deviceName"); + deviceName.trim(); + userConfig->setDeviceName(deviceName); + } +} + +bool HTTPSettings::processStepperSettings(WebServer& server) { + bool wasSettingsUpdate = false; + if (!server.arg("shiftStep").isEmpty()) { + wasSettingsUpdate = true; // shiftStep is our signal that it was a settings update. + uint64_t shiftStep = server.arg("shiftStep").toInt(); + if (shiftStep >= MIN_SHIFT_STEP && shiftStep <= MAX_SHIFT_STEP) { + userConfig->setShiftStep(shiftStep); + } + } + + if (!server.arg("stepperPower").isEmpty()) { + uint64_t stepperPower = server.arg("stepperPower").toInt(); + if (stepperPower >= MIN_STEPPER_POWER && stepperPower <= MAX_STEPPER_POWER) { + userConfig->setStepperPower(stepperPower); + ss2k->updateStepperPower(); + } + } + + if (!server.arg("stepperDir").isEmpty()) { + userConfig->setStepperDir(true); + } else if (wasSettingsUpdate) { + userConfig->setStepperDir(false); + } + + if (!server.arg("stealthChop").isEmpty()) { + userConfig->setStealthChop(true); + ss2k->updateStealthChop(); + } else if (wasSettingsUpdate) { + userConfig->setStealthChop(false); + ss2k->updateStealthChop(); + } + return wasSettingsUpdate; +} + +void HTTPSettings::processPowerSettings(WebServer& server) { + if (!server.arg("maxWatts").isEmpty()) { + uint64_t maxWatts = server.arg("maxWatts").toInt(); + if (maxWatts >= 0 && maxWatts <= DEFAULT_MAX_WATTS) { + userConfig->setMaxWatts(maxWatts); + } + } + + if (!server.arg("minWatts").isEmpty()) { + uint64_t minWatts = server.arg("minWatts").toInt(); + if (minWatts >= 0 && minWatts <= DEFAULT_MIN_WATTS) { + userConfig->setMinWatts(minWatts); + } + } + + if (!server.arg("powerCorrectionFactor").isEmpty()) { + float powerCorrectionFactor = server.arg("powerCorrectionFactor").toFloat(); + if (powerCorrectionFactor >= MIN_PCF && powerCorrectionFactor <= MAX_PCF) { + userConfig->setPowerCorrectionFactor(powerCorrectionFactor); + } + } +} + +void HTTPSettings::processERGSettings(WebServer& server) { + if (!server.arg("ERGSensitivity").isEmpty()) { + float ERGSensitivity = server.arg("ERGSensitivity").toFloat(); + if (ERGSensitivity >= 0.1 && ERGSensitivity <= 20) { + userConfig->setERGSensitivity(ERGSensitivity); + } + } + + if (!server.arg("homingSensitivity").isEmpty()) { + int homingSensitivity = server.arg("homingSensitivity").toInt(); + if (homingSensitivity >= 10 && homingSensitivity <= 100) { + userConfig->setHomingSensitivity(homingSensitivity); + } + } + + if (!server.arg("inclineMultiplier").isEmpty()) { + float inclineMultiplier = server.arg("inclineMultiplier").toFloat(); + if (inclineMultiplier >= 0 && inclineMultiplier <= 10) { + userConfig->setInclineMultiplier(inclineMultiplier); + } + } +} + +void HTTPSettings::processFeatureSettings(WebServer& server) { + if (!server.arg("autoUpdate").isEmpty()) { + userConfig->setAutoUpdate(true); + } else { + userConfig->setAutoUpdate(false); + } + + if (!server.arg("shifterDir").isEmpty()) { + userConfig->setShifterDir(true); + } else { + userConfig->setShifterDir(false); + } + + if (!server.arg("udpLogEnabled").isEmpty()) { + userConfig->setUdpLogEnabled(true); + } else { + userConfig->setUdpLogEnabled(false); + } +} + +bool HTTPSettings::processBluetoothSettings(WebServer& server) { + bool wasBTUpdate = false; + + if (!server.arg("blePMDropdown").isEmpty()) { + wasBTUpdate = true; + if (server.arg("blePMDropdown")) { + String powerMeter = server.arg("blePMDropdown"); + if (powerMeter != userConfig->getConnectedPowerMeter()) { + userConfig->setConnectedPowerMeter(powerMeter); + spinBLEClient.reconnectAllDevices(); + } + } else { + userConfig->setConnectedPowerMeter("any"); + } + } + + if (!server.arg("bleHRDropdown").isEmpty()) { + wasBTUpdate = true; + if (server.arg("bleHRDropdown")) { + String heartMonitor = server.arg("bleHRDropdown"); + if (heartMonitor != userConfig->getConnectedHeartMonitor()) { + spinBLEClient.reconnectAllDevices(); + } + userConfig->setConnectedHeartMonitor(heartMonitor); + } else { + userConfig->setConnectedHeartMonitor("any"); + } + } + + if (!server.arg("bleRemoteDropdown").isEmpty()) { + wasBTUpdate = true; + if (server.arg("bleRemoteDropdown")) { + String remote = server.arg("bleRemoteDropdown"); + if (remote != userConfig->getConnectedRemote()) { + spinBLEClient.reconnectAllDevices(); + } + userConfig->setConnectedRemote(remote); + } else { + userConfig->setConnectedRemote("any"); + } + } + + return wasBTUpdate; +} + +void HTTPSettings::processPWCSettings(WebServer& server) { + if (!server.arg("session1HR").isEmpty()) { + userPWC->session1HR = server.arg("session1HR").toInt(); + } else { // wasn't pwc settings page + return; + } + if (!server.arg("session1Pwr").isEmpty()) { + userPWC->session1Pwr = server.arg("session1Pwr").toInt(); + } + if (!server.arg("session2HR").isEmpty()) { + userPWC->session2HR = server.arg("session2HR").toInt(); + } + if (!server.arg("session2Pwr").isEmpty()) { + userPWC->session2Pwr = server.arg("session2Pwr").toInt(); + } + if (!server.arg("hr2Pwr").isEmpty()) { + userPWC->hr2Pwr = true; + } else { + userPWC->hr2Pwr = false; + } +} + +void HTTPSettings::sendSettingsResponse(WebServer& server, bool wasBTUpdate, bool wasSettingsUpdate, bool requiresReboot) { + String response; + if (wasBTUpdate) { + response = buildRedirectResponse("Selections Saved!", "/bluetoothscanner.html"); + } else if (wasSettingsUpdate || requiresReboot) { + response = buildRedirectResponse( + "Network settings will be applied at next reboot.
" + "Everything else is available immediately.", + "/settings.html"); + } else { + response = buildRedirectResponse( + "Network settings will be applied at next reboot.
" + "Everything else is available immediately.", + "/index.html"); + } + + if (requiresReboot) { + response = buildRedirectResponse("Please wait while your settings are saved and SmartSpin2k reboots.", "/bluetoothscanner.html", 5000); + } + + server.send(200, "text/html", response); +} + +String HTTPSettings::buildRedirectResponse(const String& message, const String& page, int delay) { + return "

" + message + "

"; +} + +bool HTTPSettings::processCheckbox(WebServer& server, const char* name, bool defaultValue) { + if (!server.hasArg(name)) { + return defaultValue; + } + return server.arg(name) == "true" || server.arg(name) == "1"; +} + +float HTTPSettings::processFloatValue(WebServer& server, const char* name, float min, float max) { + if (!server.hasArg(name)) { + return min; + } + float value = server.arg(name).toFloat(); + if (value < min) return min; + if (value > max) return max; + return value; +} + +int HTTPSettings::processIntValue(WebServer& server, const char* name, int min, int max) { + if (!server.hasArg(name)) { + return min; + } + int value = server.arg(name).toInt(); + if (value < min) return min; + if (value > max) return max; + return value; +} + +String HTTPSettings::processStringValue(WebServer& server, const char* name) { + if (!server.hasArg(name)) { + return ""; + } + return server.arg(name); +} diff --git a/src/wifi/WiFiManager.cpp b/src/wifi/WiFiManager.cpp new file mode 100644 index 00000000..cc56b4a6 --- /dev/null +++ b/src/wifi/WiFiManager.cpp @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2020 Anthony Doud & Joel Baranick + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "wifi/WiFiManager.h" +#include "Main.h" +#include "SS2KLog.h" +#include + +// Initialize static members +DNSServer WiFiManager::dnsServer; +IPAddress WiFiManager::myIP; + +void WiFiManager::startWifi() { + int attempts = 0; + + // Try Station mode first if SSID is not default + if (strcmp(userConfig->getSsid(), DEVICE_NAME) != 0) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Connecting to: %s", userConfig->getSsid()); + setupStationMode(); + + while (WiFi.status() != WL_CONNECTED) { + vTaskDelay(1000 / portTICK_RATE_MS); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Waiting for connection to be established..."); + attempts++; + if (attempts > WIFI_CONNECT_TIMEOUT) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Couldn't Connect. Switching to AP mode"); + WiFi.disconnect(true, true); + WiFi.setAutoReconnect(false); + WiFi.mode(WIFI_MODE_NULL); + vTaskDelay(1000 / portTICK_RATE_MS); + break; + } + } + } + + // If connected in STA mode, set IP and return + if (WiFi.status() == WL_CONNECTED) { + myIP = WiFi.localIP(); + setupMDNS(); + if (WiFi.getMode() == WIFI_STA) { + syncClock(); + } + return; + } + + // Otherwise set up AP mode + setupAPMode(); + + if (strcmp(userConfig->getSsid(), DEVICE_NAME) == 0) { + // If default SSID is still in use, let the user select a new password + WiFi.softAP(userConfig->getDeviceName(), userConfig->getPassword()); + } else { + WiFi.softAP(userConfig->getDeviceName(), DEFAULT_PASSWORD); + } + + vTaskDelay(50); + myIP = WiFi.softAPIP(); + + // Setup DNS server for captive portal + dnsServer.setErrorReplyCode(DNSReplyCode::NoError); + dnsServer.start(DNS_PORT, "*", myIP); + + setupMDNS(); + + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Connected to %s IP address: %s", + userConfig->getSsid(), myIP.toString().c_str()); + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Open http://%s.local/", userConfig->getDeviceName()); + + WiFi.setTxPower(WIFI_POWER_19_5dBm); +} + +void WiFiManager::stopWifi() { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Closing connection to: %s", userConfig->getSsid()); + WiFi.disconnect(); +} + +void WiFiManager::setupStationMode() { + WiFi.setHostname(userConfig->getDeviceName()); + WiFi.mode(WIFI_STA); + WiFi.begin(userConfig->getSsid(), userConfig->getPassword()); + WiFi.setAutoReconnect(true); +} + +void WiFiManager::setupAPMode() { + WiFi.mode(WIFI_AP); + WiFi.setHostname("reset"); // Fixes a bug when switching Arduino Core Versions + WiFi.softAPsetHostname("reset"); + WiFi.setHostname(userConfig->getDeviceName()); + WiFi.softAPsetHostname(userConfig->getDeviceName()); + WiFi.enableAP(true); + vTaskDelay(500); // Microcontroller requires some time to reset the mode +} + +void WiFiManager::setupMDNS() { + if (!MDNS.begin(userConfig->getDeviceName())) { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Error setting up MDNS responder!"); + return; + } + MDNS.addService("http", "_tcp", 80); + MDNS.addServiceTxt("http", "_tcp", "lf", "0"); +} + +void WiFiManager::syncClock() { + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Syncing clock..."); + configTime(0, 0, "pool.ntp.org"); // get UTC time via NTP + time_t now = time(nullptr); + while (now < 10) { // wait 10 seconds + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Waiting for clock sync..."); + delay(100); + now = time(nullptr); + } + SS2K_LOG(HTTP_SERVER_LOG_TAG, "Clock synced to: %.f", difftime(now, (time_t)0)); +} + +void WiFiManager::processDNS() { + if (WiFi.getMode() != WIFI_MODE_STA) { + dnsServer.processNextRequest(); + } +} + +IPAddress WiFiManager::getIP() { + return myIP; +} + +bool WiFiManager::isConnected() { + return WiFi.status() == WL_CONNECTED; +}