Plant Moisture Sensor
Fleetingon the server
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import logging
import os
from datetime import datetime, timedelta
import requests
from helpers.otel import metrics
from quart import Blueprint, request
blueprint = Blueprint("basilic", __name__)
logger = logging.getLogger(__name__)
meter = metrics.get_meter(__name__)
basilic_metric = meter.create_gauge(
name="basilic", description="Basilic gauge metric", unit="1"
)
schedules = [(8, 30)] # , (12, 0), (18, 0), (22, 0)]
threshold = int(os.environ.get("BASILIC_THRESHOLD", "200"))
if os.environ.get("BASILIC_DEBUG") == "true":
schedules = [
(hour, minute)
for hour in range(23)
for minute in (
0,
# 15,
30,
# 45,
# 50,
# 55
)
]
@blueprint.route("/basilic")
async def record():
# called with http://home:9911/basilic?value={value}
value = int(request.args.get("value"))
basilic_metric.set(value)
priority = "low" if value > threshold else "high"
requests.post(
"http://home:9705/basilic",
headers={
"title": "mp",
"priority": priority,
},
data=str(value),
timeout=30,
)
now = datetime.now()
shifted = now + timedelta(hours=1) # let's pretend we are actually past that date
times = [
now.replace(hour=hour, minute=minute, second=0, microsecond=0)
for hour, minute in schedules
]
found = None
for prev, next in zip(times, times[1:]):
if prev <= shifted < next:
found = next
break
else:
if shifted <= times[0]:
found = times[0]
else:
found = times[0] + timedelta(days=1)
wait_seconds = (found - now).total_seconds() if priority == "low" else 600
logger.info("Waiting for %s (at %s)", wait_seconds, found)
return str(wait_seconds)
on the MCU
import time
from machine import Pin, ADC
import requests
from comm import wifi, ntfy
from helpers import deep_sleep, dump_exception, lowpower
lowpower()
wifi.reset()
debug = Pin(4, Pin.IN)
pump = Pin(5, Pin.OUT)
pump.value(0)
sensor = ADC(0)
# latching = LatchingRelay(
# 4, # d2
# 0, # d3
# )
switch = Pin(1, Pin.OUT)
def wait(waiting_seconds):
if debug.value():
time.sleep(waiting_seconds)
else:
deep_sleep(waiting_seconds)
def read_stable_value(read_fn, window_size=5, threshold=1, max_iterations=50):
window = []
for _ in range(window_size):
window.append(read_fn())
prev_mean = sum(window) / len(window)
for _ in range(max_iterations):
window.pop(0)
window.append(read_fn())
curr_mean = sum(window) / len(window)
if abs(curr_mean - prev_mean) <= threshold:
return int(curr_mean), False
prev_mean = curr_mean
return int(prev_mean), True
def trypump(t=1):
pump.value(1)
time.sleep(t)
pump.value(0)
def main():
try:
if debug.value():
wifi.on()
else:
ntfy("try pump")
trypump()
ntfy("tried pump")
while True:
switch.on()
value, fallback = read_stable_value(sensor.read)
switch.off()
print(f"Uploading the value {value}")
with wifi:
if fallback:
ntfy("Used a fallback value since the sensor never converged")
waiting_seconds = int(float(requests.get(f"http://home:9911/basilic?value={value}").text))
print(f"going to sleep for {waiting_seconds}")
wait(waiting_seconds)
except Exception as e:
dump_exception(e, "error2.log")
waiting_seconds = 3600
print(f"dumped this error, waiting for {waiting_seconds}s before restarting")
wait(waiting_seconds)
Wed Jul 15 15:19:32 CEST 2026
Checking the current installation
Reading install.json from basilic
Writing into app.py in basilic
Writing into install.json in basilic
Writing into error.log in basilic
Writing into resetdeepsleep in basilic
installation
câblage / wiring
Objectif : système autonome, une LiPo rechargée au solaire via le DFRobot. Historique des tentatives d’alimentation ci-dessous, de la plus simple à celle qui devrait marcher. Fil rouge de tous les échecs : brownout de l’ESP dès qu’une charge (moteur et/ou bobine) commute sur un rail qu’il partage.
Commun aux deux plans :
- L’ESP pilote la bobine du relais : VCC=5V, GND, IN=GPIO5
(cf.
pump = Pin(5, Pin.OUT)dans le blocmain). - Diode de roue libre en travers de la pompe : bande (cathode) vers le +.
- Pompe PPMB00117 : 2,5–6 V, ~210 mA → direct sur LiPo (pas de boost). Cellule 0,2C à la limite mais OK pour des pulses de 1 s.
Points de vigilance :
- Module relais parfois actif à l’état bas → vérifier la logique, éviter le démarrage au boot.
- Ne jamais faire transiter le courant pompe par l’ESP ni par la sortie 5V du DFRobot (le boost part en oscillation avec un gros condo en sortie).
- Plan A : masse en étoile (retours ESP et pompe réunis en UN point sur le − batterie). À ~210 mA le partage de masse est négligeable (~20 mV).
tentative 1 — une seule batterie
Le plus simple : une LiPo rechargée au solaire via le DFRobot ; la pompe tape sur la LiPo brute (jamais via la sortie 5 V du DFRobot), masse en étoile (retours ESP et pompe réunis en UN point sur le − batterie ; à ~210 mA le partage est négligeable, ~20 mV). C’est l’objectif final.
Échecs rencontrés :
- Moteur sur le 5 V de l’ESP, sans diode → moteur en 5 V continu et ESP qui redémarre (brownout : le courant moteur traverse l’ESP).
- Diode de roue libre dans un sens → moteur ~1 V et diode qui chauffe : diode à l’envers (passante en direct, court-circuite le moteur).
- Diode dans l’autre sens → moteur qui ne s’arrête plus : 2N2222 grillé (C-E en court-circuit) par les pics des essais sans diode. ⇒ remplacé par un module relais (driver + diode + LED intégrés) → supprime les pannes de commande.
- Gros condo (1000 µF) sur la sortie 5 V du DFRobot → boost en oscillation : ESP ne répond plus. ⇒ jamais de gros condo sur une sortie boost.
- Moteur sur LiPo brute (3,7 V) → tourne bien ; commande OK une fois le relais en place (GPIO vérifié).
schéma — une batterie
import schemdraw
import schemdraw.elements as elm
out = "/var/run/user/1000/basilic/circuit_pompe_1bat.png"
with schemdraw.Drawing(file=out, show=False) as d:
d.config(unit=2.0, fontsize=12)
# ===== Batterie unique + domaine ESP =====
b1 = d.add(elm.Battery().right().label("LiPo 1Ah\n(unique)", loc="bottom"))
pnode = d.add(elm.Dot()) # noeud + partage (ESP + pompe)
d.add(elm.Line().right().length(0.4))
dfr = d.add(elm.RBox(w=3, h=1.4).right().label("DFRobot\nSolar 5V"))
d.add(elm.Line().right().length(0.4))
esp = d.add(elm.RBox(w=3, h=1.8).right().label("ESP32\n(deep sleep)"))
# GPIO5 -> bobine relais
d.add(elm.Line().at(esp.end).right().length(0.7))
d.add(elm.Dot())
d.add(elm.Line().down().length(1.0).label("GPIO5", loc="right"))
coil = d.add(elm.Inductor2().down().length(2.0).label("bobine\nrelais", loc="right"))
coil_bot = coil.end
# ===== Branche pompe : tap sur le + de la batterie, MEME masse (etoile) =====
d.add(elm.Line().at(pnode.center).down().length(2.6))
sw = d.add(elm.Switch().right().label("relais\ncontact"))
d.add(elm.Line().right().length(1.2))
ptop = d.add(elm.Dot()).center
pump = d.add(elm.RBox(w=1.8, h=1.4).down().label("Pompe\nPPMB00117"))
pbot = pump.end
# diode roue libre // pompe (cathode vers +)
d.add(elm.Line().at(ptop).right().length(1.3))
d.add(elm.Diode().down().toy(pbot[1]).reverse().label("roue\nlibre", loc="right"))
d.add(elm.Line().left().tox(pbot[0]))
# ===== Masse en etoile : rail bas commun =====
ygnd = pbot[1]
d.add(elm.Line().at(coil_bot).down().toy(ygnd))
d.add(elm.Line().at(b1.start).down().toy(ygnd))
star = (b1.start[0], ygnd)
d.add(elm.Dot().at(star))
d.add(elm.Label().label("masse etoile", loc="left").at(star))
d.add(elm.Line().at(star).right().tox(pbot[0]))
print(out)
https://ipfs.konubinix.eu/p/bafkreih7x3fbrq2e5rwqbmeyxf2kqeq7ryfxqvsqnnal7rlpcdiw7mzmfi?circuit_pompe_1bat.png
tentative 2 — deux batteries (bobine sur le 5 V DFRobot)
Repli du plan A : pompe sur sa propre batterie, boucle de puissance isolée de l’ESP par le contact du relais. Mais la bobine du relais reste alimentée par la sortie 5 V (boost) du DFRobot — donc sur le rail ESP.
Échec :
- Relais installé, s’arrête par intermittence. Pas un pb de commande :
pump.value(0)(trypump, blocmain) est séquentiel, toujours atteint, et GPIO5 ne colle pas au boot (vérifié). - Preuve : quand ça reste collé, on reçoit
try pumpmais jamaistried pump⇒ l’ESP reset en pleintrypump, reboote, relancetrypump→ le relais recolle → « pompe qui ne s’arrête plus ». - Cause = brownout au collage de la bobine : l’échelon de courant (~70 mA, pic de collage possiblement 300–500 mA) fait plonger le 5 V du boost fragile → le rail ESP suit → détecteur de brownout ESP32 → reset. Intermittent selon l’état de charge et le timing.
- Piège de diagnostic :
switch = Pin(1)= UART0 TX → console série HS → on ne voit pas « Brownout detector was triggered ». Bougerswitchsur une autre broche et logguermachine.reset_cause()au boot.
schéma — deux batteries (bobine sur boost)
import schemdraw
import schemdraw.elements as elm
out = "/var/run/user/1000/basilic/circuit_pompe.png"
with schemdraw.Drawing(file=out, show=False) as d:
d.config(unit=2.0, fontsize=12)
# ================= Domaine ESP (alim propre) =================
b1 = d.add(elm.Battery().right().label("LiPo 1Ah\n(ESP)", loc="bottom"))
d.add(elm.Line().right().length(0.5))
dfr = d.add(elm.RBox(w=3, h=1.4).right().label("DFRobot\nSolar 5V"))
d.add(elm.Line().right().length(0.5))
esp = d.add(elm.RBox(w=3, h=1.8).right().label("ESP32\n(deep sleep)"))
# branche GPIO5 -> bobine relais -> masse
d.add(elm.Line().at(esp.end).right().length(0.7))
d.add(elm.Dot())
d.add(elm.Line().down().length(1.0).label("GPIO5", loc="right"))
coil = d.add(elm.Inductor2().down().length(2.0).label("bobine\nrelais", loc="right"))
d.add(elm.Line().down().length(0.4))
d.add(elm.Ground())
# ================= Domaine moteur (isolé, 2e batterie) =================
cx, cy = coil.center
b2 = d.add(elm.Battery().up().at((cx + 3.5, cy - 1.6)).label("LiPo 2\n(pompe)", loc="left"))
d.add(elm.Line().up().length(0.5))
sw = d.add(elm.Switch().right().label("relais\ncontact"))
d.add(elm.Line().right().length(0.8))
node_top = d.add(elm.Dot()).center
pump = d.add(elm.RBox(w=1.8, h=1.4).down().label("Pompe\nPPMB00117"))
node_bot = pump.end
d.add(elm.Line().at(node_bot).down().length(0.4))
d.add(elm.Line().left().tox(b2.start))
# diode de roue libre en parallele sur la pompe (cathode vers +)
d.add(elm.Line().at(node_top).right().length(1.3))
d.add(elm.Diode().down().toy(node_bot[1]).reverse().label("roue\nlibre", loc="right"))
d.add(elm.Line().left().tox(node_bot[0]))
# lien mecanique bobine <-> contact (pointille)
d.add(elm.Line().at(coil.center).to(sw.center).linestyle(":").color("gray"))
print(out)
https://ipfs.konubinix.eu/p/bafkreiajcm5xp466i6jeuhfxe7v5gsw3u3wyyrl64bivg2r5jc6rsthuzq?circuit_pompe.png
tentative 3 — une batterie : charges sur le brut, ESP + capteur sur le boost
Tentative d’isoler l’ESP derrière la régulation : bobine + moteur sur la LiPo brute, ESP + capteur d’humidité sur la sortie 5 V (boost) du DFRobot (lui-même alimenté par la même LiPo). L’idée : le boost tamponne l’ESP.
Échec :
- Même brownout. L’appel de courant des charges écroule la LiPo brute → l’entrée du boost plonge aussi → sa sortie (ESP) suit → reset. Le boost ne tamponne rien (pas de réservoir, réponse lente). Rebond de masse probable en plus, si les retours de charge partagent le chemin de masse de l’ESP.
- Conclusion clé : la cause n’est pas (que) le boost — c’est le partage du rail / de la masse avec l’ESP. Une seule batterie ne tient pas l’appel de courant sans écrouler l’ESP, quelle que soit la façon dont on prend le 5 V.
tentative 4 — deux batteries, bobine isolée (cible)
Ne plus rien faire commuter sur le rail de l’ESP. Le DFRobot ne sert plus que de chargeur solaire de la LiPo ESP.
- Moteur sur sa propre batterie (LiPo 2), isolé par le contact — inchangé.
- Bobine sortie du boost : soit sur LiPo ESP brute, soit (module opto + cavalier JD-VCC) sur LiPo 2 → courant bobine 100 % hors rail ESP, masse commune conservée pour la référence du GPIO. 2 batteries suffisent (pas de 3ᵉ).
- Condo de réservoir 470–1000 µF + 100 nF au ras des broches d’alim de l’ESP — jamais monté jusqu’ici, c’est LE fix classique du brownout ; seul il peut suffire.
- Vérifier le seuil de collage (~3,75 V pour un relais 5 V) : si trop juste sur LiPo, passer à un relais bobine 3 V.
- Masse en étoile stricte, fils courts et gros, condo aussi au ras du moteur.
schéma — bobine isolée (brouillon, à générer/ajuster)
import os
import schemdraw
import schemdraw.elements as elm
out = "/var/run/user/1000/basilic/circuit_pompe_fix.png"
os.makedirs(os.path.dirname(out), exist_ok=True)
with schemdraw.Drawing(file=out, show=False) as d:
d.config(unit=2.0, fontsize=11)
# ===== Domaine ESP : DFRobot = chargeur solaire SEUL + condo reservoir =====
b1 = d.add(elm.Battery().right().label("LiPo 1\n(ESP)", loc="bottom"))
d.add(elm.Line().right().length(0.5))
dfr = d.add(elm.RBox(w=3, h=1.5).right().label("DFRobot\nchargeur\nsolaire SEUL"))
d.add(elm.Line().right().length(0.4))
vdd = d.add(elm.Dot())
d.add(elm.Capacitor().down().length(1.3).label("470µF\n//100nF", loc="right"))
d.add(elm.Ground())
d.add(elm.Line().at(vdd.center).right().length(0.4))
esp = d.add(elm.RBox(w=3, h=1.8).right().label("ESP32"))
# ===== GPIO5 -> module relais (opto) ; bobine renvoyee sur LiPo2 =====
d.add(elm.Line().at(esp.end).right().length(0.7))
d.add(elm.Dot())
d.add(elm.Line().down().length(1.0).label("GPIO5", loc="right"))
coil = d.add(elm.Inductor2().down().length(2.0).label("bobine\n(sur LiPo 2)", loc="right"))
coil_bot = d.add(elm.Dot())
# ===== Domaine moteur (LiPo2) : alimente bobine ET pompe, hors rail ESP =====
cx, cy = coil.center
b2 = d.add(elm.Battery().up().at((cx + 3.8, cy - 1.6)).label("LiPo 2\n(pompe+bobine)", loc="left"))
# retour bobine sur LiPo2- (PAS la masse ESP)
d.add(elm.Line().at(coil_bot.center).down().toy(b2.start[1]))
d.add(elm.Line().right().tox(b2.start[0]))
# contact : LiPo2+ -> contact -> pompe -> LiPo2-
d.add(elm.Line().at(b2.end).up().length(0.5))
sw = d.add(elm.Switch().right().label("relais\ncontact"))
d.add(elm.Line().right().length(0.8))
node_top = d.add(elm.Dot()).center
pump = d.add(elm.RBox(w=1.8, h=1.4).down().label("Pompe\nPPMB00117"))
node_bot = pump.end
d.add(elm.Line().at(node_bot).down().toy(b2.start[1]))
d.add(elm.Line().left().tox(b2.start[0]))
# diode roue libre // pompe (cathode vers +)
d.add(elm.Line().at(node_top).right().length(1.3))
d.add(elm.Diode().down().toy(node_bot[1]).reverse().label("roue\nlibre", loc="right"))
d.add(elm.Line().left().tox(node_bot[0]))
# lien mecanique bobine <-> contact
d.add(elm.Line().at(coil.center).to(sw.center).linestyle(":").color("gray"))
print(out)
https://ipfs.konubinix.eu/p/bafkreifv4d2qtdw4xccvssylvf5lk6wzjf7gqg4yxstertskwbqxuwfa6i?circuit_pompe_fix.png
suggestion de debug
Avant de recâbler, isoler LA charge coupable (test A/B, ~2 min) :
- Débrancher le moteur du contact (le relais commute dans le vide), garder la
bobine alimentée, lancer
trypump:- plus de reset → coupable = inrush moteur → batterie moteur séparée + condo ;
- reset quand même → coupable = bobine → condo sur son alim / source raide.
- Quantifier : multimètre en MIN/MAX (ou scope) sur le 3,3 V de l’ESP pendant
un pulse → profondeur du creux. Confirmer
machine.reset_cause() =BROWNOUT_RESET= au boot. - Récupérer le log série : bouger
switchhors dePin(1)(= UART0 TX) pour revoir le message « Brownout detector was triggered ». - Suspecter la cellule : capacité / âge → résistance interne trop haute pour l’appel de courant. Vérifier aussi que la pompe ne cale pas (bulle d’air / à sec → courant de blocage en continu, pas juste un pic).
Sources brownout ESP32 : OceanRemote · Universal-Solder · espboards (power drop under load) · ESP32 Forum (brownout prevention)