Initial commit
This commit is contained in:
107
calibrate.py
Normal file
107
calibrate.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import time
|
||||||
|
import ntptime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from hx710b import HX710B # Correct driver
|
||||||
|
from config import *
|
||||||
|
|
||||||
|
CALIB_FILE = "calibration.cfg"
|
||||||
|
|
||||||
|
tz_offset = 0
|
||||||
|
utc = time.localtime()
|
||||||
|
local = time.localtime(time.mktime(utc) + tz_offset * 3600)
|
||||||
|
ctime = f"{local[0]:02}-{local[1]:02}-{local[2]:02} {local[3]:02}:{local[4]:02}:{local[5]:02}"
|
||||||
|
#print("Time =", local)
|
||||||
|
print("Time =", ctime)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# USER INPUT
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Water depth (meters)
|
||||||
|
# KNOWN_DEPTH_M = 1.680
|
||||||
|
|
||||||
|
# Pressure created by water column (kPa)
|
||||||
|
KNOWN_PRESSURE_KPA = KNOWN_DEPTH_M * 9.81
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\nUsing depth {:.3f} m → expected pressure {:.3f} kPa".format(
|
||||||
|
KNOWN_DEPTH_M, KNOWN_PRESSURE_KPA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# SAVE CALIBRATION
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
def save_calibration(ctime, zero_offset, raw_at_pressure, scale_factor):
|
||||||
|
data = {
|
||||||
|
"zero_offset": zero_offset,
|
||||||
|
"raw_at_pressure": raw_at_pressure,
|
||||||
|
"scale_factor": scale_factor,
|
||||||
|
"Date_Time": ctime
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(CALIB_FILE, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
print("\n✔ Saved calibration:")
|
||||||
|
print(" Date_Time =", ctime)
|
||||||
|
print(" zero_offset =", zero_offset)
|
||||||
|
print(" raw_at_pressure =", raw_at_pressure)
|
||||||
|
print(" scale_factor =", scale_factor)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# RAW AVERAGING
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
def read_average(hx, samples=30):
|
||||||
|
total = 0
|
||||||
|
for _ in range(samples):
|
||||||
|
total += hx.read_raw()
|
||||||
|
time.sleep_ms(5)
|
||||||
|
return total / samples
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# CALIBRATION PROCESS
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
print("\n=== HX710B Depth-Based Calibration ===")
|
||||||
|
|
||||||
|
hx = HX710B(dout_pin=0, sck_pin=1)
|
||||||
|
|
||||||
|
# Step 1 — ZERO PRESSURE
|
||||||
|
print("\n➡ Ensure tank is OPEN TO AIR (0 kPa)...")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
zero_offset = read_average(hx)
|
||||||
|
print("Zero offset captured:", zero_offset)
|
||||||
|
|
||||||
|
# Step 2 — APPLY KNOWN PRESSURE
|
||||||
|
print(
|
||||||
|
"\n➡ Now apply EXACTLY {:.3f} kPa (depth {:.3f} m)...".format(
|
||||||
|
KNOWN_PRESSURE_KPA, KNOWN_DEPTH_M
|
||||||
|
)
|
||||||
|
)
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
raw_at_pressure = read_average(hx)
|
||||||
|
print("Raw at known depth:", raw_at_pressure)
|
||||||
|
|
||||||
|
# Compute scale factor
|
||||||
|
scale_factor = KNOWN_PRESSURE_KPA / (raw_at_pressure - zero_offset)
|
||||||
|
|
||||||
|
# Save calibration (INCLUDING raw value)
|
||||||
|
save_calibration(ctime, zero_offset, raw_at_pressure, scale_factor)
|
||||||
|
|
||||||
|
print("\n=== Calibration Complete ===")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# VERIFY
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
hx.zero_offset = zero_offset
|
||||||
|
hx.scale_factor = scale_factor
|
||||||
|
|
||||||
|
test = hx.read_kpa()
|
||||||
|
|
||||||
|
print("\nVerification reading:", test, "kPa")
|
||||||
|
print("Expected:", KNOWN_PRESSURE_KPA, "kPa")
|
||||||
|
print("\nDone.\n")
|
||||||
20
check_wifi.py
Normal file
20
check_wifi.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
SSID = "BAILIE"
|
||||||
|
PASSWORD = "hellothere"
|
||||||
|
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
wlan.active(True)
|
||||||
|
|
||||||
|
if not wlan.isconnected():
|
||||||
|
print("Connecting...")
|
||||||
|
wlan.connect(SSID, PASSWORD)
|
||||||
|
|
||||||
|
timeout = 20
|
||||||
|
while timeout > 0 and not wlan.isconnected():
|
||||||
|
time.sleep(1)
|
||||||
|
timeout -= 1
|
||||||
|
|
||||||
|
print("Connected:", wlan.isconnected())
|
||||||
|
print("Config:", wlan.ifconfig())
|
||||||
1
config.json
Normal file
1
config.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"password": "hellothere", "ssid": "BAILIE", "user": "Raymond_Bailie", "repository": "WaterTank", "branch": "main", "token": "", "ignore": []}
|
||||||
21
config.py
Normal file
21
config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
SSID = "WORKSHOP"
|
||||||
|
PASSWORD = "hellothere"
|
||||||
|
HOSTNAME = "TANK-MONITOR"
|
||||||
|
DISPLAY_ON = False
|
||||||
|
HEARTBEAT = True
|
||||||
|
AVERAGE = True
|
||||||
|
VERSION = "20260123B"
|
||||||
|
CPU_FREQ = 250_000_000
|
||||||
|
TZ_OFFSET = 2
|
||||||
|
|
||||||
|
MAX_CONNECTIONS = 5
|
||||||
|
|
||||||
|
MIN_PRESSURE_KPA = 0.0
|
||||||
|
#MAX_PRESSURE_KPA = 13.72
|
||||||
|
#MAX_PRESSURE_KPA = 50.13
|
||||||
|
MAX_DEPTH_M = 1.44
|
||||||
|
|
||||||
|
#KNOWN_DEPTH_M = 1.680
|
||||||
|
KNOWN_DEPTH_M = MAX_DEPTH_M
|
||||||
|
MAX_PRESSURE_KPA = KNOWN_DEPTH_M * 9.81
|
||||||
|
TANK_CAPACITY_L = 1800.00
|
||||||
53
display.py
Normal file
53
display.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from machine import I2C, Pin
|
||||||
|
import ssd1306
|
||||||
|
import framebuf
|
||||||
|
|
||||||
|
class OLED:
|
||||||
|
def __init__(self):
|
||||||
|
i2c = I2C(1, scl=Pin(3), sda=Pin(2), freq=400_000)
|
||||||
|
|
||||||
|
self.width = 128
|
||||||
|
self.height = 64
|
||||||
|
|
||||||
|
# Real OLED
|
||||||
|
self.oled = ssd1306.SSD1306_I2C(self.width, self.height, i2c)
|
||||||
|
|
||||||
|
# Off-screen buffer (normal orientation)
|
||||||
|
self.buf = bytearray(self.width * self.height // 8)
|
||||||
|
self.fb = framebuf.FrameBuffer(self.buf, self.width, self.height, framebuf.MONO_VLSB)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Rotate framebuffer 180°
|
||||||
|
# --------------------------------------------------
|
||||||
|
def rotate_180(self):
|
||||||
|
self.oled.fill(0)
|
||||||
|
|
||||||
|
for y in range(self.height):
|
||||||
|
for x in range(self.width):
|
||||||
|
if self.fb.pixel(x, y):
|
||||||
|
self.oled.pixel(
|
||||||
|
self.width - 1 - x,
|
||||||
|
self.height - 1 - y,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Display content
|
||||||
|
# --------------------------------------------------
|
||||||
|
def show(self, rtc, pressure, percent, cpu_temp, rssi_percent, depth_mm):
|
||||||
|
dt = rtc.datetime()
|
||||||
|
ctime = f"{dt[4]:02}:{dt[5]:02}:{dt[6]:02}"
|
||||||
|
|
||||||
|
# Draw NORMAL orientation into framebuffer
|
||||||
|
self.fb.fill(0)
|
||||||
|
|
||||||
|
self.fb.text(ctime, 30, 0)
|
||||||
|
self.fb.text(f"P: {pressure:.2f} kPa", 0, 10)
|
||||||
|
self.fb.text(f"D: {depth_mm:.0f} mm", 0, 20)
|
||||||
|
self.fb.text(f"L: {percent:.2f} %", 0, 30)
|
||||||
|
self.fb.text(f"CPU: {cpu_temp:.1f} C", 0, 40)
|
||||||
|
self.fb.text(f"WiFi: {rssi_percent}%", 0, 50)
|
||||||
|
|
||||||
|
# Rotate and display
|
||||||
|
self.rotate_180()
|
||||||
|
self.oled.show()
|
||||||
153
html/app.js
Normal file
153
html/app.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
// =========================================================
|
||||||
|
// Tank Monitor - app.js
|
||||||
|
// =========================================================
|
||||||
|
|
||||||
|
const TANK_FULL_PASSWORD = "1234"; // 🔐 CHANGE THIS
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Fetch sensor data and update UI
|
||||||
|
// ===============================
|
||||||
|
async function fetchSensorData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/sensor");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Level
|
||||||
|
const level_mm = data.pressure / 9.810;
|
||||||
|
document.getElementById("level").innerText =
|
||||||
|
level_mm.toFixed(3) + " m";
|
||||||
|
|
||||||
|
|
||||||
|
// Pressure
|
||||||
|
document.getElementById("pressure").innerText =
|
||||||
|
data.pressure.toFixed(3) + " Kpa";
|
||||||
|
|
||||||
|
// Tank % + Liters (robust)
|
||||||
|
const pct = data.tank_percent;
|
||||||
|
const capacity = Number(TANK_CAPACITY_L);
|
||||||
|
|
||||||
|
const percentEl = document.getElementById("percent");
|
||||||
|
|
||||||
|
if (!isNaN(capacity) && capacity > 0) {
|
||||||
|
const liters = (pct / 100) * capacity;
|
||||||
|
percentEl.innerText =
|
||||||
|
`${pct.toFixed(1)} % [${liters.toFixed(0)} L]`;
|
||||||
|
} else {
|
||||||
|
percentEl.innerText =
|
||||||
|
`${pct.toFixed(1)} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU temp
|
||||||
|
document.getElementById("cpu").innerText =
|
||||||
|
data.cpu_temp.toFixed(1) + " °C";
|
||||||
|
|
||||||
|
// RSSI: dBm → %
|
||||||
|
let rssiPercent = (data.rssi + 100) * 2;
|
||||||
|
rssiPercent = Math.max(0, Math.min(100, rssiPercent));
|
||||||
|
document.getElementById("rssi").innerText =
|
||||||
|
rssiPercent.toFixed(0) + " %";
|
||||||
|
|
||||||
|
// Free memory → KB
|
||||||
|
document.getElementById("mem").innerText =
|
||||||
|
(data.mem / 1024).toFixed(1) + " KB";
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
document.getElementById("uptime").innerText =
|
||||||
|
data.uptime;
|
||||||
|
|
||||||
|
updateTankBar(pct);
|
||||||
|
|
||||||
|
// Change footer on html page
|
||||||
|
// data.footer = "Powered by Raspberry Pi Pico W & HX710B - Test"
|
||||||
|
document.getElementById("footer-text").innerText = data.footer;
|
||||||
|
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Sensor fetch error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Tank animation + color scaling
|
||||||
|
// ===============================
|
||||||
|
function updateTankBar(percent) {
|
||||||
|
const fill = document.getElementById("tank-fill");
|
||||||
|
|
||||||
|
percent = Math.max(0, Math.min(100, percent));
|
||||||
|
fill.style.height = percent + "%";
|
||||||
|
fill.style.backgroundColor = tankColor(percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tankColor(p) {
|
||||||
|
let r, g, b;
|
||||||
|
|
||||||
|
if (p <= 50) {
|
||||||
|
const t = p / 50;
|
||||||
|
r = 211 + (251 - 211) * t;
|
||||||
|
g = 50 + (192 - 50) * t;
|
||||||
|
b = 47 + (45 - 47) * t;
|
||||||
|
} else {
|
||||||
|
const t = (p - 50) / 50;
|
||||||
|
r = 251 + (76 - 251) * t;
|
||||||
|
g = 192 + (175 - 192) * t;
|
||||||
|
b = 45 + (80 - 45) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Date & Time
|
||||||
|
// ===============================
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
document.getElementById("date").innerText =
|
||||||
|
`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}`;
|
||||||
|
|
||||||
|
document.getElementById("time").innerText =
|
||||||
|
`${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}:${String(now.getSeconds()).padStart(2,"0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Theme toggle
|
||||||
|
// ===============================
|
||||||
|
const modeSwitch = document.getElementById("modeSwitch");
|
||||||
|
|
||||||
|
if (localStorage.getItem("theme") === "dark") {
|
||||||
|
document.body.classList.add("dark");
|
||||||
|
if (modeSwitch) modeSwitch.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeSwitch) {
|
||||||
|
modeSwitch.addEventListener("change", () => {
|
||||||
|
document.body.classList.toggle("dark", modeSwitch.checked);
|
||||||
|
localStorage.setItem("theme", modeSwitch.checked ? "dark" : "light");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Password-protected Tank Full
|
||||||
|
// ===============================
|
||||||
|
function tankFullProtected() {
|
||||||
|
const entered = prompt("Enter password to set Tank FULL:");
|
||||||
|
if (entered !== TANK_FULL_PASSWORD) {
|
||||||
|
alert("❌ Incorrect password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm("Confirm: Set tank to FULL?")) return;
|
||||||
|
|
||||||
|
fetch("/tare");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.tankFullProtected = tankFullProtected;
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Start
|
||||||
|
// ===============================
|
||||||
|
setInterval(fetchSensorData, 1000);
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
|
fetchSensorData();
|
||||||
|
updateClock();
|
||||||
BIN
html/favicon.ico
Normal file
BIN
html/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
287
html/index.html
Normal file
287
html/index.html
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Tank Monitor</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
/* ======================= */
|
||||||
|
/* THEME VARIABLES */
|
||||||
|
/* ======================= */
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6f8;
|
||||||
|
--text: #000;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--accent: #1976d2;
|
||||||
|
--tank-bg: #e0e0e0;
|
||||||
|
--wave: rgba(255,255,255,0.35);
|
||||||
|
--footer: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark {
|
||||||
|
--bg: #121212;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--card-bg: #1f1f1f;
|
||||||
|
--accent: #1e88e5;
|
||||||
|
--tank-bg: #333;
|
||||||
|
--wave: rgba(255,255,255,0.25);
|
||||||
|
--footer: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================= */
|
||||||
|
/* HEADER + THEME SWITCH */
|
||||||
|
/* ======================= */
|
||||||
|
.header {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input {
|
||||||
|
appearance: none;
|
||||||
|
width: 50px;
|
||||||
|
height: 25px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input:checked {
|
||||||
|
background: #90caf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle input:checked::before {
|
||||||
|
transform: translateX(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================= */
|
||||||
|
/* CONTAINERS + CARDS */
|
||||||
|
/* ======================= */
|
||||||
|
.container {
|
||||||
|
width: 95%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: auto;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.25);
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-block p {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-flex-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================= */
|
||||||
|
/* VERTICAL TANK WAVE */
|
||||||
|
/* ======================= */
|
||||||
|
.tank-container {
|
||||||
|
width: min(100vw, 150px);
|
||||||
|
height: min(75vh, 250px);
|
||||||
|
background: var(--tank-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.6);
|
||||||
|
transition: width 0.3s, height 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tank-fill {
|
||||||
|
width: 100%;
|
||||||
|
height: 0%;
|
||||||
|
background: #4caf50;
|
||||||
|
position: relative;
|
||||||
|
transition: height 1s linear, background-color 1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tank-fill::before,
|
||||||
|
#tank-fill::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 200%;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--wave);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: wave 3s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tank-fill::before { top: -5px; }
|
||||||
|
#tank-fill::after {
|
||||||
|
top: -12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
animation-duration: 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
from { transform: translateX(-50%); }
|
||||||
|
to { transform: translateX(0%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================= */
|
||||||
|
/* BUTTONS */
|
||||||
|
/* ======================= */
|
||||||
|
button {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #e53935;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.button-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================= */
|
||||||
|
/* FOOTER */
|
||||||
|
/* ======================= */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
color: var(--footer);
|
||||||
|
font-size: 12px;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile tank adjustment */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.tank-container {
|
||||||
|
width: 65px;
|
||||||
|
height: 25vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
Tank Monitor
|
||||||
|
<label class="theme-toggle">
|
||||||
|
<input id="modeSwitch" type="checkbox">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- System Stats + Tank -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>System Stats</h3>
|
||||||
|
|
||||||
|
<div class="tank-flex-wrapper">
|
||||||
|
|
||||||
|
<div class="stats-block">
|
||||||
|
<p><b>Date:</b> <span id="date">--</span></p>
|
||||||
|
<p><b>Time:</b> <span id="time">--</span></p>
|
||||||
|
<p><b>Uptime:</b> <span id="uptime">--</span></p>
|
||||||
|
<p><b>Level:</b> <span id="level">--</span></p>
|
||||||
|
<p><b>Pressure:</b> <span id="pressure">--</span></p>
|
||||||
|
<p><b>Tank Level:</b> <span id="percent">--</span></p>
|
||||||
|
<p><b>CPU Temp:</b> <span id="cpu">--</span></p>
|
||||||
|
<p><b>WiFi RSSI:</b> <span id="rssi">--</span></p>
|
||||||
|
<p><b>Free Memory:</b> <span id="mem">--</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tank-container">
|
||||||
|
<div id="tank-fill"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Actions</h3>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<!-- <button onclick="fetch('/tare')">Tank Full</button> -->
|
||||||
|
<button onclick="tankFullProtected()">Tank Full</button>
|
||||||
|
<button onclick="fetch('/calibrate')">Calibrate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer" id="footer-text">
|
||||||
|
Powered by Raspberry Pi Pico W & HX710B
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
116
lib/hx710b.py
Normal file
116
lib/hx710b.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from machine import Pin
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
CALIB_FILE = "calibration.cfg"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Calibration File Helpers
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
def save_calibration(zero_offset, scale_factor):
|
||||||
|
"""Save calibration values to internal flash."""
|
||||||
|
data = {
|
||||||
|
"zero_offset": zero_offset,
|
||||||
|
"scale_factor": scale_factor
|
||||||
|
}
|
||||||
|
with open(CALIB_FILE, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
print("✔ Calibration saved.")
|
||||||
|
|
||||||
|
|
||||||
|
def load_calibration():
|
||||||
|
"""Load existing calibration or return defaults."""
|
||||||
|
if CALIB_FILE not in os.listdir():
|
||||||
|
print("⚠ No calibration file found, using defaults.")
|
||||||
|
return 0, 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(CALIB_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print("✔ Calibration loaded.")
|
||||||
|
return data.get("zero_offset", 0), data.get("scale_factor", 1)
|
||||||
|
except Exception as e:
|
||||||
|
print("⚠ Calibration load error:", e)
|
||||||
|
return 0, 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# HX710B Class
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
class HX710B:
|
||||||
|
def __init__(self, dout_pin, sck_pin):
|
||||||
|
self.dout = Pin(dout_pin, Pin.IN)
|
||||||
|
self.sck = Pin(sck_pin, Pin.OUT)
|
||||||
|
self.sck.value(0)
|
||||||
|
|
||||||
|
# Load previous calibration (or defaults)
|
||||||
|
self.zero_offset, self.scale_factor = load_calibration()
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# Low-level raw ADC read
|
||||||
|
# -----------------------------------------------------
|
||||||
|
def read_raw(self):
|
||||||
|
"""Read the 24-bit raw ADC value."""
|
||||||
|
while self.dout.value():
|
||||||
|
pass # wait for data ready
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for _ in range(24):
|
||||||
|
self.sck.value(1)
|
||||||
|
count = (count << 1) | self.dout.value()
|
||||||
|
self.sck.value(0)
|
||||||
|
|
||||||
|
# 25th pulse to set gain for next reading
|
||||||
|
self.sck.value(1)
|
||||||
|
self.sck.value(0)
|
||||||
|
|
||||||
|
# Signed 24-bit conversion
|
||||||
|
if count & 0x800000:
|
||||||
|
count -= 0x1000000
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# Zero Calibration
|
||||||
|
# -----------------------------------------------------
|
||||||
|
def calibrate_zero(self, samples=20):
|
||||||
|
"""Measure 0 kPa and determine zero offset."""
|
||||||
|
total = 0
|
||||||
|
for _ in range(samples):
|
||||||
|
total += self.read_raw()
|
||||||
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
self.zero_offset = total / samples
|
||||||
|
save_calibration(self.zero_offset, self.scale_factor)
|
||||||
|
|
||||||
|
print("✔ Zero calibrated:", self.zero_offset)
|
||||||
|
return self.zero_offset
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# Full-scale / Known Pressure Calibration
|
||||||
|
# -----------------------------------------------------
|
||||||
|
def calibrate_full(self, known_kpa, samples=20):
|
||||||
|
"""Measure raw ADC at known pressure and compute scaling."""
|
||||||
|
total = 0
|
||||||
|
for _ in range(samples):
|
||||||
|
total += self.read_raw()
|
||||||
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
raw_avg = total / samples
|
||||||
|
self.scale_factor = known_kpa / (raw_avg - self.zero_offset)
|
||||||
|
|
||||||
|
save_calibration(self.zero_offset, self.scale_factor)
|
||||||
|
|
||||||
|
print("✔ Scale calibrated:", self.scale_factor)
|
||||||
|
return self.scale_factor
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# Convert raw → kPa using saved calibration
|
||||||
|
# -----------------------------------------------------
|
||||||
|
def read_kpa(self):
|
||||||
|
raw = self.read_raw()
|
||||||
|
return (raw - self.zero_offset) * self.scale_factor
|
||||||
89
lib/ssd1306.py
Normal file
89
lib/ssd1306.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# ssd1306.py
|
||||||
|
# MicroPython SSD1306 OLED driver (I2C)
|
||||||
|
# Compatible with 128x64 and 128x32 displays
|
||||||
|
|
||||||
|
from machine import I2C, Pin
|
||||||
|
import framebuf
|
||||||
|
import time
|
||||||
|
|
||||||
|
# SSD1306 commands
|
||||||
|
SET_CONTRAST = 0x81
|
||||||
|
DISPLAY_ALL_ON_RESUME = 0xA4
|
||||||
|
DISPLAY_ALL_ON = 0xA5
|
||||||
|
NORMAL_DISPLAY = 0xA6
|
||||||
|
INVERT_DISPLAY = 0xA7
|
||||||
|
DISPLAY_OFF = 0xAE
|
||||||
|
DISPLAY_ON = 0xAF
|
||||||
|
SET_DISPLAY_OFFSET = 0xD3
|
||||||
|
SET_COM_PINS = 0xDA
|
||||||
|
SET_VCOM_DETECT = 0xDB
|
||||||
|
SET_DISPLAY_CLOCK_DIV = 0xD5
|
||||||
|
SET_PRECHARGE = 0xD9
|
||||||
|
SET_MULTIPLEX = 0xA8
|
||||||
|
SET_LOW_COLUMN = 0x00
|
||||||
|
SET_HIGH_COLUMN = 0x10
|
||||||
|
SET_START_LINE = 0x40
|
||||||
|
MEMORY_MODE = 0x20
|
||||||
|
COLUMN_ADDR = 0x21
|
||||||
|
PAGE_ADDR = 0x22
|
||||||
|
COM_SCAN_INC = 0xC0
|
||||||
|
COM_SCAN_DEC = 0xC8
|
||||||
|
SEG_REMAP = 0xA0
|
||||||
|
CHARGE_PUMP = 0x8D
|
||||||
|
EXTERNAL_VCC = 0x1
|
||||||
|
SWITCH_CAP_VCC = 0x2
|
||||||
|
|
||||||
|
class SSD1306_I2C(framebuf.FrameBuffer):
|
||||||
|
def __init__(self, width, height, i2c, addr=0x3c):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.i2c = i2c
|
||||||
|
self.addr = addr
|
||||||
|
self.pages = self.height // 8
|
||||||
|
self.buffer = bytearray(self.pages * self.width)
|
||||||
|
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
|
||||||
|
self.init_display()
|
||||||
|
|
||||||
|
def write_cmd(self, cmd):
|
||||||
|
self.i2c.writeto(self.addr, bytearray([0x00, cmd]))
|
||||||
|
|
||||||
|
def init_display(self):
|
||||||
|
for cmd in (
|
||||||
|
DISPLAY_OFF,
|
||||||
|
SET_DISPLAY_CLOCK_DIV, 0x80,
|
||||||
|
SET_MULTIPLEX, self.height - 1,
|
||||||
|
SET_DISPLAY_OFFSET, 0x00,
|
||||||
|
SET_START_LINE | 0x00,
|
||||||
|
CHARGE_PUMP, 0x14,
|
||||||
|
MEMORY_MODE, 0x00,
|
||||||
|
SEG_REMAP | 0x1,
|
||||||
|
COM_SCAN_DEC,
|
||||||
|
SET_COM_PINS, 0x12 if self.height == 64 else 0x02,
|
||||||
|
SET_CONTRAST, 0xCF,
|
||||||
|
SET_PRECHARGE, 0xF1,
|
||||||
|
SET_VCOM_DETECT, 0x40,
|
||||||
|
DISPLAY_ALL_ON_RESUME,
|
||||||
|
NORMAL_DISPLAY,
|
||||||
|
DISPLAY_ON
|
||||||
|
):
|
||||||
|
self.write_cmd(cmd)
|
||||||
|
self.fill(0)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
for page in range(self.pages):
|
||||||
|
self.write_cmd(0xB0 + page)
|
||||||
|
self.write_cmd(SET_LOW_COLUMN)
|
||||||
|
self.write_cmd(SET_HIGH_COLUMN)
|
||||||
|
start = self.width * page
|
||||||
|
end = start + self.width
|
||||||
|
self.i2c.writeto(self.addr, b'\x40' + self.buffer[start:end])
|
||||||
|
|
||||||
|
def poweroff(self):
|
||||||
|
self.write_cmd(DISPLAY_OFF)
|
||||||
|
|
||||||
|
def poweron(self):
|
||||||
|
self.write_cmd(DISPLAY_ON)
|
||||||
|
|
||||||
|
def invert(self, invert):
|
||||||
|
self.write_cmd(INVERT_DISPLAY if invert else NORMAL_DISPLAY)
|
||||||
755
lib/ugit.py
Normal file
755
lib/ugit.py
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
# ugit v2.0
|
||||||
|
# MicroPython OTA update from GitHub
|
||||||
|
# Created by TURFPTAx for the OpenMuscle project
|
||||||
|
# https://openmuscle.org
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# import mip; mip.install("github:turfptax/ugit")
|
||||||
|
#
|
||||||
|
# Quick start (one-time setup via REPL or Thonny):
|
||||||
|
# import ugit
|
||||||
|
# ugit.create_config(ssid='YourWifi', password='YourPass',
|
||||||
|
# user='github_user', repository='your-repo')
|
||||||
|
#
|
||||||
|
# Then in your main.py (safe to commit — no secrets in code):
|
||||||
|
# import ugit
|
||||||
|
# ugit.pull_all()
|
||||||
|
#
|
||||||
|
# Credentials are stored in /config.json on the device only.
|
||||||
|
# This file is auto-ignored so ugit will never delete or overwrite it.
|
||||||
|
# NEVER commit config.json to your GitHub repository.
|
||||||
|
|
||||||
|
__version__ = '2.1.0'
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urequests
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import binascii
|
||||||
|
import machine
|
||||||
|
import time
|
||||||
|
import network
|
||||||
|
|
||||||
|
# _GITHUB_API = 'https://git.ultiware.co.za/repos'
|
||||||
|
# _GITHUB_RAW = 'https://raw.githubusercontent.com'
|
||||||
|
_GITEA_API = 'https://git.ultiware.co.za/api/v1/repos'
|
||||||
|
_GITEA_RAW = 'https://git.ultiware.co.za'
|
||||||
|
_USER_AGENT = 'ugit-turfptax'
|
||||||
|
_CONFIG_PATH = '/config.json'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token=''):
|
||||||
|
h = {'User-Agent': _USER_AGENT}
|
||||||
|
if token:
|
||||||
|
h['authorization'] = 'bearer %s' % token
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _git_blob_hash(data):
|
||||||
|
"""Compute SHA1 the way GitHub does: sha1('blob {size}\\0{content}')"""
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.encode('utf-8')
|
||||||
|
header = ('blob %d\0' % len(data)).encode('utf-8')
|
||||||
|
sha = hashlib.sha1(header + data)
|
||||||
|
return binascii.hexlify(sha.digest()).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _local_file_hash(filepath):
|
||||||
|
"""Get the GitHub-compatible blob SHA1 of a local file."""
|
||||||
|
try:
|
||||||
|
f = open(filepath, 'rb')
|
||||||
|
data = f.read()
|
||||||
|
f.close()
|
||||||
|
except:
|
||||||
|
data = b''
|
||||||
|
return _git_blob_hash(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_directory(path):
|
||||||
|
try:
|
||||||
|
# stat[0] has mode bits; 0x4000 is the directory flag
|
||||||
|
return os.stat(path)[0] & 0x4000 != 0
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_internal_tree(path='/'):
|
||||||
|
"""Recursively scan the device filesystem, return {'/path': 'sha1hash'}."""
|
||||||
|
tree = {}
|
||||||
|
os.chdir(path)
|
||||||
|
for item in os.listdir():
|
||||||
|
full = path + item if path.endswith('/') else path + '/' + item
|
||||||
|
if _is_directory(full):
|
||||||
|
if os.listdir(full):
|
||||||
|
tree.update(_build_internal_tree(full))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
tree[full] = _local_file_hash(full)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.chdir('/')
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
def _get_storage_info():
|
||||||
|
"""Get filesystem storage info in bytes. Returns (total, free, used)."""
|
||||||
|
stat = os.statvfs('/')
|
||||||
|
block_size = stat[0]
|
||||||
|
total_blocks = stat[2]
|
||||||
|
free_blocks = stat[3]
|
||||||
|
total = block_size * total_blocks
|
||||||
|
free = block_size * free_blocks
|
||||||
|
used = total - free
|
||||||
|
return total, free, used
|
||||||
|
|
||||||
|
|
||||||
|
def _file_size(filepath):
|
||||||
|
"""Get size of a single file in bytes."""
|
||||||
|
try:
|
||||||
|
return os.stat(filepath)[6]
|
||||||
|
except:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_usb_cdc():
|
||||||
|
"""Detect if this board likely uses native USB-CDC for serial.
|
||||||
|
|
||||||
|
USB-CDC boards (ESP32-S2, S3, C3, C6) lose their serial port on
|
||||||
|
machine.reset(). If boot.py/main.py crashes before USB re-enumerates,
|
||||||
|
the device becomes inaccessible and requires reflashing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
machine_str = os.uname().machine.upper()
|
||||||
|
for chip in ('ESP32S3', 'ESP32S2', 'ESP32C6', 'ESP32C3', 'ESP32H2'):
|
||||||
|
if chip in machine_str:
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ignored(path, ignore):
|
||||||
|
"""Check if path matches any ignore entry (exact or directory prefix).
|
||||||
|
|
||||||
|
'/lib' in ignore matches '/lib/aioble/core.mpy' but not '/library/foo.py'.
|
||||||
|
"""
|
||||||
|
for entry in ignore:
|
||||||
|
e = entry.rstrip('/')
|
||||||
|
if path == e or path.startswith(e + '/'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_download_size(git_tree, local_tree, ignore):
|
||||||
|
"""Estimate bytes that will be downloaded (only changed/new files)."""
|
||||||
|
download = 0
|
||||||
|
for item in git_tree['tree']:
|
||||||
|
if item['type'] != 'blob':
|
||||||
|
continue
|
||||||
|
path = item['path']
|
||||||
|
if not path.startswith('/'):
|
||||||
|
path = '/' + path
|
||||||
|
if _is_ignored(path, ignore):
|
||||||
|
continue
|
||||||
|
git_sha = item.get('sha', '')
|
||||||
|
local_sha = local_tree.get(path, '')
|
||||||
|
if not (git_sha and local_sha and git_sha == local_sha):
|
||||||
|
download += item.get('size', 0)
|
||||||
|
return download
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_size(b):
|
||||||
|
"""Format bytes as human-readable string."""
|
||||||
|
if b < 1024:
|
||||||
|
return '%d B' % b
|
||||||
|
elif b < 1024 * 1024:
|
||||||
|
return '%d KB' % (b // 1024)
|
||||||
|
else:
|
||||||
|
return '%d.%d MB' % (b // (1024 * 1024), (b % (1024 * 1024)) * 10 // (1024 * 1024))
|
||||||
|
|
||||||
|
|
||||||
|
def storage_info():
|
||||||
|
"""Print human-readable storage info for the device."""
|
||||||
|
total, free, used = _get_storage_info()
|
||||||
|
print('Storage: %s total, %s used, %s free (%d%% free)' % (
|
||||||
|
_fmt_size(total), _fmt_size(used), _fmt_size(free),
|
||||||
|
(free * 100) // total if total else 0))
|
||||||
|
return {'total': total, 'free': free, 'used': used}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_ignore(ignore):
|
||||||
|
"""Make sure config.json and ugit.py are always in the ignore list."""
|
||||||
|
if ignore is None:
|
||||||
|
ignore = []
|
||||||
|
protected = ['/ugit.py', _CONFIG_PATH, '/ugit.backup', '/ugit_log.txt', '/lib', '/sd']
|
||||||
|
for p in protected:
|
||||||
|
if p not in ignore:
|
||||||
|
ignore.append(p)
|
||||||
|
return ignore
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config():
|
||||||
|
"""Load config from /config.json. Returns dict or empty dict if not found."""
|
||||||
|
try:
|
||||||
|
f = open(_CONFIG_PATH, 'r')
|
||||||
|
cfg = json.loads(f.read())
|
||||||
|
f.close()
|
||||||
|
return cfg
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config(user=None, repository=None, branch=None, token=None,
|
||||||
|
ssid=None, password=None, ignore=None):
|
||||||
|
"""Merge explicit arguments with config.json, arguments take priority."""
|
||||||
|
cfg = _load_config()
|
||||||
|
return {
|
||||||
|
'user': user or cfg.get('user', ''),
|
||||||
|
'repository': repository or cfg.get('repository', ''),
|
||||||
|
'branch': branch or cfg.get('branch', 'main'),
|
||||||
|
'token': token if token is not None else cfg.get('token', ''),
|
||||||
|
'ssid': ssid or cfg.get('ssid', ''),
|
||||||
|
'password': password or cfg.get('password', ''),
|
||||||
|
'ignore': ignore if ignore is not None else cfg.get('ignore', []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_config(ssid='', password='', user='', repository='',
|
||||||
|
branch='main', token='', ignore=None):
|
||||||
|
"""
|
||||||
|
Save credentials to /config.json on the device.
|
||||||
|
|
||||||
|
Run this once via REPL or Thonny to set up your device.
|
||||||
|
The config file stays on the device and is never synced to GitHub.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ugit.create_config(
|
||||||
|
ssid='MyWifi',
|
||||||
|
password='MyPassword',
|
||||||
|
user='turfptax',
|
||||||
|
repository='my-project',
|
||||||
|
token='ghp_xxxx' # optional, for private repos
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
if ignore is None:
|
||||||
|
ignore = []
|
||||||
|
cfg = {
|
||||||
|
'ssid': ssid,
|
||||||
|
'password': password,
|
||||||
|
'user': user,
|
||||||
|
'repository': repository,
|
||||||
|
'branch': branch,
|
||||||
|
'token': token,
|
||||||
|
'ignore': ignore,
|
||||||
|
}
|
||||||
|
# MicroPython devices have no secure keychain; credentials must be stored
|
||||||
|
# on the local filesystem. config.json is auto-protected from sync and
|
||||||
|
# should never be committed to the GitHub repository.
|
||||||
|
f = open(_CONFIG_PATH, 'w')
|
||||||
|
f.write(json.dumps(cfg))
|
||||||
|
f.close()
|
||||||
|
# Print confirmation (field names only, never values)
|
||||||
|
field_names = [k for k in cfg if cfg[k]]
|
||||||
|
print('Config saved to %s' % _CONFIG_PATH)
|
||||||
|
print('Fields stored: %s' % ', '.join(field_names))
|
||||||
|
print('WARNING: config.json contains credentials. Never commit it to GitHub.')
|
||||||
|
|
||||||
|
|
||||||
|
def show_config():
|
||||||
|
"""Display current config (passwords are masked)."""
|
||||||
|
cfg = _load_config()
|
||||||
|
if not cfg:
|
||||||
|
print('No config found. Run ugit.create_config() to set up.')
|
||||||
|
return
|
||||||
|
for k, v in cfg.items():
|
||||||
|
if k in ('password', 'token') and v:
|
||||||
|
print(' %s: %s' % (k, v[:3] + '*' * (len(v) - 3)))
|
||||||
|
else:
|
||||||
|
print(' %s: %s' % (k, v))
|
||||||
|
|
||||||
|
|
||||||
|
def wificonnect(ssid=None, password=None):
|
||||||
|
"""Connect to WiFi. Returns the WLAN object.
|
||||||
|
If ssid/password not provided, reads from config.json."""
|
||||||
|
if not ssid or not password:
|
||||||
|
cfg = _load_config()
|
||||||
|
ssid = ssid or cfg.get('ssid', '')
|
||||||
|
password = password or cfg.get('password', '')
|
||||||
|
if not ssid or not password:
|
||||||
|
raise ValueError('No WiFi credentials. Pass ssid/password or run ugit.create_config()')
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
wlan.active(False)
|
||||||
|
wlan.active(True)
|
||||||
|
wlan.connect(ssid, password)
|
||||||
|
retries = 0
|
||||||
|
while not wlan.isconnected():
|
||||||
|
time.sleep(1)
|
||||||
|
retries += 1
|
||||||
|
if retries > 30:
|
||||||
|
raise OSError('WiFi connection timed out')
|
||||||
|
print('WiFi connected:', wlan.ifconfig()[0])
|
||||||
|
return wlan
|
||||||
|
|
||||||
|
|
||||||
|
def pull_git_tree(user, repository, branch='main', token=''):
|
||||||
|
"""Fetch the full recursive tree from GitHub API."""
|
||||||
|
url = '%s/%s/%s/git/trees/%s?recursive=1' % (_GITHUB_API, user, repository, branch)
|
||||||
|
r = urequests.get(url, headers=_headers(token))
|
||||||
|
data = json.loads(r.content.decode('utf-8'))
|
||||||
|
r.close()
|
||||||
|
if 'tree' not in data:
|
||||||
|
raise Exception('Branch "%s" not found for %s/%s' % (branch, user, repository))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def pull(filepath, raw_url, token=''):
|
||||||
|
"""Download a single file from GitHub and write it to the device."""
|
||||||
|
r = urequests.get(raw_url, headers=_headers(token))
|
||||||
|
data = r.content
|
||||||
|
r.close()
|
||||||
|
# ensure parent directory exists
|
||||||
|
parts = filepath.split('/')
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
d = '/'.join(parts[:i])
|
||||||
|
if d:
|
||||||
|
try:
|
||||||
|
os.mkdir(d)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
f = open(filepath, 'wb')
|
||||||
|
f.write(data)
|
||||||
|
f.close()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def pull_all(user=None, repository=None, branch=None, token=None,
|
||||||
|
ssid=None, password=None, ignore=None,
|
||||||
|
isconnected=False, reset_after=False):
|
||||||
|
"""
|
||||||
|
Sync device filesystem with a GitHub repository.
|
||||||
|
|
||||||
|
Only downloads files whose SHA1 hash differs from the local copy.
|
||||||
|
Deletes local files not present in the repo (except ignored files).
|
||||||
|
|
||||||
|
All arguments are optional if config.json exists on the device.
|
||||||
|
Explicit arguments override config.json values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: GitHub username
|
||||||
|
repository: GitHub repository name
|
||||||
|
branch: Branch to pull from (default 'main')
|
||||||
|
token: Personal access token for private repos
|
||||||
|
ssid: WiFi SSID (skipped if isconnected=True)
|
||||||
|
password: WiFi password
|
||||||
|
ignore: Extra file paths to never touch
|
||||||
|
isconnected: Set True if already connected to WiFi
|
||||||
|
reset_after: Reset the device after update (default False).
|
||||||
|
Ignored on USB-CDC boards (ESP32-S2/S3/C3/C6) to
|
||||||
|
prevent bricking if boot code has errors.
|
||||||
|
"""
|
||||||
|
c = _resolve_config(user, repository, branch, token, ssid, password, ignore)
|
||||||
|
ignore = _ensure_ignore(c['ignore'])
|
||||||
|
|
||||||
|
if not c['user'] or not c['repository']:
|
||||||
|
raise ValueError('user and repository required. Pass them or run ugit.create_config()')
|
||||||
|
|
||||||
|
if not isconnected:
|
||||||
|
wificonnect(c['ssid'], c['password'])
|
||||||
|
|
||||||
|
os.chdir('/')
|
||||||
|
raw_base = '%s/%s/%s/%s/' % (_GITHUB_RAW, c['user'], c['repository'], c['branch'])
|
||||||
|
|
||||||
|
# fetch repo tree from GitHub
|
||||||
|
print('Fetching repository tree...')
|
||||||
|
git_tree = pull_git_tree(c['user'], c['repository'], c['branch'], c['token'])
|
||||||
|
|
||||||
|
# build local file tree with hashes
|
||||||
|
print('Scanning local files...')
|
||||||
|
local_tree = _build_internal_tree()
|
||||||
|
|
||||||
|
log = []
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
deleted = 0
|
||||||
|
git_files = set()
|
||||||
|
|
||||||
|
for item in git_tree['tree']:
|
||||||
|
path = item['path']
|
||||||
|
# normalize to absolute path
|
||||||
|
if not path.startswith('/'):
|
||||||
|
path = '/' + path
|
||||||
|
|
||||||
|
if item['type'] == 'tree':
|
||||||
|
try:
|
||||||
|
os.mkdir(path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
git_files.add(path)
|
||||||
|
|
||||||
|
if _is_ignored(path, ignore):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# compare hashes — only download if changed or new
|
||||||
|
git_sha = item.get('sha', '')
|
||||||
|
local_sha = local_tree.get(path, '')
|
||||||
|
|
||||||
|
if git_sha and local_sha and git_sha == local_sha:
|
||||||
|
skipped += 1
|
||||||
|
log.append(path + ' unchanged')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# download the file
|
||||||
|
try:
|
||||||
|
pull(path, raw_base + item['path'], c['token'])
|
||||||
|
updated += 1
|
||||||
|
log.append(path + ' updated')
|
||||||
|
print(' updated:', path)
|
||||||
|
except Exception as e:
|
||||||
|
log.append(path + ' FAILED: ' + str(e))
|
||||||
|
print(' FAILED:', path, e)
|
||||||
|
|
||||||
|
# delete local files not in the repo (except ignored)
|
||||||
|
for local_path in local_tree:
|
||||||
|
if local_path not in git_files and not _is_ignored(local_path, ignore):
|
||||||
|
try:
|
||||||
|
os.remove(local_path)
|
||||||
|
deleted += 1
|
||||||
|
log.append(local_path + ' deleted')
|
||||||
|
print(' deleted:', local_path)
|
||||||
|
except:
|
||||||
|
log.append(local_path + ' delete failed')
|
||||||
|
|
||||||
|
# write log
|
||||||
|
summary = 'ugit: %d updated, %d skipped, %d deleted' % (updated, skipped, deleted)
|
||||||
|
print(summary)
|
||||||
|
log.insert(0, summary)
|
||||||
|
try:
|
||||||
|
f = open('/ugit_log.txt', 'w')
|
||||||
|
f.write('\n'.join(log))
|
||||||
|
f.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if reset_after:
|
||||||
|
if _is_usb_cdc():
|
||||||
|
print('WARNING: USB-CDC board detected (%s).' % os.uname().machine)
|
||||||
|
print('machine.reset() skipped — reset manually or power-cycle.')
|
||||||
|
print('Auto-reset can brick USB-CDC boards if boot.py/main.py crashes.')
|
||||||
|
else:
|
||||||
|
print('Resetting device in 5 seconds...')
|
||||||
|
time.sleep(5)
|
||||||
|
machine.reset()
|
||||||
|
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_updates(user=None, repository=None, branch=None, token=None,
|
||||||
|
ignore=None, isconnected=False,
|
||||||
|
ssid=None, password=None):
|
||||||
|
"""
|
||||||
|
Check if the repo has changes without downloading anything.
|
||||||
|
Returns a dict with 'new', 'changed', 'deleted' file lists.
|
||||||
|
|
||||||
|
All arguments are optional if config.json exists on the device.
|
||||||
|
"""
|
||||||
|
c = _resolve_config(user, repository, branch, token, ssid, password, ignore)
|
||||||
|
ignore = _ensure_ignore(c['ignore'])
|
||||||
|
|
||||||
|
if not c['user'] or not c['repository']:
|
||||||
|
raise ValueError('user and repository required. Pass them or run ugit.create_config()')
|
||||||
|
|
||||||
|
if not isconnected:
|
||||||
|
wificonnect(c['ssid'], c['password'])
|
||||||
|
|
||||||
|
os.chdir('/')
|
||||||
|
git_tree = pull_git_tree(c['user'], c['repository'], c['branch'], c['token'])
|
||||||
|
local_tree = _build_internal_tree()
|
||||||
|
git_files = set()
|
||||||
|
|
||||||
|
new = []
|
||||||
|
changed = []
|
||||||
|
deleted = []
|
||||||
|
|
||||||
|
for item in git_tree['tree']:
|
||||||
|
if item['type'] != 'blob':
|
||||||
|
continue
|
||||||
|
path = item['path']
|
||||||
|
if not path.startswith('/'):
|
||||||
|
path = '/' + path
|
||||||
|
git_files.add(path)
|
||||||
|
if _is_ignored(path, ignore):
|
||||||
|
continue
|
||||||
|
local_sha = local_tree.get(path, '')
|
||||||
|
if not local_sha:
|
||||||
|
new.append(path)
|
||||||
|
elif item.get('sha', '') != local_sha:
|
||||||
|
changed.append(path)
|
||||||
|
|
||||||
|
for local_path in local_tree:
|
||||||
|
if local_path not in git_files and not _is_ignored(local_path, ignore):
|
||||||
|
deleted.append(local_path)
|
||||||
|
|
||||||
|
return {'new': new, 'changed': changed, 'deleted': deleted}
|
||||||
|
|
||||||
|
|
||||||
|
def update(branch=None, token=None):
|
||||||
|
"""Update ugit.py itself from this repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
branch: Branch to pull from (default: 'main'). Use this to test
|
||||||
|
development branches, e.g. ugit.update('fix/my-branch')
|
||||||
|
token: GitHub token for private repos (reads from config if None)
|
||||||
|
|
||||||
|
Detects where ugit was imported from (e.g. /lib/ugit.py vs /ugit.py)
|
||||||
|
and updates in-place so mip-installed copies are updated correctly.
|
||||||
|
"""
|
||||||
|
if token is None:
|
||||||
|
cfg = _load_config()
|
||||||
|
token = cfg.get('token', '')
|
||||||
|
if branch is None:
|
||||||
|
branch = 'main'
|
||||||
|
# Update in the same location ugit was imported from
|
||||||
|
try:
|
||||||
|
dest = __file__
|
||||||
|
except:
|
||||||
|
dest = '/ugit.py'
|
||||||
|
raw_url = '%s/turfptax/ugit/%s/ugit.py' % (_GITHUB_RAW, branch)
|
||||||
|
pull(dest, raw_url, token)
|
||||||
|
print('ugit updated at %s from branch %s. Reset device to use new version.' % (dest, branch))
|
||||||
|
|
||||||
|
|
||||||
|
def backup(ignore=None):
|
||||||
|
"""Backup all files on the device to /ugit.backup.
|
||||||
|
Returns the number of files backed up, or -1 if not enough space."""
|
||||||
|
ignore = _ensure_ignore(ignore)
|
||||||
|
local_tree = _build_internal_tree()
|
||||||
|
|
||||||
|
# estimate backup size: sum of file contents + metadata overhead
|
||||||
|
backup_size = 0
|
||||||
|
file_count = 0
|
||||||
|
for path in local_tree:
|
||||||
|
if not _is_ignored(path, ignore):
|
||||||
|
backup_size += _file_size(path) + len(path) + 80 # metadata per file
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
|
_, free, _ = _get_storage_info()
|
||||||
|
if backup_size > free:
|
||||||
|
print('Not enough space for backup.')
|
||||||
|
print(' Need: %s, Free: %s' % (_fmt_size(backup_size), _fmt_size(free)))
|
||||||
|
return -1
|
||||||
|
|
||||||
|
f = open('/ugit.backup', 'w')
|
||||||
|
f.write('ugit backup v2\n')
|
||||||
|
for path, sha in local_tree.items():
|
||||||
|
if _is_ignored(path, ignore):
|
||||||
|
continue
|
||||||
|
f.write('FILE:%s SHA:%s\n' % (path, sha))
|
||||||
|
try:
|
||||||
|
df = open(path, 'rb')
|
||||||
|
content = df.read()
|
||||||
|
df.close()
|
||||||
|
try:
|
||||||
|
f.write('---\n')
|
||||||
|
f.write(content.decode('utf-8'))
|
||||||
|
f.write('\n---\n')
|
||||||
|
except:
|
||||||
|
f.write('---BINARY:%d---\n' % len(content))
|
||||||
|
except:
|
||||||
|
f.write('---ERROR---\n')
|
||||||
|
f.close()
|
||||||
|
print('Backup saved to /ugit.backup (%d files, ~%s)' % (
|
||||||
|
file_count, _fmt_size(backup_size)))
|
||||||
|
return file_count
|
||||||
|
|
||||||
|
|
||||||
|
def restore(ignore=None):
|
||||||
|
"""
|
||||||
|
Restore files from /ugit.backup.
|
||||||
|
|
||||||
|
Reads the backup file, recreates directories, and writes files back.
|
||||||
|
Use this if an OTA update went wrong.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ugit.restore()
|
||||||
|
"""
|
||||||
|
ignore = _ensure_ignore(ignore)
|
||||||
|
try:
|
||||||
|
f = open('/ugit.backup', 'r')
|
||||||
|
except:
|
||||||
|
print('No backup file found at /ugit.backup')
|
||||||
|
return False
|
||||||
|
|
||||||
|
header = f.readline().strip()
|
||||||
|
if not header.startswith('ugit backup'):
|
||||||
|
print('Invalid backup file format.')
|
||||||
|
f.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
skipped = 0
|
||||||
|
current_path = None
|
||||||
|
current_content = None
|
||||||
|
in_content = False
|
||||||
|
|
||||||
|
for line in f:
|
||||||
|
line_stripped = line.rstrip('\n')
|
||||||
|
|
||||||
|
if line_stripped.startswith('FILE:'):
|
||||||
|
# save previous file if we have one
|
||||||
|
if current_path and current_content is not None:
|
||||||
|
if not _is_ignored(current_path, ignore):
|
||||||
|
_restore_file(current_path, current_content)
|
||||||
|
restored += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
# parse new file entry: FILE:/path SHA:hash
|
||||||
|
parts = line_stripped.split(' SHA:')
|
||||||
|
current_path = parts[0][5:] # strip 'FILE:'
|
||||||
|
current_content = None
|
||||||
|
in_content = False
|
||||||
|
|
||||||
|
elif line_stripped == '---' and not in_content:
|
||||||
|
in_content = True
|
||||||
|
current_content = ''
|
||||||
|
|
||||||
|
elif line_stripped == '---' and in_content:
|
||||||
|
in_content = False
|
||||||
|
|
||||||
|
elif line_stripped.startswith('---BINARY:') or line_stripped == '---ERROR---':
|
||||||
|
in_content = False
|
||||||
|
current_content = None
|
||||||
|
print(' skip (binary/error): %s' % current_path)
|
||||||
|
|
||||||
|
elif in_content:
|
||||||
|
if current_content:
|
||||||
|
current_content += '\n' + line_stripped
|
||||||
|
else:
|
||||||
|
current_content = line_stripped
|
||||||
|
|
||||||
|
# save last file
|
||||||
|
if current_path and current_content is not None:
|
||||||
|
if not _is_ignored(current_path, ignore):
|
||||||
|
_restore_file(current_path, current_content)
|
||||||
|
restored += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print('Restore complete: %d files restored, %d skipped' % (restored, skipped))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_file(filepath, content):
|
||||||
|
"""Write a file back to the device, creating directories as needed."""
|
||||||
|
parts = filepath.split('/')
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
d = '/'.join(parts[:i])
|
||||||
|
if d:
|
||||||
|
try:
|
||||||
|
os.mkdir(d)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
f = open(filepath, 'w')
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
print(' restored:', filepath)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_pull_all(user=None, repository=None, branch=None, token=None,
|
||||||
|
ssid=None, password=None, ignore=None,
|
||||||
|
isconnected=False, reset_after=False):
|
||||||
|
"""
|
||||||
|
Like pull_all(), but checks available storage first and creates a
|
||||||
|
backup before updating. If the update fails, the backup remains
|
||||||
|
on the device for manual restore via ugit.restore().
|
||||||
|
|
||||||
|
Pre-flight checks:
|
||||||
|
1. Estimates download size from GitHub tree
|
||||||
|
2. Estimates backup size from local files
|
||||||
|
3. Verifies enough free space for backup + downloads
|
||||||
|
4. Creates backup
|
||||||
|
5. Runs the update
|
||||||
|
6. If update fails, prints restore instructions
|
||||||
|
|
||||||
|
reset_after defaults to False. On USB-CDC boards (ESP32-S2/S3/C3/C6),
|
||||||
|
machine.reset() is always skipped to prevent bricking.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ugit.safe_pull_all()
|
||||||
|
"""
|
||||||
|
c = _resolve_config(user, repository, branch, token, ssid, password, ignore)
|
||||||
|
ignore = _ensure_ignore(c['ignore'])
|
||||||
|
|
||||||
|
if not c['user'] or not c['repository']:
|
||||||
|
raise ValueError('user and repository required. Pass them or run ugit.create_config()')
|
||||||
|
|
||||||
|
if not isconnected:
|
||||||
|
wificonnect(c['ssid'], c['password'])
|
||||||
|
|
||||||
|
os.chdir('/')
|
||||||
|
|
||||||
|
# step 1: gather info
|
||||||
|
print('Pre-flight check...')
|
||||||
|
total, free, used = _get_storage_info()
|
||||||
|
print(' Storage: %s free of %s' % (_fmt_size(free), _fmt_size(total)))
|
||||||
|
|
||||||
|
git_tree = pull_git_tree(c['user'], c['repository'], c['branch'], c['token'])
|
||||||
|
local_tree = _build_internal_tree()
|
||||||
|
|
||||||
|
# step 2: estimate sizes
|
||||||
|
download_size = _repo_download_size(git_tree, local_tree, ignore)
|
||||||
|
|
||||||
|
backup_size = 0
|
||||||
|
for path in local_tree:
|
||||||
|
if not _is_ignored(path, ignore):
|
||||||
|
backup_size += _file_size(path) + len(path) + 80
|
||||||
|
|
||||||
|
# we need space for: backup file + downloaded files + 4KB buffer
|
||||||
|
needed = backup_size + download_size + 4096
|
||||||
|
print(' Backup estimate: %s' % _fmt_size(backup_size))
|
||||||
|
print(' Download estimate: %s' % _fmt_size(download_size))
|
||||||
|
print(' Total needed: %s' % _fmt_size(needed))
|
||||||
|
|
||||||
|
if needed > free:
|
||||||
|
shortfall = needed - free
|
||||||
|
print('\nNot enough space! Short by %s.' % _fmt_size(shortfall))
|
||||||
|
print('Free up space or use pull_all() without backup.')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# step 3: backup
|
||||||
|
print('\nCreating backup...')
|
||||||
|
result = backup(ignore)
|
||||||
|
if result == -1:
|
||||||
|
print('Backup failed. Aborting safe update.')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# step 4: run the update
|
||||||
|
print('\nStarting update...')
|
||||||
|
try:
|
||||||
|
log = pull_all(
|
||||||
|
user=c['user'], repository=c['repository'],
|
||||||
|
branch=c['branch'], token=c['token'],
|
||||||
|
ignore=ignore, isconnected=True, reset_after=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print('\nUpdate FAILED: %s' % str(e))
|
||||||
|
print('Your backup is at /ugit.backup')
|
||||||
|
print('Run ugit.restore() to roll back.')
|
||||||
|
return None
|
||||||
|
|
||||||
|
print('\nUpdate complete with backup at /ugit.backup')
|
||||||
|
if reset_after:
|
||||||
|
if _is_usb_cdc():
|
||||||
|
print('WARNING: USB-CDC board detected (%s).' % os.uname().machine)
|
||||||
|
print('machine.reset() skipped — reset manually or power-cycle.')
|
||||||
|
print('Auto-reset can brick USB-CDC boards if boot.py/main.py crashes.')
|
||||||
|
else:
|
||||||
|
print('Resetting device in 5 seconds...')
|
||||||
|
time.sleep(5)
|
||||||
|
machine.reset()
|
||||||
|
|
||||||
|
return log
|
||||||
238
main.py
Normal file
238
main.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import time
|
||||||
|
import uasyncio as asyncio
|
||||||
|
import machine
|
||||||
|
import gc
|
||||||
|
import neopixel
|
||||||
|
|
||||||
|
from config import *
|
||||||
|
from wifi import connect_wifi
|
||||||
|
from timeutil import setup_rtc
|
||||||
|
from sensors import Sensors
|
||||||
|
from display import OLED
|
||||||
|
from webserver import WebServer
|
||||||
|
|
||||||
|
|
||||||
|
# Set CPU speed
|
||||||
|
machine.freq(CPU_FREQ)
|
||||||
|
|
||||||
|
#HOSTNAME = "Testname"
|
||||||
|
|
||||||
|
# Connect WiFi
|
||||||
|
print("Version = ", VERSION)
|
||||||
|
print("Hostname = ", HOSTNAME)
|
||||||
|
wlan = connect_wifi(SSID, PASSWORD, HOSTNAME)
|
||||||
|
rtc = setup_rtc(TZ_OFFSET)
|
||||||
|
print("Server running on http://" + wlan.ifconfig()[0])
|
||||||
|
|
||||||
|
#TANK_CAPACITY_L = 3000
|
||||||
|
print("Heart Beat =", HEARTBEAT)
|
||||||
|
print("Tank Volume =", TANK_CAPACITY_L)
|
||||||
|
# Init sensors and display
|
||||||
|
sensors = Sensors()
|
||||||
|
if DISPLAY_ON:
|
||||||
|
oled = OLED()
|
||||||
|
|
||||||
|
sensors.test_pin1.on()
|
||||||
|
sensors.test_pin2.on()
|
||||||
|
sensors.test_pin0.on()
|
||||||
|
# Capture boot time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# print("Boot timestamp:", start_time)
|
||||||
|
|
||||||
|
# Set tank full reference from current reading at boot
|
||||||
|
|
||||||
|
#MAX_PRESSURE_KPA = sensors.read_pressure()
|
||||||
|
time.sleep_ms(250)
|
||||||
|
sensors.test_pin1.off()
|
||||||
|
sensors.test_pin2.off()
|
||||||
|
sensors.test_pin0.off()
|
||||||
|
|
||||||
|
print("✔ Config loaded.")
|
||||||
|
print("Initial MAX_PRESSURE_KPA =", MAX_PRESSURE_KPA, "KPA")
|
||||||
|
print("Initial Known Depth =", KNOWN_DEPTH_M, "m")
|
||||||
|
#print("Initial Known Pressure =", MAX_PRESSURE_KPA)
|
||||||
|
|
||||||
|
def rssi_percent():
|
||||||
|
rssi = wlan.status("rssi")
|
||||||
|
rssi = max(-100, min(-50, rssi))
|
||||||
|
return int((rssi + 100) * 2)
|
||||||
|
|
||||||
|
class HeartbeatFadeNeoPixel:
|
||||||
|
def __init__(self, neopixel_obj, pixel_index=0, color=(255, 0, 0),
|
||||||
|
min_brightness=5, max_brightness=100,
|
||||||
|
step=2, interval_ms=20):
|
||||||
|
|
||||||
|
self.np = neopixel_obj
|
||||||
|
self.index = pixel_index
|
||||||
|
self.color = color
|
||||||
|
|
||||||
|
self.min_brightness = min_brightness
|
||||||
|
self.max_brightness = max_brightness
|
||||||
|
self.step = step
|
||||||
|
self.interval = interval_ms
|
||||||
|
|
||||||
|
self.brightness = min_brightness
|
||||||
|
self.direction = 1 # 1 = fade up, -1 = fade down
|
||||||
|
self.last_time = time.ticks_ms()
|
||||||
|
|
||||||
|
def apply_brightness(self, c, level):
|
||||||
|
r, g, b = c
|
||||||
|
factor = level / 100
|
||||||
|
return (int(r * factor), int(g * factor), int(b * factor))
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
now = time.ticks_ms()
|
||||||
|
if time.ticks_diff(now, self.last_time) >= self.interval:
|
||||||
|
self.last_time = now
|
||||||
|
|
||||||
|
# Update brightness
|
||||||
|
self.brightness += self.direction * self.step
|
||||||
|
|
||||||
|
# Bounce at limits
|
||||||
|
if self.brightness >= self.max_brightness:
|
||||||
|
self.brightness = self.max_brightness
|
||||||
|
self.direction = -1
|
||||||
|
|
||||||
|
if self.brightness <= self.min_brightness:
|
||||||
|
self.brightness = self.min_brightness
|
||||||
|
self.direction = 1
|
||||||
|
|
||||||
|
# Apply brightness
|
||||||
|
self.np[self.index] = self.apply_brightness(self.color, self.brightness)
|
||||||
|
self.np.write()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# NEOPIXEL SETUP
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
STRIP_PIN = 17
|
||||||
|
STRIP_LEDS = 3
|
||||||
|
strip = neopixel.NeoPixel(machine.Pin(STRIP_PIN), STRIP_LEDS)
|
||||||
|
|
||||||
|
SINGLE_PIN = 28
|
||||||
|
single = neopixel.NeoPixel(machine.Pin(SINGLE_PIN), 1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# BUTTONS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
button_pins = [20, 21, 22]
|
||||||
|
buttons = [machine.Pin(pin, machine.Pin.IN, machine.Pin.PULL_UP) for pin in button_pins]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# COLOR PALETTE
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
colors = [
|
||||||
|
(0, 0, 0), # Off
|
||||||
|
(255, 0, 0), # Red
|
||||||
|
(0, 255, 0), # Green
|
||||||
|
(0, 0, 255), # Blue
|
||||||
|
(255, 255, 255), # White
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# STRIP STATE
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
color_index = [0, 0, 0]
|
||||||
|
brightness = [50, 50, 50]
|
||||||
|
last_state = [1, 1, 1]
|
||||||
|
press_time = [0, 0, 0]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HEARTBEAT CONFIG (NOW HAS MIN + MAX)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
FLASH_COLOR_INDEX = 1 # Palette color
|
||||||
|
FLASH_MIN = 1 # << lowest brightness (0–100)
|
||||||
|
FLASH_MAX = 10 # << highest brightness
|
||||||
|
FLASH_STEP = 0.2 # fade speed
|
||||||
|
FLASH_INTERVAL_MS = 20 # smoothness
|
||||||
|
|
||||||
|
heartbeat = HeartbeatFadeNeoPixel(
|
||||||
|
neopixel_obj=single,
|
||||||
|
pixel_index=0,
|
||||||
|
color=colors[FLASH_COLOR_INDEX],
|
||||||
|
min_brightness=FLASH_MIN,
|
||||||
|
max_brightness=FLASH_MAX,
|
||||||
|
step=FLASH_STEP,
|
||||||
|
interval_ms=FLASH_INTERVAL_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HELPERS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def apply_brightness(color, level):
|
||||||
|
r, g, b = color
|
||||||
|
factor = level / 100
|
||||||
|
return (int(r * factor), int(g * factor), int(b * factor))
|
||||||
|
|
||||||
|
def update_strip():
|
||||||
|
for i in range(STRIP_LEDS):
|
||||||
|
strip[i] = apply_brightness(colors[color_index[i]], brightness[i])
|
||||||
|
strip.write()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# INITIAL
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
update_strip()
|
||||||
|
|
||||||
|
async def sensor_task():
|
||||||
|
global MAX_PRESSURE_KPA
|
||||||
|
while True:
|
||||||
|
gc.collect()
|
||||||
|
sensors.check_buttons()
|
||||||
|
# heartbeat.update()
|
||||||
|
if AVERAGE:
|
||||||
|
pressure = sensors.read_pressure_avg()
|
||||||
|
else:
|
||||||
|
pressure = sensors.read_pressure()
|
||||||
|
#sensors.test_pin1.on()
|
||||||
|
if sensors.tank_full:
|
||||||
|
sensors.tank_full = False
|
||||||
|
MAX_PRESSURE_KPA = sensors.read_pressure()
|
||||||
|
|
||||||
|
if HEARTBEAT:
|
||||||
|
# sensors.test_pin2.on()
|
||||||
|
# time.sleep_ms(100)
|
||||||
|
# sensors.test_pin2.off()
|
||||||
|
# color_index[i] = 0
|
||||||
|
i=0
|
||||||
|
color_index[i] = (color_index[i] + 1) % len(colors)
|
||||||
|
update_strip()
|
||||||
|
# time.sleep_ms(100)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# sensors.test_pin2.off()
|
||||||
|
# color_index[i] = 3
|
||||||
|
update_strip()
|
||||||
|
# time.sleep_ms(100)
|
||||||
|
|
||||||
|
percent = pressure / MAX_PRESSURE_KPA
|
||||||
|
depth_mm = percent * MAX_DEPTH_M * 1000
|
||||||
|
sensors.tank_percentage = percent * 100
|
||||||
|
|
||||||
|
if DISPLAY_ON:
|
||||||
|
oled.show(
|
||||||
|
rtc,
|
||||||
|
pressure,
|
||||||
|
sensors.tank_percentage,
|
||||||
|
sensors.cpu_temp(),
|
||||||
|
rssi_percent(),
|
||||||
|
depth_mm
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
asyncio.create_task(sensor_task())
|
||||||
|
server = WebServer(wlan, sensors, start_time, MAX_CONNECTIONS)
|
||||||
|
srv = await asyncio.start_server(server.handle, "0.0.0.0", 80)
|
||||||
|
async with srv:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
30
scan-i2c.py
Normal file
30
scan-i2c.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from machine import Pin, I2C
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Scan I2C0 on GP0=SDA, GP1=SCL
|
||||||
|
i2c0 = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
|
||||||
|
|
||||||
|
# Scan I2C1 on GP2=SDA, GP3=SCL
|
||||||
|
i2c1 = I2C(1, scl=Pin(3), sda=Pin(2), freq=400000)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("Scanning I2C buses...")
|
||||||
|
devices0 = i2c0.scan()
|
||||||
|
devices1 = i2c1.scan()
|
||||||
|
|
||||||
|
if devices0:
|
||||||
|
print("I2C0 Devices found:")
|
||||||
|
for d in devices0:
|
||||||
|
print(" - Address: 0x{:02X}".format(d))
|
||||||
|
else:
|
||||||
|
print("No devices found on I2C0")
|
||||||
|
|
||||||
|
if devices1:
|
||||||
|
print("I2C1 Devices found:")
|
||||||
|
for d in devices1:
|
||||||
|
print(" - Address: 0x{:02X}".format(d))
|
||||||
|
else:
|
||||||
|
print("No devices found on I2C1")
|
||||||
|
|
||||||
|
print("-----------------------------")
|
||||||
|
time.sleep(3)
|
||||||
57
sensors.py
Normal file
57
sensors.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from machine import Pin, ADC
|
||||||
|
import time
|
||||||
|
from hx710b import HX710B
|
||||||
|
|
||||||
|
class Sensors:
|
||||||
|
def __init__(self):
|
||||||
|
self.hx = HX710B(dout_pin=0, sck_pin=1)
|
||||||
|
|
||||||
|
print("Using calibration:", self.hx.zero_offset, self.hx.scale_factor)
|
||||||
|
|
||||||
|
self.pressure = 0.0
|
||||||
|
self.tank_full_kpa = 13.72
|
||||||
|
self.tank_full = False
|
||||||
|
self.tank_percent = 0.0
|
||||||
|
|
||||||
|
self.tare_btn = Pin(9, Pin.IN, Pin.PULL_UP)
|
||||||
|
self._tare_lock = False
|
||||||
|
|
||||||
|
self.test_pin0 = Pin(20, Pin.OUT) # Green
|
||||||
|
self.test_pin1 = Pin(21, Pin.OUT) # Blue
|
||||||
|
self.test_pin2 = Pin(22, Pin.OUT)
|
||||||
|
|
||||||
|
self.cpu_adc = ADC(4)
|
||||||
|
|
||||||
|
def read_pressure(self):
|
||||||
|
#self.test_pin2.on()
|
||||||
|
self.pressure = self.hx.read_kpa()
|
||||||
|
#self.test_pin2.off()
|
||||||
|
return self.pressure
|
||||||
|
|
||||||
|
def read_pressure_avg(self, alpha=0.2):
|
||||||
|
new = self.hx.read_kpa()
|
||||||
|
|
||||||
|
if not hasattr(self, "pressure"):
|
||||||
|
self.pressure = new
|
||||||
|
else:
|
||||||
|
self.pressure = (alpha * new) + ((1 - alpha) * self.pressure)
|
||||||
|
|
||||||
|
return self.pressure
|
||||||
|
|
||||||
|
def set_tank_full(self):
|
||||||
|
self.tank_full_kpa = self.pressure
|
||||||
|
self.tank_full = True
|
||||||
|
self.tank_percent = 100.0
|
||||||
|
print("Tank FULL set at", self.tank_full_kpa)
|
||||||
|
|
||||||
|
def check_buttons(self):
|
||||||
|
if self.tare_btn.value() == 0 and not self._tare_lock:
|
||||||
|
self._tare_lock = True
|
||||||
|
time.sleep_ms(200)
|
||||||
|
self.set_tank_full()
|
||||||
|
elif self.tare_btn.value() == 1:
|
||||||
|
self._tare_lock = False
|
||||||
|
|
||||||
|
def cpu_temp(self):
|
||||||
|
v = self.cpu_adc.read_u16() * 3.3 / 65535
|
||||||
|
return 27 - (v - 0.706) / 0.001721
|
||||||
34
timeutil.py
Normal file
34
timeutil.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import time
|
||||||
|
import ntptime
|
||||||
|
import machine
|
||||||
|
|
||||||
|
def setup_rtc(tz_offset):
|
||||||
|
ntptime.settime()
|
||||||
|
rtc = machine.RTC()
|
||||||
|
|
||||||
|
utc = time.localtime()
|
||||||
|
local = time.localtime(time.mktime(utc) + tz_offset * 3600)
|
||||||
|
|
||||||
|
rtc.datetime((
|
||||||
|
local[0], local[1], local[2], 0,
|
||||||
|
local[3], local[4], local[5], 0
|
||||||
|
))
|
||||||
|
return rtc
|
||||||
|
|
||||||
|
# Function to calculate the difference
|
||||||
|
def calculate_difference(dt1, dt2):
|
||||||
|
timestamp1 = rtc_to_timestamp(dt1)
|
||||||
|
timestamp2 = rtc_to_timestamp(dt2)
|
||||||
|
difference = abs(timestamp2 - timestamp1)
|
||||||
|
days = difference // (24 * 3600)
|
||||||
|
difference %= (24 * 3600)
|
||||||
|
hours = difference // 3600
|
||||||
|
difference %= 3600
|
||||||
|
minutes = difference // 60
|
||||||
|
seconds = difference % 60
|
||||||
|
return days, hours, minutes, seconds
|
||||||
|
|
||||||
|
# Function to convert RTC datetime tuple to a timestamp
|
||||||
|
def rtc_to_timestamp(dt):
|
||||||
|
year, month, day, weekday, hour, minute, second, subseconds = dt
|
||||||
|
return time.mktime((year, month, day, hour, minute, second, 0, 0))
|
||||||
155
update.py
Normal file
155
update.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import machine
|
||||||
|
import network
|
||||||
|
import urequests
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import gc
|
||||||
|
|
||||||
|
# -----------------------
|
||||||
|
# WIFI
|
||||||
|
# -----------------------
|
||||||
|
|
||||||
|
SSID = "YOUR_WIFI"
|
||||||
|
PASSWORD = "YOUR_PASSWORD"
|
||||||
|
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
wlan.active(True)
|
||||||
|
|
||||||
|
if not wlan.isconnected():
|
||||||
|
print("Connecting to WiFi...")
|
||||||
|
wlan.connect(SSID, PASSWORD)
|
||||||
|
|
||||||
|
timeout = 20
|
||||||
|
while timeout > 0 and not wlan.isconnected():
|
||||||
|
time.sleep(1)
|
||||||
|
print(".", end="")
|
||||||
|
timeout -= 1
|
||||||
|
|
||||||
|
print("\nConnected:", wlan.isconnected())
|
||||||
|
print("IP:", wlan.ifconfig()[0])
|
||||||
|
|
||||||
|
# -----------------------
|
||||||
|
# CONFIG
|
||||||
|
# -----------------------
|
||||||
|
|
||||||
|
BASE_URL = "https://git.ultiware.co.za/Raymond_Bailie/WaterTank/raw/branch/master/"
|
||||||
|
|
||||||
|
FILES = [
|
||||||
|
# "calibrate.py",
|
||||||
|
"main.py",
|
||||||
|
# "wifi.py",
|
||||||
|
# "sensors.py",
|
||||||
|
# "display.py",
|
||||||
|
# "webserver.py",
|
||||||
|
# "reboot.py",
|
||||||
|
# "scan-i2c.py",
|
||||||
|
# "timeutil.py",
|
||||||
|
# "config.py",
|
||||||
|
# "html/index.html",
|
||||||
|
# "html/app.js",
|
||||||
|
# "html/favicon.ico",
|
||||||
|
# "lib/hx710b.py",
|
||||||
|
# "lib/ssd1306.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
# -----------------------
|
||||||
|
# HELPERS
|
||||||
|
# -----------------------
|
||||||
|
|
||||||
|
def make_dirs(filepath):
|
||||||
|
parts = filepath.split("/")[:-1]
|
||||||
|
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
current = current + "/" + part if current else part
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.mkdir(current)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -----------------------
|
||||||
|
# DOWNLOAD
|
||||||
|
# -----------------------
|
||||||
|
|
||||||
|
for filename in FILES:
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = BASE_URL + filename
|
||||||
|
|
||||||
|
print("\nDownloading:")
|
||||||
|
print(url)
|
||||||
|
|
||||||
|
make_dirs(filename)
|
||||||
|
|
||||||
|
r = urequests.get(url)
|
||||||
|
|
||||||
|
print("HTTP:", r.status_code)
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
|
||||||
|
with open(filename, "wb") as f:
|
||||||
|
f.write(r.content)
|
||||||
|
|
||||||
|
print("Saved:", filename)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Failed:", filename)
|
||||||
|
|
||||||
|
r.close()
|
||||||
|
del r
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("Error:", e)
|
||||||
|
|
||||||
|
print("\nDownload complete")
|
||||||
|
print("Rebooting in 3 seconds...")
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
machine.reset()
|
||||||
|
|
||||||
|
# import urequests
|
||||||
|
#
|
||||||
|
# BASE_URL = "https://git.ultiware.co.za/Raymond_Bailie/WaterTank/raw/branch/master/"
|
||||||
|
#
|
||||||
|
# files = [
|
||||||
|
# "calibrate.py",
|
||||||
|
# "main.py",
|
||||||
|
# "wifi.py",
|
||||||
|
# "sensors.py",
|
||||||
|
# "display.py",
|
||||||
|
# "webserver.py",
|
||||||
|
# "reboot.py",
|
||||||
|
# "scan-i2c.py",
|
||||||
|
# "timeutil.py",
|
||||||
|
# "config.py",
|
||||||
|
# "html/index.html",
|
||||||
|
# "html/app.js",
|
||||||
|
# "html/favicon.ico",
|
||||||
|
# "lib/hx710b.py",
|
||||||
|
# "lib/ssd1306.py",
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# for filename in files:
|
||||||
|
# try:
|
||||||
|
# #print("Downloading", filename)
|
||||||
|
# print(BASE_URL + filename)
|
||||||
|
#
|
||||||
|
# r = urequests.get(BASE_URL + filename)
|
||||||
|
#
|
||||||
|
# if r.status_code == 200:
|
||||||
|
# with open(filename, "wb") as f:
|
||||||
|
# f.write(r.content)
|
||||||
|
# print(" OK")
|
||||||
|
# else:
|
||||||
|
# print(" Failed:", r.status_code)
|
||||||
|
#
|
||||||
|
# r.close()
|
||||||
|
#
|
||||||
|
# except Exception as e:
|
||||||
|
# print(" Error:", e)
|
||||||
|
#
|
||||||
|
# print("Done")
|
||||||
106
webserver.py
Normal file
106
webserver.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import json
|
||||||
|
import gc
|
||||||
|
import time
|
||||||
|
import uasyncio as asyncio
|
||||||
|
|
||||||
|
# Import config value
|
||||||
|
from config import TANK_CAPACITY_L, VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class WebServer:
|
||||||
|
def __init__(self, wlan, sensors, start_time, max_conn):
|
||||||
|
self.wlan = wlan
|
||||||
|
self.sensors = sensors
|
||||||
|
self.start_time = start_time
|
||||||
|
self.max_conn = max_conn
|
||||||
|
self.current = 0
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def handle(self, reader, writer):
|
||||||
|
async with self.lock:
|
||||||
|
if self.current >= self.max_conn:
|
||||||
|
writer.close()
|
||||||
|
return
|
||||||
|
self.current += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = await reader.readline()
|
||||||
|
path = request.decode().split(" ")[1]
|
||||||
|
|
||||||
|
# Discard headers
|
||||||
|
while await reader.readline() != b"\r\n":
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------- SENSOR JSON ----------
|
||||||
|
if path == "/sensor":
|
||||||
|
uptime = int(time.time() - self.start_time)
|
||||||
|
|
||||||
|
days = uptime // 86400
|
||||||
|
hours = (uptime % 86400) // 3600
|
||||||
|
minutes = (uptime % 3600) // 60
|
||||||
|
seconds = uptime % 60
|
||||||
|
|
||||||
|
uptime_text = f"{days}d {hours:02}h {minutes:02}m {seconds:02}s"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"pressure": self.sensors.pressure,
|
||||||
|
"tank_percent": self.sensors.tank_percentage,
|
||||||
|
"cpu_temp": self.sensors.cpu_temp(),
|
||||||
|
"rssi": self.wlan.status("rssi"),
|
||||||
|
"uptime": uptime_text,
|
||||||
|
"mem": gc.mem_free(),
|
||||||
|
"footer": "Powered by Raspberry Pi Pico W & HX710B (" + VERSION + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 200 OK\r\n"
|
||||||
|
b"Content-Type: application/json\r\n\r\n" +
|
||||||
|
json.dumps(data).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- TARE ----------
|
||||||
|
elif path == "/tare":
|
||||||
|
self.sensors.set_tank_full()
|
||||||
|
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 200 OK\r\n"
|
||||||
|
b"Content-Type: application/json\r\n\r\n" +
|
||||||
|
json.dumps({"ok": True}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- JAVASCRIPT (CONFIG INJECTED) ----------
|
||||||
|
elif path == "/app.js":
|
||||||
|
with open("/html/app.js") as f:
|
||||||
|
js = f.read()
|
||||||
|
|
||||||
|
injected = (
|
||||||
|
"// injected by webserver.py\n"
|
||||||
|
f"const TANK_CAPACITY_L = {TANK_CAPACITY_L};\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 200 OK\r\n"
|
||||||
|
b"Content-Type: application/javascript\r\n"
|
||||||
|
b"Cache-Control: no-store\r\n\r\n" +
|
||||||
|
injected.encode() +
|
||||||
|
js.encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- HTML ----------
|
||||||
|
else:
|
||||||
|
with open("/html/index.html") as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 200 OK\r\n"
|
||||||
|
b"Content-Type: text/html\r\n\r\n" +
|
||||||
|
html.encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
async with self.lock:
|
||||||
|
self.current -= 1
|
||||||
15
wifi.py
Normal file
15
wifi.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
def connect_wifi(ssid, password, hostname):
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
wlan.active(True)
|
||||||
|
wlan.config(hostname=hostname)
|
||||||
|
wlan.connect(ssid, password)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
if wlan.isconnected():
|
||||||
|
return wlan
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
raise RuntimeError("WiFi connection failed")
|
||||||
Reference in New Issue
Block a user