Initial commit

This commit is contained in:
2026-06-17 07:11:08 +02:00
commit 5075e5f87b
19 changed files with 2239 additions and 0 deletions

107
calibrate.py Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
{"password": "hellothere", "ssid": "BAILIE", "user": "Raymond_Bailie", "repository": "WaterTank", "branch": "main", "token": "", "ignore": []}

21
config.py Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

287
html/index.html Normal file
View 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
View 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
View 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
View 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
View 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 (0100)
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())

2
reboot.py Normal file
View File

@@ -0,0 +1,2 @@
import machine
machine.reset()

30
scan-i2c.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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")