From 5075e5f87ba67171217dc0a93624ee0c9b1c9ca9 Mon Sep 17 00:00:00 2001 From: Raymond Bailie Date: Wed, 17 Jun 2026 07:11:08 +0200 Subject: [PATCH] Initial commit --- calibrate.py | 107 +++++++ check_wifi.py | 20 ++ config.json | 1 + config.py | 21 ++ display.py | 53 ++++ html/app.js | 153 ++++++++++ html/favicon.ico | Bin 0 -> 15406 bytes html/index.html | 287 ++++++++++++++++++ lib/hx710b.py | 116 ++++++++ lib/ssd1306.py | 89 ++++++ lib/ugit.py | 755 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 238 +++++++++++++++ reboot.py | 2 + scan-i2c.py | 30 ++ sensors.py | 57 ++++ timeutil.py | 34 +++ update.py | 155 ++++++++++ webserver.py | 106 +++++++ wifi.py | 15 + 19 files changed, 2239 insertions(+) create mode 100644 calibrate.py create mode 100644 check_wifi.py create mode 100644 config.json create mode 100644 config.py create mode 100644 display.py create mode 100644 html/app.js create mode 100644 html/favicon.ico create mode 100644 html/index.html create mode 100644 lib/hx710b.py create mode 100644 lib/ssd1306.py create mode 100644 lib/ugit.py create mode 100644 main.py create mode 100644 reboot.py create mode 100644 scan-i2c.py create mode 100644 sensors.py create mode 100644 timeutil.py create mode 100644 update.py create mode 100644 webserver.py create mode 100644 wifi.py diff --git a/calibrate.py b/calibrate.py new file mode 100644 index 0000000..6543e8f --- /dev/null +++ b/calibrate.py @@ -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") diff --git a/check_wifi.py b/check_wifi.py new file mode 100644 index 0000000..e2c8299 --- /dev/null +++ b/check_wifi.py @@ -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()) \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..e46c16a --- /dev/null +++ b/config.json @@ -0,0 +1 @@ +{"password": "hellothere", "ssid": "BAILIE", "user": "Raymond_Bailie", "repository": "WaterTank", "branch": "main", "token": "", "ignore": []} \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..8a124c5 --- /dev/null +++ b/config.py @@ -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 \ No newline at end of file diff --git a/display.py b/display.py new file mode 100644 index 0000000..54bda0d --- /dev/null +++ b/display.py @@ -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() diff --git a/html/app.js b/html/app.js new file mode 100644 index 0000000..4dc680b --- /dev/null +++ b/html/app.js @@ -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(); diff --git a/html/favicon.ico b/html/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..69bcc7f139507899eb866009dbc1ff9193c4171f GIT binary patch literal 15406 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 m%DJXL$+?oB&=&m!a|~PgEN(i|P3%6B + + + + Tank Monitor + + + + + + + + + +
+ Tank Monitor + +
+ +
+ + +
+

System Stats

+ +
+ +
+

Date: --

+

Time: --

+

Uptime: --

+

Level: --

+

Pressure: --

+

Tank Level: --

+

CPU Temp: --

+

WiFi RSSI: --

+

Free Memory: --

+
+ +
+
+
+ +
+
+ + +
+

Actions

+ +
+ + + +
+
+ +
+ + + + + + diff --git a/lib/hx710b.py b/lib/hx710b.py new file mode 100644 index 0000000..3272ebc --- /dev/null +++ b/lib/hx710b.py @@ -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 diff --git a/lib/ssd1306.py b/lib/ssd1306.py new file mode 100644 index 0000000..976f823 --- /dev/null +++ b/lib/ssd1306.py @@ -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) diff --git a/lib/ugit.py b/lib/ugit.py new file mode 100644 index 0000000..1a8ce58 --- /dev/null +++ b/lib/ugit.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..8d876fe --- /dev/null +++ b/main.py @@ -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()) \ No newline at end of file diff --git a/reboot.py b/reboot.py new file mode 100644 index 0000000..b6cbf42 --- /dev/null +++ b/reboot.py @@ -0,0 +1,2 @@ +import machine +machine.reset() diff --git a/scan-i2c.py b/scan-i2c.py new file mode 100644 index 0000000..141d972 --- /dev/null +++ b/scan-i2c.py @@ -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) diff --git a/sensors.py b/sensors.py new file mode 100644 index 0000000..f5adc3d --- /dev/null +++ b/sensors.py @@ -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 \ No newline at end of file diff --git a/timeutil.py b/timeutil.py new file mode 100644 index 0000000..c9b273f --- /dev/null +++ b/timeutil.py @@ -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)) \ No newline at end of file diff --git a/update.py b/update.py new file mode 100644 index 0000000..349e071 --- /dev/null +++ b/update.py @@ -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") \ No newline at end of file diff --git a/webserver.py b/webserver.py new file mode 100644 index 0000000..1cd032e --- /dev/null +++ b/webserver.py @@ -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 diff --git a/wifi.py b/wifi.py new file mode 100644 index 0000000..d1f10fe --- /dev/null +++ b/wifi.py @@ -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")